{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "diff-review",
  "title": "Diff Review",
  "author": "Diogo Esteves <https://github.com/dgesteves>",
  "description": "Review agent edits across files: accept or reject hunks or whole files, comment on lines, mark files viewed, from the keyboard; returns the applied files, rejected hunks and comments.",
  "dependencies": [
    "@radix-ui/react-toggle-group@^1.2.1",
    "ai",
    "clsx@^2.1.1",
    "diff@^9.0.0",
    "tailwind-merge@^3.7.0"
  ],
  "files": [
    {
      "path": "packages/signoff-ui/src/diff-review.tsx",
      "content": "'use client';\n\nimport * as ToggleGroup from '@radix-ui/react-toggle-group';\nimport {\n  createContext,\n  Fragment,\n  memo,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type CSSProperties,\n  type MouseEvent,\n  type ReactNode,\n} from 'react';\nimport type { DiffHunk, DiffLine, HunkDecision, ParsedFileDiff } from './lib/diff';\nimport { gapLines, type ContextGap, type DiffReviewComment } from './lib/review';\nimport { mergeTokensWithSegments, TOKEN_CLASS, tokenizeLine } from './lib/highlight';\nimport { useScrollRegion } from './lib/scroll-region';\nimport { Revealed } from './lib/revealed';\nimport { hasHidden } from './lib/untrusted';\nimport { ArrowDownIcon, ArrowUpIcon, CheckIcon, ChevronIcon, CommentIcon, UndoIcon, XIcon } from './lib/icons';\nimport { Kbd, LiveRegion } from './lib/primitives';\nimport { cn, isPromiseLike, type HeadingLevel } from './lib/utils';\nimport { diffReviewLabels, commonLabels } from './lib/labels';\nimport { useLabels, WithLabels } from './labels';\nimport {\n  CONTEXT_STEP,\n  useDiffReview,\n  type DiffReviewDraft,\n  type DiffReviewFileState,\n  type ShownContext,\n  type UseDiffReviewOptions,\n  type UseDiffReviewResult,\n} from './use-diff-review';\n\n/** The labels' sections this module reads. */\nconst LABELS = { diffReview: diffReviewLabels, common: commonLabels };\n\nexport type { DiffWorkerFactory } from './lib/diff-async';\nexport type { FileChange, HunkDecision } from './lib/diff';\nexport type {\n  DiffReviewComment,\n  DiffReviewFileResult,\n  DiffReviewRejectedHunk,\n  DiffReviewResult,\n  DiffReviewResultComment,\n  FileDecision,\n} from './lib/review';\nexport type DiffViewMode = 'unified' | 'split';\n\nexport interface DiffReviewProps\n  extends\n    Omit<ComponentPropsWithoutRef<'section'>, 'title' | 'onSubmit' | 'children'>,\n    Omit<UseDiffReviewOptions, 'readOnly' | 'context' | 'maxEditLength' | 'autoAdvance' | 'collapseViewed'> {\n  /**\n   * Heading. Without one, the review uses `labels.diffReview.title`.\n   * @default 'Review changes'\n   */\n  title?: ReactNode;\n  /**\n   * Your own controls in the header, next to the layout toggle, e.g. a \"Copy as a patch\" button.\n   * Your content, like `AgentMessage`'s `renderTool`: it sits in a `data-signoff-slot` element, and\n   * keys pressed in it are yours, not the review's shortcuts.\n   */\n  actions?: ReactNode;\n  /**\n   * The layout, controlled: `unified`, one column, or `split`, the original and the proposed file\n   * side by side. Pass `onViewChange` with it to follow the layout toggle.\n   */\n  view?: DiffViewMode | undefined;\n  /**\n   * The layout to start with, when the review keeps its own.\n   * @default 'unified'\n   */\n  defaultView?: DiffViewMode;\n  /** Called with the layout the reviewer picks with the layout toggle. */\n  onViewChange?: ((view: DiffViewMode) => void) | undefined;\n  /** The apply button's text. Default \"Apply 2 of 5\", or \"Apply changes\" before any is accepted (`labels.diffReview.apply`). */\n  submitLabel?: string;\n  /**\n   * Hide review controls, e.g. once the review has been submitted.\n   * @default false\n   */\n  readOnly?: boolean;\n  /**\n   * Lines of context around each change.\n   * @default 3\n   */\n  context?: number;\n  /**\n   * Past this many lines added plus lines removed in one file, its changed region (from the first\n   * changed line to the last) is shown as one hunk that replaces it, and the review says so. Diffing\n   * costs about the square of this number.\n   * @default 2000\n   */\n  maxEditLength?: number;\n  /**\n   * After accepting or rejecting with the keyboard, move to the next undecided hunk.\n   * @default true\n   */\n  autoAdvance?: boolean;\n  /**\n   * Fold a file away once it is marked viewed.\n   * @default true\n   */\n  collapseViewed?: boolean;\n  /**\n   * Heading level for the title, to fit your document outline.\n   * @default 3\n   */\n  headingLevel?: HeadingLevel;\n}\n\nconst STATUS_BADGE: Record<ParsedFileDiff['status'], string> = {\n  modified: 'border-signoff-warn/40 text-signoff-warn-fg',\n  added: 'border-signoff-accent/45 text-signoff-accent-fg',\n  deleted: 'border-signoff-hot/45 text-signoff-hot-fg',\n  renamed: 'border-signoff-border-strong text-signoff-fg-muted',\n};\n\nconst useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\n/*\n * Long reviews render only what is near the screen. Each hunk's rows are cut into chunks; a chunk\n * far from the viewport keeps its height and its lines as visually hidden text, so the page does not\n * jump, find-in-page and screen readers still have every line, and the DOM stays small. Unchanged\n * lines shown between hunks are cut into chunks the same way. Hunks themselves always render: focus,\n * J/K and their names never depend on what is on screen. Past the first hundred, they render a\n * hundred per task, so the page keeps responding while they arrive.\n */\n/** Reviews with more rows than this are virtualized; smaller ones render every row. */\nconst VIRTUALIZE_ROWS = 400;\nconst CHUNK_ROWS = 50;\n/** Rendered in full on the first render (and on the server), before anything is measured. */\nconst INITIAL_ROWS = 100;\n/** How far beyond the viewport chunks render, so scrolling rarely meets an empty one. */\nconst NEAR_MARGIN = '800px 0px';\n/** One row of 12.5px text at line-height 1.6, until a rendered row is measured. */\nconst ROW_HEIGHT = 20;\n/** Hunks (and whole-file items) in the first render, and in each task after it. */\nconst ITEMS_BATCH = 100;\n/**\n * Past this many characters a line is plain text: no syntax colors, so one element rather than one\n * per token. A minified bundle's line has hundreds of thousands of tokens.\n */\nconst HIGHLIGHT_MAX_LINE = 2_000;\n\ninterface Virtualizer {\n  /** Calls `listener` whenever the element comes near the viewport or leaves; returns an unsubscribe. */\n  observe(el: Element, listener: (near: boolean, height: number) => void): () => void;\n  /**\n   * Measured heights of a hunk's chunks that have rendered (or of unchanged lines shown), by chunk key;\n   * a new parse starts afresh.\n   */\n  heights: WeakMap<Pick<DiffHunk, 'lines'>, Map<string, number>>;\n  rowHeight: number;\n}\n\nfunction createVirtualizer(rowHeight: number, heights: Virtualizer['heights']): Virtualizer {\n  let observer: IntersectionObserver | undefined;\n  const listeners = new Map<Element, (near: boolean, height: number) => void>();\n  return {\n    heights,\n    rowHeight,\n    observe(el, listener) {\n      // Without IntersectionObserver (old browsers, jsdom), everything renders.\n      if (typeof IntersectionObserver === 'undefined') {\n        listener(true, 0);\n        return () => {};\n      }\n      observer ??= new IntersectionObserver(\n        (entries) => {\n          for (const entry of entries)\n            listeners.get(entry.target)?.(entry.isIntersecting, entry.boundingClientRect.height);\n        },\n        // `scrollMargin` extends the margin into scrolling containers, such as a chat panel, where supported.\n        { rootMargin: NEAR_MARGIN, scrollMargin: NEAR_MARGIN } as IntersectionObserverInit,\n      );\n      listeners.set(el, listener);\n      observer.observe(el);\n      return () => {\n        listeners.delete(el);\n        observer?.unobserve(el);\n      };\n    },\n  };\n}\n\nconst VirtualizerContext = createContext<Virtualizer | null>(null);\n\n/** What memoized hunks call: stable functions that always reach the latest render of the review. */\ninterface ReviewActions {\n  register(id: string, el: HTMLElement | null): void;\n  activate(index: number): void;\n  decide(id: string, decision: HunkDecision): void;\n  comment(itemId: string): void;\n  edit(commentId: string): void;\n  remove(commentId: string): void;\n  lineNumber: UseDiffReviewResult['getLineNumberProps'];\n  registerDraft(el: HTMLTextAreaElement | null): void;\n  setText(text: string): void;\n  save(): void;\n  cancel(): void;\n  showContext: UseDiffReviewResult['showContext'];\n}\n\n/**\n * Review agent file edits hunk by hunk, in unified or split view, with word-level highlights, and\n * send back what was accepted, what was rejected and what the reviewer said about it.\n *\n * Keyboard: J/K or the arrows move between hunks and Shift+J/K between files; A accepts, R rejects,\n * U resets, Alt+A / Alt+R decide the file and Shift+A / Shift+R everything; Shift+arrows select lines\n * and C comments on them (or on the hunk); E shows more context; V marks the file viewed;\n * ⌘/Ctrl+Enter applies.\n *\n * Large files stay responsive: a big diff is computed in a worker while the file shows\n * \"Comparing…\", one past `maxEditLength` becomes a single replacing hunk, and long reviews\n * render only the rows near the screen. For your own markup, `useDiffReview` is this component's\n * state and keyboard without it.\n */\nexport function DiffReview({\n  files,\n  title: titleProp,\n  actions: actionsProp,\n  view: viewProp,\n  defaultView = 'unified',\n  onViewChange,\n  decisions,\n  defaultDecisions,\n  onDecisionsChange,\n  comments,\n  defaultComments,\n  onCommentsChange,\n  viewed,\n  defaultViewed,\n  onViewedChange,\n  onSubmit,\n  submitLabel,\n  readOnly = false,\n  context = 3,\n  maxEditLength,\n  diffWorker,\n  autoAdvance = true,\n  collapseViewed = true,\n  headingLevel = 3,\n  labels,\n  className,\n  ...props\n}: DiffReviewProps) {\n  const L = useLabels(LABELS, labels);\n  const T = L.diffReview;\n  const title = titleProp ?? T.title;\n  const Heading = `h${headingLevel}` as const;\n  // What Apply sent, said until the review has something else to say: `after` is what it said then.\n  const [sent, setSent] = useState<{ text: string; after: string }>();\n  const review = useDiffReview({\n    files,\n    decisions,\n    defaultDecisions,\n    onDecisionsChange,\n    comments,\n    defaultComments,\n    onCommentsChange,\n    viewed,\n    defaultViewed,\n    onViewedChange,\n    onSubmit:\n      onSubmit &&\n      ((result, helpers) => {\n        const returned = onSubmit(result, helpers);\n        const total = result.accepted + result.rejected + result.pending;\n        const text = T.submitted(result.accepted, total, result.comments.length);\n        const say = () => setSent({ text, after: latest.current.announcement });\n        // A returned promise means the review went once it resolves. If it rejects, the app says what failed.\n        if (isPromiseLike(returned)) returned.then(say, () => {});\n        else say();\n        return returned;\n      }),\n    readOnly,\n    context,\n    maxEditLength,\n    diffWorker,\n    autoAdvance,\n    collapseViewed,\n    labels,\n  });\n  const { items, counts, comparing, isMac } = review;\n  // Once the review says something else, \"Review sent\" is over, even if the review later repeats\n  // what it said before Apply.\n  if (sent && sent.after !== review.announcement) setSent(undefined);\n\n  const [viewState, setViewState] = useState<DiffViewMode>(defaultView);\n  const view = viewProp ?? viewState;\n  const setView = (v: DiffViewMode) => {\n    if (viewProp === undefined) setViewState(v);\n    onViewChange?.(v);\n  };\n\n  // Rows per hunk in the current view, and where each hunk's rows start in the review.\n  const layout = useMemo(() => {\n    const offsets = new Map<string, number>();\n    let rows = 0;\n    for (const item of items) {\n      if (!item.hunk) continue;\n      offsets.set(item.id, rows);\n      rows += view === 'split' ? splitRowCount(item.hunk.lines) : item.hunk.lines.length;\n    }\n    return { offsets, rows };\n  }, [items, view]);\n  const virtualize = layout.rows > VIRTUALIZE_ROWS;\n  const rootRef = useRef<HTMLElement>(null);\n  const [rowHeight, setRowHeight] = useState(ROW_HEIGHT);\n  // One observer per review and row height; chunks keep their measured heights across views.\n  const [heights] = useState<Virtualizer['heights']>(() => new WeakMap());\n  const virtualizer = useMemo(() => createVirtualizer(rowHeight, heights), [rowHeight, heights]);\n  useIsoLayoutEffect(() => {\n    if (!virtualize) return;\n    const row = rootRef.current?.querySelector('[data-slot=\"signoff-diff-hunk\"] [data-line]');\n    const measured = row?.getBoundingClientRect().height ?? 0;\n    if (measured > 0 && Math.abs(measured - rowHeight) > 0.5) setRowHeight(measured);\n  }, [virtualize, view, rowHeight]);\n\n  const latest = useRef(review);\n  useIsoLayoutEffect(() => {\n    latest.current = review;\n  });\n  const [actions] = useState<ReviewActions>(() => ({\n    register: review.registerItem,\n    activate: review.activate,\n    decide: (id, decision) => latest.current.decide(id, decision),\n    comment: (itemId) => latest.current.startComment({ itemId }),\n    edit: (commentId) => latest.current.startComment({ commentId }),\n    remove: (commentId) => latest.current.removeComment(commentId),\n    lineNumber: (index, line) => latest.current.getLineNumberProps(index, line),\n    registerDraft: review.registerDraft,\n    setText: (text) => latest.current.setDraftText(text),\n    save: () => latest.current.saveDraft(),\n    cancel: () => latest.current.cancelDraft(),\n    showContext: (...args) => latest.current.showContext(...args),\n  }));\n\n  // Past the first hundred, hunks render a hundred per task: a thousand in one task blocked the page\n  // for a quarter of a second. The item J, K or V moves to renders at once, with its batch, and focus\n  // follows it. The count only grows: moving back, or past folded files, unmounts nothing.\n  const [batches, setBatches] = useState(1);\n  const reached = Math.ceil((review.activeIndex + 1) / ITEMS_BATCH);\n  if (reached > batches) setBatches(reached);\n  const rendered = Math.max(batches, reached) * ITEMS_BATCH;\n  useEffect(() => {\n    if (batches * ITEMS_BATCH >= items.length) return;\n    const timer = setTimeout(() => setBatches((n) => n + 1));\n    return () => clearTimeout(timer);\n  }, [batches, items.length]);\n  const renderedBefore = useRef(rendered);\n  useIsoLayoutEffect(() => {\n    const hunk = document.activeElement?.closest('[data-slot=\"signoff-diff-hunk\"]') ?? null;\n    if (review.activeIndex >= renderedBefore.current && rootRef.current?.contains(hunk))\n      latest.current.focusItem(review.activeIndex);\n    renderedBefore.current = rendered;\n  }, [rendered, review.activeIndex]);\n\n  // Comments by the hunk (or whole-file item) they are on, kept per array so memoized hunks keep theirs.\n  const commentsByItem = useMemo(() => {\n    const map = new Map<string, DiffReviewComment[]>();\n    for (const comment of review.comments) {\n      const key = comment.hunkId ?? `${comment.fileId}:file`;\n      map.set(key, [...(map.get(key) ?? []), comment]);\n    }\n    return map;\n  }, [review.comments]);\n\n  const additions = review.files.reduce((n, f) => n + (f.parsed?.additions ?? 0), 0);\n  const deletions = review.files.reduce((n, f) => n + (f.parsed?.deletions ?? 0), 0);\n  const hunkCount = items.filter((item) => item.hunk).length;\n  const viewedCount = review.files.filter((f) => f.viewed).length;\n  // What React renders as nothing gets no slot: undefined, null, booleans and the empty string.\n  const hasActions = actionsProp != null && typeof actionsProp !== 'boolean' && actionsProp !== '';\n  const label = submitLabel ?? T.apply(counts.accepted, counts.total);\n  const mod = L.common.modKey(isMac);\n  const indexOf = useMemo(() => new Map(items.map((item, i) => [item.id, i])), [items]);\n\n  return (\n    // Review shortcuts are scoped to focus within the diff (WCAG 2.1.4); every action is also a button.\n    <WithLabels labels={labels}>\n      <section\n        ref={rootRef}\n        data-signoff\n        data-slot=\"signoff-diff-review\"\n        aria-label={typeof title === 'string' ? title : T.title}\n        // tabIndex -1: a click on a button in Safari, or on text, focuses the review rather than the page.\n        {...review.getRootProps()}\n        className={cn(\n          'rounded-signoff border-signoff-border bg-signoff-surface font-signoff-sans text-signoff-fg overflow-hidden border outline-none',\n          className,\n        )}\n        {...props}\n      >\n        <div className=\"border-signoff-border flex flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-3\">\n          {/* With actions, the title keeps 12rem: past that, the controls wrap under it. */}\n          <div className={cn('min-w-0 flex-1', hasActions && 'basis-48')}>\n            <Heading className=\"text-signoff-fg text-sm font-semibold\">{title}</Heading>\n            <p className=\"font-signoff-mono text-signoff-fg-subtle mt-0.5 text-xs\">\n              {T.files(review.files.length)} · {T.hunks(hunkCount, comparing)} ·{' '}\n              <span className=\"whitespace-nowrap\">\n                <span className=\"text-signoff-accent-fg\">+{additions}</span>{' '}\n                <span className=\"text-signoff-hot-fg\">−{deletions}</span>\n              </span>\n            </p>\n          </div>\n          {hasActions && (\n            <div data-signoff-slot className=\"flex flex-wrap items-center gap-2\">\n              {actionsProp}\n            </div>\n          )}\n          <ToggleGroup.Root\n            type=\"single\"\n            value={view}\n            onValueChange={(v) => v && setView(v as DiffViewMode)}\n            aria-label={T.layout}\n            className=\"border-signoff-border bg-signoff-bg/50 inline-flex rounded-lg border p-0.5\"\n          >\n            {(['unified', 'split'] as const).map((mode) => (\n              <ToggleGroup.Item\n                key={mode}\n                value={mode}\n                className=\"text-signoff-fg-muted hover:text-signoff-fg focus-visible:outline-signoff-ring data-[state=on]:bg-signoff-surface-2 data-[state=on]:text-signoff-fg cursor-pointer rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 data-[state=on]:shadow-[inset_0_0_0_1px_var(--signoff-border-strong)] forced-colors:data-[state=on]:underline forced-colors:data-[state=on]:decoration-2 forced-colors:data-[state=on]:underline-offset-4\"\n              >\n                {T.views[mode]}\n              </ToggleGroup.Item>\n            ))}\n          </ToggleGroup.Root>\n        </div>\n\n        {review.files.length > 1 && <FileNavigator review={review} />}\n\n        <VirtualizerContext.Provider value={virtualize ? virtualizer : null}>\n          {review.files.map((file) => (\n            <FileSection key={file.id} file={file} review={review}>\n              {file.items.map((item) => {\n                const index = indexOf.get(item.id)!;\n                if (index >= rendered) return null;\n                const itemProps = review.getItemProps(index);\n                const selection = review.selection?.item.id === item.id ? review.selection : undefined;\n                const draft = review.draft && draftItem(review.draft) === item.id ? review.draft : undefined;\n                if (!item.hunk) {\n                  return (\n                    <WholeFile\n                      key={item.id}\n                      file={file}\n                      index={index}\n                      name={itemProps['aria-label']}\n                      active={itemProps.tabIndex === 0}\n                      decision={itemProps['data-decision']}\n                      readOnly={readOnly}\n                      comments={commentsByItem.get(item.id)}\n                      draft={draft}\n                      actions={actions}\n                    />\n                  );\n                }\n                const parsed = file.parsed!;\n                // The gap before this hunk, or after it, the last: numbered by the hunk below it.\n                const gap = (before: number, number: number) => {\n                  const found = gapsOf(file).get(before);\n                  return (\n                    found && (\n                      <ContextGapView\n                        key={`gap-${before}`}\n                        fileId={file.id}\n                        parsed={parsed}\n                        gap={found}\n                        view={view}\n                        shown={review.shown[`${file.id}:${before}`]}\n                        readOnly={readOnly}\n                        number={number}\n                        virtualizer={virtualizer}\n                        actions={actions}\n                      />\n                    )\n                  );\n                };\n                const hunk = item.hunk;\n                const last = hunk.index === parsed.hunks.length - 1;\n                return (\n                  <Fragment key={item.id}>\n                    {gap(hunk.index, index + 1)}\n                    <Hunk\n                      hunk={hunk}\n                      file={parsed}\n                      index={index}\n                      name={itemProps['aria-label']}\n                      view={view}\n                      decision={itemProps['data-decision']}\n                      active={itemProps.tabIndex === 0}\n                      readOnly={readOnly}\n                      rowOffset={layout.offsets.get(item.id) ?? 0}\n                      selection={selection && { from: selection.from, to: selection.to, head: selection.head }}\n                      comments={commentsByItem.get(item.id)}\n                      draft={draft}\n                      actions={actions}\n                    />\n                    {last && gap(hunk.index + 1, index + 2)}\n                  </Fragment>\n                );\n              })}\n            </FileSection>\n          ))}\n        </VirtualizerContext.Provider>\n\n        {!readOnly && (\n          <div className=\"border-signoff-border bg-signoff-surface-2/30 flex flex-wrap items-center gap-x-4 gap-y-3 border-t px-4 py-3\">\n            <div className=\"flex min-w-0 flex-col gap-1.5\">\n              <p className=\"text-signoff-fg-muted text-xs\">\n                <span className=\"text-signoff-fg font-semibold tabular-nums\">\n                  {counts.accepted + counts.rejected}/{counts.total}\n                </span>{' '}\n                {T.reviewed}\n                {review.files.length > 1 && (\n                  <span className=\"text-signoff-fg-subtle\"> · {T.filesViewed(viewedCount, review.files.length)}</span>\n                )}\n                {review.comments.length > 0 && (\n                  <span className=\"text-signoff-fg-subtle\"> · {T.comments(review.comments.length)}</span>\n                )}\n                {comparing ? (\n                  <span className=\"text-signoff-fg-subtle\"> · {T.comparingFiles}</span>\n                ) : (\n                  counts.pending > 0 &&\n                  counts.accepted + counts.rejected > 0 && (\n                    <span className=\"text-signoff-fg-subtle\"> · {T.skipped}</span>\n                  )\n                )}\n              </p>\n              <div aria-hidden=\"true\" className=\"flex h-1 w-40 gap-0.5 overflow-hidden rounded-full\">\n                {items.map((item) => {\n                  const d = review.decisions[item.id] ?? 'pending';\n                  return (\n                    <span\n                      key={item.id}\n                      className={cn(\n                        'h-full flex-1 transition-colors',\n                        d === 'accepted'\n                          ? 'bg-signoff-accent'\n                          : d === 'rejected'\n                            ? 'bg-signoff-hot'\n                            : 'bg-signoff-border-strong',\n                      )}\n                    />\n                  );\n                })}\n              </div>\n            </div>\n            <p\n              className=\"text-signoff-fg-subtle hidden flex-wrap items-center gap-1 text-[11px] lg:flex pointer-coarse:hidden\"\n              aria-hidden=\"true\"\n            >\n              <Kbd>J</Kbd>\n              <Kbd>K</Kbd>\n              <span className=\"mr-1.5\">{T.keyHints.move}</span>\n              <Kbd>A</Kbd>\n              <span className=\"mr-1.5\">{T.keyHints.accept}</span>\n              <Kbd>R</Kbd>\n              <span className=\"mr-1.5\">{T.keyHints.reject}</span>\n              <Kbd>C</Kbd>\n              <span className=\"mr-1.5\">{T.keyHints.comment}</span>\n              <Kbd>{mod}</Kbd>\n              <Kbd>↵</Kbd>\n              <span>{T.keyHints.apply}</span>\n            </p>\n            <p className=\"sr-only\">{T.keyboardHelp(mod)}</p>\n            {/* Longer labels, as in a translation, wrap the buttons onto more rows, never their words. */}\n            <div className=\"ml-auto flex flex-wrap items-center justify-end gap-2\">\n              {/* aria-disabled rather than disabled while comparing: the buttons keep focus. */}\n              <button\n                type=\"button\"\n                aria-disabled={comparing || undefined}\n                aria-keyshortcuts=\"Shift+R\"\n                onClick={() => review.decideAll('rejected')}\n                className={secondaryButton}\n              >\n                {T.rejectAll}\n              </button>\n              <button\n                type=\"button\"\n                aria-disabled={comparing || undefined}\n                aria-keyshortcuts=\"Shift+A\"\n                onClick={() => review.decideAll('accepted')}\n                className={secondaryButton}\n              >\n                {T.acceptAll}\n              </button>\n              {onSubmit && (\n                <button\n                  data-slot=\"signoff-diff-submit\"\n                  {...review.getSubmitProps()}\n                  className=\"bg-signoff-accent text-signoff-on-accent hover:bg-signoff-accent/90 focus-visible:outline-signoff-ring inline-flex h-8 cursor-pointer items-center rounded-lg px-3 text-[13px] font-semibold whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 aria-disabled:cursor-not-allowed aria-disabled:opacity-60 forced-colors:border\"\n                >\n                  {label}\n                </button>\n              )}\n            </div>\n          </div>\n        )}\n        <LiveRegion>{sent ? sent.text : review.announcement}</LiveRegion>\n      </section>\n    </WithLabels>\n  );\n}\n\nconst gapsByParse = new WeakMap<ParsedFileDiff, Map<number, ContextGap>>();\n\n/**\n * A file's gaps by the hunk they go before, made once per parse: the review makes them again with\n * every decision, and a gap that keeps its identity lets its memoized view skip the render.\n */\nfunction gapsOf(file: DiffReviewFileState) {\n  let gaps = gapsByParse.get(file.parsed!);\n  if (!gaps) gapsByParse.set(file.parsed!, (gaps = new Map(file.gaps.map((gap) => [gap.before, gap]))));\n  return gaps;\n}\n\n/** The item a draft is written on: its hunk, or for a file comment, the file's whole-file item or first hunk. */\nfunction draftItem(draft: DiffReviewDraft) {\n  return draft.hunkId ?? `${draft.fileId}:file`;\n}\n\nconst secondaryButton =\n  'inline-flex h-8 cursor-pointer items-center rounded-lg border border-signoff-border-strong px-3 text-[13px] font-medium whitespace-nowrap text-signoff-fg-muted transition-colors hover:bg-signoff-surface-2 hover:text-signoff-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signoff-ring aria-disabled:cursor-not-allowed aria-disabled:opacity-60';\n\nconst smallButton =\n  'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 text-xs font-medium whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-signoff-ring';\n\n/** A pressed toggle in forced colours, which replace the tint that shows it: a thicker border. */\nconst pressed = 'forced-colors:aria-pressed:border-2';\n\nfunction PathLabel({ path, oldPath }: { path: string; oldPath?: string | undefined }) {\n  const T = useLabels(LABELS).diffReview;\n  const slash = path.lastIndexOf('/');\n  return (\n    <span dir=\"ltr\" className=\"min-w-0 truncate\">\n      {oldPath && oldPath !== path && (\n        <>\n          <span className=\"text-signoff-fg-subtle\">\n            <Revealed text={oldPath} blanks />\n          </span>\n          <span className=\"text-signoff-fg-subtle\" aria-hidden=\"true\">\n            {' → '}\n          </span>\n          <span className=\"sr-only\">{` ${T.renamedTo} `}</span>\n        </>\n      )}\n      {slash > 0 && (\n        <span className=\"text-signoff-fg-subtle\">\n          <Revealed text={path.slice(0, slash + 1)} blanks />\n        </span>\n      )}\n      <span className=\"text-signoff-fg font-medium\">\n        <Revealed text={path.slice(slash + 1)} blanks />\n      </span>\n    </span>\n  );\n}\n\nfunction StatusBadge({ status }: { status: ParsedFileDiff['status'] }) {\n  const badge = useLabels(LABELS).diffReview.status[status];\n  return (\n    <span\n      className={cn(\n        'inline-flex size-[18px] shrink-0 items-center justify-center rounded border text-[10px] font-bold',\n        STATUS_BADGE[status],\n      )}\n      title={badge.name}\n    >\n      <span aria-hidden=\"true\">{badge.letter}</span>\n      <span className=\"sr-only\">{badge.name}:</span>\n    </span>\n  );\n}\n\n/** Every file of a multi-file review, to jump to: one tab stop, ↑ and ↓ between files. */\nfunction FileNavigator({ review }: { review: UseDiffReviewResult }) {\n  const T = useLabels(LABELS).diffReview;\n  return (\n    <nav\n      aria-label={T.navigator}\n      data-slot=\"signoff-diff-files\"\n      className=\"border-signoff-border bg-signoff-bg/30 border-b px-2 py-1.5\"\n    >\n      <ol className=\"flex max-h-48 flex-col overflow-y-auto\">\n        {review.files.map((file) => {\n          const total = file.items.length;\n          return (\n            <li key={file.id}>\n              <button\n                {...review.getNavigatorItemProps(file.id)}\n                disabled={total === 0}\n                className=\"font-signoff-mono hover:bg-signoff-surface-2 focus-visible:outline-signoff-ring aria-[current=true]:bg-signoff-surface-2 flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left text-xs focus-visible:outline-2 focus-visible:-outline-offset-2 disabled:cursor-default\"\n              >\n                {file.parsed && <StatusBadge status={file.parsed.status} />}\n                <PathLabel path={file.path} oldPath={file.parsed?.oldPath} />\n                <span className=\"text-signoff-fg-subtle ml-auto shrink-0 tabular-nums\">\n                  {file.parsed ? (\n                    <>\n                      <span aria-hidden=\"true\">\n                        {file.decided}/{total}\n                      </span>\n                      <span className=\"sr-only\">{T.navigatorState(file.decided, total, file.viewed)}</span>\n                    </>\n                  ) : (\n                    T.comparingShort\n                  )}\n                </span>\n                <span className=\"text-signoff-accent-fg w-3.5 shrink-0\">{file.viewed && <CheckIcon size={13} />}</span>\n              </button>\n            </li>\n          );\n        })}\n      </ol>\n    </nav>\n  );\n}\n\n/** A file's header and body: its hunks, or \"Comparing…\" while it is diffed in the background. */\nfunction FileSection({\n  file,\n  review,\n  children,\n}: {\n  file: DiffReviewFileState;\n  review: UseDiffReviewResult;\n  children: ReactNode;\n}) {\n  const T = useLabels(LABELS).diffReview;\n  const parsed = file.parsed;\n  const decidable = file.items.length > 0 && !review.readOnly;\n  const bodyId = `${useId()}-body`;\n  return (\n    <div\n      data-slot=\"signoff-diff-file\"\n      data-fallback={parsed?.fallback}\n      data-viewed={file.viewed || undefined}\n      data-decision={file.decision}\n      aria-busy={parsed ? undefined : true}\n      className=\"border-signoff-border border-b last:border-b-0\"\n    >\n      <div className=\"border-signoff-border bg-signoff-surface-2/60 font-signoff-mono flex flex-wrap items-center gap-x-2.5 gap-y-1.5 border-b px-4 py-2 text-xs\">\n        {file.items.length > 0 && (\n          <button\n            type=\"button\"\n            aria-expanded={!file.collapsed}\n            aria-controls={bodyId}\n            aria-label={T.toggleFile(file.collapsed, file.path)}\n            onClick={() => review.setCollapsed(file.id, !file.collapsed)}\n            className=\"text-signoff-fg-subtle hover:text-signoff-fg focus-visible:outline-signoff-ring -ml-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded focus-visible:outline-2\"\n          >\n            <ChevronIcon size={13} className={cn('transition-transform', !file.collapsed && 'rotate-90')} />\n          </button>\n        )}\n        {parsed && <StatusBadge status={parsed.status} />}\n        <PathLabel path={file.path} oldPath={parsed?.oldPath} />\n        {/* Shrinks and wraps, its buttons too, so longer labels stay inside a narrow review. */}\n        <span className=\"ml-auto flex flex-wrap items-center justify-end gap-x-2.5 gap-y-1.5\">\n          {parsed && parsed.hunks.length > 0 && (\n            <span className=\"whitespace-nowrap tabular-nums\">\n              <span className=\"text-signoff-accent-fg\">+{parsed.additions}</span>{' '}\n              <span className=\"text-signoff-hot-fg\">−{parsed.deletions}</span>\n            </span>\n          )}\n          {decidable && (\n            <span className=\"flex flex-wrap items-center justify-end gap-1.5 font-sans\">\n              <label className=\"text-signoff-fg-muted hover:text-signoff-fg flex cursor-pointer items-center gap-1.5 px-1 text-xs\">\n                <input\n                  {...review.getViewedProps(file.id)}\n                  className=\"accent-signoff-accent focus-visible:outline-signoff-ring size-3.5 cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2\"\n                />\n                {T.viewed}\n                <span className=\"sr-only\"> {file.path}</span>\n              </label>\n              <button\n                {...review.getFileDecisionProps(file.id, 'rejected')}\n                className={cn(\n                  smallButton,\n                  'border-signoff-border text-signoff-fg-muted hover:border-signoff-hot/50 hover:text-signoff-hot-fg',\n                )}\n              >\n                {T.rejectFile}\n                <span className=\"sr-only\"> {file.path}</span>\n              </button>\n              <button\n                {...review.getFileDecisionProps(file.id, 'accepted')}\n                className={cn(\n                  smallButton,\n                  'border-signoff-border text-signoff-fg-muted hover:border-signoff-accent/50 hover:text-signoff-accent-fg',\n                )}\n              >\n                {T.acceptFile}\n                <span className=\"sr-only\"> {file.path}</span>\n              </button>\n            </span>\n          )}\n        </span>\n      </div>\n      <div id={bodyId} hidden={file.collapsed}>\n        {!parsed && <p className=\"text-signoff-fg-subtle px-4 py-3 text-xs\">{T.comparing}</p>}\n        {parsed?.fallback === 'replace' && (\n          <p data-slot=\"signoff-diff-fallback\" className=\"text-signoff-fg-muted px-4 pt-3 pb-1 text-xs\">\n            {T.fallback}\n          </p>\n        )}\n        {parsed && file.items.length === 0 && <p className=\"text-signoff-fg-subtle px-4 py-3 text-xs\">{T.noChanges}</p>}\n        {children}\n      </div>\n    </div>\n  );\n}\n\nconst ROW_BG: Record<DiffLine['type'], string> = {\n  context: '',\n  add: 'bg-signoff-add',\n  del: 'bg-signoff-del',\n};\nconst SIGN: Record<DiffLine['type'], { glyph: string; className: string }> = {\n  context: { glyph: ' ', className: '' },\n  add: { glyph: '+', className: 'text-signoff-accent-fg' },\n  del: { glyph: '−', className: 'text-signoff-hot-fg' },\n};\n\n/** What a screen reader hears before a line: \"Added: \", \"Removed: \" or nothing. */\nfunction useLinePrefix() {\n  const { added, removed } = useLabels(LABELS).diffReview;\n  return (type: DiffLine['type']) => (type === 'add' ? added : type === 'del' ? removed : '');\n}\n\n/** Decision buttons for an item: Reset (once decided), Reject and Accept, each a toggle. */\nfunction DecisionButtons({\n  id,\n  label,\n  decision,\n  actions,\n  commentLabel,\n}: {\n  id: string;\n  /** \"hunk 2\" or \"change 3\", for the buttons' names. */\n  label: string;\n  decision: HunkDecision;\n  actions: ReviewActions;\n  commentLabel: string;\n}) {\n  const T = useLabels(LABELS).diffReview;\n  const rejected = decision === 'rejected';\n  const accepted = decision === 'accepted';\n  return (\n    <span className=\"ml-auto flex shrink-0 items-center gap-1\">\n      <button\n        type=\"button\"\n        onClick={() => actions.comment(id)}\n        aria-label={commentLabel}\n        aria-keyshortcuts=\"C\"\n        className=\"text-signoff-fg-subtle hover:bg-signoff-surface-2 hover:text-signoff-fg focus-visible:outline-signoff-ring inline-flex size-7 cursor-pointer items-center justify-center rounded-md focus-visible:outline-2 focus-visible:outline-offset-1\"\n      >\n        <CommentIcon size={13} />\n      </button>\n      {decision !== 'pending' && (\n        <button\n          type=\"button\"\n          onClick={() => actions.decide(id, 'pending')}\n          aria-label={T.resetItem(label)}\n          aria-keyshortcuts=\"U\"\n          className=\"text-signoff-fg-subtle hover:bg-signoff-surface-2 hover:text-signoff-fg focus-visible:outline-signoff-ring inline-flex size-7 cursor-pointer items-center justify-center rounded-md focus-visible:outline-2 focus-visible:outline-offset-1\"\n        >\n          <UndoIcon size={13} />\n        </button>\n      )}\n      <button\n        type=\"button\"\n        aria-pressed={rejected}\n        aria-label={T.rejectItem(label)}\n        aria-keyshortcuts=\"R\"\n        onClick={() => actions.decide(id, rejected ? 'pending' : 'rejected')}\n        className={cn(\n          smallButton,\n          pressed,\n          rejected\n            ? 'border-signoff-hot/60 bg-signoff-hot/15 text-signoff-hot-fg'\n            : 'border-signoff-border text-signoff-fg-muted hover:border-signoff-hot/50 hover:text-signoff-hot-fg',\n        )}\n      >\n        <XIcon size={12} strokeWidth={2.5} />\n        {T.reject}\n      </button>\n      <button\n        type=\"button\"\n        aria-pressed={accepted}\n        aria-label={T.acceptItem(label)}\n        aria-keyshortcuts=\"A\"\n        onClick={() => actions.decide(id, accepted ? 'pending' : 'accepted')}\n        className={cn(\n          smallButton,\n          pressed,\n          accepted\n            ? 'border-signoff-accent/60 bg-signoff-accent/15 text-signoff-accent-fg'\n            : 'border-signoff-border text-signoff-fg-muted hover:border-signoff-accent/50 hover:text-signoff-accent-fg',\n        )}\n      >\n        <CheckIcon size={12} strokeWidth={2.5} />\n        {T.accept}\n      </button>\n    </span>\n  );\n}\n\nfunction DecisionBadge({ decision }: { decision: HunkDecision }) {\n  const badge = useLabels(LABELS).diffReview.badge;\n  if (decision === 'pending') return null;\n  return (\n    <span\n      className={cn(\n        'font-signoff-mono rounded-full px-1.5 py-px text-[10px] font-semibold tracking-wide uppercase',\n        decision === 'accepted'\n          ? 'bg-signoff-accent/15 text-signoff-accent-fg'\n          : 'bg-signoff-hot/15 text-signoff-hot-fg',\n      )}\n    >\n      {badge[decision]}\n    </span>\n  );\n}\n\nconst itemClass = (decision: HunkDecision) =>\n  cn(\n    'border-signoff-border/70 focus-visible:outline-signoff-ring relative border-b last:border-b-0 focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2',\n    \"before:absolute before:inset-y-0 before:left-0 before:z-[1] before:w-[3px] before:content-['']\",\n    decision === 'accepted' && 'before:bg-signoff-accent',\n    decision === 'rejected' && 'before:bg-signoff-hot',\n  );\n\n/** A file with nothing to diff but something to decide: binary, a rename, or created or deleted empty. */\nconst WholeFile = memo(function WholeFile({\n  file,\n  index,\n  name,\n  active,\n  decision,\n  readOnly,\n  comments,\n  draft,\n  actions,\n}: {\n  file: DiffReviewFileState;\n  index: number;\n  name: string;\n  active: boolean;\n  decision: HunkDecision;\n  readOnly: boolean;\n  comments: DiffReviewComment[] | undefined;\n  draft: DiffReviewDraft | undefined;\n  actions: ReviewActions;\n}) {\n  const T = useLabels(LABELS).diffReview;\n  const parsed = file.parsed!;\n  const id = `${file.id}:file`;\n  const notePath = parsed.oldPath ?? file.path;\n  const note = T.wholeFile(parsed.status, !!parsed.binary, notePath);\n  return (\n    // A roving-tabindex item: focus tracking only, all actions are buttons.\n    <div\n      ref={(el) => actions.register(id, el)}\n      role=\"group\"\n      aria-label={name}\n      tabIndex={active ? 0 : -1}\n      data-slot=\"signoff-diff-hunk\"\n      data-decision={decision}\n      onFocus={() => actions.activate(index)}\n      className={itemClass(decision)}\n    >\n      <div className=\"bg-signoff-bg/35 flex min-h-9 items-center gap-2 py-1 pr-2 pl-4\">\n        <span className=\"text-signoff-fg-muted min-w-0 truncate text-xs\">\n          {/* The path in it is the agent's: it shows marks as the header does, when it has any. */}\n          {hasHidden(notePath) ? <Revealed text={note} /> : note}\n        </span>\n        <DecisionBadge decision={decision} />\n        {!readOnly && (\n          <DecisionButtons\n            id={id}\n            label={T.itemName('file', index + 1)}\n            decision={decision}\n            actions={actions}\n            commentLabel={T.commentOn(file.path)}\n          />\n        )}\n      </div>\n      {comments?.map((comment) => (\n        <CommentView key={comment.id} comment={comment} actions={actions} readOnly={readOnly} editing={draft} />\n      ))}\n      {draft && !draft.commentId && <CommentEditor draft={draft} actions={actions} />}\n    </div>\n  );\n});\n\n/** Inside a hunk's code, a comment stays put while the code scrolls sideways, as wide as what shows. */\nconst inCode = 'sticky left-0 col-span-3 w-[100cqw]';\n\ninterface HunkProps {\n  hunk: DiffHunk;\n  file: ParsedFileDiff;\n  index: number;\n  name: string;\n  view: DiffViewMode;\n  decision: HunkDecision;\n  active: boolean;\n  readOnly: boolean;\n  /** Rows of the review before this hunk's, for which chunks render at first. */\n  rowOffset: number;\n  /** Lines selected in this hunk, by index. */\n  selection: { from: number; to: number; head: number } | undefined;\n  comments: DiffReviewComment[] | undefined;\n  draft: DiffReviewDraft | undefined;\n  actions: ReviewActions;\n}\n\n/** What renders after a row: the comments that end on it, and the editor when its range ends there. */\ninterface Annotations {\n  after: Map<number, ReactNode>;\n  /** Rows a chunk must keep rendered: a selected line, a comment or the editor. */\n  pinned: Set<number>;\n}\n\n/** Memoized: a decision re-renders the hunk it changed and the ones gaining or losing focus. */\nconst Hunk = memo(function Hunk({\n  hunk,\n  file,\n  index,\n  name,\n  view,\n  decision,\n  active,\n  readOnly,\n  rowOffset,\n  selection,\n  comments,\n  draft,\n  actions,\n}: HunkProps) {\n  const T = useLabels(LABELS).diffReview;\n  const codeRef = useScrollRegion<HTMLDivElement>(T.hunkCode(index + 1, file.path));\n  const ref = useRef<HTMLDivElement | null>(null);\n  const virtualizer = useContext(VirtualizerContext);\n  const rejected = decision === 'rejected';\n  const head = selection?.head;\n\n  // The selected line in view as the keyboard moves it.\n  useEffect(() => {\n    if (head === undefined) return;\n    ref.current?.querySelector(`[data-line-index=\"${head}\"]`)?.scrollIntoView?.({ block: 'nearest' });\n  }, [head]);\n\n  const annotations = useMemo<Annotations>(() => {\n    const after = new Map<number, ReactNode[]>();\n    const pinned = new Set<number>();\n    const add = (row: number, node: ReactNode) => {\n      after.set(row, [...(after.get(row) ?? []), node]);\n      pinned.add(row);\n    };\n    const end = hunk.lines.length - 1;\n    for (const comment of comments ?? []) {\n      const span = comment.target === 'lines' ? spanOf(hunk, comment) : undefined;\n      add(\n        span?.[1] ?? end,\n        <CommentView\n          key={comment.id}\n          comment={comment}\n          actions={actions}\n          readOnly={readOnly}\n          editing={draft}\n          className={inCode}\n        />,\n      );\n    }\n    if (draft && !draft.commentId) {\n      const span =\n        draft.target === 'lines' && draft.range ? spanOf(hunk, { target: 'lines', ...draft.range }) : undefined;\n      add(span?.[1] ?? end, <CommentEditor key=\"draft\" draft={draft} actions={actions} className={inCode} />);\n    }\n    if (selection) for (let i = selection.from; i <= selection.to; i++) pinned.add(i);\n    return { after: new Map([...after].map(([row, nodes]) => [row, <Fragment key={row}>{nodes}</Fragment>])), pinned };\n  }, [hunk, comments, draft, selection, actions, readOnly]);\n\n  const selectionLabel = selection ? T.lines(rangeOf(hunk, selection.from, selection.to)) : undefined;\n\n  return (\n    // A roving-tabindex item: focus tracking only, all actions are buttons.\n    <div\n      ref={(el) => {\n        ref.current = el;\n        actions.register(hunk.id, el);\n      }}\n      role=\"group\"\n      aria-label={name}\n      tabIndex={active ? 0 : -1}\n      data-slot=\"signoff-diff-hunk\"\n      data-decision={decision}\n      onFocus={() => actions.activate(index)}\n      className={itemClass(decision)}\n    >\n      {/* Under 30rem, the decision badge goes under the range rather than beside it, so the range keeps its room;\n          where the buttons leave it too little, they wrap to a line of their own rather than cut it short. */}\n      <div className=\"bg-signoff-bg/35 @container flex min-h-9 flex-wrap items-center gap-2 py-1 pr-2 pl-4\">\n        <span className=\"flex min-w-0 items-center gap-2 @max-[30rem]:flex-col @max-[30rem]:items-start @max-[30rem]:gap-0.5\">\n          <span dir=\"ltr\" className=\"font-signoff-mono text-signoff-fg-subtle max-w-full min-w-0 truncate text-[11px]\">\n            {hunk.header}\n          </span>\n          <DecisionBadge decision={decision} />\n        </span>\n        {!readOnly && (\n          <DecisionButtons\n            id={hunk.id}\n            label={T.itemName('hunk', index + 1)}\n            decision={decision}\n            actions={actions}\n            commentLabel={T.commentOn(selectionLabel ?? T.itemName('hunk', index + 1))}\n          />\n        )}\n      </div>\n      {/* Long lines scroll sideways, or wrap under 30rem; while they scroll, the code is a named group the keyboard can reach. */}\n      <div\n        ref={codeRef}\n        // Code reads left to right in a right-to-left page too, so bidi cannot reorder it.\n        dir=\"ltr\"\n        className=\"focus-visible:outline-signoff-ring @container overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2\"\n      >\n        {view === 'unified' ? (\n          <UnifiedRows\n            hunk={hunk}\n            index={index}\n            language={file.language}\n            rejected={rejected}\n            virtualizer={virtualizer}\n            rowOffset={rowOffset}\n            selection={selection}\n            annotations={annotations}\n            actions={actions}\n            readOnly={readOnly}\n          />\n        ) : (\n          <SplitRows\n            hunk={hunk}\n            index={index}\n            language={file.language}\n            rejected={rejected}\n            virtualizer={virtualizer}\n            rowOffset={rowOffset}\n            selection={selection}\n            annotations={annotations}\n            actions={actions}\n            readOnly={readOnly}\n          />\n        )}\n      </div>\n    </div>\n  );\n});\n\n/** Indices of the lines a comment or draft covers. */\nfunction spanOf(\n  hunk: DiffHunk,\n  where: {\n    target: DiffReviewComment['target'];\n    side?: 'new' | 'old' | undefined;\n    startLine?: number;\n    endLine?: number;\n  },\n): [number, number] | undefined {\n  const { side, startLine, endLine } = where;\n  if (!side || startLine === undefined || endLine === undefined) return undefined;\n  const num = (l: DiffLine) => (side === 'new' ? l.newNumber : l.oldNumber);\n  const from = hunk.lines.findIndex((l) => num(l) === startLine);\n  let to = -1;\n  hunk.lines.forEach((l, i) => {\n    if (num(l) === endLine) to = i;\n  });\n  return from === -1 || to < from ? undefined : [from, to];\n}\n\n/** The lines selected, numbered as a comment on them would be: in the new file, else the original. */\nfunction rangeOf(hunk: DiffHunk, from: number, to: number) {\n  const lines = hunk.lines.slice(from, to + 1);\n  const numbers = (side: 'new' | 'old') =>\n    lines.map((l) => (side === 'new' ? l.newNumber : l.oldNumber)).filter((n): n is number => n !== undefined);\n  const news = numbers('new');\n  const [list, side] = news.length ? [news, 'new' as const] : [numbers('old'), 'old' as const];\n  return { side, startLine: list[0] ?? 0, endLine: list.at(-1) ?? 0 };\n}\n\nfunction CommentView({\n  comment,\n  actions,\n  readOnly,\n  editing,\n  className,\n}: {\n  comment: DiffReviewComment;\n  actions: ReviewActions;\n  readOnly: boolean;\n  editing: DiffReviewDraft | undefined;\n  className?: string;\n}) {\n  const T = useLabels(LABELS).diffReview;\n  if (editing?.commentId === comment.id)\n    return <CommentEditor draft={editing} actions={actions} className={className} />;\n  const where = T.where(comment);\n  const heading = T.commentHeading(where);\n  return (\n    <div\n      data-slot=\"signoff-diff-comment\"\n      className={cn('border-signoff-border bg-signoff-surface font-signoff-sans border-y px-4 py-2.5', className)}\n    >\n      <div className=\"flex items-start gap-2\">\n        <CommentIcon size={13} className=\"text-signoff-fg-subtle mt-0.5 shrink-0\" />\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"text-signoff-fg-subtle text-[11px]\">\n            <span className=\"sr-only\">{heading.hidden}</span>\n            {heading.visible}\n          </p>\n          <p dir=\"auto\" className=\"text-signoff-fg mt-0.5 text-[13px] leading-relaxed whitespace-pre-wrap\">\n            {comment.text}\n          </p>\n        </div>\n        {!readOnly && (\n          <span className=\"flex shrink-0 items-center gap-1\">\n            <button\n              type=\"button\"\n              onClick={() => actions.edit(comment.id)}\n              className=\"text-signoff-fg-muted hover:text-signoff-fg focus-visible:outline-signoff-ring cursor-pointer rounded px-1.5 py-0.5 text-xs focus-visible:outline-2\"\n              aria-label={T.editComment(where)}\n            >\n              {T.edit}\n            </button>\n            <button\n              type=\"button\"\n              onClick={() => actions.remove(comment.id)}\n              className=\"text-signoff-fg-muted hover:text-signoff-hot-fg focus-visible:outline-signoff-ring cursor-pointer rounded px-1.5 py-0.5 text-xs focus-visible:outline-2\"\n              aria-label={T.deleteComment(where)}\n            >\n              {T.delete}\n            </button>\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction CommentEditor({\n  draft,\n  actions,\n  className,\n}: {\n  draft: DiffReviewDraft;\n  actions: ReviewActions;\n  className?: string | undefined;\n}) {\n  const T = useLabels(LABELS).diffReview;\n  const id = `${useId()}-comment`;\n  const where = T.where({\n    target: draft.target,\n    side: draft.range?.side,\n    startLine: draft.range?.startLine,\n    endLine: draft.range?.endLine,\n  });\n  const empty = draft.text.trim() === '';\n  return (\n    <div\n      data-slot=\"signoff-diff-comment-editor\"\n      className={cn(\n        'border-signoff-border bg-signoff-surface font-signoff-sans flex flex-col gap-2 border-y px-4 py-3',\n        className,\n      )}\n    >\n      <label htmlFor={id} className=\"text-signoff-fg-muted text-xs font-medium\">\n        {T.commentField(where)}\n      </label>\n      <textarea\n        ref={(el) => actions.registerDraft(el)}\n        id={id}\n        dir=\"auto\"\n        rows={3}\n        value={draft.text}\n        onChange={(event) => actions.setText(event.target.value)}\n        onKeyDown={(event) => {\n          // The Enter that commits an IME composition (Japanese, Chinese, Korean…) is not a submit.\n          if (event.nativeEvent.isComposing || event.keyCode === 229) return;\n          if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n            event.preventDefault();\n            event.stopPropagation();\n            actions.save();\n          } else if (event.key === 'Escape') {\n            event.preventDefault();\n            event.stopPropagation();\n            actions.cancel();\n          }\n        }}\n        placeholder={T.commentPlaceholder}\n        className=\"border-signoff-border-strong bg-signoff-bg text-signoff-fg placeholder:text-signoff-fg-subtle focus-visible:outline-signoff-ring w-full resize-y rounded-lg border px-3 py-2 text-[13px] focus-visible:outline-2 focus-visible:outline-offset-0 pointer-coarse:text-[length:max(16px,1rem)]\"\n      />\n      <div className=\"flex items-center justify-end gap-2\">\n        <button\n          type=\"button\"\n          onClick={() => actions.cancel()}\n          aria-keyshortcuts=\"Escape\"\n          className=\"text-signoff-fg-muted hover:text-signoff-fg focus-visible:outline-signoff-ring cursor-pointer rounded-md px-2 py-1.5 text-xs focus-visible:outline-2\"\n        >\n          {T.cancel}\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => actions.save()}\n          aria-disabled={empty || undefined}\n          className=\"bg-signoff-accent text-signoff-on-accent hover:bg-signoff-accent/90 focus-visible:outline-signoff-ring inline-flex h-7 cursor-pointer items-center rounded-md px-2.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 aria-disabled:cursor-not-allowed aria-disabled:opacity-60 forced-colors:border\"\n        >\n          {draft.commentId ? T.saveComment : T.addComment}\n        </button>\n      </div>\n    </div>\n  );\n}\n\n/**\n * Rows of a virtualized hunk, `CHUNK_ROWS` at a time. Near the viewport a chunk renders its rows;\n * away from it, only its height (measured once it has rendered) and its lines as hidden text.\n * A chunk that holds focus, a selected line, a comment or the editor stays rendered.\n */\nfunction Chunk({\n  hunk,\n  id,\n  rows,\n  initiallyNear,\n  pinned,\n  virtualizer,\n  text,\n  className,\n  children,\n}: {\n  /** The hunk, or the unchanged lines, whose rows it holds. */\n  hunk: Pick<DiffHunk, 'lines'>;\n  /** The chunk within its hunk, by view and first row. */\n  id: string;\n  rows: number;\n  initiallyNear: boolean;\n  pinned: boolean;\n  virtualizer: Virtualizer;\n  text: () => string;\n  className?: string;\n  children: () => ReactNode;\n}) {\n  const ref = useRef<HTMLDivElement>(null);\n  const [nearState, setNear] = useState(initiallyNear);\n  const near = nearState || pinned;\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    return virtualizer.observe(el, (isNear, height) => {\n      if (!isNear && el.contains(document.activeElement)) return;\n      if (!isNear && height > 0 && el.hasAttribute('data-rendered')) {\n        let measured = virtualizer.heights.get(hunk);\n        if (!measured) virtualizer.heights.set(hunk, (measured = new Map()));\n        measured.set(id, height);\n      }\n      setNear(isNear);\n    });\n  }, [virtualizer, hunk, id]);\n  if (near) {\n    return (\n      <div ref={ref} data-slot=\"signoff-diff-chunk\" data-rendered=\"\" className={className}>\n        {children()}\n      </div>\n    );\n  }\n  const height = virtualizer.heights.get(hunk)?.get(id) ?? rows * virtualizer.rowHeight;\n  return (\n    <div ref={ref} data-slot=\"signoff-diff-chunk\" className=\"relative\" style={{ height }}>\n      <div className=\"sr-only whitespace-pre\">{text()}</div>\n    </div>\n  );\n}\n\n/** Chunk boundaries for `count` rows: [start, end) pairs. */\nfunction chunks(count: number): Array<[number, number]> {\n  const out: Array<[number, number]> = [];\n  for (let start = 0; start < count; start += CHUNK_ROWS) out.push([start, Math.min(count, start + CHUNK_ROWS)]);\n  return out;\n}\n\nconst pinnedIn = (annotations: Annotations, start: number, end: number) => {\n  for (const row of annotations.pinned) if (row >= start && row < end) return true;\n  return false;\n};\n\nfunction LineContent({\n  line,\n  language,\n  rejected,\n  wrap = false,\n}: {\n  line: DiffLine;\n  language: string;\n  rejected: boolean;\n  wrap?: boolean;\n}) {\n  const prefix = useLinePrefix();\n  const pieces = useMemo(\n    () =>\n      mergeTokensWithSegments(\n        tokenizeLine(line.content, line.content.length > HIGHLIGHT_MAX_LINE ? 'text' : language),\n        line.segments,\n      ),\n    [line, language],\n  );\n  // A bidi control can make code read as other code (Trojan Source): such characters show as marks.\n  const reveal = useMemo(() => hasHidden(line.content), [line]);\n  return (\n    <span\n      className={cn(\n        wrap\n          ? 'min-w-0 flex-1 pr-3 break-all whitespace-pre-wrap'\n          : 'pr-6 whitespace-pre @max-[30rem]:min-w-0 @max-[30rem]:flex-1 @max-[30rem]:pr-3 @max-[30rem]:break-all @max-[30rem]:whitespace-pre-wrap',\n        // A rejected hunk's lines are muted, with no syntax colors or word highlights, and its added\n        // lines struck through: muted rather than faded, so the text keeps its 4.5:1 contrast.\n        rejected && 'text-signoff-fg-muted',\n        rejected && line.type === 'add' && 'decoration-signoff-hot/50 line-through',\n      )}\n    >\n      <span className=\"sr-only\">{prefix(line.type)}</span>\n      {pieces.map((p, i) => (\n        <span\n          key={i}\n          className={\n            rejected\n              ? undefined\n              : cn(\n                  TOKEN_CLASS[p.kind],\n                  p.changed && 'rounded-[3px]',\n                  p.changed && (line.type === 'add' ? 'bg-signoff-add-strong' : 'bg-signoff-del-strong'),\n                )\n          }\n        >\n          {reveal ? <Revealed text={p.text} /> : p.text}\n        </span>\n      ))}\n      {line.content === '' && ' '}\n    </span>\n  );\n}\n\n/**\n * A line number. Click selects the line, Shift-click extends the selection; the keyboard selects with\n * Shift and the arrows from the hunk, so the numbers stay out of the tab order and the reading.\n */\nfunction LineNumber({\n  value,\n  onSelect,\n  className,\n}: {\n  value: number | undefined;\n  onSelect: ((event: MouseEvent) => void) | undefined;\n  className: string;\n}) {\n  if (!onSelect || value === undefined)\n    return (\n      <span aria-hidden=\"true\" className={className}>\n        {value ?? ''}\n      </span>\n    );\n  return (\n    <button\n      type=\"button\"\n      tabIndex={-1}\n      aria-hidden=\"true\"\n      onClick={onSelect}\n      className={cn(className, 'hover:text-signoff-fg cursor-pointer')}\n    >\n      {value}\n    </button>\n  );\n}\n\ninterface RowOptions {\n  /** The hunk's item index, for the line numbers' selection; `undefined` for unchanged context lines. */\n  index?: number | undefined;\n  actions?: ReviewActions | undefined;\n  selected?: boolean;\n  head?: boolean;\n}\n\nconst gutter = 'text-signoff-fg-subtle shrink-0 pr-2 text-right select-none';\n\nfunction UnifiedRow({\n  line,\n  lineIndex,\n  language,\n  rejected,\n  index,\n  actions,\n  selected = false,\n  head = false,\n}: { line: DiffLine; lineIndex?: number; language: string; rejected: boolean } & RowOptions) {\n  const sign = SIGN[line.type];\n  const select =\n    actions && index !== undefined && lineIndex !== undefined\n      ? (event: MouseEvent) => actions.lineNumber(index, lineIndex).onClick(event)\n      : undefined;\n  return (\n    <div\n      data-line={line.type}\n      data-line-index={lineIndex}\n      data-selected={selected || undefined}\n      className={cn(\n        // Wrapped lines (under 30rem) keep their numbers by their first line.\n        'flex @max-[30rem]:items-start',\n        ROW_BG[line.type],\n        selected && 'shadow-[inset_3px_0_0_var(--signoff-ring)]',\n        selected && !ROW_BG[line.type] && 'bg-signoff-accent/8',\n        head && 'outline-signoff-ring outline-1 -outline-offset-1',\n      )}\n    >\n      <LineNumber value={line.oldNumber} onSelect={select} className={cn(gutter, 'w-11')} />\n      <LineNumber value={line.newNumber} onSelect={select} className={cn(gutter, 'w-11')} />\n      <span aria-hidden=\"true\" className={cn('w-5 shrink-0 text-center select-none', sign.className)}>\n        {sign.glyph}\n      </span>\n      <LineContent line={line} language={language} rejected={rejected} />\n    </div>\n  );\n}\n\ninterface RowsProps {\n  /** The hunk, or unchanged lines shown around hunks: those have no `index`, selection or `actions`. */\n  hunk: Pick<DiffHunk, 'lines'>;\n  index?: number;\n  language: string;\n  rejected: boolean;\n  /** Set when the review is long enough to render only what is near the screen. */\n  virtualizer: Virtualizer | null;\n  rowOffset: number;\n  selection?: { from: number; to: number; head: number } | undefined;\n  annotations: Annotations;\n  actions?: ReviewActions;\n  readOnly: boolean;\n}\n\nfunction UnifiedRows({\n  hunk,\n  index,\n  language,\n  rejected,\n  virtualizer,\n  rowOffset,\n  selection,\n  annotations,\n  actions,\n  readOnly,\n}: RowsProps) {\n  const prefix = useLinePrefix();\n  const lines = hunk.lines;\n  // Unrendered rows have no width: keep the widest line's, so the sideways scroll does not change.\n  const widest = useMemo(() => lines.reduce((n, l) => Math.max(n, l.content.length), 0), [lines]);\n  const row = (line: DiffLine, i: number) => (\n    <Fragment key={i}>\n      <UnifiedRow\n        line={line}\n        lineIndex={i}\n        language={language}\n        rejected={rejected}\n        index={index}\n        actions={readOnly ? undefined : actions}\n        selected={!!selection && i >= selection.from && i <= selection.to}\n        head={selection?.head === i}\n      />\n      {annotations.after.get(i)}\n    </Fragment>\n  );\n  return (\n    // Under 30rem wide, the lines wrap instead (LineContent), so nothing holds the rows wider.\n    <div className=\"font-signoff-mono min-w-max py-1 text-[12.5px] leading-[1.6] @max-[30rem]:min-w-0\">\n      {virtualizer ? (\n        <div\n          className=\"min-w-(--signoff-widest) @max-[30rem]:min-w-0\"\n          style={{ '--signoff-widest': `calc(${widest}ch + 8.25rem)` } as CSSProperties}\n        >\n          {chunks(lines.length).map(([start, end]) => (\n            <Chunk\n              key={start}\n              hunk={hunk}\n              id={`u${start}`}\n              rows={end - start}\n              initiallyNear={rowOffset + start < INITIAL_ROWS}\n              pinned={pinnedIn(annotations, start, end)}\n              virtualizer={virtualizer}\n              text={() =>\n                lines\n                  .slice(start, end)\n                  .map((line) => prefix(line.type) + line.content)\n                  .join('\\n')\n              }\n            >\n              {() => lines.slice(start, end).map((line, i) => row(line, start + i))}\n            </Chunk>\n          ))}\n        </div>\n      ) : (\n        lines.map(row)\n      )}\n    </div>\n  );\n}\n\ntype SplitRow = {\n  left?: DiffLine | undefined;\n  right?: DiffLine | undefined;\n  /** Indices into the hunk's lines. */\n  leftIndex?: number | undefined;\n  rightIndex?: number | undefined;\n};\n\nfunction toSplitRows(lines: readonly DiffLine[]): SplitRow[] {\n  const rows: SplitRow[] = [];\n  let i = 0;\n  while (i < lines.length) {\n    const line = lines[i]!;\n    if (line.type === 'context') {\n      rows.push({ left: line, right: line, leftIndex: i, rightIndex: i });\n      i++;\n      continue;\n    }\n    const dels: number[] = [];\n    const adds: number[] = [];\n    while (lines[i]?.type === 'del') dels.push(i++);\n    while (lines[i]?.type === 'add') adds.push(i++);\n    for (let k = 0; k < Math.max(dels.length, adds.length); k++) {\n      const [l, r] = [dels[k], adds[k]];\n      rows.push({\n        left: l === undefined ? undefined : lines[l],\n        right: r === undefined ? undefined : lines[r],\n        leftIndex: l,\n        rightIndex: r,\n      });\n    }\n  }\n  return rows;\n}\n\n/** How many rows `toSplitRows` makes, without making them. */\nfunction splitRowCount(lines: readonly DiffLine[]): number {\n  let rows = 0;\n  let i = 0;\n  while (i < lines.length) {\n    if (lines[i]!.type === 'context') {\n      rows++;\n      i++;\n      continue;\n    }\n    const start = i;\n    while (lines[i]?.type === 'del') i++;\n    const dels = i - start;\n    while (lines[i]?.type === 'add') i++;\n    rows += Math.max(dels, i - start - dels);\n  }\n  return rows;\n}\n\nfunction SplitCell({\n  line,\n  lineIndex,\n  side,\n  language,\n  rejected,\n  hidden = false,\n  index,\n  actions,\n  selected = false,\n  head = false,\n}: {\n  line: DiffLine | undefined;\n  lineIndex?: number | undefined;\n  side: 'left' | 'right';\n  language: string;\n  rejected: boolean;\n  /** Context lines appear on both sides; hide the duplicate from assistive tech. */\n  hidden?: boolean;\n} & RowOptions) {\n  if (!line) return <div aria-hidden=\"true\" className=\"bg-signoff-bg/30\" />;\n  const number = side === 'left' ? line.oldNumber : line.newNumber;\n  const sign = SIGN[line.type];\n  const select =\n    actions && index !== undefined && lineIndex !== undefined\n      ? (event: MouseEvent) => actions.lineNumber(index, lineIndex).onClick(event)\n      : undefined;\n  return (\n    <div\n      data-line={line.type}\n      data-line-index={hidden ? undefined : lineIndex}\n      data-selected={selected || undefined}\n      aria-hidden={hidden || undefined}\n      className={cn(\n        'flex min-w-0',\n        ROW_BG[line.type],\n        selected && 'shadow-[inset_3px_0_0_var(--signoff-ring)]',\n        selected && !ROW_BG[line.type] && 'bg-signoff-accent/8',\n        head && 'outline-signoff-ring outline-1 -outline-offset-1',\n      )}\n    >\n      <LineNumber value={number} onSelect={select} className={cn(gutter, 'w-10')} />\n      <span aria-hidden=\"true\" className={cn('w-4 shrink-0 text-center select-none', sign.className)}>\n        {sign.glyph}\n      </span>\n      <LineContent line={line} language={language} rejected={rejected} wrap />\n    </div>\n  );\n}\n\nconst SPLIT_GRID = 'grid grid-cols-[minmax(0,1fr)_1px_minmax(0,1fr)]';\n\n/** Split view: one shared grid so the two columns stay aligned; long lines wrap. */\nfunction SplitRows({\n  hunk,\n  index,\n  language,\n  rejected,\n  virtualizer,\n  rowOffset,\n  selection,\n  annotations,\n  actions,\n  readOnly,\n}: RowsProps) {\n  const prefix = useLinePrefix();\n  const rows = useMemo(() => toSplitRows(hunk.lines), [hunk.lines]);\n  const inSelection = (i: number | undefined) =>\n    !!selection && i !== undefined && i >= selection.from && i <= selection.to;\n  // A comment goes after the row holding the last line it covers.\n  const lastRowOf = useMemo(() => {\n    const map = new Map<number, number>();\n    rows.forEach((row, r) => {\n      if (row.leftIndex !== undefined) map.set(row.leftIndex, r);\n      if (row.rightIndex !== undefined) map.set(row.rightIndex, r);\n    });\n    return map;\n  }, [rows]);\n  const afterRow = useMemo(() => {\n    const map = new Map<number, ReactNode[]>();\n    for (const [line, node] of annotations.after) {\n      const r = lastRowOf.get(line) ?? rows.length - 1;\n      map.set(r, [...(map.get(r) ?? []), node]);\n    }\n    return map;\n  }, [annotations, lastRowOf, rows.length]);\n  const pinnedRows = useMemo(\n    () => new Set([...annotations.pinned].map((line) => lastRowOf.get(line) ?? rows.length - 1)),\n    [annotations, lastRowOf, rows.length],\n  );\n  const cells = (row: SplitRow, r: number) => (\n    <Fragment key={r}>\n      <SplitCell\n        line={row.left}\n        lineIndex={row.leftIndex}\n        side=\"left\"\n        language={language}\n        rejected={rejected}\n        index={index}\n        actions={readOnly ? undefined : actions}\n        selected={inSelection(row.leftIndex)}\n        head={selection?.head === row.leftIndex && row.left !== undefined}\n      />\n      <span aria-hidden=\"true\" className=\"bg-signoff-border\" />\n      <SplitCell\n        line={row.right}\n        lineIndex={row.rightIndex}\n        side=\"right\"\n        language={language}\n        rejected={rejected}\n        hidden={row.left !== undefined && row.left === row.right}\n        index={index}\n        actions={readOnly ? undefined : actions}\n        selected={inSelection(row.rightIndex)}\n        head={selection?.head === row.rightIndex && row.left !== row.right && row.right !== undefined}\n      />\n      {afterRow.get(r)}\n    </Fragment>\n  );\n  if (!virtualizer) {\n    return (\n      <div className={cn('font-signoff-mono py-1 text-[12.5px] leading-[1.6]', SPLIT_GRID)}>{rows.map(cells)}</div>\n    );\n  }\n  // Read in the same order as the cells: the left line, then the right one unless it is the same.\n  const text = (start: number, end: number) =>\n    rows\n      .slice(start, end)\n      .flatMap((row) => [row.left, row.right === row.left ? undefined : row.right])\n      .filter((line): line is DiffLine => line !== undefined)\n      .map((line) => prefix(line.type) + line.content)\n      .join('\\n');\n  return (\n    <div className=\"font-signoff-mono py-1 text-[12.5px] leading-[1.6]\">\n      {chunks(rows.length).map(([start, end]) => (\n        <Chunk\n          key={start}\n          hunk={hunk}\n          id={`s${start}`}\n          rows={end - start}\n          initiallyNear={rowOffset + start < INITIAL_ROWS}\n          pinned={[...pinnedRows].some((r) => r >= start && r < end)}\n          virtualizer={virtualizer}\n          text={() => text(start, end)}\n          className={SPLIT_GRID}\n        >\n          {() => rows.slice(start, end).map((row, i) => cells(row, start + i))}\n        </Chunk>\n      ))}\n    </div>\n  );\n}\n\n/**\n * Unchanged lines between hunks, before the first or after the last: those shown so far, and a bar\n * to show more. Its buttons are outside the tab order, like the line numbers: from a hunk, E shows\n * more lines around it; a screen reader reaches the buttons by reading. Memoized, on props that\n * keep their identity: a decision, or a message streaming around the review, does not render it.\n */\nconst ContextGapView = memo(function ContextGapView({\n  fileId,\n  parsed,\n  gap,\n  view,\n  shown = { start: 0, end: 0 },\n  readOnly,\n  number,\n  virtualizer,\n  actions,\n}: {\n  fileId: string;\n  parsed: ParsedFileDiff;\n  gap: ContextGap;\n  view: DiffViewMode;\n  shown: ShownContext | undefined;\n  readOnly: boolean;\n  /** The number of the hunk below it, as the review numbers its items: the one above is one less. */\n  number: number;\n  virtualizer: Virtualizer;\n  actions: ReviewActions;\n}) {\n  const T = useLabels(LABELS).diffReview;\n  const size = gap.oldEnd - gap.oldStart + 1;\n  const hidden = size - shown.start - shown.end;\n  const top = useMemo(\n    () => (shown.start > 0 ? gapLines(parsed, gap, gap.oldStart, gap.oldStart + shown.start - 1) : []),\n    [parsed, gap, shown.start],\n  );\n  const bottom = useMemo(\n    () => (shown.end > 0 ? gapLines(parsed, gap, gap.oldEnd - shown.end + 1, gap.oldEnd) : []),\n    [parsed, gap, shown.end],\n  );\n  const first = gap.before === 0;\n  const last = gap.before === parsed.hunks.length;\n  const button =\n    'text-signoff-fg-muted hover:text-signoff-fg hover:bg-signoff-surface-2 focus-visible:outline-signoff-ring inline-flex cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 focus-visible:outline-2';\n  return (\n    <div data-slot=\"signoff-diff-gap\" className=\"border-signoff-border/70 border-b\">\n      <ContextLines lines={top} view={view} language={parsed.language} virtualizer={virtualizer} />\n      {hidden > 0 && !readOnly && (\n        <div className=\"bg-signoff-bg/50 text-signoff-fg-subtle flex flex-wrap items-center gap-x-3 gap-y-1 py-1 pr-2 pl-4 text-[11px]\">\n          <span className=\"font-signoff-mono\">{T.unchangedLines(hidden)}</span>\n          {hidden > CONTEXT_STEP && !first && (\n            <button\n              type=\"button\"\n              tabIndex={-1}\n              onClick={() => actions.showContext(fileId, gap, 'start')}\n              className={button}\n            >\n              <ArrowDownIcon size={11} />\n              <GapLabel {...T.moreAfter(CONTEXT_STEP, number - 1)} />\n            </button>\n          )}\n          {hidden > CONTEXT_STEP && !last && (\n            <button\n              type=\"button\"\n              tabIndex={-1}\n              onClick={() => actions.showContext(fileId, gap, 'end')}\n              className={button}\n            >\n              <ArrowUpIcon size={11} />\n              <GapLabel {...T.moreBefore(CONTEXT_STEP, number)} />\n            </button>\n          )}\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            onClick={() => actions.showContext(fileId, gap, 'all')}\n            className={button}\n          >\n            <GapLabel {...T.showAll(hidden)} />\n          </button>\n        </div>\n      )}\n      <ContextLines lines={bottom} view={view} language={parsed.language} virtualizer={virtualizer} />\n    </div>\n  );\n});\n\n/** A gap button's words: what shows, then what only a screen reader hears. */\nfunction GapLabel({ visible, hidden }: { visible: string; hidden: string }) {\n  return (\n    <>\n      {visible}\n      <span className=\"sr-only\">{hidden}</span>\n    </>\n  );\n}\n\n/** Nothing after a row, and no row to keep rendered: unchanged lines have no comments or selection. */\nconst NO_ANNOTATIONS: Annotations = { after: new Map(), pinned: new Set() };\n\n/**\n * Unchanged lines shown around hunks, in the review's layout, rendered as a hunk's rows are: past\n * `INITIAL_ROWS` of them, cut into chunks that render only near the screen.\n */\nfunction ContextLines({\n  lines,\n  view,\n  language,\n  virtualizer,\n}: {\n  lines: DiffLine[];\n  view: DiffViewMode;\n  language: string;\n  virtualizer: Virtualizer;\n}) {\n  // The chunks keep their measured heights under it for as long as these lines show.\n  const rows = useMemo(() => ({ lines }), [lines]);\n  if (lines.length === 0) return null;\n  const Rows = view === 'unified' ? UnifiedRows : SplitRows;\n  return (\n    <div dir=\"ltr\" className=\"@container overflow-x-auto\">\n      <Rows\n        hunk={rows}\n        language={language}\n        rejected={false}\n        virtualizer={lines.length > INITIAL_ROWS ? virtualizer : null}\n        rowOffset={0}\n        annotations={NO_ANNOTATIONS}\n        readOnly\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/signoff-ui/diff-review.tsx"
    },
    {
      "path": "packages/signoff-ui/src/labels.tsx",
      "content": "'use client';\n\nimport { createContext, useContext, useMemo, type ReactNode } from 'react';\nimport { defaultLabels, mergeLabels, type SignoffLabels, type SignoffLabelsInput } from './lib/labels';\n\n/** What the providers above give: their labels merged, the nearest winning. Empty without one. */\nconst LabelsContext = createContext<SignoffLabelsInput>({});\n\n/**\n * Labels for every component inside: a translation of `defaultLabels`, whole or in part. Providers\n * nest, the inner one winning key by key. Labels hold functions, so render the provider from a\n * Client Component (a module with 'use client') and keep the object stable: at module scope, or memoized.\n */\nexport function SignoffLabelsProvider({ labels, children }: { labels: SignoffLabelsInput; children: ReactNode }) {\n  const parent = useContext(LabelsContext);\n  const value = useMemo(() => mergeLabels(parent, labels), [parent, labels]);\n  return <LabelsContext.Provider value={value}>{children}</LabelsContext.Provider>;\n}\n\n/** A component's own `labels`, for what it renders inside too; nothing extra without them. */\nexport function WithLabels({ labels, children }: { labels: SignoffLabelsInput | undefined; children: ReactNode }) {\n  return labels ? <SignoffLabelsProvider labels={labels}>{children}</SignoffLabelsProvider> : children;\n}\n\n/** Every label in effect here: the English, the providers' over it, and `overrides` over both. */\nexport function useSignoffLabels(overrides?: SignoffLabelsInput): SignoffLabels {\n  const context = useContext(LabelsContext);\n  return useMemo(() => mergeLabels(defaultLabels, context, overrides), [context, overrides]);\n}\n\n/**\n * The sections a component reads, from their English defaults (a module-level object, such as\n * `{ sources: sourcesLabels }`), with the providers' labels and the component's own over them.\n * Only those sections' defaults end up in the component's bundle.\n */\nexport function useLabels<T extends Partial<SignoffLabels>>(defaults: T, overrides?: SignoffLabelsInput): T {\n  const context = useContext(LabelsContext);\n  return useMemo(() => {\n    const keys = Object.keys(defaults) as (keyof T & keyof SignoffLabelsInput)[];\n    if (!overrides && keys.every((key) => context[key] === undefined)) return defaults;\n    const own: SignoffLabelsInput = {};\n    const theirs: SignoffLabelsInput = {};\n    for (const key of keys) {\n      if (context[key] !== undefined) Object.assign(theirs, { [key]: context[key] });\n      if (overrides?.[key] !== undefined) Object.assign(own, { [key]: overrides[key] });\n    }\n    return mergeLabels(defaults as SignoffLabelsInput, theirs, own) as T;\n  }, [defaults, context, overrides]);\n}\n",
      "type": "registry:component",
      "target": "components/signoff-ui/labels.tsx"
    },
    {
      "path": "packages/signoff-ui/src/lib/ai.ts",
      "content": "/**\n * Thin, dependency-free helpers over AI SDK v7 `UIMessage` parts.\n * Only types are imported from `ai`, so nothing from the SDK runtime ends up in the bundle.\n */\nimport type {\n  ChatStatus,\n  DynamicToolUIPart,\n  LanguageModelUsage,\n  ReasoningUIPart,\n  SourceDocumentUIPart,\n  SourceUrlUIPart,\n  TextUIPart,\n  ToolUIPart,\n  UIMessage,\n  UITools,\n} from 'ai';\n\n/**\n * Fields AI SDK 7 added to a tool part's `approval`: whether a `toolApproval` policy decided it,\n * and why the tool asked. AI SDK 6 has neither, so they are read defensively.\n */\ntype ApprovalExtras = { isAutomatic?: unknown; requestReason?: unknown } | undefined;\n\n/** Whether a policy decided the approval rather than a person. Always false on AI SDK 6. */\nexport function isAutomaticApproval(approval: object | undefined): boolean {\n  return (approval as ApprovalExtras)?.isAutomatic === true;\n}\n\n/**\n * Denied after a person approved it, by a check on the server: AI SDK 7 checks edited arguments\n * against `toolApproval` again. That denial streams with no reason, and the part keeps the approval.\n */\nexport function isDeniedAfterApproval(part: { state: string; approval?: object | undefined }): boolean {\n  return part.state === 'output-denied' && (part.approval as { approved?: unknown } | undefined)?.approved === true;\n}\n\n/** The reason the tool gave for asking (AI SDK 7's `requestReason`), if any. */\nexport function getApprovalRequestReason(approval: object | undefined): string | undefined {\n  const reason = (approval as ApprovalExtras)?.requestReason;\n  return typeof reason === 'string' ? reason : undefined;\n}\n\nexport type { ChatStatus, UIMessage };\n\n/** Any part of any `UIMessage`. */\nexport type AnyUIPart = UIMessage['parts'][number];\n/** A static (`tool-${name}`) or dynamic tool part. */\nexport type ToolPart = ToolUIPart<UITools> | DynamicToolUIPart;\n/** The AI SDK v7 tool part states. */\nexport type ToolState = ToolPart['state'];\nexport type SourcePart = SourceUrlUIPart | SourceDocumentUIPart;\nexport type { ReasoningUIPart, SourceDocumentUIPart, SourceUrlUIPart, TextUIPart };\n\n/** Token usage, compatible with the AI SDK `LanguageModelUsage` shape. */\nexport type RunUsage = {\n  inputTokens?: number | undefined;\n  outputTokens?: number | undefined;\n  totalTokens?: number | undefined;\n  inputTokenDetails?: Partial<LanguageModelUsage['inputTokenDetails']> | undefined;\n  outputTokenDetails?: Partial<LanguageModelUsage['outputTokenDetails']> | undefined;\n};\n\nexport const TOOL_STATES = [\n  'input-streaming',\n  'input-available',\n  'approval-requested',\n  'approval-responded',\n  'output-available',\n  'output-error',\n  'output-denied',\n] as const satisfies readonly ToolState[];\n\nexport function isToolPart(part: { type: string }): part is ToolPart {\n  return part.type === 'dynamic-tool' || part.type.startsWith('tool-');\n}\n\nexport function isSourcePart(part: { type: string }): part is SourcePart {\n  return part.type === 'source-url' || part.type === 'source-document';\n}\n\n/** Same semantics as the SDK's `getToolName`, without the runtime import. */\nexport function getToolPartName(part: ToolPart): string {\n  return part.type === 'dynamic-tool' ? part.toolName : part.type.slice('tool-'.length);\n}\n\n/**\n * A UI-level phase derived from the SDK tool state. Several SDK states collapse\n * into one visual phase (e.g. an approved call that has not returned yet is \"running\").\n */\nexport type ToolPhase = 'streaming' | 'running' | 'awaiting-approval' | 'success' | 'error' | 'denied';\n\nexport function getToolPhase(\n  part: Pick<ToolPart, 'state'> & {\n    approval?: { approved?: boolean; isAutomatic?: boolean } | undefined;\n    preliminary?: boolean | undefined;\n  },\n): ToolPhase {\n  switch (part.state) {\n    case 'input-streaming':\n      return 'streaming';\n    case 'input-available':\n      return 'running';\n    case 'approval-requested':\n      // A policy decision (`isAutomatic`) streams as a request immediately followed by its response.\n      return part.approval?.isAutomatic ? 'running' : 'awaiting-approval';\n    case 'approval-responded':\n      return part.approval?.approved ? 'running' : 'denied';\n    case 'output-available':\n      return part.preliminary ? 'running' : 'success';\n    case 'output-error':\n      return 'error';\n    case 'output-denied':\n      return 'denied';\n  }\n}\n\n/** The English phase names. The timeline reads them from `labels.toolCallTimeline.phases`. */\nexport const TOOL_PHASE_LABEL: Record<ToolPhase, string> = {\n  streaming: 'Preparing',\n  running: 'Running',\n  'awaiting-approval': 'Needs approval',\n  success: 'Done',\n  error: 'Failed',\n  denied: 'Denied',\n};\n\nexport function isSettledPhase(phase: ToolPhase): boolean {\n  return phase === 'success' || phase === 'error' || phase === 'denied';\n}\n\nexport function getToolParts(parts: readonly AnyUIPart[]): ToolPart[] {\n  return parts.filter(isToolPart);\n}\n\n/** Where a sub-agent is: working, finished, failed, or paused until a later run (AG-UI's `suspended`). */\nexport type SubagentStatus = 'running' | 'done' | 'error' | 'suspended';\n\n/** A sub-agent's run, as the `data` of a `data-signoff-subagent` part. */\nexport interface SubagentData {\n  subagentRunId: string;\n  /** The tool call that started it: the timeline nests the sub-agent under that call. */\n  parentToolCallId?: string | undefined;\n  name?: string | undefined;\n  status: SubagentStatus;\n  /** What went wrong, when `status` is `error`. */\n  errorText?: string | undefined;\n  /** Its tool calls and text, as message parts. A sub-agent it started is a part here too. */\n  parts: AnyUIPart[];\n}\n\n/** A sub-agent's work, in its parent's message: the AG-UI adapter makes one, and you can stream your own. */\nexport interface SubagentPart {\n  type: 'data-signoff-subagent';\n  id?: string | undefined;\n  data: SubagentData;\n}\n\nexport function isSubagentPart(part: { type: string }): part is SubagentPart {\n  return part.type === 'data-signoff-subagent' && Array.isArray((part as SubagentPart).data?.parts);\n}\n\n/** Every tool call in `parts`, with those of its sub-agents, at any depth. */\nexport function getAllToolParts(parts: readonly AnyUIPart[]): ToolPart[] {\n  const out: ToolPart[] = [];\n  for (const part of parts) {\n    if (isToolPart(part)) out.push(part);\n    else if (isSubagentPart(part)) out.push(...getAllToolParts(part.data.parts));\n  }\n  return out;\n}\n\n/**\n * Calls that only the model or a tool can still finish: input still streaming, running, or partial\n * output. Once the run has ended, these were cut off. An approved call waiting for the app to send\n * the continuation (`approval-responded`) is not one: `useChat` sends it after a render. Once that\n * request has `failed`, it is: its result will not come.\n */\nexport function isInterruptibleToolPart(part: ToolPart, failed = false): boolean {\n  return (\n    part.state === 'input-streaming' ||\n    part.state === 'input-available' ||\n    (part.state === 'output-available' && part.preliminary === true) ||\n    (failed && part.state === 'approval-responded' && part.approval.approved === true)\n  );\n}\n\nexport type ApprovalStatus = 'pending' | 'approved' | 'denied';\n\n/** The approval state of a tool part, or `undefined` for parts outside the approval flow. */\nexport function getApprovalStatus(part: ToolPart): ApprovalStatus | undefined {\n  if (!part.approval) return undefined;\n  if (part.state === 'approval-requested') return 'pending';\n  if (part.state === 'output-denied') return 'denied';\n  return part.approval.approved ? 'approved' : part.approval.approved === false ? 'denied' : 'pending';\n}\n\nexport interface ToolTiming {\n  /** First time the call was observed (input started streaming). */\n  startedAt?: number | undefined;\n  /** When execution began: input complete, or approval granted. Unset while the call waits for approval. */\n  runningAt?: number | undefined;\n  /** When the call settled (output, error or denial). */\n  endedAt?: number | undefined;\n}\n\nexport type ToolTimings = Readonly<Record<string, ToolTiming>>;\n\n/** Pure reducer behind `useToolTimings`, exported for tests and custom stores. */\nexport function observeToolTimings(prev: ToolTimings, tools: readonly ToolPart[], now: number): ToolTimings {\n  let next: Record<string, ToolTiming> | undefined;\n  const write = (id: string, value: ToolTiming) => {\n    next ??= { ...prev };\n    next[id] = value;\n  };\n  for (const tool of tools) {\n    const phase = getToolPhase(tool);\n    const current = (next ?? prev)[tool.toolCallId];\n    if (!current) {\n      if (isSettledPhase(phase)) write(tool.toolCallId, {});\n      else write(tool.toolCallId, { startedAt: now, runningAt: phase === 'running' ? now : undefined });\n      continue;\n    }\n    if (current.startedAt === undefined || current.endedAt !== undefined) continue;\n    if (phase === 'running' && current.runningAt === undefined) write(tool.toolCallId, { ...current, runningAt: now });\n    // A call can look running before its approval request arrives (AI SDK 6 renders `input-available`\n    // first; AG-UI does between TOOL_CALL_END and the interrupt). Its run starts again once approved.\n    else if (phase === 'awaiting-approval' && current.runningAt !== undefined)\n      write(tool.toolCallId, { ...current, runningAt: undefined });\n    else if (isSettledPhase(phase))\n      write(tool.toolCallId, { ...current, runningAt: current.runningAt ?? now, endedAt: now });\n  }\n  return next ?? prev;\n}\n\n/** Collect sources, de-duplicated by URL (or source id for documents). */\nexport function getSourceParts(parts: readonly AnyUIPart[]): SourcePart[] {\n  const seen = new Set<string>();\n  const out: SourcePart[] = [];\n  for (const part of parts) {\n    if (!isSourcePart(part)) continue;\n    const key = part.type === 'source-url' ? part.url : `doc:${part.sourceId}`;\n    if (seen.has(key)) continue;\n    seen.add(key);\n    out.push(part);\n  }\n  return out;\n}\n\n/** A source in app-level shape. AI SDK `source-url` / `source-document` parts are accepted too. */\nexport interface SourceItem {\n  id: string;\n  url?: string | undefined;\n  title?: string | undefined;\n  /** Optional snippet shown in the cards variant. */\n  description?: string | undefined;\n  /** For documents. */\n  filename?: string | undefined;\n  mediaType?: string | undefined;\n}\n\nexport function toSourceItem(source: SourcePart | SourceItem): SourceItem {\n  if ('type' in source && isSourcePart(source as SourcePart)) {\n    const part = source as SourcePart;\n    return part.type === 'source-url'\n      ? { id: part.sourceId, url: part.url, title: part.title }\n      : { id: part.sourceId, title: part.title, filename: part.filename, mediaType: part.mediaType };\n  }\n  return source as SourceItem;\n}\n\nexport type AgentState = 'idle' | 'thinking' | 'working' | 'awaiting-approval' | 'done' | 'stopped' | 'error';\n\n/** The English state names. `AgentStatus` reads them from `labels.agentStatus.states`. */\nexport const AGENT_STATE_LABEL: Record<AgentState, string> = {\n  idle: 'Idle',\n  thinking: 'Thinking',\n  working: 'Working',\n  'awaiting-approval': 'Waiting for approval',\n  done: 'Done',\n  stopped: 'Stopped',\n  error: 'Error',\n};\n\nexport interface DerivedAgentState {\n  state: AgentState;\n  /** Short human detail, e.g. \"read_file\" or \"Writing response\". */\n  detail?: string | undefined;\n}\n\n/**\n * Map `useChat` status plus the latest assistant message to a single agent state.\n * Human-in-the-loop waits (approvals, client-side tools awaiting output) take\n * precedence over transport status, because the run cannot progress without the user.\n *\n * A run that ended (`ready`) with work still unfinished in the message is `stopped`, not `done`:\n * text or reasoning still streaming, a tool call still preparing, running or partial, or a\n * sub-agent still running. That is what `stop()` leaves behind, and what an AG-UI run cancelled or\n * aborted mid-way does. A client-side tool the app runs itself is the exception: name it in\n * `clientTools`.\n */\nexport function deriveAgentState({\n  status,\n  message,\n  pendingClientTools = [],\n  clientTools = [],\n  writingResponse = 'Writing response',\n}: {\n  status: ChatStatus;\n  message?: Pick<UIMessage, 'role' | 'parts'> | undefined;\n  /**\n   * Names of client-side tools whose `input-available` state means \"waiting for the user\". Pass the\n   * same names to `AgentMessage`, so their calls show no duration: the time is the person's.\n   */\n  pendingClientTools?: readonly string[];\n  /**\n   * Names of client-side tools the app runs itself (`onToolCall`, then `addToolOutput`). `useChat`\n   * is `ready` while one runs: its `input-available` call then reads `working` with the tool's name\n   * as the detail, not `stopped`, until the output lands and the run continues.\n   */\n  clientTools?: readonly string[];\n  /** The detail while the model writes its answer. Default \"Writing response\": pass `labels.agentStatus.writingResponse`. */\n  writingResponse?: string;\n}): DerivedAgentState {\n  if (status === 'error') return { state: 'error' };\n  const parts = message?.role === 'assistant' ? message.parts : [];\n  // A sub-agent's calls count as the message's own: one waiting on approval holds up the run.\n  const tools = getAllToolParts(parts);\n  const approval = tools.find((t) => t.state === 'approval-requested' && !isAutomaticApproval(t.approval));\n  if (approval) return { state: 'awaiting-approval', detail: getToolPartName(approval) };\n  const clientWait = tools.find(\n    (t) => t.state === 'input-available' && pendingClientTools.includes(getToolPartName(t)),\n  );\n  if (clientWait && status !== 'submitted') return { state: 'awaiting-approval', detail: getToolPartName(clientWait) };\n  if (status === 'submitted') return { state: 'thinking' };\n  if (status === 'streaming') {\n    let last = parts.at(-1);\n    if (!last || last.type === 'step-start' || last.type === 'reasoning') return { state: 'thinking' };\n    // A settled call (done, failed or denied) or a sub-agent that has finished is not work: the model\n    // is answering it. Parallel calls settle in any order, so a call or sub-agent of this step still\n    // running before it is. Calls of earlier steps had all settled when this one started.\n    if (isSettledWork(last)) {\n      const step = parts.slice(parts.findLastIndex((p) => p.type === 'step-start') + 1);\n      last = step.findLast((p) => (isToolPart(p) || isSubagentPart(p)) && !isSettledWork(p));\n      if (!last) return { state: 'thinking' };\n    }\n    if (isToolPart(last)) return { state: 'working', detail: getToolPartName(last) };\n    if (isSubagentPart(last)) {\n      // Its call still going, or the sub-agent itself while it thinks between calls.\n      const call = getAllToolParts([last]).findLast((t) => !isSettledPhase(getToolPhase(t)));\n      return { state: 'working', detail: call ? getToolPartName(call) : last.data.name };\n    }\n    if (last.type === 'text') return { state: 'working', detail: writingResponse };\n    return { state: 'working' };\n  }\n  if (status === 'ready') {\n    const running = tools.find((t) => t.state === 'input-available' && clientTools.includes(getToolPartName(t)));\n    if (running) return { state: 'working', detail: getToolPartName(running) };\n    if (parts.some(isUnfinishedPart)) return { state: 'stopped' };\n  }\n  return parts.length > 0 ? { state: 'done' } : { state: 'idle' };\n}\n\n/** A call that has settled (done, failed or denied), or a sub-agent no longer running. */\nfunction isSettledWork(part: AnyUIPart): boolean {\n  if (isToolPart(part)) return isSettledPhase(getToolPhase(part));\n  return isSubagentPart(part) && part.data.status !== 'running';\n}\n\n/**\n * A part the run left unfinished: text or reasoning still streaming, an interrupted tool call, or a\n * sub-agent still running (the run ended between its steps, with its calls settled).\n */\nfunction isUnfinishedPart(part: AnyUIPart): boolean {\n  if (part.type === 'text' || part.type === 'reasoning') return part.state === 'streaming';\n  if (isSubagentPart(part)) return part.data.status === 'running' || part.data.parts.some(isUnfinishedPart);\n  return isToolPart(part) && isInterruptibleToolPart(part);\n}\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/ai.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/diff-async.ts",
      "content": "import { parseFileChange, type FileChange, type ParsedFileDiff, type ParseFileChangeOptions } from './diff';\nimport type { DiffWorkerRequest, DiffWorkerResponse } from './diff-worker';\n\n/** Starts a worker that answers `DiffReview`'s diff requests: see lib/diff-worker.ts. */\nexport type DiffWorkerFactory = () => Worker;\n\n/**\n * The package's own worker. Webpack 5 (and so Next.js, with webpack or Turbopack), Vite and Parcel\n * see this `new URL(…, import.meta.url)` and emit the worker with its imports. Where nothing does,\n * the request for it fails and files are diffed on the main thread instead.\n */\nexport const defaultDiffWorker: DiffWorkerFactory = () =>\n  new Worker(new URL('./diff-worker.ts', import.meta.url), { type: 'module' });\n\ninterface Pool {\n  worker: Worker;\n  /** Pending requests by id; `undefined` means the worker failed and the file is diffed here. */\n  jobs: Map<number, (response: DiffWorkerResponse | undefined) => void>;\n  idle?: ReturnType<typeof setTimeout> | undefined;\n}\n\n/** A worker with nothing to do for this long is stopped; the next large file starts another. */\nconst IDLE_MS = 30_000;\n\n/** One worker per factory, started on the first large file, or `null` once it has failed. */\nconst pools = new Map<DiffWorkerFactory, Pool | null>();\nlet nextId = 0;\n\nfunction poolFor(factory: DiffWorkerFactory): Pool | null {\n  const existing = pools.get(factory);\n  if (existing !== undefined) return existing;\n  let pool: Pool | null = null;\n  if (typeof Worker !== 'undefined') {\n    try {\n      const worker = factory();\n      const started: Pool = { worker, jobs: new Map() };\n      // The script did not load (no bundler emitted it, a content security policy blocks it) or\n      // the worker crashed: stop using it, and diff what it was given on the main thread.\n      const fail = () => {\n        pools.set(factory, null);\n        worker.terminate();\n        const waiting = [...started.jobs.values()];\n        started.jobs.clear();\n        for (const done of waiting) done(undefined);\n      };\n      worker.addEventListener('message', (event: MessageEvent<DiffWorkerResponse>) => {\n        const done = started.jobs.get(event.data.id);\n        started.jobs.delete(event.data.id);\n        done?.(event.data);\n        if (started.jobs.size === 0) {\n          clearTimeout(started.idle);\n          started.idle = setTimeout(() => {\n            if (started.jobs.size > 0 || pools.get(factory) !== started) return;\n            pools.delete(factory);\n            worker.terminate();\n          }, IDLE_MS);\n        }\n      });\n      worker.addEventListener('error', fail);\n      worker.addEventListener('messageerror', fail);\n      pool = started;\n    } catch {\n      pool = null;\n    }\n  }\n  pools.set(factory, pool);\n  return pool;\n}\n\n/** After the browser has painted, so the review shows its files before the main thread is busy. */\nfunction onMainThread(change: FileChange, options: ParseFileChangeOptions): Promise<ParsedFileDiff> {\n  return new Promise((resolve, reject) => {\n    setTimeout(() => {\n      try {\n        resolve(parseFileChange(change, options));\n      } catch (error) {\n        reject(error);\n      }\n    }, 0);\n  });\n}\n\n/**\n * `parseFileChange` off the main thread: in the worker `factory` starts, or with `false` (or no\n * `Worker`, or a worker that fails) on the main thread after the next paint.\n */\nexport function parseInBackground(\n  change: FileChange,\n  options: ParseFileChangeOptions,\n  factory: DiffWorkerFactory | false = defaultDiffWorker,\n): Promise<ParsedFileDiff> {\n  const pool = factory ? poolFor(factory) : null;\n  if (!pool) return onMainThread(change, options);\n  // Only the fields the diff reads: anything else on the object might not survive a structured clone.\n  const { path, oldPath, oldContent, newContent, patch, language } = change;\n  const request: DiffWorkerRequest = {\n    id: ++nextId,\n    change: { path, oldPath, oldContent, newContent, patch, language },\n    options,\n  };\n  clearTimeout(pool.idle);\n  return new Promise((resolve, reject) => {\n    pool.jobs.set(request.id, (response) => {\n      if (response && 'parsed' in response) resolve(response.parsed);\n      else onMainThread(change, options).then(resolve, reject);\n    });\n    try {\n      pool.worker.postMessage(request);\n    } catch {\n      pool.jobs.delete(request.id);\n      onMainThread(change, options).then(resolve, reject);\n    }\n  });\n}\n\n/** Forget started workers, for tests. @internal */\nexport function resetDiffWorkers() {\n  for (const pool of pools.values()) {\n    clearTimeout(pool?.idle);\n    pool?.worker.terminate();\n  }\n  pools.clear();\n}\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/diff-async.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/diff-worker.ts",
      "content": "/**\n * The diff worker. `DiffReview` diffs a small file while it renders and sends a larger one here, so\n * the page keeps responding while it is compared. It is started from\n * `new URL('./diff-worker.ts', import.meta.url)`, which bundlers with worker support emit as a\n * worker of its own (lib/diff-async.ts).\n */\nimport { parseFileChange, type FileChange, type ParsedFileDiff, type ParseFileChangeOptions } from './diff';\n\nexport interface DiffWorkerRequest {\n  id: number;\n  change: FileChange;\n  options: ParseFileChangeOptions;\n}\n\nexport type DiffWorkerResponse = { id: number; parsed: ParsedFileDiff } | { id: number; error: string };\n\n/** What the worker answers a request with. */\nexport function answerDiffRequest(request: DiffWorkerRequest): DiffWorkerResponse {\n  try {\n    return { id: request.id, parsed: parseFileChange(request.change, request.options) };\n  } catch (error) {\n    return { id: request.id, error: error instanceof Error ? error.message : String(error) };\n  }\n}\n\ninterface WorkerScope {\n  onmessage: ((event: MessageEvent<DiffWorkerRequest>) => void) | null;\n  postMessage(message: DiffWorkerResponse): void;\n}\n\n// Answer requests when running as a worker; imported anywhere else, as in tests, do nothing.\nconst scope = globalThis as unknown as WorkerScope & { WorkerGlobalScope?: new () => unknown };\nif (typeof scope.WorkerGlobalScope === 'function' && scope instanceof scope.WorkerGlobalScope) {\n  scope.onmessage = (event) => scope.postMessage(answerDiffRequest(event.data));\n}\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/diff-worker.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/diff.ts",
      "content": "import { diffWordsWithSpace, parsePatch, structuredPatch, type StructuredPatch, type StructuredPatchHunk } from 'diff';\n\n/** A file edit proposed by an agent. Provide either full contents or a unified patch. */\nexport interface FileChange {\n  /**\n   * The file's path, e.g. `app/api/chat/route.ts`: the review shows it and the result returns it.\n   * Its extension picks the highlighting, unless `language` is given.\n   */\n  path: string;\n  /** Previous path, for renames. Read from a git rename `patch` when omitted. */\n  oldPath?: string | undefined;\n  /** Original file contents. Omit (or pass '') for new files. */\n  oldContent?: string | undefined;\n  /** Proposed file contents. Omit (or pass '') for deletions. */\n  newContent?: string | undefined;\n  /** A unified diff for this file, used when contents are not available. */\n  patch?: string | undefined;\n  /** Language id for highlighting, inferred from the extension when omitted. */\n  language?: string | undefined;\n  /**\n   * A binary file: shown as \"Binary file, not shown\" and decided as a whole. Detected when omitted:\n   * contents with a NUL character in their first 8,000, or a patch that says `Binary files … differ`.\n   */\n  binary?: boolean | undefined;\n}\n\nexport type DiffLineType = 'context' | 'add' | 'del';\n\nexport interface DiffSegment {\n  text: string;\n  /** True when this segment is the part of the line that changed. */\n  changed: boolean;\n}\n\nexport interface DiffLine {\n  type: DiffLineType;\n  content: string;\n  oldNumber?: number | undefined;\n  newNumber?: number | undefined;\n  /** Word-level segments, present on add/del lines that pair with a counterpart. */\n  segments?: DiffSegment[] | undefined;\n  /** The last line of its file, with no newline after it (jsdiff's \"No newline at end of file\"). */\n  noNewline?: true | undefined;\n}\n\nexport interface DiffHunk {\n  /** Stable id: `${fileId}:${index}`, e.g. `app/route.ts:0`. */\n  id: string;\n  index: number;\n  oldStart: number;\n  oldLines: number;\n  newStart: number;\n  newLines: number;\n  header: string;\n  lines: DiffLine[];\n  additions: number;\n  deletions: number;\n}\n\nexport type FileStatus = 'added' | 'deleted' | 'modified' | 'renamed';\n\nexport interface ParsedFileDiff {\n  /** The path, unless another id was given (`DiffReview` suffixes repeated paths: `a.ts#2`). */\n  id: string;\n  path: string;\n  oldPath?: string | undefined;\n  status: FileStatus;\n  language: string;\n  hunks: DiffHunk[];\n  additions: number;\n  deletions: number;\n  /** Original contents, when known. Needed to compute the reviewed result. */\n  oldContent?: string | undefined;\n  newContent?: string | undefined;\n  /**\n   * A binary file, or one whose contents or patch could not be read (not strings, or a patch with\n   * hunk counts that don't match its lines): no hunks, decided as a whole.\n   */\n  binary?: true | undefined;\n  /**\n   * `'replace'` when the diff went over `maxEditLength`: the changed region, from the first\n   * changed line to the last, is one hunk that replaces it, with no word-level highlights.\n   */\n  fallback?: 'replace' | undefined;\n}\n\nconst EXT_LANG: Record<string, string> = {\n  ts: 'ts',\n  tsx: 'tsx',\n  mts: 'ts',\n  cts: 'ts',\n  js: 'js',\n  jsx: 'jsx',\n  mjs: 'js',\n  cjs: 'js',\n  json: 'json',\n  css: 'css',\n  md: 'md',\n  mdx: 'md',\n  sh: 'sh',\n  bash: 'sh',\n  yml: 'yaml',\n  yaml: 'yaml',\n  py: 'py',\n};\n\nexport function inferLanguage(path: string): string {\n  const ext = path.split('.').pop()?.toLowerCase() ?? '';\n  return EXT_LANG[ext] ?? 'text';\n}\n\nfunction toDiffLines(hunk: StructuredPatchHunk, words: boolean): DiffLine[] {\n  const lines: DiffLine[] = [];\n  let oldNo = hunk.oldLines === 0 ? hunk.oldStart + 1 : hunk.oldStart;\n  let newNo = hunk.newLines === 0 ? hunk.newStart + 1 : hunk.newStart;\n  for (const raw of hunk.lines) {\n    const marker = raw[0];\n    const content = raw.slice(1);\n    if (marker === '\\\\') {\n      // \"\\ No newline at end of file\": the line before it ends its file.\n      const last = lines.at(-1);\n      if (last) last.noNewline = true;\n      continue;\n    }\n    if (marker === '+') lines.push({ type: 'add', content, newNumber: newNo++ });\n    else if (marker === '-') lines.push({ type: 'del', content, oldNumber: oldNo++ });\n    else lines.push({ type: 'context', content, oldNumber: oldNo++, newNumber: newNo++ });\n  }\n  if (words) pairWordSegments(lines);\n  return lines;\n}\n\n/**\n * jsdiff reads the clock on every call, for a `timeout` option the kit does not use (its limits are\n * deterministic). Reading the clock makes a render impure, and Next.js `cacheComponents` fails the\n * build on it, in Server and Client Components alike. Without a timeout the clock decides nothing,\n * so jsdiff gets a constant one for the length of its synchronous call.\n */\nfunction withConstantClock<T>(fn: () => T): T {\n  const now = Date.now;\n  Date.now = () => 0;\n  try {\n    return fn();\n  } finally {\n    Date.now = now;\n  }\n}\n\n/*\n * Word-level diffing is quadratic in the worst case: two rewritten 46k-character lines took 13 s.\n * Lines past these limits get no word highlights. Both are deterministic (unlike a timeout), so the\n * server and the client always agree. A line edited in more than 100 places is mostly new anyway.\n */\nconst WORD_DIFF_MAX_LINE = 10_000;\nconst WORD_DIFF_MAX_EDITS = 100;\n\n/**\n * For each run of deletions immediately followed by the same number of additions,\n * compute word-level segments so the UI can highlight what changed inside a line.\n */\nfunction pairWordSegments(lines: DiffLine[]) {\n  let i = 0;\n  while (i < lines.length) {\n    if (lines[i]?.type !== 'del') {\n      i++;\n      continue;\n    }\n    let delEnd = i;\n    while (lines[delEnd]?.type === 'del') delEnd++;\n    let addEnd = delEnd;\n    while (lines[addEnd]?.type === 'add') addEnd++;\n    const dels = delEnd - i;\n    const adds = addEnd - delEnd;\n    if (dels > 0 && dels === adds) {\n      for (let k = 0; k < dels; k++) {\n        const del = lines[i + k]!;\n        const add = lines[delEnd + k]!;\n        if (Math.max(del.content.length, add.content.length) > WORD_DIFF_MAX_LINE) continue;\n        const changes = withConstantClock(() =>\n          diffWordsWithSpace(del.content, add.content, { maxEditLength: WORD_DIFF_MAX_EDITS }),\n        );\n        if (!changes) continue;\n        const unchanged = changes.filter((c) => !c.added && !c.removed).reduce((n, c) => n + c.value.length, 0);\n        // Only highlight when the lines are actually similar; otherwise it is just noise.\n        if (unchanged / Math.max(del.content.length, add.content.length, 1) < 0.4) continue;\n        del.segments = changes.filter((c) => !c.added).map((c) => ({ text: c.value, changed: !!c.removed }));\n        add.segments = changes.filter((c) => !c.removed).map((c) => ({ text: c.value, changed: !!c.added }));\n      }\n    }\n    i = Math.max(addEnd, i + 1);\n  }\n}\n\nfunction hunkHeader(h: StructuredPatchHunk) {\n  return `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`;\n}\n\n/**\n * The most lines added plus lines removed a file's diff may take before it is shown as one hunk\n * that replaces the changed region. Diffing costs about the square of this number: 2,000 is about\n * 0.2 s on a laptop, in a worker for large files, while 10,000 (a 5,000-line rewrite) was 2.7 s.\n */\nexport const DEFAULT_MAX_EDIT_LENGTH = 2_000;\n\nexport interface ParseFileChangeOptions {\n  /** Lines of context around each change. Default 3. */\n  context?: number | undefined;\n  /** Id for the file and prefix of its hunk ids. Default: the path. */\n  id?: string | undefined;\n  /**\n   * Past this many lines added plus lines removed, the changed region (from the first changed line\n   * to the last) becomes one hunk that replaces it, and `fallback` is `'replace'`. Default 2,000.\n   */\n  maxEditLength?: number | undefined;\n}\n\ninterface Lines {\n  lines: string[];\n  /** Whether the last line ends with a newline. */\n  eol: boolean;\n}\n\n/** Whether line `i` of `a` equals line `j` of `b`, its line ending included, as jsdiff compares lines. */\nfunction sameLine(a: Lines, i: number, b: Lines, j: number) {\n  if (a.lines[i] !== b.lines[j]) return false;\n  const aEnds = i < a.lines.length - 1 || a.eol;\n  const bEnds = j < b.lines.length - 1 || b.eol;\n  return aEnds === bEnds;\n}\n\nfunction joinLines(source: Lines, from: number, to: number) {\n  if (to <= from) return '';\n  const end = to === source.lines.length ? (source.eol ? '\\n' : '') : '\\n';\n  return source.lines.slice(from, to).join('\\n') + end;\n}\n\ntype ContentDiff = { hunks: StructuredPatchHunk[]; fallback?: 'replace' | undefined };\n\n/**\n * Hunks between two contents. The common first and last lines are set aside first (bar the context\n * the hunks show), so a large file with a small edit diffs only the region that changed. Past\n * `maxEditLength` the region becomes one replacing hunk; past `budget`, smaller than that, the\n * result is `undefined`, for the caller to finish elsewhere (DiffReview: in a worker).\n */\nfunction diffContents(\n  oldContent: string,\n  newContent: string,\n  context: number,\n  maxEditLength: number,\n  budget: number,\n): ContentDiff | undefined {\n  const a = splitLines(oldContent);\n  const b = splitLines(newContent);\n  let prefix = 0;\n  const shortest = Math.min(a.lines.length, b.lines.length);\n  while (prefix < shortest && sameLine(a, prefix, b, prefix)) prefix++;\n  let suffix = 0;\n  while (suffix < shortest - prefix && sameLine(a, a.lines.length - 1 - suffix, b, b.lines.length - 1 - suffix))\n    suffix++;\n  const oldMiddle = a.lines.length - prefix - suffix;\n  const newMiddle = b.lines.length - prefix - suffix;\n  if (oldMiddle === 0 && newMiddle === 0) return { hunks: [] };\n\n  // Keep the context the hunks show; everything before or after it is the same on both sides.\n  const before = Math.min(context, prefix);\n  const after = Math.min(context, suffix);\n  const from = prefix - before;\n  const oldTo = a.lines.length - suffix + after;\n  const newTo = b.lines.length - suffix + after;\n  // Only inserted or only removed lines: one cheap pass, whatever their number.\n  const oneSided = oldMiddle === 0 || newMiddle === 0;\n  const limit = Math.min(maxEditLength, budget);\n  const oldText = joinLines(a, from, oldTo);\n  const newText = joinLines(b, from, newTo);\n  const patch = withConstantClock(() =>\n    oneSided\n      ? structuredPatch('', '', oldText, newText, undefined, undefined, { context })\n      : structuredPatch('', '', oldText, newText, undefined, undefined, { context, maxEditLength: limit }),\n  );\n  if (patch) {\n    for (const hunk of patch.hunks) {\n      hunk.oldStart += from;\n      hunk.newStart += from;\n    }\n    return { hunks: patch.hunks };\n  }\n  if (budget < maxEditLength) return undefined;\n\n  // Over the limit: the changed region, replaced. A side's last line gets jsdiff's marker when its\n  // file ends without a newline, so the hunk reads as structuredPatch's would.\n  const NO_NEWLINE = '\\\\ No newline at end of file';\n  const run = (source: Lines, start: number, end: number, sign: string) => {\n    const out = source.lines.slice(start, end).map((line) => sign + line);\n    if (end === source.lines.length && end > start && !source.eol) out.push(NO_NEWLINE);\n    return out;\n  };\n  const lines = [\n    ...run(a, from, prefix, ' '),\n    ...run(a, prefix, a.lines.length - suffix, '-'),\n    ...run(b, prefix, b.lines.length - suffix, '+'),\n    ...run(a, a.lines.length - suffix, oldTo, ' '),\n  ];\n  const hunk: StructuredPatchHunk = {\n    oldStart: from + 1,\n    oldLines: oldTo - from,\n    newStart: from + 1,\n    newLines: newTo - from,\n    lines,\n  };\n  return { hunks: [hunk], fallback: 'replace' };\n}\n\n/**\n * Turn a `FileChange` into hunks with line numbers and word-level segments. Contents are diffed;\n * past `maxEditLength` the changed region is one replacing hunk (`fallback: 'replace'`).\n */\nexport function parseFileChange(change: FileChange, options: ParseFileChangeOptions = {}): ParsedFileDiff {\n  return parseWithin(change, options, Infinity)!;\n}\n\n/**\n * `parseFileChange`, unless diffing the contents would take more than `budget` edits: then\n * `undefined`. Deterministic, so a server render and the hydrating client agree on which files\n * render at once and which wait for the worker.\n * @internal\n */\nexport function parseWithin(\n  change: FileChange,\n  options: ParseFileChangeOptions,\n  budget: number,\n): ParsedFileDiff | undefined {\n  // Model-written input can break the types or the patch format. A missing path reads as ''.\n  // Contents that are not strings, or a patch that jsdiff refuses (hunk counts that don't match its\n  // lines, a line that isn't +, - or space) or that is not a string, make the file unreadable: it\n  // shows as a binary file does, without contents, and is decided as a whole, rather than failing\n  // the render and every other file with it.\n  const path = String(change.path ?? '');\n  const id = options.id ?? path;\n  const context = options.context ?? 3;\n  let oldContent = change.oldContent ?? (change.patch ? undefined : '');\n  let newContent = change.newContent ?? (change.patch ? undefined : '');\n  const patched =\n    change.patch && (oldContent === undefined || newContent === undefined) ? readPatch(change.patch) : undefined;\n  const unreadable = patched === null || !isTextOrMissing(oldContent) || !isTextOrMissing(newContent);\n  if (unreadable) oldContent = newContent = undefined;\n  const binary =\n    unreadable ||\n    (change.binary ??\n      (isBinaryText(oldContent) ||\n        isBinaryText(newContent) ||\n        !!patched?.isBinary ||\n        (!!change.patch && /^GIT binary patch$/m.test(change.patch))));\n  let hunks: StructuredPatchHunk[];\n  let fallback: ContentDiff['fallback'];\n  if (binary) {\n    hunks = [];\n  } else if (oldContent !== undefined && newContent !== undefined) {\n    const diffed = diffContents(\n      oldContent,\n      newContent,\n      context,\n      options.maxEditLength ?? DEFAULT_MAX_EDIT_LENGTH,\n      budget,\n    );\n    if (!diffed) return undefined;\n    hunks = diffed.hunks;\n    fallback = diffed.fallback;\n  } else {\n    hunks = patched?.hunks ?? [];\n  }\n\n  const parsed: DiffHunk[] = hunks.map((h, index) => {\n    const lines = toDiffLines(h, !fallback);\n    return {\n      id: `${id}:${index}`,\n      index,\n      oldStart: h.oldStart,\n      oldLines: h.oldLines,\n      newStart: h.newStart,\n      newLines: h.newLines,\n      header: hunkHeader(h),\n      lines,\n      additions: lines.filter((l) => l.type === 'add').length,\n      deletions: lines.filter((l) => l.type === 'del').length,\n    };\n  });\n\n  // A git rename patch (`git diff` finds renames by default) names the old path.\n  const oldPath = change.oldPath ?? (patched?.isRename ? renamedFrom(change.patch!, patched) : undefined);\n  const renamed = !!oldPath && oldPath !== path;\n  let status: FileStatus = 'modified';\n  if (renamed) status = 'renamed';\n  else if (change.oldContent === '' || (change.oldContent === undefined && !change.patch)) status = 'added';\n  else if (change.newContent === '' || (change.newContent === undefined && !change.patch)) status = 'deleted';\n  if (change.patch && change.oldContent === undefined && change.newContent === undefined) {\n    status =\n      patched?.isCreate || /^--- \\/dev\\/null/m.test(change.patch)\n        ? 'added'\n        : patched?.isDelete || /^\\+\\+\\+ \\/dev\\/null/m.test(change.patch)\n          ? 'deleted'\n          : renamed || patched?.isRename || /^rename from /m.test(change.patch)\n            ? 'renamed'\n            : 'modified';\n  }\n\n  return {\n    id,\n    path,\n    oldPath,\n    status,\n    language: change.language ?? inferLanguage(path),\n    hunks: parsed,\n    additions: parsed.reduce((n, h) => n + h.additions, 0),\n    deletions: parsed.reduce((n, h) => n + h.deletions, 0),\n    oldContent,\n    newContent,\n    ...(fallback ? { fallback } : {}),\n    ...(binary ? { binary: true as const } : {}),\n  };\n}\n\n/** The patch's first file, or `null` when jsdiff cannot read it (or it is not a string). */\nfunction readPatch(patch: string): StructuredPatch | undefined | null {\n  try {\n    return parsePatch(patch)[0];\n  } catch {\n    return null;\n  }\n}\n\n/**\n * The old path of a git rename patch, from its `rename from` line, which has no `a/` prefix (or\n * `i/`, or none, as git may be set to write on the `---` line). A quoted one is read by jsdiff.\n */\nfunction renamedFrom(patch: string, parsed: StructuredPatch) {\n  const from = /^rename from (.+?)\\r?$/m.exec(patch)?.[1];\n  return from && !from.startsWith('\"') ? from : parsed.oldFileName?.replace(/^a\\//, '');\n}\n\n/** Contents as the types allow them: a string, or missing (`null` too, from JSON). */\nfunction isTextOrMissing(value: unknown) {\n  return value == null || typeof value === 'string';\n}\n\n/** Git's test: a NUL character in the first 8,000 characters. */\nfunction isBinaryText(text: string | undefined) {\n  return !!text && text.slice(0, 8_000).includes('\\0');\n}\n\nfunction splitLines(text: string): Lines {\n  if (text === '') return { lines: [], eol: false };\n  const eol = text.endsWith('\\n');\n  const lines = (eol ? text.slice(0, -1) : text).split('\\n');\n  return { lines, eol };\n}\n\n/**\n * Apply only the accepted hunks to the original contents.\n * Accepting every hunk yields `newContent`; rejecting every hunk yields `oldContent`.\n */\nexport function applyHunks(\n  file: Pick<ParsedFileDiff, 'hunks' | 'oldContent' | 'newContent'>,\n  accepted: ReadonlySet<string> | readonly string[],\n): string {\n  const acceptedSet = accepted instanceof Set ? accepted : new Set(accepted as readonly string[]);\n  const old = splitLines(file.oldContent ?? '');\n  const next = splitLines(file.newContent ?? '');\n  const out: string[] = [];\n  let cursor = 0;\n  let eol = old.eol;\n  const hunks = [...file.hunks].sort((a, b) => a.oldStart - b.oldStart);\n  for (const hunk of hunks) {\n    // jsdiff (structuredPatch and parsePatch alike) reports a pure insertion's `oldStart` as the\n    // 1-based line it goes before, so every hunk starts at index `oldStart - 1`.\n    const start = Math.max(0, hunk.oldStart - 1);\n    out.push(...old.lines.slice(cursor, start));\n    const take = acceptedSet.has(hunk.id);\n    for (const line of hunk.lines) {\n      if (line.type === 'context' || (take ? line.type === 'add' : line.type === 'del')) out.push(line.content);\n    }\n    cursor = start + hunk.oldLines;\n    if (cursor >= old.lines.length && take) eol = next.eol;\n  }\n  out.push(...old.lines.slice(cursor));\n  if (out.length === 0) return '';\n  return out.join('\\n') + (eol ? '\\n' : '');\n}\n\nexport type HunkDecision = 'pending' | 'accepted' | 'rejected';\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/diff.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/format.ts",
      "content": "/** Formatting helpers shared by the components. All output is locale-stable. */\n\n/*\n * Each unit is chosen by the value as it will be printed, not the raw value: otherwise rounding\n * produces \"1000ms\", \"60.0s\" or \"1000.0k\" instead of moving up to the next unit.\n */\n\nexport function formatDuration(ms: number | undefined | null): string {\n  if (ms == null || !Number.isFinite(ms) || ms < 0) return '–';\n  if (Math.round(ms) < 1000) return `${Math.round(ms)}ms`;\n  const centis = (ms / 1000).toFixed(2);\n  if (Number(centis) < 10) return `${centis}s`;\n  const tenths = (ms / 1000).toFixed(1);\n  if (Number(tenths) < 60) return `${tenths}s`;\n  const seconds = Math.round(ms / 1000);\n  return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;\n}\n\n/** Spoken form for screen readers, e.g. \"1.2 seconds\". */\nexport function formatDurationLong(ms: number | undefined | null): string {\n  if (ms == null || !Number.isFinite(ms) || ms < 0) return 'unknown duration';\n  if (Math.round(ms) < 1000) return `${Math.round(ms)} milliseconds`;\n  const tenths = (ms / 1000).toFixed(1);\n  if (Number(tenths) < 60) return `${tenths} seconds`;\n  const total = Math.round(ms / 1000);\n  const minutes = Math.floor(total / 60);\n  const rest = total % 60;\n  return `${minutes} minute${minutes === 1 ? '' : 's'} ${rest} second${rest === 1 ? '' : 's'}`;\n}\n\nexport function formatTokens(n: number | undefined | null): string {\n  if (n == null || !Number.isFinite(n)) return '–';\n  if (Math.abs(Math.round(n)) < 1000) return String(Math.round(n));\n  const hundredths = (n / 1000).toFixed(2);\n  if (Math.abs(Number(hundredths)) < 10) return `${hundredths}k`;\n  const tenths = (n / 1000).toFixed(1);\n  if (Math.abs(Number(tenths)) < 1000) return `${tenths}k`;\n  return `${(n / 1_000_000).toFixed(2)}M`;\n}\n\nexport function formatCost(usd: number | undefined | null): string {\n  if (usd == null || !Number.isFinite(usd)) return '–';\n  if (usd === 0) return '$0.00';\n  const four = usd.toFixed(4);\n  if (Math.abs(Number(four)) < 0.01) return `$${four}`;\n  const three = usd.toFixed(3);\n  if (Math.abs(Number(three)) < 1) return `$${three}`;\n  return `$${usd.toFixed(2)}`;\n}\n\n/** `read_file` / `readFile` / `read-file` → \"Read file\". */\nexport function humanizeToolName(name: string): string {\n  const spaced = name\n    .replace(/[_-]+/g, ' ')\n    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n    .trim()\n    .toLowerCase();\n  return spaced.charAt(0).toUpperCase() + spaced.slice(1);\n}\n\nexport function getHostname(url: string): string {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, '');\n  } catch {\n    return url;\n  }\n}\n\n/** Compact one-line preview of an arbitrary tool input. */\nexport function summarizeValue(value: unknown, max = 72): string | undefined {\n  if (value == null) return undefined;\n  let text: string;\n  if (typeof value === 'string') text = value;\n  else if (typeof value === 'number' || typeof value === 'boolean') text = String(value);\n  else if (typeof value === 'object') {\n    const entries = Object.entries(value as Record<string, unknown>);\n    if (entries.length === 0) return undefined;\n    // Prefer the first string-ish field: it is usually the most descriptive (query, path, command).\n    const primary = entries.find(([, v]) => typeof v === 'string');\n    text = primary\n      ? String(primary[1])\n      : entries.map(([k, v]) => `${k}: ${typeof v === 'object' ? '…' : String(v)}`).join(', ');\n  } else return undefined;\n  text = text.replace(/\\s+/g, ' ').trim();\n  return text.length > max ? `${text.slice(0, max - 1)}…` : text;\n}\n\n/**\n * A value as JSON, for showing it: a bigint as `10n`, and an object inside itself as `[Circular]`.\n * Only a cycle is: an object that comes twice, side by side (`{ a: env, b: env }`), is written out\n * each time, as the call that receives it reads it.\n */\nexport function safeStringify(value: unknown, space = 2): string {\n  if (value === undefined) return 'undefined';\n  if (typeof value === 'string') return JSON.stringify(value);\n  // The objects from the root down to the one being written, which only a cycle comes back to.\n  const path: object[] = [];\n  try {\n    return (\n      JSON.stringify(\n        value,\n        function (this: unknown, _key, v: unknown) {\n          if (typeof v === 'bigint') return `${v.toString()}n`;\n          if (typeof v === 'object' && v !== null) {\n            // `this` holds `v`: what is below it on the path was written, and is not around `v`.\n            while (path.length > 0 && path.at(-1) !== this) path.pop();\n            if (path.includes(v)) return '[Circular]';\n            path.push(v);\n          }\n          return v;\n        },\n        space,\n      ) ?? String(value)\n    );\n  } catch {\n    return String(value);\n  }\n}\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/format.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/highlight.ts",
      "content": "/**\n * A deliberately small, line-oriented syntax tokenizer. It covers the languages\n * agents edit most (TS/JS/JSON/CSS/shell/YAML/Python) well enough for review UI\n * without shipping a grammar engine. Multi-line constructs are tokenized per line.\n */\nexport type TokenKind = 'plain' | 'keyword' | 'string' | 'number' | 'comment' | 'punctuation' | 'property';\n\nexport interface Token {\n  text: string;\n  kind: TokenKind;\n}\n\nconst JS_KEYWORDS = new Set(\n  (\n    'abstract as async await break case catch class const continue debugger declare default delete do else enum export extends ' +\n    'false finally for from function get if implements import in instanceof interface let new null of private protected public ' +\n    'readonly return satisfies set static super switch this throw true try type typeof undefined var void while with yield'\n  ).split(' '),\n);\nconst PY_KEYWORDS = new Set(\n  'and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield'.split(\n    ' ',\n  ),\n);\nconst SH_KEYWORDS = new Set(\n  'if then else elif fi for in do done while case esac function export local return'.split(' '),\n);\nconst LITERALS = new Set(['true', 'false', 'null']);\n\nconst HASH_COMMENT = new Set(['sh', 'py', 'yaml']);\n\nconst RULES: Array<[RegExp, TokenKind | 'word']> = [\n  [/^\\/\\/.*/, 'comment'],\n  [/^\\/\\*.*?(\\*\\/|$)/, 'comment'],\n  [/^\"(?:[^\"\\\\]|\\\\.)*\"?/, 'string'],\n  [/^'(?:[^'\\\\]|\\\\.)*'?/, 'string'],\n  [/^`(?:[^`\\\\]|\\\\.)*`?/, 'string'],\n  [/^-?(?:0x[\\da-f]+|\\d[\\d_]*(?:\\.\\d+)?(?:e[+-]?\\d+)?)\\b/i, 'number'],\n  [/^[A-Za-z_$][\\w$-]*/, 'word'],\n  [/^\\s+/, 'plain'],\n  [/^[{}()[\\];,.<>/=+\\-*!?:&|%^~@#]/, 'punctuation'],\n];\n\nexport function tokenizeLine(line: string, language = 'text'): Token[] {\n  if (language === 'text' || language === 'md' || !line) return [{ text: line, kind: 'plain' }];\n  const tokens: Token[] = [];\n  const keywords = language === 'py' ? PY_KEYWORDS : language === 'sh' ? SH_KEYWORDS : JS_KEYWORDS;\n  let rest = line;\n  while (rest.length > 0) {\n    if (HASH_COMMENT.has(language) && rest.startsWith('#')) {\n      tokens.push({ text: rest, kind: 'comment' });\n      break;\n    }\n    let matched = false;\n    for (const [re, kind] of RULES) {\n      if (\n        (kind === 'comment' && HASH_COMMENT.has(language)) ||\n        (language === 'css' && kind === 'comment' && re.source.startsWith('^\\\\/\\\\/'))\n      )\n        continue;\n      const m = re.exec(rest);\n      if (!m) continue;\n      const text = m[0];\n      let resolved: TokenKind;\n      if (kind === 'word') {\n        if (language === 'json') resolved = LITERALS.has(text) ? 'keyword' : 'plain';\n        else if (language === 'css') resolved = /^\\s*:/.test(rest.slice(text.length)) ? 'property' : 'plain';\n        else if (language === 'yaml')\n          resolved = /^\\s*:/.test(rest.slice(text.length)) ? 'property' : LITERALS.has(text) ? 'keyword' : 'plain';\n        else resolved = keywords.has(text) ? 'keyword' : 'plain';\n        // Identifiers may not contain '-' outside CSS/YAML/shell; give the dash back.\n        if (text.includes('-') && !['css', 'yaml', 'sh'].includes(language)) {\n          const word = text.slice(0, text.indexOf('-'));\n          tokens.push({ text: word, kind: keywords.has(word) ? 'keyword' : 'plain' });\n          rest = rest.slice(word.length);\n          matched = true;\n          break;\n        }\n      } else if (\n        kind === 'string' &&\n        (language === 'json' || language === 'yaml') &&\n        /^\\s*:/.test(rest.slice(text.length))\n      ) {\n        resolved = 'property';\n      } else {\n        resolved = kind;\n      }\n      pushToken(tokens, text, resolved);\n      rest = rest.slice(text.length);\n      matched = true;\n      break;\n    }\n    if (!matched) {\n      pushToken(tokens, rest[0]!, 'plain');\n      rest = rest.slice(1);\n    }\n  }\n  return tokens;\n}\n\nfunction pushToken(tokens: Token[], text: string, kind: TokenKind) {\n  const prev = tokens.at(-1);\n  if (prev && prev.kind === kind) prev.text += text;\n  else tokens.push({ text, kind });\n}\n\nexport interface HighlightedPiece extends Token {\n  changed: boolean;\n}\n\n/** Split syntax tokens along word-diff boundaries so both can be rendered at once. */\nexport function mergeTokensWithSegments(\n  tokens: readonly Token[],\n  segments: ReadonlyArray<{ text: string; changed: boolean }> | undefined,\n): HighlightedPiece[] {\n  if (!segments || segments.length === 0) return tokens.map((t) => ({ ...t, changed: false }));\n  const out: HighlightedPiece[] = [];\n  let ti = 0;\n  let tOffset = 0;\n  for (const seg of segments) {\n    let remaining = seg.text.length;\n    while (remaining > 0 && ti < tokens.length) {\n      const token = tokens[ti]!;\n      const available = token.text.length - tOffset;\n      const take = Math.min(available, remaining);\n      out.push({ text: token.text.slice(tOffset, tOffset + take), kind: token.kind, changed: seg.changed });\n      remaining -= take;\n      tOffset += take;\n      if (tOffset >= token.text.length) {\n        ti++;\n        tOffset = 0;\n      }\n    }\n  }\n  return out;\n}\n\nexport const TOKEN_CLASS: Record<TokenKind, string> = {\n  plain: '',\n  keyword: 'text-signoff-code-keyword',\n  string: 'text-signoff-code-string',\n  number: 'text-signoff-code-number',\n  comment: 'text-signoff-code-comment italic',\n  punctuation: 'text-signoff-code-punctuation',\n  property: 'text-signoff-code-keyword',\n};\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/highlight.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/hooks.ts",
      "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport { getAllToolParts, observeToolTimings, type AnyUIPart, type ChatStatus, type ToolTimings } from './ai';\n\nexport type { ToolTiming, ToolTimings } from './ai';\n\nconst useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\nconst REDUCED_MOTION = '(prefers-reduced-motion: reduce)';\n\nfunction subscribeReducedMotion(callback: () => void) {\n  if (typeof window === 'undefined' || !window.matchMedia) return () => {};\n  const mql = window.matchMedia(REDUCED_MOTION);\n  mql.addEventListener('change', callback);\n  return () => mql.removeEventListener('change', callback);\n}\n\n/** Tracks the user's `prefers-reduced-motion` setting. */\nexport function usePrefersReducedMotion(): boolean {\n  return useSyncExternalStore(\n    subscribeReducedMotion,\n    () => (typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(REDUCED_MOTION).matches : false),\n    () => false,\n  );\n}\n\n/**\n * Current time in ms, re-rendering on an interval while `active`.\n * The value can lag by up to one interval; clamp derived durations at 0.\n *\n * 0 on the server: reading the clock there makes the render impure (Next.js `cacheComponents`\n * fails the build on it), and clock-derived output waits for hydration anyway (`useHydrated`).\n */\nexport function useNow(active: boolean, intervalMs = 100): number {\n  const [now, setNow] = useState(() => (typeof window === 'undefined' ? 0 : Date.now()));\n  useEffect(() => {\n    if (!active) return;\n    const tick = () => setNow(Date.now());\n    const first = setTimeout(tick, 0);\n    const id = setInterval(tick, intervalMs);\n    return () => {\n      clearTimeout(first);\n      clearInterval(id);\n    };\n  }, [active, intervalMs]);\n  return now;\n}\n\n/** A tiny external store: subscribe/getSnapshot plus an `update` that only notifies on change. */\nfunction createStore<T>(initial: T) {\n  let state = initial;\n  const listeners = new Set<() => void>();\n  return {\n    get: () => state,\n    set(next: T) {\n      if (next === state) return;\n      state = next;\n      listeners.forEach((l) => l());\n    },\n    subscribe(listener: () => void) {\n      listeners.add(listener);\n      return () => listeners.delete(listener);\n    },\n  };\n}\n\n/**\n * Measures tool-call timings client-side by watching AI SDK state transitions, a sub-agent's calls\n * included. Calls that are already settled when first observed (e.g. restored history)\n * get no timing rather than a misleading zero.\n */\nexport function useToolTimings(parts: readonly AnyUIPart[]): ToolTimings {\n  const [store] = useState(() => createStore<ToolTimings>({}));\n  const snapshot = useSyncExternalStore(store.subscribe, store.get, store.get);\n  useIsoLayoutEffect(() => {\n    store.set(observeToolTimings(store.get(), getAllToolParts(parts), Date.now()));\n  }, [parts, store]);\n  return snapshot;\n}\n\nexport interface RunTiming {\n  /** When the run was first seen in flight: its first request, or later if it was already running. */\n  startedAt?: number | undefined;\n  /** When the run's first response arrived. Undefined while unknown, as `ttftMs`. */\n  firstTokenAt?: number | undefined;\n  /** Milliseconds the agent spent working, excluding time waiting on the user. */\n  activeMs: number;\n  /**\n   * Time to first token of the run: from submitting its first request to the first streamed status.\n   * A first request that ends without a `streaming` status (its response arrived in one burst) counts\n   * its end as the first token. Undefined until then, and when it cannot be known: the run was already\n   * streaming when first seen, its first request failed, or (given the messages) it ended with no reply.\n   */\n  ttftMs?: number | undefined;\n  isRunning: boolean;\n}\n\n/** What `useRunTiming` reads of a message. AI SDK `UIMessage`s and `useAgUiAgent`'s messages fit. */\nexport interface RunTimingMessage {\n  id: string;\n  role: string;\n}\n\ninterface RunTimingState {\n  /** The run's key, and the last status seen. */\n  key?: unknown;\n  status?: ChatStatus | undefined;\n  startedAt?: number | undefined;\n  /** When the run's first request was submitted, while its first token can still be timed. */\n  submittedAt?: number | undefined;\n  firstTokenAt?: number | undefined;\n  accumulatedMs: number;\n  segmentStart?: number | undefined;\n}\n\ninterface RunTimingInput {\n  status: ChatStatus;\n  now: number;\n  /** A new run starts when it changes. */\n  key: unknown;\n  /** Whether a request submitted after the last one ended starts a new run. */\n  newRunOnSubmit: boolean;\n  /** Whether a message follows the last user message. Undefined without the messages. */\n  replied: boolean | undefined;\n}\n\n/**\n * Derives run latency from `useChat` status transitions: time to first token, and active time. A run\n * can span several requests (e.g. continuing after an approval); time spent waiting on the human\n * between requests is excluded from `activeMs`.\n *\n * Pass the chat's `messages` (`useChat().messages`, or `useAgUiAgent`'s): a run then starts with each\n * user message (or a regenerated reply) and spans its approval round trips. Without them, each request\n * submitted after the last one ended starts a new run, so a turn that pauses for approval is timed per\n * request. A key of your own instead (any value but an array) starts a new run only when it changes.\n */\nexport function useRunTiming(status: ChatStatus, messages?: readonly RunTimingMessage[]): RunTiming;\nexport function useRunTiming(status: ChatStatus, resetKey: unknown): RunTiming;\nexport function useRunTiming(status: ChatStatus, messagesOrKey?: unknown): RunTiming {\n  const [store] = useState(() => createStore<RunTimingState>({ accumulatedMs: 0 }));\n  const state = useSyncExternalStore(store.subscribe, store.get, store.get);\n  let key = messagesOrKey;\n  let replied: boolean | undefined;\n  if (Array.isArray(messagesOrKey)) {\n    const messages = messagesOrKey as readonly RunTimingMessage[];\n    let lastUser = messages.length - 1;\n    while (lastUser >= 0 && messages[lastUser]?.role !== 'user') lastUser--;\n    key = messages[lastUser]?.id;\n    replied = lastUser < messages.length - 1;\n  }\n  // With the messages, a request submitted with no reply after the last user message is a new turn\n  // (or a regenerated reply); one that continues a reply, after an approval, is the same run.\n  const newRunOnSubmit = messagesOrKey === undefined || replied === false;\n  useIsoLayoutEffect(() => {\n    store.set(reduceRunTiming(store.get(), { status, now: Date.now(), key, newRunOnSubmit, replied }));\n  }, [status, key, newRunOnSubmit, replied, store]);\n  const isRunning = state.segmentStart !== undefined;\n  const now = useNow(isRunning, 100);\n  return {\n    startedAt: state.startedAt,\n    firstTokenAt: state.firstTokenAt,\n    activeMs: state.accumulatedMs + (state.segmentStart !== undefined ? Math.max(0, now - state.segmentStart) : 0),\n    ttftMs:\n      state.submittedAt !== undefined && state.firstTokenAt !== undefined\n        ? state.firstTokenAt - state.submittedAt\n        : undefined,\n    isRunning,\n  };\n}\n\nconst isInFlight = (status: ChatStatus | undefined) => status === 'submitted' || status === 'streaming';\n\nfunction reduceRunTiming(prev: RunTimingState, input: RunTimingInput): RunTimingState {\n  const { status, now, key, newRunOnSubmit, replied } = input;\n  const submitted = status === 'submitted' && !isInFlight(prev.status);\n  const s: RunTimingState =\n    !Object.is(key, prev.key) || (submitted && newRunOnSubmit) ? { key, accumulatedMs: 0 } : prev;\n  if (isInFlight(status)) {\n    const startedAt = s.startedAt ?? now;\n    // The first token is timed only from the run's first request, seen from its submission: a run\n    // first seen already streaming has an unknown time to first token, not 0.\n    const submittedAt = s.startedAt === undefined ? (status === 'submitted' ? now : undefined) : s.submittedAt;\n    const segmentStart = s.segmentStart ?? now;\n    const firstTokenAt = status === 'streaming' && submittedAt !== undefined ? (s.firstTokenAt ?? now) : s.firstTokenAt;\n    if (\n      s === prev &&\n      status === s.status &&\n      startedAt === s.startedAt &&\n      submittedAt === s.submittedAt &&\n      segmentStart === s.segmentStart &&\n      firstTokenAt === s.firstTokenAt\n    )\n      return s;\n    return { ...s, status, startedAt, submittedAt, segmentStart, firstTokenAt };\n  }\n  if (s.segmentStart === undefined) return status === s.status && s === prev ? s : { ...s, status };\n  // useChat can go straight from submitted to ready when a response arrives in one burst; then the\n  // response is the first token, unless the messages show that none arrived (it was stopped).\n  const burst = s.firstTokenAt === undefined && s.submittedAt !== undefined && status === 'ready' && replied !== false;\n  const firstTokenAt = burst ? now : s.firstTokenAt;\n  return {\n    ...s,\n    status,\n    firstTokenAt,\n    // Once the first request has ended, a later one (after an approval wait) is not the first token.\n    submittedAt: firstTokenAt === undefined ? undefined : s.submittedAt,\n    accumulatedMs: s.accumulatedMs + (now - s.segmentStart),\n    segmentStart: undefined,\n  };\n}\n\n/** Eases a number toward its target; jumps immediately under reduced motion. */\nexport function useAnimatedNumber(value: number, durationMs = 450): number {\n  const reduced = usePrefersReducedMotion();\n  const [display, setDisplay] = useState(value);\n  const displayRef = useRef(value);\n  useEffect(() => {\n    if (reduced || typeof requestAnimationFrame === 'undefined') {\n      displayRef.current = value;\n      return;\n    }\n    const from = displayRef.current;\n    if (from === value) return;\n    const start = performance.now();\n    let frame = 0;\n    const tick = (t: number) => {\n      // A frame's timestamp is when the frame began, which can be before `start`: not below 0.\n      const p = Math.max(0, Math.min(1, (t - start) / durationMs));\n      const eased = 1 - Math.pow(1 - p, 3);\n      const v = from + (value - from) * eased;\n      displayRef.current = v;\n      setDisplay(v);\n      if (p < 1) frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [value, durationMs, reduced]);\n  return reduced || typeof requestAnimationFrame === 'undefined' ? value : display;\n}\n\nexport interface ActivityWindow {\n  startedAt?: number | undefined;\n  endedAt?: number | undefined;\n}\n\n/** Records when `active` last turned on and off. Restarts when it turns on again. */\nexport function useActivityWindow(active: boolean): ActivityWindow {\n  const [store] = useState(() => createStore<ActivityWindow>({}));\n  const state = useSyncExternalStore(store.subscribe, store.get, store.get);\n  useIsoLayoutEffect(() => {\n    const cur = store.get();\n    const now = Date.now();\n    if (active && (cur.startedAt === undefined || cur.endedAt !== undefined)) store.set({ startedAt: now });\n    else if (!active && cur.startedAt !== undefined && cur.endedAt === undefined) store.set({ ...cur, endedAt: now });\n  }, [active, store]);\n  return state;\n}\n\nconst noopSubscribe = () => () => {};\nconst detectMac = () =>\n  typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);\n\n/** Platform check that is safe to use during hydration (server renders the non-Mac variant). */\nexport function useIsMac(): boolean {\n  return useSyncExternalStore(noopSubscribe, detectMac, () => false);\n}\n\nconst hydratedSnapshot = () => true;\nconst serverSnapshot = () => false;\n\n/**\n * False during SSR and hydration, true afterwards. Clock-derived values (live\n * durations, waterfall widths) render only once hydrated so server and client markup match.\n */\nexport function useHydrated(): boolean {\n  return useSyncExternalStore(noopSubscribe, hydratedSnapshot, serverSnapshot);\n}\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/hooks.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/icons.tsx",
      "content": "import type { SVGProps } from 'react';\n\ntype IconProps = SVGProps<SVGSVGElement> & { size?: number };\n\nfunction Svg({ size = 16, children, ...props }: IconProps) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      {...props}\n    >\n      {children}\n    </svg>\n  );\n}\n\nexport const CheckIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M5 12.5 9.5 17 19 7.5\" />\n  </Svg>\n);\nexport const XIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M6.5 6.5l11 11M17.5 6.5l-11 11\" />\n  </Svg>\n);\nexport const ChevronIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M9 6l6 6-6 6\" />\n  </Svg>\n);\nexport const BanIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <circle cx=\"12\" cy=\"12\" r=\"8.5\" />\n    <path d=\"M6 6l12 12\" />\n  </Svg>\n);\nexport const AlertIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M12 3.5 21.5 20h-19L12 3.5Z\" />\n    <path d=\"M12 10v4.5M12 17.25v.25\" />\n  </Svg>\n);\nexport const ShieldIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M12 3 19.5 6v5.5c0 4.6-3.1 8-7.5 9.5-4.4-1.5-7.5-4.9-7.5-9.5V6L12 3Z\" />\n    <path d=\"M12 8.5v4M12 15.5v.25\" />\n  </Svg>\n);\nexport const SparkIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M12 3.5c.6 4.4 2.6 6.9 7.5 8.5-4.9 1.6-6.9 4.1-7.5 8.5-.6-4.4-2.6-6.9-7.5-8.5 4.9-1.6 6.9-4.1 7.5-8.5Z\" />\n  </Svg>\n);\nexport const CopyIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <rect x=\"8.5\" y=\"8.5\" width=\"12\" height=\"12\" rx=\"2.5\" />\n    <path d=\"M15.5 5.5v-.5a2 2 0 0 0-2-2H5.5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h.5\" />\n  </Svg>\n);\nexport const ExternalIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M14 4h6v6M20 4l-9 9\" />\n    <path d=\"M18 14v4.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 18.5v-11A1.5 1.5 0 0 1 5.5 6H10\" />\n  </Svg>\n);\nexport const FileIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M14 3.5H7A1.5 1.5 0 0 0 5.5 5v14A1.5 1.5 0 0 0 7 20.5h10a1.5 1.5 0 0 0 1.5-1.5V8L14 3.5Z\" />\n    <path d=\"M14 3.5V8h4.5\" />\n  </Svg>\n);\nexport const ImageIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" />\n    <circle cx=\"9\" cy=\"9\" r=\"2\" />\n    <path d=\"m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21\" />\n  </Svg>\n);\nexport const ToolIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M13 3 5.5 13.5h6L10.5 21 18.5 10.5h-6L13 3Z\" />\n  </Svg>\n);\nexport const TerminalIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <rect x=\"3\" y=\"4.5\" width=\"18\" height=\"15\" rx=\"2.5\" />\n    <path d=\"M7.5 9.5 10.5 12l-3 2.5M12.5 15h4\" />\n  </Svg>\n);\nexport const ClockIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <circle cx=\"12\" cy=\"12\" r=\"8.5\" />\n    <path d=\"M12 7.5V12l3 2\" />\n  </Svg>\n);\nexport const ArrowUpIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M12 19V5M6 11l6-6 6 6\" />\n  </Svg>\n);\nexport const ArrowDownIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M12 5v14M6 13l6 6 6-6\" />\n  </Svg>\n);\nexport const CommentIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-8l-4 3.5V16H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z\" />\n  </Svg>\n);\nexport const UndoIcon = (p: IconProps) => (\n  <Svg {...p}>\n    <path d=\"M9 14 4 9l5-5\" />\n    <path d=\"M4 9h10.5a5.5 5.5 0 0 1 0 11H11\" />\n  </Svg>\n);\n\n/** Indeterminate spinner: a quarter arc on a faint track. */\nexport const SpinnerIcon = ({ className, ...p }: IconProps) => (\n  <Svg {...p} className={className}>\n    <circle cx=\"12\" cy=\"12\" r=\"8.5\" opacity=\"0.25\" />\n    <path d=\"M12 3.5a8.5 8.5 0 0 1 8.5 8.5\" />\n  </Svg>\n);\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/icons.tsx"
    },
    {
      "path": "packages/signoff-ui/src/lib/labels.ts",
      "content": "/**\n * Every word the components show or announce, in one object: pass a translation of it to\n * `SignoffLabelsProvider`, or part of one to a component's `labels`. Strings are fixed text;\n * functions build text from values (counts, names, line numbers), so a translation can get its\n * plurals and word order right. The English here is the default.\n */\nimport { formatCost, formatDuration, formatDurationLong, formatTokens } from './format';\n\n/*\n * The states the labels name, spelled out here so this module imports nothing but the formatters:\n * a copied registry item that only shows sources does not need the diff engine. They are the same\n * unions as `AgentState`, `ToolPhase`, `FileStatus`, `HunkDecision` and `ApprovalDecision`, which\n * test/labels-types.test.ts checks.\n */\ntype AgentState = 'idle' | 'thinking' | 'working' | 'awaiting-approval' | 'done' | 'stopped' | 'error';\ntype ToolPhase = 'streaming' | 'running' | 'awaiting-approval' | 'success' | 'error' | 'denied';\ntype FileStatus = 'added' | 'deleted' | 'modified' | 'renamed';\ntype HunkDecision = 'pending' | 'accepted' | 'rejected';\ntype ApprovalDecision = 'allow-once' | 'allow-session' | 'allow-always' | 'deny-once' | 'deny-always';\ntype CommentSide = 'new' | 'old';\n\nexport type RiskLevel = 'low' | 'medium' | 'high' | 'critical';\n\n/** A rule, as its description needs it: the tool and the argument patterns. */\nexport interface RuleLabel {\n  tool: string;\n  args?: Readonly<Record<string, string>> | undefined;\n}\n\n/** Where a comment is, as its description needs it. */\nexport interface CommentLabel {\n  target: 'lines' | 'hunk' | 'file';\n  side?: CommentSide | undefined;\n  startLine?: number | undefined;\n  endLine?: number | undefined;\n}\n\n/** Which choices an approval card offers, for its keyboard hint. */\nexport interface OfferedChoices {\n  approve: boolean;\n  session: boolean;\n  always: boolean;\n  deny: boolean;\n  alwaysDeny: boolean;\n}\n\n/** Lines of a hunk, as a range in one of the files. */\nexport interface RangeLabel {\n  side: CommentSide;\n  startLine: number;\n  endLine: number;\n}\n\nexport interface SignoffLabels {\n  /** How values read. Swap these for your locale's number formats. */\n  format: {\n    /** \"120ms\", \"1.20s\", \"2m 05s\". */\n    duration: (ms: number | undefined | null) => string;\n    /** For screen readers: \"1.2 seconds\". */\n    durationLong: (ms: number | undefined | null) => string;\n    /** \"980\", \"1.20k\", \"1.25M\". */\n    tokens: (n: number | undefined | null) => string;\n    /** \"$0.0123\". */\n    cost: (usd: number | undefined | null) => string;\n    /** \"42%\", from a fraction. */\n    percent: (fraction: number) => string;\n  };\n  common: {\n    /** A copy button's name for a moment after it copied. */\n    copied: string;\n    /** A copy button for something named: \"Copy arguments\". */\n    copyThe: (what: string) => string;\n    showAllLines: (count: number) => string;\n    showLess: string;\n    /** After a link that opens a new tab, for screen readers. */\n    opensInNewTab: string;\n    /** The modifier key as printed on the keyboard: ⌘ on a Mac, Ctrl elsewhere. */\n    modKey: (mac: boolean) => string;\n  };\n  agentStatus: {\n    states: Record<AgentState, string>;\n    /** What a pill says along with its state and detail, for screen readers. */\n    spoken: (state: string, detail: string | undefined) => string;\n    /** `deriveAgentState`'s detail while the model writes. */\n    writingResponse: string;\n  };\n  toolCallTimeline: {\n    list: string;\n    phases: Record<ToolPhase, string>;\n    stopped: string;\n    finished: (name: string, durationLong: string) => string;\n    failed: (name: string, error: string | undefined) => string;\n    blockedByPolicy: (name: string) => string;\n    denied: (name: string) => string;\n    input: string;\n    inputStreaming: string;\n    output: string;\n    outputPartial: string;\n    error: string;\n    deniedBy: (automatic: boolean) => string;\n    waitingForInput: string;\n    /** A sub-agent nested under the call that started it. */\n    subagent: {\n      /** Its name, when the agent gave none. */\n      name: string;\n      /** How many tool calls it made. */\n      calls: (count: number) => string;\n      /** The name of the list of its calls. */\n      list: (name: string) => string;\n      /** Paused until a later run (AG-UI's `suspended`). */\n      paused: string;\n    };\n  };\n  approvalCard: {\n    required: string;\n    risk: Record<RiskLevel, string>;\n    /** What a pending card's preview of a command is called, while it scrolls sideways. */\n    command: string;\n    arguments: string;\n    approve: string;\n    session: string;\n    always: string;\n    deny: string;\n    alwaysDeny: string;\n    sendDenial: string;\n    confirm: string;\n    denyWithFeedback: string;\n    reasonLabel: string;\n    reasonPlaceholder: string;\n    approveHint: string;\n    keyboardHint: (offered: OfferedChoices, mod: string) => string;\n    /**\n     * What a pending card says after its reason, for screen readers: the risk, what will run (the\n     * arguments' `command` and `cwd`), the first names and values of the arguments besides those\n     * (of all of them when there is no command), and the calls a lasting decision covers, as\n     * `describeRule` words them. Each is `undefined` or empty when the card has none.\n     */\n    summary: (card: {\n      risk: string | undefined;\n      command: string | undefined;\n      cwd: string | undefined;\n      args: readonly (readonly [name: string, value: string])[];\n      rule: string | undefined;\n    }) => string;\n    /** What a resolved card says of a person's decision. */\n    resolved: Record<ApprovalDecision, string>;\n    /** A lasting decision, announced with the calls its rule covers: `resolved`'s text and `describeRule`'s. */\n    resolvedFor: (resolved: string, rule: string) => string;\n    approved: string;\n    denied: string;\n    autoApproved: string;\n    blockedByPolicy: string;\n    allowedByRule: string;\n    deniedByRule: string;\n    /** A rule in words: \"run_command with command npm test*\". */\n    describeRule: (rule: RuleLabel) => string;\n    critical: string;\n    /** Announced when a critical action's first press is let go: Escape, or focus going elsewhere. */\n    confirmCancelled: string;\n    invalidJson: string;\n    editArguments: string;\n    doneEditing: string;\n    undoEdits: string;\n    edited: string;\n    argumentsJson: string;\n    notValidJson: (error: string) => string;\n    ruleLegend: string;\n    /** Around the tool's name: \"Remembered for \", run_command, \" calls where:\". */\n    rememberedFor: (anyArguments: boolean) => { before: string; after: string };\n    argumentMatches: (name: string) => string;\n    anyArguments: string;\n    globHelp: string;\n    batch: {\n      group: string;\n      waiting: (count: number) => string;\n      approveAll: string;\n      denyAll: string;\n      confirm: string;\n      critical: string;\n      answered: (count: number, approved: boolean) => string;\n    };\n  };\n  diffReview: {\n    title: string;\n    layout: string;\n    views: { unified: string; split: string };\n    files: (count: number) => string;\n    hunks: (count: number, comparing: boolean) => string;\n    status: Record<FileStatus, { letter: string; name: string }>;\n    navigator: string;\n    /** A file in the navigator, after its path, for screen readers. */\n    navigatorState: (decided: number, total: number, viewed: boolean) => string;\n    comparingShort: string;\n    /** Between a renamed file's old and new path, for screen readers (the screen shows an arrow). */\n    renamedTo: string;\n    toggleFile: (collapsed: boolean, path: string) => string;\n    viewed: string;\n    rejectFile: string;\n    acceptFile: string;\n    comparing: string;\n    fallback: string;\n    noChanges: string;\n    /** A whole file, decided as one: what the card in its place says. */\n    wholeFile: (status: FileStatus, binary: boolean, oldPath: string | undefined) => string;\n    accept: string;\n    reject: string;\n    /** \"hunk 2\" or \"change 5\", in the decision buttons' names. */\n    itemName: (kind: 'hunk' | 'file', n: number) => string;\n    acceptItem: (item: string) => string;\n    rejectItem: (item: string) => string;\n    resetItem: (item: string) => string;\n    commentOn: (what: string) => string;\n    /** The badge on a decided hunk. */\n    badge: Record<Exclude<HunkDecision, 'pending'>, string>;\n    /** A hunk's name: where it is and how it was decided. */\n    hunk: (n: number, total: number, path: string, from: number, to: number, decision: HunkDecision) => string;\n    /** A whole-file item's name; `what` is `describeFile`'s. */\n    fileItem: (n: number, total: number, path: string, what: string, decision: HunkDecision) => string;\n    describeFile: (status: FileStatus, binary: boolean, oldPath: string | undefined) => string;\n    hunkCode: (n: number, path: string) => string;\n    added: string;\n    removed: string;\n    decided: (kind: 'hunk' | 'file', n: number, total: number, decision: HunkDecision, remaining: number) => string;\n    decidedAll: (total: number, decision: Exclude<HunkDecision, 'pending'>) => string;\n    decidedFile: (count: number, path: string, decision: Exclude<HunkDecision, 'pending'>) => string;\n    viewedAnnouncement: (path: string, viewed: number, total: number) => string;\n    notViewedAnnouncement: (path: string) => string;\n    lines: (range: RangeLabel) => string;\n    selected: (line: string, range: RangeLabel) => string;\n    where: (comment: CommentLabel) => string;\n    /** Over a comment: what it is on, and before that, for screen readers only, what it is. */\n    commentHeading: (where: string) => { hidden: string; visible: string };\n    commentField: (where: string) => string;\n    commentPlaceholder: string;\n    editComment: (where: string) => string;\n    deleteComment: (where: string) => string;\n    edit: string;\n    delete: string;\n    cancel: string;\n    saveComment: string;\n    addComment: string;\n    commentAdded: (where: string, path: string) => string;\n    commentUpdated: string;\n    commentDeleted: string;\n    unchangedLines: (count: number) => string;\n    /** The gap buttons: what shows, then what only screen readers hear after it. */\n    moreAfter: (count: number, hunk: number) => { visible: string; hidden: string };\n    moreBefore: (count: number, hunk: number) => { visible: string; hidden: string };\n    showAll: (count: number) => { visible: string; hidden: string };\n    expanded: (count: number) => string;\n    /** After \"3/5\". */\n    reviewed: string;\n    filesViewed: (viewed: number, total: number) => string;\n    comments: (count: number) => string;\n    comparingFiles: string;\n    skipped: string;\n    apply: (accepted: number, total: number) => string;\n    /** Announced when the review is sent: items accepted of all, and the comments sent with them. */\n    submitted: (accepted: number, total: number, comments: number) => string;\n    acceptAll: string;\n    rejectAll: string;\n    keyHints: { move: string; accept: string; reject: string; comment: string; apply: string };\n    keyboardHelp: (mod: string) => string;\n  };\n  markdown: {\n    code: (language: string | undefined) => string;\n    /** The badge on a code block with no language. */\n    codeBadge: string;\n    copyCode: string;\n    table: string;\n    image: string;\n    imageFallback: string;\n    citation: (n: number | string) => string;\n    /** The name of a task list's checkbox (`- [x] done`), read with its state. */\n    task: string;\n  };\n  reasoning: {\n    thinking: string;\n    thoughtFor: (duration: string) => string;\n    reasoning: string;\n  };\n  runMeter: {\n    title: string;\n    metrics: (live: boolean) => string;\n    summary: (values: {\n      input: string;\n      output: string;\n      cost: string | undefined;\n      ttft: string | undefined;\n      total: string | undefined;\n    }) => string;\n    inputTokens: string;\n    outputTokens: string;\n    estimatedCost: string;\n    timeToFirstToken: string;\n    totalTime: string;\n    ttft: string;\n    live: string;\n    tokens: string;\n    estCost: string;\n    breakdown: string;\n    split: (input: string, output: string) => string;\n    input: string;\n    output: string;\n    cached: (n: string) => string;\n    reasoningTokens: (n: string) => string;\n    total: string;\n    cacheHit: string;\n    costSplit: (input: string, output: string) => string;\n    rates: (input: number, output: number) => string;\n  };\n  approvalPolicy: {\n    /** The reason a deny rule sends the agent when it has none of its own; resolved cards show it. */\n    ruleDenial: string;\n  };\n  sources: { label: string; document: string };\n  agentMessage: { attachedImage: string };\n  /** The marks on text from the agent, where it could read as something it is not. */\n  untrusted: {\n    /** A mark in place of characters that do not show, such as a bidi control or a zero-width space; `codes` lists each once. */\n    invisible: (count: number, codes: readonly string[]) => string;\n    /**\n     * A mark in place of a long run of spaces and tabs, or of blank lines, in a command or a path:\n     * `lines` is how many line breaks a run of blank lines has, which its mark shows, and 0 for spaces.\n     */\n    blank: (count: number, lines: number) => string;\n    /** Beside a link whose text names another host than the one it goes to. */\n    linksTo: (host: string) => string;\n  };\n}\n\nconst plural = (n: number, one: string, many = `${one}s`) => (n === 1 ? one : many);\n\nconst lines = (range: RangeLabel) =>\n  `${range.startLine === range.endLine ? `line ${range.startLine}` : `lines ${range.startLine} to ${range.endLine}`}${range.side === 'old' ? ' of the original' : ''}`;\n\nconst DECISION: Record<HunkDecision, string> = { pending: 'not reviewed', accepted: 'accepted', rejected: 'rejected' };\n\n/*\n * Each section on its own, so a component's bundle keeps the sections it reads and no others.\n */\nexport const formatLabels: SignoffLabels['format'] = {\n  duration: formatDuration,\n  durationLong: formatDurationLong,\n  tokens: formatTokens,\n  cost: formatCost,\n  percent: (fraction) => `${Math.round(fraction * 100)}%`,\n};\n\nexport const commonLabels: SignoffLabels['common'] = {\n  copied: 'Copied',\n  copyThe: (what) => `Copy ${what.toLowerCase()}`,\n  showAllLines: (count) => `Show all ${count} lines`,\n  showLess: 'Show less',\n  opensInNewTab: '(opens in a new tab)',\n  modKey: (mac) => (mac ? '⌘' : 'Ctrl'),\n};\n\nexport const agentStatusLabels: SignoffLabels['agentStatus'] = {\n  states: {\n    idle: 'Idle',\n    thinking: 'Thinking',\n    working: 'Working',\n    'awaiting-approval': 'Waiting for approval',\n    done: 'Done',\n    stopped: 'Stopped',\n    error: 'Error',\n  },\n  spoken: (state, detail) => (detail ? `${state}: ${detail}` : state),\n  writingResponse: 'Writing response',\n};\n\nexport const toolCallTimelineLabels: SignoffLabels['toolCallTimeline'] = {\n  list: 'Tool calls',\n  phases: {\n    streaming: 'Preparing',\n    running: 'Running',\n    'awaiting-approval': 'Needs approval',\n    success: 'Done',\n    error: 'Failed',\n    denied: 'Denied',\n  },\n  stopped: 'Stopped',\n  finished: (name, duration) => `${name} finished in ${duration}`,\n  failed: (name, error) => `${name} failed: ${error ?? 'unknown error'}`,\n  blockedByPolicy: (name) => `${name} was blocked by policy`,\n  denied: (name) => `${name} was denied`,\n  input: 'Input',\n  inputStreaming: 'Input (streaming)',\n  output: 'Output',\n  outputPartial: 'Output (partial)',\n  error: 'Error',\n  deniedBy: (automatic) => (automatic ? 'Blocked by policy' : 'Denied by user'),\n  waitingForInput: 'Waiting for input…',\n  subagent: {\n    name: 'Sub-agent',\n    calls: (count) => `${count} ${count === 1 ? 'call' : 'calls'}`,\n    list: (name) => `${name}’s tool calls`,\n    paused: 'Paused',\n  },\n};\n\nexport const approvalCardLabels: SignoffLabels['approvalCard'] = {\n  required: 'Approval required',\n  risk: { low: 'Low risk', medium: 'Medium risk', high: 'High risk', critical: 'Critical' },\n  command: 'Command',\n  arguments: 'Arguments',\n  approve: 'Approve',\n  session: 'For this session',\n  always: 'Always',\n  deny: 'Deny',\n  alwaysDeny: 'Always deny',\n  sendDenial: 'Send denial',\n  confirm: 'Confirm approval',\n  denyWithFeedback: 'Deny with feedback',\n  reasonLabel: 'Tell the agent why (optional)',\n  reasonPlaceholder: 'What should the agent do instead?',\n  approveHint: 'approve',\n  keyboardHint: (offered, mod) =>\n    `Keyboard: press ${[\n      offered.approve && 'Y to approve',\n      offered.session && 'S to approve for this session',\n      offered.always && 'A to always approve',\n      offered.deny && 'N to deny',\n      offered.alwaysDeny && 'Shift N to always deny',\n    ]\n      .filter(Boolean)\n      .join(', ')}, or ${mod} Enter to approve.`,\n  summary: ({ risk, command, cwd, args, rule }) =>\n    [\n      risk && `${risk}.`,\n      command !== undefined && `Runs ${command}${cwd ? ` in ${cwd}` : ''}.`,\n      args.length > 0 &&\n        `${command !== undefined ? 'With' : 'Arguments:'} ${args.map((arg) => arg.join(' ')).join(', ')}.`,\n      rule && `A lasting decision covers ${rule}.`,\n    ]\n      .filter(Boolean)\n      .join(' '),\n  resolved: {\n    'allow-once': 'Approved',\n    'allow-session': 'Approved for this session',\n    'allow-always': 'Always approved',\n    'deny-once': 'Denied',\n    'deny-always': 'Always denied',\n  },\n  resolvedFor: (resolved, rule) => `${resolved}: ${rule}.`,\n  approved: 'Approved',\n  denied: 'Denied',\n  autoApproved: 'Auto-approved',\n  blockedByPolicy: 'Blocked by policy',\n  allowedByRule: 'Allowed by your rule',\n  deniedByRule: 'Denied by your rule',\n  describeRule: (rule) => {\n    const args = Object.entries(rule.args ?? {});\n    const tool = rule.tool === '*' ? 'any tool' : rule.tool.replace(/\\\\(.)/g, '$1');\n    return args.length === 0\n      ? `${tool}, any arguments`\n      : `${tool} with ${args.map(([name, pattern]) => `${name} ${pattern}`).join(', ')}`;\n  },\n  critical: 'Critical action. Press approve again to confirm.',\n  confirmCancelled: 'Confirmation cancelled.',\n  invalidJson: 'The arguments are not valid JSON.',\n  editArguments: 'Edit arguments',\n  doneEditing: 'Done editing',\n  undoEdits: 'Undo edits',\n  edited: 'Edited: approving runs the new arguments.',\n  argumentsJson: 'Arguments, as JSON',\n  notValidJson: (error) => `Not valid JSON: ${error}`,\n  ruleLegend: 'Calls a lasting decision covers',\n  rememberedFor: (any) => ({ before: 'Remembered for ', after: any ? ' with any arguments.' : ' calls where:' }),\n  argumentMatches: (name) => `${name} matches`,\n  anyArguments: 'Any arguments',\n  globHelp: '* matches any text but shell operators (; & | > < and backticks); ** matches anything.',\n  batch: {\n    group: 'Approvals waiting',\n    waiting: (count) => `${count} approvals are waiting.`,\n    approveAll: 'Approve all',\n    denyAll: 'Deny all',\n    confirm: 'Confirm approve all',\n    critical: 'One of them is critical. Press Approve all again to confirm.',\n    answered: (count, approved) => `${count} approvals ${approved ? 'approved' : 'denied'}.`,\n  },\n};\n\nexport const diffReviewLabels: SignoffLabels['diffReview'] = {\n  title: 'Review changes',\n  layout: 'Diff layout',\n  views: { unified: 'Unified', split: 'Split' },\n  files: (count) => `${count} ${plural(count, 'file')}`,\n  hunks: (count, comparing) => `${count} ${plural(count, 'hunk')}${comparing ? ' so far' : ''}`,\n  status: {\n    modified: { letter: 'M', name: 'Modified' },\n    added: { letter: 'A', name: 'Added' },\n    deleted: { letter: 'D', name: 'Deleted' },\n    renamed: { letter: 'R', name: 'Renamed' },\n  },\n  navigator: 'Files in this review',\n  navigatorState: (decided, total, viewed) => `, ${decided} of ${total} decided${viewed ? ', viewed' : ''}`,\n  comparingShort: 'comparing…',\n  renamedTo: 'to',\n  toggleFile: (collapsed, path) => `${collapsed ? 'Show' : 'Hide'} ${path}`,\n  viewed: 'Viewed',\n  rejectFile: 'Reject file',\n  acceptFile: 'Accept file',\n  comparing: 'Comparing changes…',\n  fallback: 'Too many changes to compare line by line: the changed lines are shown as one hunk that replaces them.',\n  noChanges: 'No textual changes.',\n  wholeFile: (status, binary, oldPath) =>\n    binary\n      ? 'Binary file, not shown.'\n      : status === 'renamed'\n        ? `Renamed from ${oldPath}, without changes.`\n        : status === 'deleted'\n          ? 'Empty file, deleted.'\n          : 'New empty file.',\n  accept: 'Accept',\n  reject: 'Reject',\n  itemName: (kind, n) => `${kind === 'hunk' ? 'hunk' : 'change'} ${n}`,\n  acceptItem: (item) => `Accept ${item}`,\n  rejectItem: (item) => `Reject ${item}`,\n  resetItem: (item) => `Reset ${item}`,\n  commentOn: (what) => `Comment on ${what}`,\n  badge: { accepted: 'accepted', rejected: 'rejected' },\n  hunk: (n, total, path, from, to, decision) =>\n    `Hunk ${n} of ${total}, ${path}, lines ${from} to ${to}, ${DECISION[decision]}`,\n  fileItem: (n, total, path, what, decision) => `Change ${n} of ${total}, ${path}, ${what}, ${DECISION[decision]}`,\n  describeFile: (status, binary, oldPath) =>\n    binary\n      ? 'binary file'\n      : status === 'renamed'\n        ? `renamed from ${oldPath}`\n        : status === 'deleted'\n          ? 'deleted empty file'\n          : 'new empty file',\n  hunkCode: (n, path) => `Hunk ${n} code, ${path}`,\n  added: 'Added: ',\n  removed: 'Removed: ',\n  decided: (kind, n, total, decision, remaining) =>\n    `${kind === 'hunk' ? 'Hunk' : 'Change'} ${n} of ${total} ${decision === 'pending' ? 'reset' : decision}. ${\n      remaining === 0 ? 'All hunks reviewed.' : `${remaining} remaining.`\n    }`,\n  decidedAll: (total, decision) => `All ${total} hunks ${decision}.`,\n  decidedFile: (count, path, decision) => `${count === 1 ? 'The hunk' : `All ${count} hunks`} in ${path} ${decision}.`,\n  viewedAnnouncement: (path, viewed, total) => `${path} viewed. ${viewed} of ${total} files viewed.`,\n  notViewedAnnouncement: (path) => `${path} no longer viewed.`,\n  lines,\n  selected: (line, range) => {\n    const text = lines(range);\n    return `${line}. ${text.charAt(0).toUpperCase()}${text.slice(1)} selected.`;\n  },\n  where: (comment) =>\n    comment.target === 'file'\n      ? 'the file'\n      : comment.target === 'hunk'\n        ? 'the hunk'\n        : lines({ side: comment.side ?? 'new', startLine: comment.startLine ?? 0, endLine: comment.endLine ?? 0 }),\n  commentHeading: (where) => ({ hidden: 'Comment ', visible: `On ${where}` }),\n  commentField: (where) => `Comment on ${where}, for the agent`,\n  commentPlaceholder: 'What should change here?',\n  editComment: (where) => `Edit comment on ${where}`,\n  deleteComment: (where) => `Delete comment on ${where}`,\n  edit: 'Edit',\n  delete: 'Delete',\n  cancel: 'Cancel',\n  saveComment: 'Save comment',\n  addComment: 'Comment',\n  commentAdded: (where, path) => `Comment added on ${where} of ${path}.`,\n  commentUpdated: 'Comment updated.',\n  commentDeleted: 'Comment deleted.',\n  unchangedLines: (count) => `${count} unchanged ${plural(count, 'line')}`,\n  moreAfter: (count, hunk) => ({\n    visible: `${count} more`,\n    hidden: ` unchanged ${plural(count, 'line')} after hunk ${hunk}`,\n  }),\n  moreBefore: (count, hunk) => ({\n    visible: `${count} more`,\n    hidden: ` unchanged ${plural(count, 'line')} before hunk ${hunk}`,\n  }),\n  showAll: (count) => ({ visible: 'Show all', hidden: ` ${count} unchanged ${plural(count, 'line')}` }),\n  expanded: (count) => `Showing ${count} more unchanged ${plural(count, 'line')}.`,\n  reviewed: 'reviewed',\n  filesViewed: (viewed, total) => `${viewed} of ${total} files viewed`,\n  comments: (count) => `${count} ${plural(count, 'comment')}`,\n  comparingFiles: 'comparing files…',\n  skipped: 'unreviewed hunks are skipped',\n  apply: (accepted, total) => (accepted > 0 ? `Apply ${accepted} of ${total}` : 'Apply changes'),\n  submitted: (accepted, total, comments) =>\n    `Review sent: ${accepted} of ${total} accepted${comments > 0 ? `, ${comments} ${plural(comments, 'comment')}` : ''}.`,\n  acceptAll: 'Accept all',\n  rejectAll: 'Reject all',\n  keyHints: { move: 'move', accept: 'accept', reject: 'reject', comment: 'comment', apply: 'apply' },\n  keyboardHelp: (mod) =>\n    `Keyboard: J or K to move between hunks, Shift J or Shift K between files, A to accept, R to reject, U to reset, Alt A or Alt R for the whole file, Shift A or Shift R for every hunk, Shift with the up or down arrow to select lines, C to comment on them or on the hunk, E to show more unchanged lines, V to mark the file viewed, ${mod} Enter to apply.`,\n};\n\nexport const markdownLabels: SignoffLabels['markdown'] = {\n  code: (language) => (language && language !== 'text' ? `Code, ${language}` : 'Code'),\n  codeBadge: 'code',\n  copyCode: 'Copy code',\n  table: 'Table',\n  image: 'Image:',\n  imageFallback: 'image',\n  citation: (n) => `Source ${n}`,\n  task: 'Task',\n};\n\nexport const reasoningLabels: SignoffLabels['reasoning'] = {\n  thinking: 'Thinking',\n  thoughtFor: (duration) => `Thought for ${duration}`,\n  reasoning: 'Reasoning',\n};\n\nexport const runMeterLabels: SignoffLabels['runMeter'] = {\n  title: 'Run',\n  metrics: (live) => `Run metrics${live ? ' (live)' : ''}`,\n  summary: ({ input, output, cost, ttft, total }) =>\n    [\n      `${input} input tokens`,\n      `${output} output tokens`,\n      cost !== undefined ? `estimated cost ${cost}` : undefined,\n      ttft !== undefined ? `time to first token ${ttft}` : undefined,\n      total !== undefined ? `total ${total}` : undefined,\n    ]\n      .filter(Boolean)\n      .join(', '),\n  inputTokens: 'Input tokens',\n  outputTokens: 'Output tokens',\n  estimatedCost: 'Estimated cost',\n  timeToFirstToken: 'Time to first token',\n  totalTime: 'Total time',\n  ttft: 'TTFT',\n  live: 'Live',\n  tokens: 'Tokens',\n  estCost: 'Est. cost',\n  breakdown: 'Token breakdown',\n  split: (input, output) => `Input ${input} · Output ${output}`,\n  input: 'Input',\n  output: 'Output',\n  cached: (n) => `(${n} cached)`,\n  reasoningTokens: (n) => `(${n} reasoning)`,\n  total: 'Total',\n  cacheHit: 'Cache hit',\n  costSplit: (input, output) => `${input} input · ${output} output`,\n  rates: (input, output) => `$${input}/$${output} per 1M`,\n};\n\nexport const approvalPolicyLabels: SignoffLabels['approvalPolicy'] = { ruleDenial: 'Denied by a rule' };\n\nexport const sourcesLabels: SignoffLabels['sources'] = { label: 'Sources', document: 'Document' };\n\nexport const agentMessageLabels: SignoffLabels['agentMessage'] = { attachedImage: 'Attached image' };\n\nexport const untrustedLabels: SignoffLabels['untrusted'] = {\n  invisible: (count, codes) =>\n    count === 1 ? `Invisible character ${codes[0]}` : `${count} invisible characters: ${codes.join(', ')}`,\n  blank: (count, lines) => (lines ? `${lines} line breaks` : `${count} blank characters`),\n  linksTo: (host) => `Links to ${host}`,\n};\n\n/** The English labels, as the components read without any. */\nexport const defaultLabels: SignoffLabels = {\n  format: formatLabels,\n  common: commonLabels,\n  agentStatus: agentStatusLabels,\n  toolCallTimeline: toolCallTimelineLabels,\n  approvalCard: approvalCardLabels,\n  diffReview: diffReviewLabels,\n  markdown: markdownLabels,\n  reasoning: reasoningLabels,\n  runMeter: runMeterLabels,\n  approvalPolicy: approvalPolicyLabels,\n  sources: sourcesLabels,\n  agentMessage: agentMessageLabels,\n  untrusted: untrustedLabels,\n};\n\n/** A part of the labels: any section, any key, at any depth. */\nexport type SignoffLabelsInput = {\n  [K in keyof SignoffLabels]?: DeepPartial<SignoffLabels[K]>;\n};\n\ntype DeepPartial<T> = T extends (...args: never[]) => unknown\n  ? T\n  : T extends object\n    ? { [K in keyof T]?: DeepPartial<T[K]> }\n    : T;\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n  typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/** Labels with some replaced: plain objects merge key by key, strings and functions replace. */\nexport function mergeLabels<T extends SignoffLabelsInput>(\n  base: T,\n  ...overrides: (SignoffLabelsInput | undefined)[]\n): T {\n  const merge = (a: unknown, b: unknown): unknown => {\n    if (b === undefined) return a;\n    if (!isRecord(a) || !isRecord(b)) return b;\n    const out: Record<string, unknown> = { ...a };\n    for (const [key, value] of Object.entries(b)) out[key] = merge(a[key], value);\n    return out;\n  };\n  return overrides.reduce<T>((labels, next) => merge(labels, next) as T, base);\n}\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/labels.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/primitives.tsx",
      "content": "'use client';\n\nimport {\n  createElement,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type ReactNode,\n} from 'react';\nimport { CheckIcon, CopyIcon } from './icons';\nimport { safeStringify } from './format';\nimport { TOKEN_CLASS, tokenizeLine } from './highlight';\nimport { cn } from './utils';\nimport { commonLabels, type SignoffLabelsInput } from './labels';\nimport { useLabels, WithLabels } from '../labels';\n\n/** The labels' sections this module reads. */\nconst LABELS = { common: commonLabels };\n\n/**\n * An <img> for images from messages, written without JSX. Next.js' `no-img-element` lint rule\n * flags the element and suggests next/image, which cannot load these URLs without every host\n * configured; a comment disabling that rule would itself be an error where ESLint has no Next.js\n * plugin. The repository's ESLint config checks `Img` for alt text like an <img>.\n */\nexport function Img({ alt, ...props }: ComponentPropsWithoutRef<'img'> & { alt: string }) {\n  return createElement('img', { alt, ...props });\n}\n\n/**\n * Keyboard key hint. Purely visual: shortcuts are also described in text for assistive tech. Left to\n * right in a right-to-left page too, so `⇧N` does not show as `N⇧`.\n */\nexport function Kbd({ className, ...props }: ComponentPropsWithoutRef<'kbd'>) {\n  return (\n    <kbd\n      dir=\"ltr\"\n      className={cn(\n        'border-signoff-border-strong bg-signoff-bg/40 font-signoff-mono text-signoff-fg-muted inline-flex h-5 min-w-5 items-center justify-center rounded-[5px] border px-1 text-[11px] leading-none font-medium',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** A visually hidden live region. Render it unconditionally so updates are announced. */\nexport function LiveRegion({\n  children,\n  politeness = 'polite',\n}: {\n  children?: ReactNode;\n  politeness?: 'polite' | 'assertive';\n}) {\n  return (\n    <div\n      className=\"sr-only\"\n      role={politeness === 'assertive' ? 'alert' : 'status'}\n      aria-live={politeness}\n      aria-atomic=\"true\"\n    >\n      {children}\n    </div>\n  );\n}\n\n/** Returns `value` after it has been stable for `delayMs`. */\nexport function useDebouncedValue<T>(value: T, delayMs: number): T {\n  const [debounced, setDebounced] = useState(value);\n  useEffect(() => {\n    const id = setTimeout(() => setDebounced(value), delayMs);\n    return () => clearTimeout(id);\n  }, [value, delayMs]);\n  return debounced;\n}\n\nexport function CopyButton({ text, label, className }: { text: string; label: string; className?: string }) {\n  const L = useLabels(LABELS);\n  const [copied, setCopied] = useState(false);\n  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  useEffect(() => () => clearTimeout(timer.current), []);\n  return (\n    <button\n      type=\"button\"\n      onClick={async () => {\n        try {\n          await navigator.clipboard.writeText(text);\n          setCopied(true);\n          clearTimeout(timer.current);\n          timer.current = setTimeout(() => setCopied(false), 1500);\n        } catch {\n          // Clipboard can be unavailable (permissions, insecure context); fail quietly.\n        }\n      }}\n      aria-label={copied ? L.common.copied : label}\n      className={cn(\n        'text-signoff-fg-subtle hover:bg-signoff-surface-2 hover:text-signoff-fg focus-visible:outline-signoff-ring inline-flex size-7 cursor-pointer items-center justify-center rounded-md transition-colors focus-visible:outline-2 focus-visible:outline-offset-1',\n        className,\n      )}\n    >\n      {copied ? <CheckIcon size={14} className=\"text-signoff-accent-fg\" /> : <CopyIcon size={14} />}\n    </button>\n  );\n}\n\n/** Syntax-tinted, line-tokenized code. */\nexport function CodeLines({ code, language }: { code: string; language: string }) {\n  const lines = useMemo(() => code.split('\\n'), [code]);\n  return (\n    <code>\n      {lines.map((line, i) => (\n        <span key={i} className=\"block min-h-[1lh]\">\n          {tokenizeLine(line, language).map((t, j) => (\n            <span key={j} className={TOKEN_CLASS[t.kind] || undefined}>\n              {t.text}\n            </span>\n          ))}\n        </span>\n      ))}\n    </code>\n  );\n}\n\nexport interface JsonViewProps {\n  value: unknown;\n  /** Accessible name for the code block, e.g. \"Input\". */\n  label: string;\n  /** Lines shown before \"Show all\" collapses the rest. */\n  collapseAfter?: number;\n  className?: string;\n  /** Words to use instead of the English defaults: see `SignoffLabelsProvider`. */\n  labels?: SignoffLabelsInput | undefined;\n}\n\n/** Read-only JSON with light syntax tinting, a copy button and long-output folding. */\nexport function JsonView({ value, label, collapseAfter = 24, className, labels }: JsonViewProps) {\n  const L = useLabels(LABELS, labels);\n  const text = useMemo(() => (typeof value === 'string' ? value : safeStringify(value)), [value]);\n  const language = typeof value === 'string' ? 'text' : 'json';\n  const lineCount = useMemo(() => text.split('\\n').length, [text]);\n  const [showAll, setShowAll] = useState(false);\n  const folded = !showAll && lineCount > collapseAfter;\n  const shown = folded ? text.split('\\n').slice(0, collapseAfter).join('\\n') : text;\n  return (\n    <div data-signoff data-slot=\"signoff-json-view\" className={cn('group/json relative', className)}>\n      {/* The code keeps the kit's font: Tailwind v3's preflight `code` rule outranks the kit's reset. */}\n      <pre\n        role=\"group\"\n        aria-label={label}\n        tabIndex={0}\n        className=\"border-signoff-border bg-signoff-bg/60 font-signoff-mono text-signoff-fg focus-visible:outline-signoff-ring [&>code]:font-signoff-mono max-h-80 overflow-auto rounded-lg border px-3 py-2.5 text-xs leading-5 whitespace-pre focus-visible:outline-2 focus-visible:outline-offset-1\"\n      >\n        <CodeLines code={shown} language={language} />\n      </pre>\n      <div className=\"absolute top-1.5 right-1.5 opacity-0 transition-opacity group-focus-within/json:opacity-100 group-hover/json:opacity-100 [@media(hover:none)]:opacity-100\">\n        <WithLabels labels={labels}>\n          <CopyButton text={text} label={L.common.copyThe(label)} />\n        </WithLabels>\n      </div>\n      {lineCount > collapseAfter && (\n        <button\n          type=\"button\"\n          onClick={() => setShowAll((v) => !v)}\n          className=\"font-signoff-mono text-signoff-fg-subtle hover:text-signoff-fg focus-visible:outline-signoff-ring mt-1.5 cursor-pointer rounded text-[11px] underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-1\"\n        >\n          {showAll ? L.common.showLess : L.common.showAllLines(lineCount)}\n        </button>\n      )}\n    </div>\n  );\n}\n\nexport const focusRing = 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signoff-ring';\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/primitives.tsx"
    },
    {
      "path": "packages/signoff-ui/src/lib/revealed.tsx",
      "content": "'use client';\n\nimport { useLabels } from '../labels';\nimport { untrustedLabels } from './labels';\nimport { MARK_CLASS, revealText } from './untrusted';\n\n/** The labels' sections this module reads. */\nconst LABELS = { untrusted: untrustedLabels };\n\n/**\n * Text from the agent, with marks in place of what does not show, so a command, a path or a line\n * reads as what it is: see `revealText`. `blanks` marks long runs of spaces and of blank lines too,\n * for a command or a path; not for code, which lines up with spaces.\n */\nexport function Revealed({ text, blanks = false }: { text: string; blanks?: boolean }) {\n  const L = useLabels(LABELS).untrusted;\n  return (\n    <>\n      {revealText(text, { blanks }).map((part, i) => {\n        if (typeof part === 'string') return part;\n        const name = part.kind === 'blank' ? L.blank(part.count, part.lines) : L.invisible(part.count, part.codes);\n        return (\n          <span key={i} data-slot=\"signoff-untrusted\" data-kind={part.kind} title={name} className={MARK_CLASS}>\n            <span aria-hidden=\"true\">{part.shown}</span>\n            <span className=\"sr-only\">{name}</span>\n          </span>\n        );\n      })}\n    </>\n  );\n}\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/revealed.tsx"
    },
    {
      "path": "packages/signoff-ui/src/lib/review.ts",
      "content": "/**\n * What a review is made of beyond the diff: the decisions it asks for, comments on lines and\n * hunks, unchanged lines that can be shown around a hunk, the result the agent gets back, and that\n * result as a unified patch, an AI SDK tool output or an AG-UI resume entry. Pure, with no React.\n */\nimport { formatPatch, type StructuredPatch } from 'diff';\nimport {\n  applyHunks,\n  type DiffHunk,\n  type DiffLine,\n  type FileStatus,\n  type HunkDecision,\n  type ParsedFileDiff,\n} from './diff';\n\n/** The id of a file's one decision when it has no hunks to decide: `${fileId}:file`. */\nexport function fileItemId(fileId: string): string {\n  return `${fileId}:file`;\n}\n\n/** One thing to decide: a hunk, or a file with no hunks as a whole (binary, a rename, an empty file). */\nexport interface ReviewItem {\n  /** The decision key: the hunk's id, or `${fileId}:file`. */\n  id: string;\n  /** The parsed file it belongs to. */\n  file: ParsedFileDiff;\n  /** `undefined` for a whole file. */\n  hunk: DiffHunk | undefined;\n}\n\n/** What a file asks to be decided: its hunks, or the file itself when it has none but changed. */\nexport function reviewItems(file: ParsedFileDiff): ReviewItem[] {\n  if (file.hunks.length > 0) return file.hunks.map((hunk) => ({ id: hunk.id, file, hunk }));\n  if (file.binary || file.status !== 'modified') return [{ id: fileItemId(file.id), file, hunk: undefined }];\n  return [];\n}\n\nexport type CommentSide = 'new' | 'old';\n\n/** A reviewer's comment for the agent, on lines of a hunk, a whole hunk or a whole file. */\nexport interface DiffReviewComment {\n  /** Unique within the review. */\n  id: string;\n  /** The file's id in the review: its path, or `path#2`… for a path that repeats. */\n  fileId: string;\n  /** The file's path, as in its `FileChange`. */\n  path: string;\n  /** What it is on. */\n  target: 'lines' | 'hunk' | 'file';\n  /** The hunk the lines are in, for `lines` and `hunk`. */\n  hunkId?: string | undefined;\n  /**\n   * Which file the line numbers count in: the proposed one (`new`), or for removed lines only,\n   * the original (`old`). Absent for a comment on a whole file.\n   */\n  side?: CommentSide | undefined;\n  /** The first line it is on, numbered in the `side` file. Absent for a comment on a whole file. */\n  startLine?: number | undefined;\n  /** The last line it is on, numbered like `startLine`. */\n  endLine?: number | undefined;\n  /**\n   * With `side: 'new'`, when removed lines are among the lines it is on: the first of them all that\n   * is in the original, numbered there. Removed lines have no number in the proposed file.\n   */\n  oldStartLine?: number | undefined;\n  /** The last line it is on that is in the original, numbered like `oldStartLine`. */\n  oldEndLine?: number | undefined;\n  /** What the reviewer wrote. */\n  text: string;\n}\n\nexport interface LineRange {\n  side: CommentSide;\n  startLine: number;\n  endLine: number;\n}\n\n/**\n * The lines a comment records: their range, and with removed lines among lines of the proposed\n * file, their range in the original too.\n */\nexport interface CommentRange extends LineRange {\n  /** With `side: 'new'` and removed lines among the lines: the first one in the original, numbered there. */\n  oldStartLine?: number | undefined;\n  /** The last of them that is in the original, numbered like `oldStartLine`. */\n  oldEndLine?: number | undefined;\n}\n\nconst number = (line: DiffLine, side: CommentSide) => (side === 'new' ? line.newNumber : line.oldNumber);\n\n/**\n * The line range of `hunk.lines[from…to]`: numbered in the proposed file when any of them is in\n * it, else (removed lines only) in the original.\n */\nexport function linesRange(hunk: DiffHunk, from: number, to: number): LineRange {\n  const lines = hunk.lines.slice(Math.min(from, to), Math.max(from, to) + 1);\n  for (const side of ['new', 'old'] as const) {\n    const numbers = lines.map((l) => number(l, side)).filter((n): n is number => n !== undefined);\n    if (numbers.length > 0) return { side, startLine: numbers[0]!, endLine: numbers.at(-1)! };\n  }\n  return { side: 'new', startLine: hunk.newStart, endLine: hunk.newStart };\n}\n\n/**\n * What a comment on `hunk.lines[from…to]` records: `linesRange`, and when it is numbered in the\n * proposed file with removed lines among the lines, their range in the original as well, which\n * removed lines are only in.\n */\nexport function commentRange(hunk: DiffHunk, from: number, to: number): CommentRange {\n  const range = linesRange(hunk, from, to);\n  const lines = hunk.lines.slice(Math.min(from, to), Math.max(from, to) + 1);\n  if (range.side !== 'new' || !lines.some((l) => l.type === 'del')) return range;\n  const olds = lines.map((l) => l.oldNumber).filter((n): n is number => n !== undefined);\n  return { ...range, oldStartLine: olds[0]!, oldEndLine: olds.at(-1)! };\n}\n\n/** Indices of a hunk's first and last changed line. */\nexport function changedSpan(hunk: DiffHunk): [number, number] {\n  const first = hunk.lines.findIndex((l) => l.type !== 'context');\n  if (first === -1) return [0, Math.max(0, hunk.lines.length - 1)];\n  let last = hunk.lines.length - 1;\n  while (last > first && hunk.lines[last]!.type === 'context') last--;\n  return [first, last];\n}\n\n/** The range a comment on the whole hunk refers to: its changed lines. */\nexport function hunkRange(hunk: DiffHunk): LineRange {\n  const [first, last] = changedSpan(hunk);\n  return linesRange(hunk, first, last);\n}\n\n/** Indices of the lines numbered `startLine…endLine` in `side`, or `undefined` when they are not in the hunk. */\nfunction sideSpan(hunk: DiffHunk, side: CommentSide, startLine: number, endLine: number): [number, number] | undefined {\n  const from = hunk.lines.findIndex((l) => number(l, side) === startLine);\n  let to = -1;\n  hunk.lines.forEach((l, i) => {\n    if (number(l, side) === endLine) to = i;\n  });\n  return from === -1 || to === -1 || to < from ? undefined : [from, to];\n}\n\n/** Indices of the lines a comment covers in its hunk, or `undefined` when they are not in it. */\nexport function commentSpan(hunk: DiffHunk, comment: DiffReviewComment): [number, number] | undefined {\n  if (comment.target === 'hunk') return changedSpan(hunk);\n  const { side, startLine, endLine, oldStartLine, oldEndLine } = comment;\n  if (side === undefined || startLine === undefined || endLine === undefined) return undefined;\n  const span = sideSpan(hunk, side, startLine, endLine);\n  if (!span || side !== 'new' || oldStartLine === undefined || oldEndLine === undefined) return span;\n  // The removed lines, at either end of the selection, are in the original's range only.\n  const old = sideSpan(hunk, 'old', oldStartLine, oldEndLine);\n  return old ? [Math.min(span[0], old[0]), Math.max(span[1], old[1])] : span;\n}\n\nconst SIGN: Record<DiffLine['type'], string> = { context: ' ', add: '+', del: '-' };\nconst MAX_EXCERPT_LINES = 40;\n\n/** Lines as a unified diff: ` ` unchanged, `-` removed, `+` added. Long runs are cut short with `…`. */\nfunction diffText(lines: readonly DiffLine[]) {\n  const shown = lines.slice(0, MAX_EXCERPT_LINES).map((l) => SIGN[l.type] + l.content);\n  if (lines.length > MAX_EXCERPT_LINES) shown.push(`… ${lines.length - MAX_EXCERPT_LINES} more lines`);\n  return shown.join('\\n');\n}\n\n/** A gap of unchanged lines between hunks (or before the first, or after the last) that can be shown. */\nexport interface ContextGap {\n  /** The hunk after the gap; the file's hunk count for the gap at its end. */\n  before: number;\n  /** The gap's first and last line in the original file, 1-based and inclusive. */\n  oldStart: number;\n  oldEnd: number;\n  /** Add to a line's number in the original for its number in the proposed file. */\n  delta: number;\n}\n\nconst lineCount = (text: string) => (text === '' ? 0 : text.replace(/\\n$/, '').split('\\n').length);\n\n/** The unchanged lines around a file's hunks. Only with both contents: a patch alone has none. */\nexport function contextGaps(file: ParsedFileDiff): ContextGap[] {\n  if (file.oldContent === undefined || file.newContent === undefined || file.binary || file.hunks.length === 0)\n    return [];\n  const gaps: ContextGap[] = [];\n  let next = 1;\n  let delta = 0;\n  file.hunks.forEach((hunk, i) => {\n    // jsdiff numbers a hunk with no original lines by the line it goes before, so this holds for all.\n    if (hunk.oldStart - 1 >= next) gaps.push({ before: i, oldStart: next, oldEnd: hunk.oldStart - 1, delta });\n    next = hunk.oldStart + hunk.oldLines;\n    delta = hunk.newStart + hunk.newLines - next;\n  });\n  const total = lineCount(file.oldContent);\n  if (total >= next) gaps.push({ before: file.hunks.length, oldStart: next, oldEnd: total, delta });\n  return gaps;\n}\n\n/** A file's original lines, split once. */\nconst originalLines = new WeakMap<ParsedFileDiff, string[]>();\n\n/** The unchanged lines `oldStart…oldEnd` of a gap, as context lines numbered in both files. */\nexport function gapLines(file: ParsedFileDiff, gap: ContextGap, oldStart: number, oldEnd: number): DiffLine[] {\n  let lines = originalLines.get(file);\n  if (!lines) originalLines.set(file, (lines = (file.oldContent ?? '').split('\\n')));\n  const out: DiffLine[] = [];\n  for (let n = Math.max(oldStart, gap.oldStart); n <= Math.min(oldEnd, gap.oldEnd); n++) {\n    out.push({ type: 'context', content: lines[n - 1] ?? '', oldNumber: n, newNumber: n + gap.delta });\n  }\n  return out;\n}\n\n/** How a file came out of the review. `partial`: some of its changes were accepted, not all. */\nexport type FileDecision = 'accepted' | 'rejected' | 'partial' | 'pending' | 'unchanged';\n\n/** One file of a `DiffReviewResult`: how it came out of the review. */\nexport interface DiffReviewFileResult {\n  /** The file's path, as in its `FileChange`. */\n  path: string;\n  /** The path before, for a renamed file. */\n  oldPath?: string | undefined;\n  /** What the edit does to the file: `added`, `deleted`, `modified` or `renamed`. */\n  status: FileStatus;\n  /** `true` for a binary file, which is decided as a whole; absent otherwise. */\n  binary?: true | undefined;\n  /**\n   * `accepted` when every change was accepted, `rejected` when every one was, `partial` for some of\n   * each or some left undecided, `pending` when none was decided, `unchanged` with nothing to decide.\n   */\n  decision: FileDecision;\n  /** Marked \"Viewed\" by the reviewer. */\n  viewed: boolean;\n  /** File contents with only accepted hunks applied. `undefined` for patch-only input. */\n  content: string | undefined;\n  /** The ids of its accepted items: hunk ids, or `${fileId}:file` for a file with no hunks, decided as a whole. */\n  accepted: string[];\n  /** The ids of its rejected items, like `accepted`. */\n  rejected: string[];\n  /** The ids of its undecided items, like `accepted`. They are not applied to `content`. */\n  pending: string[];\n}\n\n/** A hunk the reviewer turned down, with its lines, so the agent knows exactly what not to do. */\nexport interface DiffReviewRejectedHunk {\n  /** The hunk's id, `${fileId}:${index}`, as in the decisions. */\n  id: string;\n  /** The file's path. */\n  path: string;\n  /** The hunk's header line in a unified diff, with where it starts and how many lines it spans in each file. */\n  header: string;\n  /** The hunk as unified diff lines: ` ` unchanged, `-` removed, `+` added. */\n  diff: string;\n}\n\n/** A comment as the result carries it: with the lines it is on, when the review has them. */\nexport interface DiffReviewResultComment extends DiffReviewComment {\n  /** The lines commented on, as unified diff lines, when they are in the review. */\n  excerpt?: string | undefined;\n}\n\n/**\n * What `onSubmit` gets: each file as reviewed, the decisions counted, the rejected hunks and the\n * comments. `reviewToolOutput` shapes it for the agent.\n */\nexport interface DiffReviewResult {\n  /** Each file in the review, in the order of `files`: its decision, its contents as applied and its item ids. */\n  files: DiffReviewFileResult[];\n  /** How many items were accepted, across the review: hunks, and files with no hunks (decided as a whole). */\n  accepted: number;\n  /** How many items were rejected, counted like `accepted`. */\n  rejected: number;\n  /** How many items were left undecided, counted like `accepted`. */\n  pending: number;\n  /**\n   * Each hunk the reviewer rejected, with its lines, so the agent knows what not to do, including\n   * every hunk of a file or a review rejected at once (Alt+R rejects each hunk of the file). A file\n   * with no hunks (binary, a rename, an empty file) is decided as a whole and appears only in its\n   * file's `rejected`.\n   */\n  rejectedHunks: DiffReviewRejectedHunk[];\n  /** Every comment, with the lines it is on (`excerpt`) when they are in the review. */\n  comments: DiffReviewResultComment[];\n}\n\nexport function fileDecision(items: readonly ReviewItem[], decisions: Readonly<Record<string, HunkDecision>>) {\n  if (items.length === 0) return 'unchanged' as const;\n  const states = new Set(items.map((item) => decisions[item.id] ?? 'pending'));\n  if (states.size === 1) return [...states][0] as 'accepted' | 'rejected' | 'pending';\n  return 'partial' as const;\n}\n\n/** Compute the review result for a set of parsed files, decisions, comments and viewed files. */\nexport function computeReviewResult(\n  files: readonly ParsedFileDiff[],\n  decisions: Readonly<Record<string, HunkDecision>>,\n  {\n    comments = [],\n    viewed = {},\n  }: { comments?: readonly DiffReviewComment[]; viewed?: Readonly<Record<string, boolean>> } = {},\n): DiffReviewResult {\n  const rejectedHunks: DiffReviewRejectedHunk[] = [];\n  const out: DiffReviewFileResult[] = files.map((file) => {\n    const items = reviewItems(file);\n    const pick = (d: HunkDecision) => items.filter((item) => (decisions[item.id] ?? 'pending') === d).map((i) => i.id);\n    const accepted = pick('accepted');\n    for (const hunk of file.hunks) {\n      if (decisions[hunk.id] === 'rejected')\n        rejectedHunks.push({ id: hunk.id, path: file.path, header: hunk.header, diff: diffText(hunk.lines) });\n    }\n    const known = file.oldContent !== undefined && file.newContent !== undefined;\n    // A file decided as a whole is its proposed contents once accepted, its original otherwise.\n    const whole = items.length === 1 && items[0]!.hunk === undefined;\n    const content = !known\n      ? undefined\n      : whole\n        ? accepted.length > 0\n          ? file.newContent\n          : file.oldContent\n        : applyHunks(file, accepted);\n    return {\n      path: file.path,\n      ...(file.oldPath !== undefined && file.oldPath !== file.path ? { oldPath: file.oldPath } : {}),\n      status: file.status,\n      ...(file.binary ? { binary: true as const } : {}),\n      decision: fileDecision(items, decisions),\n      viewed: viewed[file.id] === true,\n      content,\n      accepted,\n      rejected: pick('rejected'),\n      pending: pick('pending'),\n    };\n  });\n  const byId = new Map(files.map((file) => [file.id, file]));\n  return {\n    files: out,\n    accepted: out.reduce((n, f) => n + f.accepted.length, 0),\n    rejected: out.reduce((n, f) => n + f.rejected.length, 0),\n    pending: out.reduce((n, f) => n + f.pending.length, 0),\n    rejectedHunks,\n    comments: comments.map((comment) => {\n      const hunk = byId.get(comment.fileId)?.hunks.find((h) => h.id === comment.hunkId);\n      const span = hunk && commentSpan(hunk, comment);\n      return span ? { ...comment, excerpt: diffText(hunk.lines.slice(span[0], span[1] + 1)) } : { ...comment };\n    }),\n  };\n}\n\nconst NO_NEWLINE = '\\\\ No newline at end of file';\n\nfunction patchLines(hunk: DiffHunk): string[] {\n  return hunk.lines.flatMap((line) => {\n    const text = SIGN[line.type] + line.content;\n    return line.noNewline ? [text, NO_NEWLINE] : [text];\n  });\n}\n\n/**\n * The accepted changes as a git-style unified diff, from the original files: `git apply` and\n * `patch -p1` read it. Rejected and undecided changes are left out, and the hunks after them are\n * renumbered to match. A binary file accepted is listed as changed, without its contents.\n */\nexport function toPatch(files: readonly ParsedFileDiff[], decisions: Readonly<Record<string, HunkDecision>>): string {\n  const out: string[] = [];\n  for (const file of files) {\n    const items = reviewItems(file);\n    const accepted = items.filter((item) => decisions[item.id] === 'accepted');\n    if (accepted.length === 0) continue;\n    const oldPath = file.oldPath ?? file.path;\n    const added = file.status === 'added';\n    const deleted = file.status === 'deleted';\n    if (file.binary) {\n      out.push(\n        `diff --git a/${oldPath} b/${file.path}\\n` +\n          (added ? 'new file mode 100644\\n' : deleted ? 'deleted file mode 100644\\n' : '') +\n          `Binary files ${added ? '/dev/null' : `a/${oldPath}`} and ${deleted ? '/dev/null' : `b/${file.path}`} differ\\n`,\n      );\n      continue;\n    }\n    let offset = 0;\n    const hunks = accepted\n      .flatMap((item) => (item.hunk ? [item.hunk] : []))\n      .map((hunk) => {\n        const renumbered = {\n          oldStart: hunk.oldStart,\n          oldLines: hunk.oldLines,\n          newStart: hunk.oldStart + offset,\n          newLines: hunk.newLines,\n          lines: patchLines(hunk),\n        };\n        offset += hunk.newLines - hunk.oldLines;\n        return renumbered;\n      });\n    const patch: StructuredPatch = {\n      oldFileName: added ? '/dev/null' : `a/${oldPath}`,\n      newFileName: deleted ? '/dev/null' : `b/${file.path}`,\n      oldHeader: undefined,\n      newHeader: undefined,\n      hunks,\n      isGit: true,\n      ...(added ? { isCreate: true } : {}),\n      ...(deleted ? { isDelete: true } : {}),\n      ...(file.status === 'renamed' ? { isRename: true } : {}),\n    };\n    out.push(formatPatch(patch));\n  }\n  return out.join('');\n}\n\n/** The review as the agent reads it, for `addToolOutput` (AI SDK) or an AG-UI resume payload. */\nexport interface DiffReviewToolOutput {\n  /** One sentence: what was applied, rejected, left undecided and commented. */\n  summary: string;\n  /** Decisions across the review: hunks, and files decided as a whole. */\n  accepted: number;\n  rejected: number;\n  pending: number;\n  files: Array<{\n    path: string;\n    oldPath?: string;\n    status: FileStatus;\n    decision: FileDecision;\n    /** `true` on a file the reviewer marked \"Viewed\", and absent on the others. */\n    viewed?: true;\n    /** With `contents` (the default): the file with only the accepted hunks applied. */\n    content?: string;\n  }>;\n  rejectedHunks: Array<{ path: string; header: string; diff: string }>;\n  comments: Array<{\n    path: string;\n    side?: CommentSide;\n    startLine?: number;\n    endLine?: number;\n    /** With `side: 'new'` and removed lines among the lines commented on: their range in the original. */\n    oldStartLine?: number;\n    oldEndLine?: number;\n    text: string;\n    excerpt?: string;\n  }>;\n  /**\n   * Each decided item by id, a hunk's `${path}:${index}` or a whole file's `${path}:file`, as in\n   * `DiffReview`'s `decisions`: what `decisionsFromToolOutput` reads to show the review again.\n   * Undecided items are left out. Absent from outputs saved by versions before it was added.\n   */\n  decisions?: Record<string, Exclude<HunkDecision, 'pending'>>;\n  /** With `patch`: the accepted changes as a unified diff. */\n  patch?: string;\n}\n\nexport interface DiffReviewToolOutputOptions {\n  /** Include each file's contents as applied. Default `true`; turn off when sending a `patch`. */\n  contents?: boolean;\n  /** A unified diff of the accepted changes, e.g. `toPatch()` from `onSubmit`'s second argument. */\n  patch?: string;\n}\n\nconst plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;\n\nfunction summarize(result: DiffReviewResult) {\n  const total = result.accepted + result.rejected + result.pending;\n  const changed = result.files.filter((f) => f.accepted.length > 0).length;\n  const parts = [\n    `Applied ${result.accepted} of ${plural(total, 'change')}${changed ? ` in ${plural(changed, 'file')}` : ''}`,\n  ];\n  if (result.rejected) parts.push(`rejected ${result.rejected}`);\n  if (result.pending) parts.push(`left ${result.pending} undecided`);\n  const comments = result.comments.length ? ` The reviewer left ${plural(result.comments.length, 'comment')}.` : '';\n  return `${parts.join(', ')}.${comments}`;\n}\n\n/**\n * A review result shaped for the agent: a one-line summary, each file's decision (with its contents\n * by default, and `viewed: true` when the reviewer marked it viewed), the rejected hunks with their\n * lines, every comment with its lines, and each decided hunk by id, so the review can be shown\n * again from it (`decisionsFromToolOutput`). JSON, so it is an AI SDK tool output as it is:\n * `addToolOutput({ tool, toolCallId, output: reviewToolOutput(result) })`.\n * The summary is written in English for the model, whatever language the UI is in.\n */\nexport function reviewToolOutput(\n  result: DiffReviewResult,\n  { contents = true, patch }: DiffReviewToolOutputOptions = {},\n): DiffReviewToolOutput {\n  return {\n    summary: summarize(result),\n    accepted: result.accepted,\n    rejected: result.rejected,\n    pending: result.pending,\n    files: result.files.map((file) => ({\n      path: file.path,\n      ...(file.oldPath ? { oldPath: file.oldPath } : {}),\n      status: file.status,\n      decision: file.decision,\n      ...(file.viewed ? { viewed: true as const } : {}),\n      ...(contents && file.content !== undefined ? { content: file.content } : {}),\n    })),\n    rejectedHunks: result.rejectedHunks.map(({ path, header, diff }) => ({ path, header, diff })),\n    comments: result.comments.map(({ path, side, startLine, endLine, oldStartLine, oldEndLine, text, excerpt }) => ({\n      path,\n      ...(side ? { side } : {}),\n      ...(startLine !== undefined ? { startLine } : {}),\n      ...(endLine !== undefined ? { endLine } : {}),\n      ...(oldStartLine !== undefined && oldEndLine !== undefined ? { oldStartLine, oldEndLine } : {}),\n      text,\n      ...(excerpt ? { excerpt } : {}),\n    })),\n    decisions: Object.fromEntries(\n      result.files.flatMap((file) => [\n        ...file.accepted.map((id) => [id, 'accepted'] as const),\n        ...file.rejected.map((id) => [id, 'rejected'] as const),\n      ]),\n    ),\n    ...(patch !== undefined ? { patch } : {}),\n  };\n}\n\n/**\n * The decisions a review's tool output records, keyed as `DiffReview`'s `decisions`: to show a\n * review again from its saved result, read-only, with the same `files`. Anything that is not such\n * an output, or one made before it recorded decisions, gives none.\n */\nexport function decisionsFromToolOutput(output: unknown): Record<string, HunkDecision> {\n  const recorded = (output as { decisions?: unknown } | null | undefined)?.decisions;\n  const decisions: Record<string, HunkDecision> = {};\n  if (!recorded || typeof recorded !== 'object') return decisions;\n  for (const [id, decision] of Object.entries(recorded)) {\n    if (decision === 'accepted' || decision === 'rejected') decisions[id] = decision;\n  }\n  return decisions;\n}\n\n/** An AG-UI `ResumeEntry` answering an interrupt with a review (`AgUiResumeEntry`, typed here). */\nexport interface DiffReviewResumeEntry {\n  interruptId: string;\n  status: 'resolved';\n  payload: DiffReviewToolOutput;\n}\n\n/**\n * The review as the answer to an AG-UI interrupt: pass it to `useAgUiAgent`'s `resolve`, or put it\n * in `RunAgentInput.resume` yourself. The payload is `reviewToolOutput(result, options)`.\n */\nexport function reviewResumeEntry(\n  interruptId: string,\n  result: DiffReviewResult,\n  options?: DiffReviewToolOutputOptions,\n): DiffReviewResumeEntry {\n  return { interruptId, status: 'resolved', payload: reviewToolOutput(result, options) };\n}\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/review.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/scroll-region.ts",
      "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useRef } from 'react';\n\nconst useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\n/**\n * Keyboard access to a horizontal scroller (WCAG 2.1.1). While its content is wider than it is, it\n * joins the tab order and, given a `label`, becomes a named group, so the arrow keys scroll it\n * natively; once everything fits it leaves the tab order again and adds no tab stop. Measured after\n * every render (streamed content widens it) and whenever it or its content resizes.\n *\n * A group rather than a region, as `JsonView` does: a long conversation with many code blocks\n * would otherwise flood landmark navigation, with several landmarks named \"Code, ts\".\n *\n * The attributes are set on the element directly rather than through state: measuring needs\n * layout, and a re-render on every resize would re-render the content inside too.\n */\nexport function useScrollRegion<T extends HTMLElement>(label?: string) {\n  const ref = useRef<T>(null);\n  const sync = useCallback(() => {\n    const el = ref.current;\n    if (!el) return;\n    if (el.scrollWidth > el.clientWidth + 1) {\n      el.tabIndex = 0;\n      if (label) {\n        el.setAttribute('role', 'group');\n        el.setAttribute('aria-label', label);\n      }\n    } else {\n      el.removeAttribute('tabindex');\n      if (label) {\n        el.removeAttribute('role');\n        el.removeAttribute('aria-label');\n      }\n    }\n  }, [label]);\n\n  useIsoLayoutEffect(() => {\n    sync();\n  });\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === 'undefined') return;\n    const observer = new ResizeObserver(sync);\n    observer.observe(el);\n    for (const child of el.children) observer.observe(child);\n    return () => observer.disconnect();\n  }, [sync]);\n\n  return ref;\n}\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/scroll-region.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/untrusted.ts",
      "content": "/**\n * Text and URLs from the agent, shown to the person who decides on them. A prompt injection can\n * write them too, so the components show what would make them read as something other than what\n * runs or opens: characters that do not show, a long run of spaces or of blank lines, and a link\n * whose text names another host. No React here, so servers can call these too.\n */\n\n/**\n * Characters that do not show: format characters (Default_Ignorable_Code_Point: bidi controls,\n * zero-width spaces and joiners, Unicode tags, variation selectors, fillers), the interlinear\n * annotation characters, control characters, and the line and paragraph separators. Tab, newline\n * and a carriage return that ends a line show as what they are, and are left out in `invisible` below.\n */\nconst INVISIBLE = /[\\p{Default_Ignorable_Code_Point}\\p{Cc}\\p{Zl}\\p{Zp}\\uFFF9-\\uFFFB]/u;\n/** The quick test: text with none of these has nothing to show. */\nconst ANY_INVISIBLE = /(?![\\t\\n\\r])[\\p{Cc}\\p{Default_Ignorable_Code_Point}\\p{Zl}\\p{Zp}\\uFFF9-\\uFFFB]|\\r(?!\\n|$)/u;\n/** Spaces other than U+0020, and the blank braille pattern: they look like a space and are not one. */\nconst OTHER_SPACE = /[\\u00A0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000\\u2800]/u;\nconst ANY_BLANK = /[ \\t]{2}|[\\u00A0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000\\u2800]|\\n(?:[ \\t\\r]*\\n){3}/u;\nconst PICTOGRAPH = /\\p{Extended_Pictographic}/u;\n/** A character that can be an emoji, which a variation selector after it shows as one or as text. */\nconst EMOJI = /\\p{Emoji}/u;\nconst IN_EMOJI = /[\\p{Extended_Pictographic}\\p{Emoji_Modifier}\\uFE0F]/u;\n/** A run of spaces and tabs this wide, a tab counting 8, shows as a mark: about a third of a narrow card. */\nconst BLANK_RUN = 16;\n/**\n * A run of blank characters with this many blank lines in it or more, so 4 line breaks, shows as a\n * mark: blank lines push what follows down, out of view below. Code has up to 2 in a row (PEP 8\n * between definitions), and keeps them.\n */\nconst BLANK_LINES = 3;\n\nexport interface RevealTextOptions {\n  /**\n   * Also mark spaces: a run of spaces and tabs 16 columns wide or more, as its count, a run of 3\n   * blank lines or more, as its count too, and spaces other than U+0020, as their code points. For a\n   * command or a path; not for code, which lines up with spaces.\n   */\n  blanks?: boolean | undefined;\n}\n\n/** A run of characters to show as a mark, in place of the characters. */\nexport interface RevealedMark {\n  /** `invisible`: characters that do not show. `blank`: a long run of spaces and tabs, or of blank lines. */\n  kind: 'invisible' | 'blank';\n  /** The characters. */\n  text: string;\n  /** How many characters there are. */\n  count: number;\n  /** For a run of blank lines, how many line breaks it has, which the mark shows (`↵ ×60`); 0 for any other mark. */\n  lines: number;\n  /** Their code points, each once, in order (`U+202E`); empty for a blank run. */\n  codes: string[];\n  /** What the mark shows: `U+202E`, `U+200B ×240`, `␣ ×240`, and `↵ ×60` for a run of 60 line breaks. */\n  shown: string;\n}\n\n/** A part of the text: as it is, or a mark. */\nexport type RevealedPart = string | RevealedMark;\n\nconst codeOf = (char: string) => `U+${char.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`;\n\n/**\n * The quick test: false when `revealText` would mark nothing in the text. True does not always mean\n * it marks something: the joiner in an emoji, or a run of 2 spaces, is left as it is.\n */\nexport function hasHidden(text: string, options: RevealTextOptions = {}): boolean {\n  return ANY_INVISIBLE.test(text) || (!!options.blanks && ANY_BLANK.test(text));\n}\n\n/**\n * The text in parts, with marks in place of what would make it read as something it is not:\n * characters that do not show, such as a bidi override (`U+202E`), which shows the text after it\n * reversed, or a zero-width space, and with `blanks`, long runs of spaces or of blank lines. Text\n * with none of them is one part, the text itself. The joiners and variation selectors in an emoji\n * are left as they are.\n */\nexport function revealText(text: string, options: RevealTextOptions = {}): RevealedPart[] {\n  if (!hasHidden(text, options)) return [text];\n  const chars = Array.from(text);\n  const parts: RevealedPart[] = [];\n  let plain = '';\n  let mark: RevealedMark | undefined;\n  /** The spaces, tabs and line breaks since the last other character, which may make a long run. */\n  let run = '';\n  const blank = (blanks: string, lines: number) => {\n    if (plain) parts.push(plain);\n    plain = '';\n    const count = blanks.length;\n    // A run of blank lines shows how many lines it pushes down, not its characters: the spaces on\n    // those lines and the carriage returns of CRLF count in `count` only.\n    const shown = lines ? `↵ ×${lines}` : `␣ ×${count}`;\n    parts.push({ kind: 'blank', text: blanks, count, lines, codes: [], shown });\n  };\n  const flushRun = () => {\n    const lines = run.split('\\n').length - 1;\n    // Enough blank lines to push what follows out of view below are one mark, line breaks and all.\n    if (lines > BLANK_LINES) blank(run, lines);\n    else {\n      // Otherwise each line's spaces count on their own, and the line breaks stay as they are.\n      for (const piece of run.split(/(\\r?\\n)/)) {\n        let width = 0;\n        for (const char of piece) width += char === '\\t' ? 8 : char === ' ' ? 1 : 0;\n        if (width >= BLANK_RUN) blank(piece, 0);\n        else plain += piece;\n      }\n    }\n    run = '';\n  };\n  const invisible = (char: string, i: number) => {\n    if (char === '\\t' || char === '\\n') return false;\n    if (char === '\\r') return i < chars.length - 1 && chars[i + 1] !== '\\n';\n    // The joiner between two emoji that make one (woman, joiner, laptop), and a variation selector\n    // after a character that can be an emoji, such as the one that makes a heart an emoji; after a\n    // digit, `#` or `*`, only in a keycap (1, U+FE0F, U+20E3). After a letter one shows nothing, and\n    // would hide in a word or a host: `git` U+FE0F `hub.com`. Compared by code point: a bundler may\n    // print the escape in a string as the character itself, which does not show.\n    const code = char.codePointAt(0);\n    if (code === 0x200d) return !(IN_EMOJI.test(chars[i - 1] ?? '') && PICTOGRAPH.test(chars[i + 1] ?? ''));\n    if (code === 0xfe0e || code === 0xfe0f) {\n      const before = chars[i - 1];\n      if (before === undefined || !EMOJI.test(before)) return true;\n      return /^[\\d#*]$/.test(before) && chars[i + 1]?.codePointAt(0) !== 0x20e3;\n    }\n    return INVISIBLE.test(char) || (!!options.blanks && OTHER_SPACE.test(char));\n  };\n  chars.forEach((char, i) => {\n    if (invisible(char, i)) {\n      if (run) flushRun();\n      if (!mark) {\n        if (plain) parts.push(plain);\n        plain = '';\n        mark = { kind: 'invisible', text: '', count: 0, lines: 0, codes: [], shown: '' };\n        parts.push(mark);\n      }\n      mark.text += char;\n      mark.count++;\n      const code = codeOf(char);\n      if (!mark.codes.includes(code)) mark.codes.push(code);\n      return;\n    }\n    mark = undefined;\n    // A carriage return here ends a line: one inside a line is a mark, above.\n    if (options.blanks && (char === ' ' || char === '\\t' || char === '\\n' || char === '\\r')) {\n      run += char;\n      return;\n    }\n    if (run) flushRun();\n    plain += char;\n  });\n  if (run) flushRun();\n  if (plain) parts.push(plain);\n  for (const part of parts) {\n    if (typeof part === 'string' || part.kind === 'blank') continue;\n    const { codes, count } = part;\n    part.shown =\n      codes.length <= 2 && count === codes.length\n        ? codes.join(' ')\n        : `${codes[0]}${codes.length > 1 ? ' …' : ''} ×${count}`;\n  }\n  return parts;\n}\n\n/** The classes of a mark: a small badge in the warning color, on one line with the text. */\nexport const MARK_CLASS =\n  'bg-signoff-warn/12 font-signoff-mono text-signoff-warn-fg ring-signoff-warn/40 mx-px inline-block rounded-[3px] px-0.5 text-[0.85em] leading-none whitespace-pre ring-1 ring-inset';\n\nexport interface SafeUrlOptions {\n  /** Also allow `data:` and `blob:` URLs, which an attached file or an image can have. */\n  files?: boolean | undefined;\n}\n\n/**\n * The URL when a link may use it: http, https, mailto, a relative URL, and with `files`, `data:`\n * and `blob:`. Anything else, `javascript:` first, is `undefined`, to render as text instead: React\n * 18 renders a `javascript:` link as it is, and React 19 only replaces it with one that throws.\n */\nexport function safeUrl(url: string | null | undefined, options: SafeUrlOptions = {}): string | undefined {\n  if (!url) return undefined;\n  // A scheme ends at the first colon, before any slash, question mark or hash; without one, the URL\n  // is relative. Browsers skip leading spaces and control characters, so this does too, after\n  // reading the scheme: a regex that skips them first reads a long run of them with no colon again\n  // from each of its characters.\n  const scheme = /^([^:/?#]*):/u\n    .exec(url)?.[1]\n    ?.replace(/^[\\p{Cc} ]+/u, '')\n    .toLowerCase();\n  if (scheme === undefined || scheme === 'http' || scheme === 'https' || scheme === 'mailto') return url;\n  return options.files && (scheme === 'data' || scheme === 'blob') ? url : undefined;\n}\n\n/**\n * Each host link text names, anywhere in it. Group 3: in a URL (`https://github.com/x`), after\n * `www.`, or in an email address, read with the rest of the URL; the name before its `@` is group\n * 1 in a URL (`https://github.com@evil.example`) and group 2 in an email address. Group 4: a word\n * that reads as a domain (`Visit github.com →`, `/github.com`) or an IP address (`8.8.8.8`), read\n * with its path, if any, as group 5; not the name before an `@` (`first.last@`). Each starts only\n * where a word does, so that long text is read once, not once from each of its characters.\n */\nconst NAMED_HOST =\n  /(?:(?<![a-z\\d+.-])[a-z\\d+.-]*:\\/\\/(?:([^\\s/?#@]*)@)?|\\bwww\\.|(?<![^\\s@<>()[\\]/])([^\\s@<>()[\\]/]+)@)([\\p{L}\\p{N}-]+(?:\\.[\\p{L}\\p{N}-]+)*)\\S*|(?<![\\p{L}\\p{M}\\p{N}-]|[\\p{L}\\p{M}\\p{N}-]\\.)((?:[\\p{L}\\p{M}\\p{N}-]+\\.)+\\p{L}[\\p{L}\\p{M}\\p{N}-]+|(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d))(?![\\p{L}\\p{M}\\p{N}-]|[.@][\\p{L}\\p{M}\\p{N}-])\\.?([/?#:]\\S*)?/giu;\n/** A whole word that reads as a domain or an IP address, as group 4 of `NAMED_HOST` does. */\nconst DOMAIN =\n  /^(?:(?:[\\p{L}\\p{M}\\p{N}-]+\\.)+\\p{L}[\\p{L}\\p{M}\\p{N}-]+|(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d))$/u;\n/**\n * Characters a person reads as a dot, which read as one here: the full stops a URL reads as dots,\n * ideographic (U+3002), full-width (U+FF0E) and halfwidth ideographic (U+FF61), so `github` U+3002\n * `com` goes to github.com; and the stops that only look like one, the one dot leader (U+2024), the\n * small full stop (U+FE52), the raised and middle dots (U+2E33, U+00B7), and the Syriac and Vai full\n * stops (U+0701, U+0702, U+A60E). Only between letters or digits, not beside Chinese, Japanese,\n * Korean, Syriac or Vai, where such a stop ends a sentence, and a middle dot not between two l's,\n * as in Catalan (`col\\u00B7legi`). The Arabic zeros (U+0660, U+06F0) and the Lisu tone mark U+A4F8, which\n * are a digit and a letter, only between Latin letters.\n */\nconst OTHER_DOT =\n  /(?<=[\\p{L}\\p{N}])(?<![\\p{scx=Han}\\p{scx=Hiragana}\\p{scx=Katakana}\\p{scx=Hangul}\\p{scx=Syriac}\\p{scx=Vai}])(?:[\\u3002\\uFF0E\\uFF61\\u2024\\uFE52\\u2E33\\u0701\\u0702\\uA60E]|(?<![lL])\\u00B7|\\u00B7(?![lL]))(?=[\\p{L}\\p{N}])(?![\\p{scx=Han}\\p{scx=Hiragana}\\p{scx=Katakana}\\p{scx=Hangul}\\p{scx=Syriac}\\p{scx=Vai}])|(?<=\\p{scx=Latin})[\\u0660\\u06F0\\uA4F8](?=\\p{scx=Latin})/gu;\n/** A file's or a product's name, which reads like a domain and is not one: `README.md`, `Next.js`. */\nconst FILE_NAME =\n  /\\.(?:[cm]?[jt]sx?|json|mdx?|s?css|html?|py|rb|go|rs|sh|txt|ya?ml|toml|lock|vue|svelte|php|java|kt|swift|cpp|cs|sql|xml|csv|log|zip|pdf|png|jpe?g|gif|svg|webp)$/i;\n/** Of those, the extensions that are top-level domains too: `docs.rs`, `bun.sh`, `get.zip`. */\nconst TLD_EXTENSION = /\\.(?:md|py|rs|sh|zip)$/i;\n\n/** A host as the browser resolves it (lowercase, look-alike letters in punycode), without `www.`. */\nfunction normalHost(host: string): string | undefined {\n  try {\n    return new URL(`http://${host}`).hostname.replace(/^www\\./, '').replace(/\\.$/, '');\n  } catch {\n    return undefined;\n  }\n}\n\n/** Whether a word in a name, such as the one before an `@`, reads as a domain other than `target`. */\nfunction namesOtherDomain(name: string, target: string): boolean {\n  return name.split(/[^\\p{L}\\p{M}\\p{N}.-]/u).some((word) => {\n    const domain = word.replace(/^\\.+|\\.+$/g, '');\n    return DOMAIN.test(domain) && normalHost(domain) !== target;\n  });\n}\n\n/** The last segment of a URL's path, decoded and in lowercase: `readme.md` for `/a/b/README.md`. */\nfunction lastSegment(url: URL): string {\n  const segment = url.pathname.split('/').pop() ?? '';\n  try {\n    return decodeURIComponent(segment).toLowerCase();\n  } catch {\n    return segment.toLowerCase();\n  }\n}\n\n/**\n * The host a link goes to, when its text could make a person think it goes elsewhere: the text\n * names another host anywhere in it (`Visit github.com →`), counting any word that reads as a\n * domain or an IP address and is not a file's name, or a domain before an `@`\n * (`github.com@evil.example`), or names one in look-alike letters (the host is in punycode,\n * `xn--`), or has characters that do not show, or the URL puts a name before the host\n * (`https://github.com@evil.example`). Otherwise `undefined`, and the link needs nothing beside it.\n */\nexport function linkHostMismatch(text: string, href: string): string | undefined {\n  let url: URL;\n  let target: string | undefined;\n  try {\n    // Against a base of its own, so that a relative URL, which stays on the page's site, has no host\n    // to show, and `//evil.example/x` has evil.example.\n    url = new URL(href, 'https://self.invalid');\n    if (url.host === 'self.invalid') return undefined;\n    if (url.protocol === 'http:' || url.protocol === 'https:') target = normalHost(url.hostname);\n    else if (url.protocol === 'mailto:') target = normalHost(url.pathname.split('@').pop() ?? '');\n  } catch {\n    return undefined;\n  }\n  if (!target) return undefined;\n  // What comes before an @ reads as the host, and is only a user name: whatever the text says.\n  if (url.username || url.password) return target;\n  const trimmed = text.trim();\n  // Characters that do not show, as `revealText` marks them: not the joiner or the variation\n  // selector inside an emoji, as in a red heart or a woman at a laptop.\n  if (revealText(trimmed).some((part) => typeof part !== 'string')) return target;\n  // Those left are an emoji's, and split no name: read without them, so that one after a host does\n  // not keep the text from naming it. Characters that read as a dot read as one.\n  const shown = trimmed.replace(/\\u200D|\\uFE0E|\\uFE0F/gu, '').replace(OTHER_DOT, '.');\n  const mailto = url.protocol === 'mailto:';\n  let named = false;\n  for (const [, user, local, host, domain, path] of shown.matchAll(NAMED_HOST)) {\n    // A domain before an @ reads as the host, as in the URL: `https://github.com@evil.example`, and\n    // `github.com@evil.example`, unless that is the email address a mailto link writes to.\n    const before = user ?? (mailto ? undefined : local);\n    if (before && namesOtherDomain(before, target)) return target;\n    // A name such as `README.md` is a file's, unless its extension is a top-level domain too, as in\n    // `docs.rs`: then only when the link goes to that file.\n    if (\n      domain &&\n      FILE_NAME.test(domain) &&\n      (!TLD_EXTENSION.test(domain) || (!path && lastSegment(url) === domain.toLowerCase()))\n    )\n      continue;\n    if (normalHost(host ?? domain!) !== target) return target;\n    named = true;\n  }\n  // The text names only the host it goes to: still shown when that host is in look-alike letters.\n  return named && target.split('.').some((label) => label.startsWith('xn--')) ? target : undefined;\n}\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/untrusted.ts"
    },
    {
      "path": "packages/signoff-ui/src/lib/utils.ts",
      "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\n/** Merge class names, resolving Tailwind utility conflicts (last one wins). */\nexport function cn(...inputs: ClassValue[]): string {\n  return twMerge(clsx(inputs));\n}\n\n/** True when a keyboard event originates from a text-entry control. */\nexport function isTypingTarget(target: EventTarget | null): boolean {\n  if (!(target instanceof HTMLElement)) return false;\n  if (target.isContentEditable) return true;\n  const tag = target.tagName;\n  if (tag === 'TEXTAREA' || tag === 'SELECT') return true;\n  if (tag === 'INPUT') {\n    const type = (target as HTMLInputElement).type;\n    return !['button', 'checkbox', 'radio', 'submit', 'reset'].includes(type);\n  }\n  return false;\n}\n\n/**\n * True for a key pressed on the app's own content inside a component: an element in a\n * `data-signoff-slot` within it (such as `ApprovalCard`'s `preview`), or a portal rendered from\n * there, such as a menu, whose keys reach the component through React but come from outside its\n * element. A component that is itself in another component's slot still gets its keys, since that\n * slot is outside it.\n */\nexport function fromSlot(event: { target: EventTarget | null; currentTarget: Element }): boolean {\n  const { target, currentTarget } = event;\n  if (!(target instanceof Element)) return false;\n  if (!currentTarget.contains(target)) return true;\n  const slot = target.closest('[data-signoff-slot]');\n  return slot !== null && currentTarget.contains(slot);\n}\n\n/** True for a promise or any other thenable, e.g. what an async event handler returns. */\nexport function isPromiseLike(value: unknown): value is PromiseLike<unknown> {\n  return (\n    value !== null &&\n    (typeof value === 'object' || typeof value === 'function') &&\n    typeof (value as { then?: unknown }).then === 'function'\n  );\n}\n\n/** True when a key event carries a modifier we should not intercept. */\nexport function hasModifier(event: { altKey: boolean; ctrlKey: boolean; metaKey: boolean }) {\n  return event.altKey || event.ctrlKey || event.metaKey;\n}\n\n/** Heading levels a component title can render as. */\nexport type HeadingLevel = 2 | 3 | 4 | 5 | 6;\n",
      "type": "registry:lib",
      "target": "components/signoff-ui/lib/utils.ts"
    },
    {
      "path": "packages/signoff-ui/src/use-diff-review.ts",
      "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type MouseEvent } from 'react';\nimport {\n  DEFAULT_MAX_EDIT_LENGTH,\n  parseWithin,\n  type DiffHunk,\n  type FileChange,\n  type HunkDecision,\n  type ParsedFileDiff,\n} from './lib/diff';\nimport { defaultDiffWorker, parseInBackground, type DiffWorkerFactory } from './lib/diff-async';\nimport {\n  changedSpan,\n  commentRange,\n  computeReviewResult,\n  contextGaps,\n  fileDecision,\n  hunkRange,\n  linesRange,\n  reviewItems,\n  toPatch,\n  type CommentRange,\n  type ContextGap,\n  type DiffReviewComment,\n  type DiffReviewResult,\n  type FileDecision,\n  type ReviewItem,\n} from './lib/review';\nimport { useIsMac } from './lib/hooks';\nimport { diffReviewLabels, type SignoffLabelsInput } from './lib/labels';\nimport { useLabels } from './labels';\nimport { fromSlot, hasModifier, isPromiseLike, isTypingTarget } from './lib/utils';\n\n/** The labels' sections this module reads. */\nconst LABELS = { diffReview: diffReviewLabels };\n\nexport type { DiffWorkerFactory } from './lib/diff-async';\nexport type {\n  CommentRange,\n  ContextGap,\n  DiffReviewComment,\n  DiffReviewResult,\n  FileDecision,\n  LineRange,\n  ReviewItem,\n} from './lib/review';\n\ninterface ParsedFiles {\n  files: readonly FileChange[];\n  context: number;\n  maxEditLength: number;\n  ids: string[];\n  /** One per file; `undefined` while the file is diffed in the background. */\n  parsed: (ParsedFileDiff | undefined)[];\n  /**\n   * Each file's change by id as last seen, files since left out included: what the decisions,\n   * viewed marks and comments on it were made on.\n   */\n  seen: ReadonlyMap<string, FileChange>;\n  /** The files, by id, this parse found changed since they were last seen: revised by the agent. */\n  revised: ReadonlySet<string>;\n}\n\n/**\n * A diff that needs at most this many lines added plus removed is computed while rendering, on\n * the server too: at most a few milliseconds. A larger one renders as \"Comparing…\" until the\n * worker has it. Deterministic, so the server and the hydrating client agree.\n */\nconst SYNC_EDIT_LENGTH = 300;\n/** How many unchanged lines `E` and the gap buttons show at a time. */\nexport const CONTEXT_STEP = 20;\n\n/** The same edit to review: `language` aside, which only changes its colors. */\nconst sameEdit = (a: FileChange, b: FileChange) =>\n  a.path === b.path &&\n  a.oldPath === b.oldPath &&\n  a.oldContent === b.oldContent &&\n  a.newContent === b.newContent &&\n  a.patch === b.patch &&\n  a.binary === b.binary;\n\nconst sameChange = (a: FileChange, b: FileChange) => sameEdit(a, b) && a.language === b.language;\n\n/** Equal when every key has the same value, counting a missing one as `fallback`. */\nfunction sameRecord<T>(a: Readonly<Record<string, T>>, b: Readonly<Record<string, T>>, fallback: T) {\n  if (a === b) return true;\n  for (const id of new Set([...Object.keys(a), ...Object.keys(b)])) {\n    if ((a[id] ?? fallback) !== (b[id] ?? fallback)) return false;\n  }\n  return true;\n}\n\nconst sameComments = (a: readonly DiffReviewComment[], b: readonly DiffReviewComment[]) =>\n  a === b ||\n  (a.length === b.length &&\n    a.every((c, i) => {\n      const d = b[i]!;\n      return (\n        c.id === d.id &&\n        c.text === d.text &&\n        c.fileId === d.fileId &&\n        c.hunkId === d.hunkId &&\n        c.target === d.target &&\n        c.side === d.side &&\n        c.startLine === d.startLine &&\n        c.endLine === d.endLine &&\n        c.oldStartLine === d.oldStartLine &&\n        c.oldEndLine === d.oldEndLine\n      );\n    }));\n\n/** `record` without the keys `drop` picks: the same object when it picks none. */\nfunction omitKeys<T>(record: Readonly<Record<string, T>>, drop: (key: string) => boolean): Readonly<Record<string, T>> {\n  const keys = Object.keys(record).filter(drop);\n  if (keys.length === 0) return record;\n  const rest = { ...record };\n  for (const key of keys) delete rest[key];\n  return rest;\n}\n\n/** The file id in an item id (`${fileId}:${index}`, `${fileId}:file`) or a gap's key (`${fileId}:${before}`). */\nfunction fileIdOf(key: string) {\n  const at = key.lastIndexOf(':');\n  const rest = key.slice(at + 1);\n  return at !== -1 && (rest === 'file' || /^\\d+$/.test(rest)) ? key.slice(0, at) : undefined;\n}\n\n/** `record` with only the keys `keep` picks. */\nfunction pickKeys<T>(record: Readonly<Record<string, T>>, keep: (key: string) => boolean): Readonly<Record<string, T>> {\n  const picked: Record<string, T> = {};\n  for (const key of Object.keys(record)) if (keep(key)) picked[key] = record[key]!;\n  return picked;\n}\n\n/** How one of the review's values (decisions, viewed marks or comments) holds each file's entries. */\ninterface FileEntries<T> {\n  /** No entries. */\n  none: T;\n  /** `value`'s entries for the file `id`. */\n  of: (value: T, id: string) => T;\n  /** The same entries, by value. */\n  same: (a: T, b: T) => boolean;\n  /** `value` without the entries of the files `ids` has: the same value when it has none of them. */\n  without: (value: T, ids: { has: (id: string) => boolean }) => T;\n  /** A copy for the caller to keep. */\n  copy: (value: T) => T;\n}\n\nconst DECISION_ENTRIES: FileEntries<Readonly<Record<string, HunkDecision>>> = {\n  none: {},\n  of: (value, id) => pickKeys(value, (key) => fileIdOf(key) === id),\n  same: (a, b) => sameRecord(a, b, 'pending'),\n  without: (value, ids) =>\n    omitKeys(value, (key) => {\n      const id = fileIdOf(key);\n      return id !== undefined && ids.has(id);\n    }),\n  copy: (value) => ({ ...value }),\n};\n\nconst VIEWED_ENTRIES: FileEntries<Readonly<Record<string, boolean>>> = {\n  none: {},\n  of: (value, id) => pickKeys(value, (key) => key === id),\n  same: (a, b) => sameRecord(a, b, false),\n  without: (value, ids) => omitKeys(value, (id) => ids.has(id)),\n  copy: (value) => ({ ...value }),\n};\n\nconst COMMENT_ENTRIES: FileEntries<readonly DiffReviewComment[]> = {\n  none: [],\n  of: (value, id) => value.filter((c) => c.fileId === id),\n  same: sameComments,\n  without: (value, ids) => (value.some((c) => ids.has(c.fileId)) ? value.filter((c) => !ids.has(c.fileId)) : value),\n  copy: (value) => [...value],\n};\n\n/** Entries a revision left over in one of the review's values: about the old edit, so read without. */\ninterface LeftOver<T> {\n  /** The value they were left over in. The hook's own value is read without them while it is this one. */\n  value: T;\n  /** By file id, the file's entries then. A controlled value is read without a file's while it has the same ones. */\n  files: ReadonlyMap<string, T>;\n  /** New with each revision that leaves entries over, so the owner is told once. */\n  revision: object;\n}\n\n/** `left` without the files whose entries `value` no longer has as they were: `undefined` when none are left. */\nfunction stillLeftOver<T>(\n  kind: FileEntries<T>,\n  left: LeftOver<T>,\n  value: T,\n  controlled: boolean,\n): LeftOver<T> | undefined {\n  if (value === left.value) return left;\n  // The hook's own value only changes when the reviewer acts on the review as it reads.\n  if (!controlled) return undefined;\n  let files: Map<string, T> | undefined;\n  for (const [id, entries] of left.files) {\n    if (kind.same(kind.of(value, id), entries)) continue;\n    (files ??= new Map(left.files)).delete(id);\n  }\n  if (!files) return left;\n  return files.size > 0 ? { ...left, files } : undefined;\n}\n\n/**\n * One of the review's values (decisions, viewed marks or comments) as the review reads it: without\n * a revised file's entries, which were about the edit before. The hook's own value is read without\n * them until the reviewer changes it. A controlled one is read without them while it has the same\n * entries for that file, as the same object or one built on every render, and `onChange` is called\n * once with it without them. Entries for the file that change in the render the file does are the\n * caller's say about the new edit, and are kept.\n */\nfunction useWithoutRevised<T>(\n  kind: FileEntries<T>,\n  value: T,\n  controlled: T | undefined,\n  revised: ReadonlySet<string> | undefined,\n  onChange: ((next: T) => void) | undefined,\n): T {\n  // The controlled value as of the last render, for a revision to compare with. Set only when its\n  // contents change, so a value built anew on every render settles.\n  const [previous, setPrevious] = useState(controlled);\n  const same =\n    previous === controlled || (previous !== undefined && controlled !== undefined && kind.same(previous, controlled));\n  if (!same) setPrevious(controlled);\n  const [leftOver, setLeftOver] = useState<LeftOver<T>>();\n  let left = leftOver && stillLeftOver(kind, leftOver, value, controlled !== undefined);\n  if (revised) {\n    let files: Map<string, T> | undefined;\n    for (const id of revised) {\n      const entries = kind.of(value, id);\n      if (kind.same(entries, kind.none)) continue;\n      const theirs = controlled !== undefined && (previous === undefined || !kind.same(kind.of(previous, id), entries));\n      if (theirs) continue;\n      (files ??= new Map(left?.files)).set(id, entries);\n    }\n    if (files) left = { value, files, revision: {} };\n  }\n  if (left !== leftOver) setLeftOver(left);\n  const read = useMemo(() => (left ? kind.without(value, left.files) : value), [kind, value, left]);\n  const told = useRef<object | undefined>(undefined);\n  useEffect(() => {\n    if (!leftOver || leftOver.revision === told.current) return;\n    told.current = leftOver.revision;\n    onChange?.(kind.copy(read));\n  });\n  return read;\n}\n\n/** A change's path, as a string: '' when model-written input left it out, as in `parseFileChange`. */\nconst pathOf = (change: FileChange) => String(change.path ?? '');\n\n/**\n * One id per file: its path, or for a repeated path `path#2`, `path#3`… skipping any id already\n * taken, since a real path can look like a repeat (`a.ts`, `a.ts`, `a.ts#2`).\n */\nfunction fileIds(files: readonly FileChange[]): string[] {\n  const used = new Set<string>();\n  const next = new Map<string, number>();\n  return files.map((change) => {\n    const path = pathOf(change);\n    let id = path;\n    if (used.has(id)) {\n      let n = next.get(path) ?? 2;\n      while (used.has(`${path}#${n}`)) n++;\n      next.set(path, n + 1);\n      id = `${path}#${n}`;\n    }\n    used.add(id);\n    return id;\n  });\n}\n\n/** Parse each change, reusing `previous` parses of unchanged files (diffing is the expensive part). */\nfunction parseFiles(\n  files: readonly FileChange[],\n  context: number,\n  maxEditLength: number,\n  previous?: ParsedFiles,\n): ParsedFiles {\n  // Two changes must not share hunk ids, or deciding one hunk decides the other too.\n  const ids = fileIds(files);\n  const sameOptions = previous?.context === context && previous.maxEditLength === maxEditLength;\n  const parsed = files.map((f, i) => {\n    const id = ids[i]!;\n    const before = previous?.files[i];\n    if (sameOptions && before && previous.ids[i] === id && sameChange(before, f)) return previous.parsed[i];\n    return parseWithin(f, { context, id, maxEditLength }, SYNC_EDIT_LENGTH);\n  });\n  // Keep the previous array when nothing changed, so memoized work downstream is kept too.\n  const same = previous && parsed.length === previous.parsed.length && parsed.every((p, i) => p === previous.parsed[i]);\n  const seen = new Map(previous?.seen);\n  const revised = new Set<string>();\n  files.forEach((f, i) => {\n    const before = seen.get(ids[i]!);\n    if (before && !sameEdit(before, f)) revised.add(ids[i]!);\n    seen.set(ids[i]!, f);\n  });\n  return { files, context, maxEditLength, ids, parsed: same ? previous.parsed : parsed, seen, revised };\n}\n\n/** State that is either the caller's (controlled) or the hook's own. */\nfunction useControllable<T>(value: T | undefined, initial: () => T, onChange: ((next: T) => void) | undefined) {\n  const [own, setOwn] = useState(initial);\n  const current = value ?? own;\n  const set = (next: T) => {\n    if (value === undefined) setOwn(next);\n    onChange?.(next);\n  };\n  return [current, set] as const;\n}\n\nexport interface UseDiffReviewOptions {\n  /**\n   * The edits to review, one per file, with full contents (`oldContent` and `newContent`) or a\n   * unified `patch`. Compared by content: a new array of the same edits, such as one written\n   * inline, is not diffed again. A file whose contents change starts its review over: its\n   * decisions, viewed mark and comments are dropped, and controlled ones are reported without them.\n   */\n  files: readonly FileChange[];\n  /**\n   * Controlled decisions keyed by item id: a hunk's `${path}:${index}`, or `${path}:file` for a file\n   * with no hunks (binary, renamed or empty). A repeated path gets `${path}#2`, `#3` and so on,\n   * skipping a suffix another file's path already has.\n   */\n  decisions?: Readonly<Record<string, HunkDecision>> | undefined;\n  /**\n   * The decisions to start with, keyed like `decisions`, when the review keeps its own: to restore\n   * a review in progress, say. Read on the first render only.\n   */\n  defaultDecisions?: Readonly<Record<string, HunkDecision>> | undefined;\n  /**\n   * Called with every decision, keyed like `decisions`, whenever one changes: an item accepted,\n   * rejected or reset, or a whole file or the whole review decided at once.\n   */\n  onDecisionsChange?: ((decisions: Record<string, HunkDecision>) => void) | undefined;\n  /** Controlled comments. */\n  comments?: readonly DiffReviewComment[] | undefined;\n  /** The comments to start with, when the review keeps its own. Read on the first render only. */\n  defaultComments?: readonly DiffReviewComment[] | undefined;\n  /** Called with every comment whenever one is added, edited or removed. */\n  onCommentsChange?: ((comments: DiffReviewComment[]) => void) | undefined;\n  /** Controlled \"Viewed\" marks, by file id (the path, or `path#2`… for a repeated path). */\n  viewed?: Readonly<Record<string, boolean>> | undefined;\n  /** The \"Viewed\" marks to start with, by file id, when the review keeps its own. Read on the first render only. */\n  defaultViewed?: Readonly<Record<string, boolean>> | undefined;\n  /** Called with every \"Viewed\" mark, by file id, whenever one is set or cleared. */\n  onViewedChange?: ((viewed: Record<string, boolean>) => void) | undefined;\n  /**\n   * Called with the reviewed result, and a `toPatch` that gives the accepted changes as a unified\n   * diff. Pending hunks are not applied. Fires once per review state, so a double click or a\n   * repeated ⌘/Ctrl+Enter sends one review: submitting again needs a changed decision, comment or\n   * viewed mark, changed `files`, the returned promise to settle, or the handler to throw. Files\n   * compare by content and the rest by value: an equal new array or object does not count as a change.\n   */\n  onSubmit?: ((result: DiffReviewResult, review: { toPatch: () => string }) => void | PromiseLike<void>) | undefined;\n  /**\n   * Decide nothing, comment on nothing: only read.\n   * @default false\n   */\n  readOnly?: boolean | undefined;\n  /**\n   * Lines of context around each change.\n   * @default 3\n   */\n  context?: number | undefined;\n  /**\n   * Past this many lines added plus lines removed in one file, its changed region (from the first\n   * changed line to the last) is shown as one hunk that replaces it, and the review says so. Diffing\n   * costs about the square of this number.\n   * @default 2000\n   */\n  maxEditLength?: number | undefined;\n  /**\n   * Where files too large to diff while rendering are diffed: by default in the package's own\n   * worker, which bundlers with worker support emit (webpack 5 and Next.js, Vite, Parcel). Pass a\n   * function that starts your own `Worker` running `signoff-ui`'s diff worker, or `false` to diff\n   * them on the main thread after the first paint. Without a working worker the latter is the fallback.\n   */\n  diffWorker?: DiffWorkerFactory | false | undefined;\n  /**\n   * After accepting or rejecting with the keyboard, move to the next undecided item.\n   * @default true\n   */\n  autoAdvance?: boolean | undefined;\n  /**\n   * Fold a file away once it is marked viewed.\n   * @default true\n   */\n  collapseViewed?: boolean | undefined;\n  /** Words to use instead of the English defaults, for what the hook announces and names: see `SignoffLabelsProvider`. */\n  labels?: SignoffLabelsInput | undefined;\n}\n\n/** A file in the review, as it stands now. */\nexport interface DiffReviewFileState {\n  /** The file's id: its path, or `path#2`… for a path that repeats. */\n  id: string;\n  /** The file's path, from its `FileChange`. */\n  path: string;\n  /** The `FileChange` it comes from, as passed in `files`. */\n  change: FileChange;\n  /** `undefined` while the file is diffed in the background (\"Comparing…\"). */\n  parsed: ParsedFileDiff | undefined;\n  /** What it asks to decide: its hunks, or the file as a whole. */\n  items: ReviewItem[];\n  /**\n   * `accepted`, `rejected` or `pending` when all its items are, `partial` for a mix, and\n   * `unchanged` with nothing to decide (or while it is being compared).\n   */\n  decision: FileDecision;\n  /** How many of its items are decided. */\n  decided: number;\n  /** Marked \"Viewed\" by the reviewer. */\n  viewed: boolean;\n  /** Folded away: its items are skipped by J and K. */\n  collapsed: boolean;\n  /** Unchanged lines around its hunks that can be shown, when both contents are known. */\n  gaps: ContextGap[];\n}\n\n/** Lines selected in a hunk, by index into `hunk.lines`. */\nexport interface DiffReviewSelection {\n  /** The item the lines are in. */\n  item: ReviewItem;\n  /** The hunk the lines are in: `item.hunk`. */\n  hunk: DiffHunk;\n  /** The index the selection started at: Shift and the arrows extend it from here. */\n  anchor: number;\n  /** The index it ends at: the one Shift and the arrows move. */\n  head: number;\n  /** The first selected index. */\n  from: number;\n  /** The last selected index. */\n  to: number;\n  /**\n   * The selected lines' numbers, in the proposed file or, for removed lines only, in the original,\n   * and with removed lines among lines in the proposed file, in the original too: what a comment on\n   * them records.\n   */\n  range: CommentRange;\n}\n\n/** A comment being written: a new one, or an edit of `commentId`. */\nexport interface DiffReviewDraft {\n  /** The comment being edited; absent for a new one. */\n  commentId?: string | undefined;\n  /** The file it is on, by id. */\n  fileId: string;\n  /** What it is on: lines, a hunk or a whole file. */\n  target: DiffReviewComment['target'];\n  /** The hunk it is on, for `lines` and `hunk`. */\n  hunkId?: string | undefined;\n  /** The lines it is on, for `lines` and `hunk`: the selected lines, or the hunk's changed lines. */\n  range?: CommentRange | undefined;\n  /** What has been written so far. */\n  text: string;\n}\n\n/** Unchanged lines shown in a gap: from its start (after the hunk above) and from its end. */\nexport interface ShownContext {\n  start: number;\n  end: number;\n}\n\nexport interface UseDiffReviewResult {\n  /** Every file, in the order of `files`: its id, diff, items, decision, and viewed and folded state. */\n  files: DiffReviewFileState[];\n  /** Every item to decide, in file order; `index` in the getters refers to this list. */\n  items: ReviewItem[];\n  /** The item in the tab order, focused by J and K. */\n  activeIndex: number;\n  /** How many items are accepted, rejected and still pending, out of `total`. */\n  counts: { accepted: number; rejected: number; pending: number; total: number };\n  /** Some file is still being diffed in the background; applying waits for it. */\n  comparing: boolean;\n  /** The decisions, keyed by item id; an item missing from it is `pending`. */\n  decisions: Readonly<Record<string, HunkDecision>>;\n  /** The comments, in the order they were added. */\n  comments: readonly DiffReviewComment[];\n  /** The \"Viewed\" marks, by file id. */\n  viewed: Readonly<Record<string, boolean>>;\n  /** The lines selected in a hunk, with Shift and the arrows or a click on a line number. */\n  selection: DiffReviewSelection | undefined;\n  /** The comment being written: render the editor (`getDraftProps()`) while it is set. */\n  draft: DiffReviewDraft | undefined;\n  /** Unchanged lines shown around hunks, by `${fileId}:${gap.before}`. */\n  shown: Readonly<Record<string, ShownContext>>;\n  /** What to announce: render it in a polite live region. */\n  announcement: string;\n  /**\n   * The `readOnly` option. While it is `true` the keys, comments and `submit` do nothing: hide your\n   * decision buttons too, as `DiffReview` does.\n   */\n  readOnly: boolean;\n  /** `true` on a Mac, to print ⌘ rather than Ctrl in your shortcut hints. `false` on the server and while hydrating. */\n  isMac: boolean;\n\n  /**\n   * Decide one item by id: accept, reject, or `pending` to reset it. With `advance`, focus moves on\n   * to the next undecided item, as A and R do with `autoAdvance`.\n   */\n  decide(id: string, decision: HunkDecision, options?: { advance?: boolean }): void;\n  /** Accept or reject every item of a file, as Alt+A and Alt+R do. */\n  decideFile(fileId: string, decision: Exclude<HunkDecision, 'pending'>): void;\n  /** Accept or reject every item, as Shift+A and Shift+R do. Does nothing while a file is being compared. */\n  decideAll(decision: Exclude<HunkDecision, 'pending'>): void;\n  /**\n   * Mark a file viewed, or clear the mark, as its checkbox and V do. With `collapseViewed`, a viewed\n   * file folds away.\n   */\n  setViewed(fileId: string, viewed: boolean): void;\n  /** Fold a file away or unfold it, whatever its viewed mark. J and K skip a folded file's items. */\n  setCollapsed(fileId: string, collapsed: boolean): void;\n  /** Focus an item by index (clamped), as J and K do. */\n  focusItem(index: number): void;\n  /** Make an item the one in the tab order, as focusing it does. Stable across renders. */\n  activate(index: number): void;\n  /** Record an item's element, for focus. Stable across renders. */\n  registerItem(id: string, el: HTMLElement | null): void;\n  /** Unfold a file and focus its first item, as the file navigator does. */\n  focusFile(fileId: string): void;\n  /** Select lines `from…to` of a hunk item; `to` defaults to `from`. */\n  selectLines(itemId: string, from: number, to?: number): void;\n  /** Clear the line selection, as Escape does. */\n  clearSelection(): void;\n  /** Start a comment: on the selected lines, else on the item (a hunk or a whole file), or edit one. */\n  startComment(options?: { itemId?: string; commentId?: string }): void;\n  /** Set the text of the comment being written, for an editor that doesn't use `getDraftProps()`. */\n  setDraftText(text: string): void;\n  /** The comment editor's element, for focus. Stable across renders. */\n  registerDraft(el: HTMLTextAreaElement | null): void;\n  /**\n   * Save the comment being written, or the edit of one, as ⌘/Ctrl+Enter in the editor does. With\n   * nothing written but spaces, it cancels instead.\n   */\n  saveDraft(): void;\n  /** Close the comment editor without saving, as Escape does, and return focus to the item it was on. */\n  cancelDraft(): void;\n  /** Delete a comment by id, and return focus to the item it was on. */\n  removeComment(id: string): void;\n  /** Show more unchanged lines of a file's gap: from its start, its end, or all of it. */\n  showContext(fileId: string, gap: ContextGap, from: 'start' | 'end' | 'all'): void;\n  /** Send the review to `onSubmit`, once per review state. */\n  submit(): void;\n  /** The review as `onSubmit` receives it, for the current state. */\n  result(): DiffReviewResult;\n  /** The accepted changes as a unified diff. */\n  toPatch(): string;\n\n  /**\n   * The review's keyboard, for the element that contains it. Keys pressed inside an element with\n   * `data-signoff-slot` in it, your own controls, are left to them, and so are keys from a portal\n   * rendered inside it, such as a menu. `tabIndex: -1` makes that element focusable, outside the tab\n   * order: Safari and every browser on iOS don't focus a button or a checkbox on click, so focus goes\n   * to the closest focusable element around it, this one rather than the page, and the keys keep\n   * working. Spread your own `tabIndex` after it to replace it.\n   */\n  getRootProps(): { tabIndex: -1; onKeyDown: (event: KeyboardEvent<HTMLElement>) => void };\n  /** A focusable item: a roving tabindex, its accessible name and its decision. */\n  getItemProps(index: number): {\n    ref: (el: HTMLElement | null) => void;\n    role: 'group';\n    tabIndex: 0 | -1;\n    'aria-label': string;\n    'data-decision': HunkDecision;\n    onFocus: () => void;\n  };\n  /** Accept or reject one item; pressing it again resets it. */\n  getDecisionProps(\n    index: number,\n    decision: Exclude<HunkDecision, 'pending'>,\n  ): { type: 'button'; 'aria-pressed': boolean; onClick: () => void };\n  /** A button that accepts or rejects a whole file, with its shortcut, Alt+A or Alt+R, in `aria-keyshortcuts`. */\n  getFileDecisionProps(\n    fileId: string,\n    decision: Exclude<HunkDecision, 'pending'>,\n  ): { type: 'button'; onClick: () => void; 'aria-keyshortcuts': string };\n  /** The checkbox that marks a file viewed, with V in `aria-keyshortcuts`. Give it a label of your own. */\n  getViewedProps(fileId: string): {\n    ref: (el: HTMLInputElement | null) => void;\n    type: 'checkbox';\n    checked: boolean;\n    onChange: (event: ChangeEvent<HTMLInputElement>) => void;\n    'aria-keyshortcuts': 'V';\n  };\n  /** Click a line number to select it; Shift-click extends the selection in the same hunk. */\n  getLineNumberProps(index: number, line: number): { onClick: (event: MouseEvent) => void };\n  /**\n   * The comment editor's text area: its value, focus when it opens, and its keys (⌘/Ctrl+Enter\n   * saves, Escape cancels). Give it a label of your own.\n   */\n  getDraftProps(): {\n    ref: (el: HTMLTextAreaElement | null) => void;\n    value: string;\n    onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;\n    onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;\n  };\n  /**\n   * The apply button: it calls `submit`, is `aria-disabled` while a file is being compared, and has\n   * ⌘/Ctrl+Enter in `aria-keyshortcuts`.\n   */\n  getSubmitProps(): {\n    type: 'button';\n    onClick: () => void;\n    'aria-disabled': true | undefined;\n    'aria-keyshortcuts': string;\n  };\n  /** One button per file in a navigator list, with a roving tabindex: ↑ and ↓ move, Enter goes. */\n  getNavigatorItemProps(fileId: string): {\n    type: 'button';\n    tabIndex: 0 | -1;\n    ref: (el: HTMLButtonElement | null) => void;\n    'aria-current': true | undefined;\n    onClick: () => void;\n    onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;\n  };\n}\n\n/**\n * The review without its markup: parsing (in a worker for large files), decisions, comments,\n * viewed files, unchanged context, the keyboard and the result, for teams that render their own.\n * `DiffReview` is this hook and its styled markup. Spread `getRootProps()` on the element that\n * contains the review, `getItemProps(i)` on each item's element, and render `announcement` in a\n * polite live region; the keys are DiffReview's.\n */\nexport function useDiffReview({\n  files: changes,\n  decisions: decisionsProp,\n  defaultDecisions,\n  onDecisionsChange,\n  comments: commentsProp,\n  defaultComments,\n  onCommentsChange,\n  viewed: viewedProp,\n  defaultViewed,\n  onViewedChange,\n  onSubmit,\n  readOnly = false,\n  context = 3,\n  maxEditLength = DEFAULT_MAX_EDIT_LENGTH,\n  diffWorker = defaultDiffWorker,\n  autoAdvance = true,\n  collapseViewed = true,\n  labels,\n}: UseDiffReviewOptions): UseDiffReviewResult {\n  const T = useLabels(LABELS, labels).diffReview;\n  // Parsed by content rather than by `files` identity, so an inline array (a new one on every\n  // parent render) does not re-diff every file on every decision.\n  const [parsedFiles, setParsedFiles] = useState<ParsedFiles>(() => parseFiles(changes, context, maxEditLength));\n  let current = parsedFiles;\n  if (parsedFiles.files !== changes || parsedFiles.context !== context || parsedFiles.maxEditLength !== maxEditLength) {\n    current = parseFiles(changes, context, maxEditLength, parsedFiles);\n    setParsedFiles(current);\n  }\n  // Files whose contents changed in this render: a revision, not the edit the reviewer saw.\n  const revised = current !== parsedFiles && current.revised.size > 0 ? current.revised : undefined;\n\n  // Files too large to diff while rendering, diffed in the background; a newer change replaces an older one.\n  const [failure, setFailure] = useState<{ error: unknown }>();\n  const jobs = useRef(new Map<string, FileChange>());\n  const mounted = useRef(false);\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n    };\n  }, []);\n  useEffect(() => {\n    const { files: list, ids, parsed: results, context: ctx, maxEditLength: limit } = parsedFiles;\n    results.forEach((result, i) => {\n      if (result) return;\n      const change = list[i]!;\n      const id = ids[i]!;\n      const running = jobs.current.get(id);\n      if (running && sameChange(running, change)) return;\n      jobs.current.set(id, change);\n      parseInBackground(change, { context: ctx, id, maxEditLength: limit }, diffWorker).then(\n        (file) => {\n          if (jobs.current.get(id) !== change) return;\n          jobs.current.delete(id);\n          if (!mounted.current) return;\n          setParsedFiles((prev) => {\n            const k = prev.ids.indexOf(id);\n            const stale =\n              k === -1 ||\n              prev.parsed[k] !== undefined ||\n              !sameChange(prev.files[k]!, change) ||\n              prev.context !== ctx ||\n              prev.maxEditLength !== limit;\n            if (stale) return prev;\n            const next = [...prev.parsed];\n            next[k] = file;\n            return { ...prev, parsed: next };\n          });\n        },\n        (error: unknown) => {\n          if (jobs.current.get(id) === change) jobs.current.delete(id);\n          if (mounted.current) setFailure({ error });\n        },\n      );\n    });\n  }, [parsedFiles, diffWorker]);\n  // A file that failed to diff in the background fails the render, as one diffed while rendering does.\n  if (failure) throw failure.error;\n\n  const [givenDecisions, setDecisions] = useControllable(\n    decisionsProp,\n    () => ({ ...defaultDecisions }) as Readonly<Record<string, HunkDecision>>,\n    onDecisionsChange as ((next: Readonly<Record<string, HunkDecision>>) => void) | undefined,\n  );\n  const [givenComments, setComments] = useControllable(\n    commentsProp,\n    () => [...(defaultComments ?? [])] as readonly DiffReviewComment[],\n    onCommentsChange as ((next: readonly DiffReviewComment[]) => void) | undefined,\n  );\n  const [givenViewed, setViewedMap] = useControllable(\n    viewedProp,\n    () => ({ ...defaultViewed }) as Readonly<Record<string, boolean>>,\n    onViewedChange as ((next: Readonly<Record<string, boolean>>) => void) | undefined,\n  );\n  const [folded, setFolded] = useState<Readonly<Record<string, boolean>>>({});\n  const [shown, setShown] = useState<Readonly<Record<string, ShownContext>>>({});\n  const [selectionState, setSelection] = useState<{ itemId: string; anchor: number; head: number }>();\n  const [draft, setDraft] = useState<DiffReviewDraft>();\n\n  // A revised file starts its review over: what the reviewer decided, marked viewed and wrote on\n  // the old edit is not about the new one, and would otherwise apply to it by position.\n  const decisions = useWithoutRevised(\n    DECISION_ENTRIES,\n    givenDecisions,\n    decisionsProp,\n    revised,\n    onDecisionsChange as ((next: Readonly<Record<string, HunkDecision>>) => void) | undefined,\n  );\n  const viewed = useWithoutRevised(\n    VIEWED_ENTRIES,\n    givenViewed,\n    viewedProp,\n    revised,\n    onViewedChange as ((next: Readonly<Record<string, boolean>>) => void) | undefined,\n  );\n  const comments = useWithoutRevised(\n    COMMENT_ENTRIES,\n    givenComments,\n    commentsProp,\n    revised,\n    onCommentsChange as ((next: readonly DiffReviewComment[]) => void) | undefined,\n  );\n  if (revised) {\n    const onRevised = (key: string) => {\n      const id = fileIdOf(key);\n      return id !== undefined && revised.has(id);\n    };\n    const unfolded = omitKeys(folded, (id) => revised.has(id));\n    if (unfolded !== folded) setFolded(unfolded);\n    const hidden = omitKeys(shown, onRevised);\n    if (hidden !== shown) setShown(hidden);\n    if (selectionState && onRevised(selectionState.itemId)) setSelection(undefined);\n    if (draft && revised.has(draft.fileId)) setDraft(undefined);\n  }\n  const [announcement, setAnnouncement] = useState('');\n  const [activeState, setActive] = useState(0);\n  const [navActive, setNavActive] = useState<string>();\n  const elements = useRef(new Map<string, HTMLElement>());\n  const navElements = useRef(new Map<string, HTMLButtonElement>());\n  const viewedElements = useRef(new Map<string, HTMLInputElement>());\n  /** An item to focus once it is rendered: after unfolding a file, or closing a comment editor. */\n  const pendingFocus = useRef<string | HTMLElement | undefined>(undefined);\n  const draftRef = useRef<HTMLTextAreaElement | null>(null);\n  const commentIds = useRef(0);\n  const isMac = useIsMac();\n  const [registerItem] = useState(() => (id: string, el: HTMLElement | null) => {\n    if (el) elements.current.set(id, el);\n    else elements.current.delete(id);\n  });\n  const [registerDraft] = useState(() => (el: HTMLTextAreaElement | null) => {\n    draftRef.current = el;\n  });\n\n  const all = current.parsed;\n  const comparing = all.some((p) => p === undefined);\n  const parsed = useMemo(() => all.filter((p): p is ParsedFileDiff => p !== undefined), [all]);\n\n  const files = useMemo<DiffReviewFileState[]>(\n    () =>\n      all.map((file, i) => {\n        const items = file ? reviewItems(file) : [];\n        const id = current.ids[i]!;\n        const isViewed = viewed[id] === true;\n        return {\n          id,\n          path: pathOf(current.files[i]!),\n          change: current.files[i]!,\n          parsed: file,\n          items,\n          decision: fileDecision(items, decisions),\n          decided: items.filter((item) => (decisions[item.id] ?? 'pending') !== 'pending').length,\n          viewed: isViewed,\n          collapsed: folded[id] ?? (collapseViewed && isViewed),\n          gaps: file ? contextGaps(file) : [],\n        };\n      }),\n    [all, current.ids, current.files, viewed, decisions, folded, collapseViewed],\n  );\n  const items = useMemo(() => files.flatMap((f) => f.items), [files]);\n  const fileOf = useMemo(() => {\n    const map = new Map<string, DiffReviewFileState>();\n    for (const file of files) for (const item of file.items) map.set(item.id, file);\n    return map;\n  }, [files]);\n  const indexOf = useMemo(() => new Map(items.map((item, i) => [item.id, i])), [items]);\n  const navigable = (i: number) => !fileOf.get(items[i]?.id ?? '')?.collapsed;\n  /** The next item from `index` (exclusive) in `step` direction that is not folded away. */\n  const nextNavigable = (index: number, step: 1 | -1) => {\n    for (let i = index + step; i >= 0 && i < items.length; i += step) if (navigable(i)) return i;\n    return undefined;\n  };\n  // Clamped, so an item stays in the tab order when `files` shrinks below the active one, and moved\n  // off a folded file to the nearest item that shows.\n  const clamped = Math.min(activeState, Math.max(0, items.length - 1));\n  const activeIndex = navigable(clamped)\n    ? clamped\n    : (nextNavigable(clamped, 1) ?? nextNavigable(clamped, -1) ?? clamped);\n\n  const counts = useMemo(() => {\n    let accepted = 0;\n    let rejected = 0;\n    for (const item of items) {\n      const d = decisions[item.id];\n      if (d === 'accepted') accepted++;\n      else if (d === 'rejected') rejected++;\n    }\n    return { accepted, rejected, pending: items.length - accepted - rejected, total: items.length };\n  }, [items, decisions]);\n\n  const selection = useMemo<DiffReviewSelection | undefined>(() => {\n    if (!selectionState) return undefined;\n    const item = items[indexOf.get(selectionState.itemId) ?? -1];\n    const hunk = item?.hunk;\n    if (!item || !hunk) return undefined;\n    const last = hunk.lines.length - 1;\n    const anchor = Math.min(selectionState.anchor, last);\n    const head = Math.min(selectionState.head, last);\n    const from = Math.min(anchor, head);\n    const to = Math.max(anchor, head);\n    return { item, hunk, anchor, head, from, to, range: commentRange(hunk, from, to) };\n  }, [selectionState, items, indexOf]);\n\n  const result = () => computeReviewResult(parsed, decisions, { comments, viewed });\n  const patch = () => toPatch(parsed, decisions);\n\n  const focusItem = (index: number) => {\n    const target = items[Math.max(0, Math.min(items.length - 1, index))];\n    if (!target) return;\n    setActive(indexOf.get(target.id)!);\n    elements.current.get(target.id)?.focus();\n  };\n  const decide = (id: string, decision: HunkDecision, { advance = false }: { advance?: boolean } = {}) => {\n    const index = indexOf.get(id);\n    if (index === undefined) return;\n    const next = { ...decisions, [id]: decision };\n    setDecisions(next);\n    const remaining = items.filter((item) => (next[item.id] ?? 'pending') === 'pending').length;\n    const kind = items[index]!.hunk ? 'hunk' : 'file';\n    setAnnouncement(T.decided(kind, index + 1, items.length, decision, remaining));\n    if (advance && decision !== 'pending') {\n      const pending = (i: number) => navigable(i) && (next[items[i]!.id] ?? 'pending') === 'pending';\n      const after = items.findIndex((_, i) => i > index && pending(i));\n      const wrap = after !== -1 ? after : items.findIndex((_, i) => pending(i));\n      if (wrap !== -1) focusItem(wrap);\n    }\n  };\n\n  const decideMany = (ids: readonly string[], decision: Exclude<HunkDecision, 'pending'>) => {\n    const next = { ...decisions };\n    for (const id of ids) next[id] = decision;\n    setDecisions(next);\n  };\n  const decideAll = (decision: Exclude<HunkDecision, 'pending'>) => {\n    if (comparing) return;\n    decideMany(\n      items.map((item) => item.id),\n      decision,\n    );\n    setAnnouncement(T.decidedAll(items.length, decision));\n  };\n  const decideFile = (fileId: string, decision: Exclude<HunkDecision, 'pending'>) => {\n    const file = files.find((f) => f.id === fileId);\n    if (!file || file.items.length === 0) return;\n    decideMany(\n      file.items.map((item) => item.id),\n      decision,\n    );\n    setAnnouncement(T.decidedFile(file.items.length, file.path, decision));\n  };\n\n  const setCollapsed = (fileId: string, collapsed: boolean) => setFolded({ ...folded, [fileId]: collapsed });\n  const setViewed = (fileId: string, value: boolean) => {\n    const file = files.find((f) => f.id === fileId);\n    if (!file) return;\n    const next = { ...viewed, [fileId]: value };\n    setViewedMap(next);\n    // A file folds with its viewed mark again, until it is folded or unfolded by hand.\n    const rest = { ...folded };\n    delete rest[fileId];\n    setFolded(rest);\n    const count = files.filter((f) => next[f.id] === true).length;\n    setAnnouncement(value ? T.viewedAnnouncement(file.path, count, files.length) : T.notViewedAnnouncement(file.path));\n  };\n\n  const focusFile = (fileId: string) => {\n    const file = files.find((f) => f.id === fileId);\n    const first = file?.items[0];\n    if (!file || !first) return;\n    if (file.collapsed) setFolded({ ...folded, [fileId]: false });\n    setActive(indexOf.get(first.id)!);\n    // Unfolding renders the file's items first; focus them once they are in the page.\n    pendingFocus.current = first.id;\n    elements.current.get(first.id)?.focus();\n  };\n  useEffect(() => {\n    const target = pendingFocus.current;\n    if (!target) return;\n    pendingFocus.current = undefined;\n    const el = typeof target === 'string' ? elements.current.get(target) : target;\n    if (el && document.activeElement !== el) el.focus();\n  });\n\n  const announceSelection = (item: ReviewItem, anchor: number, head: number) => {\n    const hunk = item.hunk!;\n    const line = hunk.lines[head];\n    if (line) {\n      const spoken = `${line.type === 'add' ? T.added : line.type === 'del' ? T.removed : ''}${line.content}`;\n      setAnnouncement(T.selected(spoken, linesRange(hunk, anchor, head)));\n    }\n  };\n  const selectLines = (itemId: string, from: number, to = from) => {\n    const item = items[indexOf.get(itemId) ?? -1];\n    if (!item?.hunk) return;\n    setSelection({ itemId, anchor: from, head: to });\n    announceSelection(item, from, to);\n  };\n  const clearSelection = () => setSelection(undefined);\n\n  /** Move or extend the selection in the focused hunk by a line, or start one. */\n  const stepSelection = (item: ReviewItem, step: 1 | -1, extend: boolean) => {\n    const hunk = item.hunk;\n    if (!hunk) return;\n    const last = hunk.lines.length - 1;\n    const mine = selection?.item.id === item.id ? selection : undefined;\n    if (!mine) {\n      const [first, end] = changedSpan(hunk);\n      const start = step === 1 ? first : end;\n      selectLines(item.id, start, start);\n      return;\n    }\n    const head = Math.max(0, Math.min(last, mine.head + step));\n    const anchor = extend ? mine.anchor : head;\n    selectLines(item.id, anchor, head);\n  };\n\n  const startComment = ({ itemId, commentId }: { itemId?: string; commentId?: string } = {}) => {\n    if (readOnly) return;\n    if (commentId) {\n      const comment = comments.find((c) => c.id === commentId);\n      if (!comment) return;\n      setDraft({\n        commentId,\n        fileId: comment.fileId,\n        target: comment.target,\n        hunkId: comment.hunkId,\n        range:\n          comment.side && comment.startLine !== undefined && comment.endLine !== undefined\n            ? { side: comment.side, startLine: comment.startLine, endLine: comment.endLine }\n            : undefined,\n        text: comment.text,\n      });\n      return;\n    }\n    const item = items[itemId ? (indexOf.get(itemId) ?? -1) : activeIndex];\n    if (!item) return;\n    const file = fileOf.get(item.id)!;\n    if (!item.hunk) {\n      setDraft({ fileId: file.id, target: 'file', text: '' });\n    } else if (selection?.item.id === item.id) {\n      setDraft({ fileId: file.id, target: 'lines', hunkId: item.id, range: selection.range, text: '' });\n    } else {\n      setDraft({ fileId: file.id, target: 'hunk', hunkId: item.id, range: hunkRange(item.hunk), text: '' });\n    }\n  };\n  useEffect(() => {\n    if (draft && document.activeElement !== draftRef.current) draftRef.current?.focus();\n    // Only when a draft opens, not on every keystroke in it.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [draft?.commentId, draft?.hunkId, draft?.fileId, draft?.target, draft?.range?.startLine, draft?.range?.endLine]);\n\n  /** Back to the item a comment was on, once its editor goes away. */\n  const refocus = (fileId: string, hunkId: string | undefined) => {\n    const id = hunkId ?? files.find((f) => f.id === fileId)?.items[0]?.id;\n    if (id) pendingFocus.current = id;\n  };\n  const cancelDraft = () => {\n    if (!draft) return;\n    refocus(draft.fileId, draft.hunkId);\n    setDraft(undefined);\n  };\n  const saveDraft = () => {\n    if (!draft) return;\n    const text = draft.text.trim();\n    if (!text) return cancelDraft();\n    const file = files.find((f) => f.id === draft.fileId);\n    if (draft.commentId) {\n      setComments(comments.map((c) => (c.id === draft.commentId ? { ...c, text } : c)));\n      setAnnouncement(T.commentUpdated);\n    } else {\n      // Unique among the comments already there, controlled ones included.\n      let id: string;\n      do id = `comment-${++commentIds.current}`;\n      while (comments.some((c) => c.id === id));\n      const comment: DiffReviewComment = {\n        id,\n        fileId: draft.fileId,\n        path: file?.path ?? draft.fileId,\n        target: draft.target,\n        ...(draft.hunkId ? { hunkId: draft.hunkId } : {}),\n        ...(draft.range ? draft.range : {}),\n        text,\n      };\n      setComments([...comments, comment]);\n      setAnnouncement(T.commentAdded(T.where(comment), comment.path));\n    }\n    setSelection(undefined);\n    refocus(draft.fileId, draft.hunkId);\n    setDraft(undefined);\n  };\n  const removeComment = (id: string) => {\n    const comment = comments.find((c) => c.id === id);\n    if (!comment) return;\n    setComments(comments.filter((c) => c.id !== id));\n    setAnnouncement(T.commentDeleted);\n    refocus(comment.fileId, comment.hunkId);\n  };\n\n  const showContext = (fileId: string, gap: ContextGap, from: 'start' | 'end' | 'all') => {\n    const key = `${fileId}:${gap.before}`;\n    const size = gap.oldEnd - gap.oldStart + 1;\n    const now = shown[key] ?? { start: 0, end: 0 };\n    const hidden = size - now.start - now.end;\n    if (hidden <= 0) return;\n    const step = from === 'all' ? hidden : Math.min(CONTEXT_STEP, hidden);\n    const next = from === 'end' ? { ...now, end: now.end + step } : { ...now, start: now.start + step };\n    setShown({ ...shown, [key]: next });\n    setAnnouncement(T.expanded(step));\n  };\n  /** `E`: more unchanged lines above and below a hunk. */\n  const showAround = (item: ReviewItem) => {\n    const file = fileOf.get(item.id);\n    if (!file || !item.hunk) return;\n    const next = { ...shown };\n    let added = 0;\n    for (const gap of file.gaps) {\n      const key = `${file.id}:${gap.before}`;\n      const now = next[key] ?? { start: 0, end: 0 };\n      const hidden = gap.oldEnd - gap.oldStart + 1 - now.start - now.end;\n      const step = Math.min(CONTEXT_STEP, hidden);\n      if (step <= 0) continue;\n      // The gap above the hunk ends at it; the gap below starts at it.\n      if (gap.before === item.hunk.index) next[key] = { ...now, end: now.end + step };\n      else if (gap.before === item.hunk.index + 1) next[key] = { ...now, start: now.start + step };\n      else continue;\n      added += step;\n    }\n    if (added === 0) return;\n    setShown(next);\n    setAnnouncement(T.expanded(added));\n  };\n\n  // One submission per review state, so a double click or a repeated Ctrl+Enter sends it once.\n  // Re-armed when the files change by content, or a decision, comment or viewed mark by value: an\n  // equal `files` array or controlled object passed on every render must not re-arm it.\n  const submitted = useRef(false);\n  const reviewed = useRef({ parsed, decisions, comments, viewed });\n  useEffect(() => {\n    const before = reviewed.current;\n    if (\n      before.parsed !== parsed ||\n      !sameRecord(before.decisions, decisions, 'pending') ||\n      !sameComments(before.comments, comments) ||\n      !sameRecord(before.viewed, viewed, false)\n    )\n      submitted.current = false;\n    reviewed.current = { parsed, decisions, comments, viewed };\n  }, [parsed, decisions, comments, viewed]);\n  const submit = () => {\n    // Every file has to be compared before the review means anything.\n    if (!onSubmit || submitted.current || comparing || readOnly) return;\n    submitted.current = true;\n    let returned: void | PromiseLike<void>;\n    try {\n      returned = onSubmit(result(), { toPatch: patch });\n    } catch (error) {\n      // A failed handler sent nothing: let the user try again.\n      submitted.current = false;\n      throw error;\n    }\n    if (isPromiseLike(returned)) {\n      void Promise.resolve(returned).finally(() => {\n        submitted.current = false;\n      });\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {\n    if (readOnly || isTypingTarget(event.target) || fromSlot(event)) return;\n    if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n      event.preventDefault();\n      submit();\n      return;\n    }\n    const target = event.target as Node;\n    const index = items.findIndex((item) => elements.current.get(item.id)?.contains(target));\n    const inside = index !== -1;\n    const item = items[inside ? index : activeIndex];\n    const key = event.key;\n    // Alt+A, Alt+R: the whole file. By key position, since Option on a Mac types another character.\n    if (event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey && inside && item) {\n      if (event.code === 'KeyA' || event.code === 'KeyR') {\n        event.preventDefault();\n        decideFile(fileOf.get(item.id)!.id, event.code === 'KeyA' ? 'accepted' : 'rejected');\n      }\n      return;\n    }\n    if (hasModifier(event)) return;\n    const mine = inside && selection?.item.id === item?.id;\n    if (key === 'Escape' && selection) {\n      event.preventDefault();\n      clearSelection();\n      return;\n    }\n    if (key === 'ArrowDown' || key === 'ArrowUp') {\n      // Arrow keys only act when an item (not e.g. the view toggle) has focus.\n      if (!inside || !item) return;\n      event.preventDefault();\n      const step = key === 'ArrowDown' ? 1 : -1;\n      if (event.shiftKey || mine) stepSelection(item, step, event.shiftKey);\n      else {\n        const next = nextNavigable(index, step);\n        if (next !== undefined) focusItem(next);\n      }\n      return;\n    }\n    // Shift+J is \"J\" in a browser, and \"j\" with Shift from some keyboard drivers and test tools.\n    const letter = key.length === 1 ? key.toLowerCase() : key;\n    if ((letter === 'j' || letter === 'k') && !event.shiftKey) {\n      event.preventDefault();\n      clearSelection();\n      if (!inside) return focusItem(activeIndex);\n      const next = nextNavigable(index, letter === 'j' ? 1 : -1);\n      if (next !== undefined) focusItem(next);\n      return;\n    }\n    if (letter === 'j' || letter === 'k') {\n      // The first item of the next or previous file that is not folded away.\n      event.preventDefault();\n      clearSelection();\n      const here = fileOf.get(item?.id ?? '');\n      const at = files.indexOf(here!);\n      const step = letter === 'j' ? 1 : -1;\n      for (let f = at + step; f >= 0 && f < files.length; f += step) {\n        const first = files[f]!.items[0];\n        if (first && !files[f]!.collapsed) return focusItem(indexOf.get(first.id)!);\n      }\n      return;\n    }\n    if (event.shiftKey && (letter === 'a' || letter === 'r')) {\n      event.preventDefault();\n      decideAll(letter === 'a' ? 'accepted' : 'rejected');\n      return;\n    }\n    if (!item || !inside || event.shiftKey) return;\n    if (key === 'a') {\n      event.preventDefault();\n      decide(item.id, 'accepted', { advance: autoAdvance });\n    } else if (key === 'r' || key === 'x') {\n      event.preventDefault();\n      decide(item.id, 'rejected', { advance: autoAdvance });\n    } else if (key === 'u') {\n      event.preventDefault();\n      decide(item.id, 'pending');\n    } else if (key === 'c') {\n      event.preventDefault();\n      startComment({ itemId: item.id });\n    } else if (key === 'e') {\n      event.preventDefault();\n      showAround(item);\n    } else if (key === 'v') {\n      event.preventDefault();\n      const file = fileOf.get(item.id)!;\n      const marking = !file.viewed;\n      setViewed(file.id, marking);\n      if (marking && collapseViewed) {\n        // Folded away: go on to the next file's first item, or after the last file, to its checkbox.\n        const next = files.slice(files.indexOf(file) + 1).find((f) => !f.collapsed && f.items[0]);\n        if (next) focusItem(indexOf.get(next.items[0]!.id)!);\n        else pendingFocus.current = viewedElements.current.get(file.id);\n      }\n    }\n  };\n\n  const navOrder = files.filter((f) => f.items.length > 0).map((f) => f.id);\n  const navCurrent =\n    navActive && navOrder.includes(navActive) ? navActive : fileOf.get(items[activeIndex]?.id ?? '')?.id;\n\n  const review: UseDiffReviewResult = {\n    files,\n    items,\n    activeIndex,\n    counts,\n    comparing,\n    decisions,\n    comments,\n    viewed,\n    selection,\n    draft,\n    shown,\n    announcement,\n    readOnly,\n    isMac,\n    decide,\n    decideFile,\n    decideAll,\n    setViewed,\n    setCollapsed,\n    focusItem,\n    activate: setActive,\n    registerItem,\n    focusFile,\n    selectLines,\n    clearSelection,\n    startComment,\n    setDraftText: (text) => setDraft((d) => d && { ...d, text }),\n    registerDraft,\n    saveDraft,\n    cancelDraft,\n    removeComment,\n    showContext,\n    submit,\n    result,\n    toPatch: patch,\n    getRootProps: () => ({ tabIndex: -1, onKeyDown }),\n    getItemProps: (index) => {\n      const item = items[index]!;\n      const file = fileOf.get(item.id)!;\n      const decision = decisions[item.id] ?? 'pending';\n      const hunk = item.hunk;\n      const name = hunk\n        ? T.hunk(\n            index + 1,\n            items.length,\n            file.path,\n            hunk.newStart,\n            hunk.newLines > 0 ? hunk.newStart + hunk.newLines - 1 : hunk.newStart,\n            decision,\n          )\n        : T.fileItem(\n            index + 1,\n            items.length,\n            file.path,\n            T.describeFile(item.file.status, !!item.file.binary, item.file.oldPath ?? item.file.path),\n            decision,\n          );\n      return {\n        ref: (el: HTMLElement | null) => registerItem(item.id, el),\n        role: 'group' as const,\n        tabIndex: index === activeIndex ? 0 : -1,\n        'aria-label': name,\n        'data-decision': decision,\n        onFocus: () => setActive(index),\n      };\n    },\n    getDecisionProps: (index, decision) => {\n      const item = items[index]!;\n      const pressed = (decisions[item.id] ?? 'pending') === decision;\n      return {\n        type: 'button' as const,\n        'aria-pressed': pressed,\n        onClick: () => decide(item.id, pressed ? 'pending' : decision),\n      };\n    },\n    getFileDecisionProps: (fileId, decision) => ({\n      type: 'button' as const,\n      onClick: () => decideFile(fileId, decision),\n      'aria-keyshortcuts': decision === 'accepted' ? 'Alt+A' : 'Alt+R',\n    }),\n    getViewedProps: (fileId) => ({\n      ref: (el: HTMLInputElement | null) => {\n        if (el) viewedElements.current.set(fileId, el);\n        else viewedElements.current.delete(fileId);\n      },\n      type: 'checkbox' as const,\n      checked: viewed[fileId] === true,\n      onChange: (event) => setViewed(fileId, event.target.checked),\n      'aria-keyshortcuts': 'V' as const,\n    }),\n    getLineNumberProps: (index, line) => ({\n      onClick: (event) => {\n        const item = items[index];\n        if (!item?.hunk || readOnly) return;\n        const extend = event.shiftKey && selection?.item.id === item.id;\n        selectLines(item.id, extend ? selection.anchor : line, line);\n        setActive(index);\n        elements.current.get(item.id)?.focus({ preventScroll: true });\n      },\n    }),\n    getDraftProps: () => ({\n      ref: registerDraft,\n      value: draft?.text ?? '',\n      onChange: (event) => {\n        const text = event.target.value;\n        setDraft((d) => d && { ...d, text });\n      },\n      onKeyDown: (event) => {\n        // The Enter that commits an IME composition (Japanese, Chinese, Korean…) is not a submit.\n        if (event.nativeEvent.isComposing || event.keyCode === 229) return;\n        if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n          event.preventDefault();\n          event.stopPropagation();\n          saveDraft();\n        } else if (event.key === 'Escape') {\n          event.preventDefault();\n          event.stopPropagation();\n          cancelDraft();\n        }\n      },\n    }),\n    getSubmitProps: () => ({\n      type: 'button' as const,\n      onClick: submit,\n      'aria-disabled': comparing || undefined,\n      'aria-keyshortcuts': isMac ? 'Meta+Enter' : 'Control+Enter',\n    }),\n    getNavigatorItemProps: (fileId) => ({\n      type: 'button' as const,\n      tabIndex: fileId === (navCurrent ?? navOrder[0]) ? 0 : -1,\n      ref: (el: HTMLButtonElement | null) => {\n        if (el) navElements.current.set(fileId, el);\n        else navElements.current.delete(fileId);\n      },\n      'aria-current': fileId === fileOf.get(items[activeIndex]?.id ?? '')?.id || undefined,\n      onClick: () => focusFile(fileId),\n      onKeyDown: (event) => {\n        const at = navOrder.indexOf(fileId);\n        const to =\n          event.key === 'ArrowDown'\n            ? at + 1\n            : event.key === 'ArrowUp'\n              ? at - 1\n              : event.key === 'Home'\n                ? 0\n                : event.key === 'End'\n                  ? navOrder.length - 1\n                  : undefined;\n        if (to === undefined) return;\n        event.preventDefault();\n        event.stopPropagation();\n        const id = navOrder[Math.max(0, Math.min(navOrder.length - 1, to))]!;\n        setNavActive(id);\n        navElements.current.get(id)?.focus();\n      },\n    }),\n  };\n  return review;\n}\n",
      "type": "registry:component",
      "target": "components/signoff-ui/use-diff-review.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-signoff-bg": "var(--signoff-bg)",
      "color-signoff-surface": "var(--signoff-surface)",
      "color-signoff-surface-2": "var(--signoff-surface-2)",
      "color-signoff-border": "var(--signoff-border)",
      "color-signoff-border-strong": "var(--signoff-border-strong)",
      "color-signoff-fg": "var(--signoff-fg)",
      "color-signoff-fg-muted": "var(--signoff-fg-muted)",
      "color-signoff-fg-subtle": "var(--signoff-fg-subtle)",
      "color-signoff-accent": "var(--signoff-accent)",
      "color-signoff-accent-fg": "var(--signoff-accent-fg)",
      "color-signoff-on-accent": "var(--signoff-on-accent)",
      "color-signoff-hot": "var(--signoff-hot)",
      "color-signoff-hot-fg": "var(--signoff-hot-fg)",
      "color-signoff-on-hot": "var(--signoff-on-hot)",
      "color-signoff-warn": "var(--signoff-warn)",
      "color-signoff-warn-fg": "var(--signoff-warn-fg)",
      "color-signoff-ring": "var(--signoff-ring)",
      "color-signoff-add": "var(--signoff-add-bg)",
      "color-signoff-add-strong": "var(--signoff-add-strong)",
      "color-signoff-del": "var(--signoff-del-bg)",
      "color-signoff-del-strong": "var(--signoff-del-strong)",
      "color-signoff-chart-input": "var(--signoff-chart-input)",
      "color-signoff-chart-output": "var(--signoff-chart-output)",
      "color-signoff-code-keyword": "var(--signoff-code-keyword)",
      "color-signoff-code-string": "var(--signoff-code-string)",
      "color-signoff-code-number": "var(--signoff-code-number)",
      "color-signoff-code-comment": "var(--signoff-code-comment)",
      "color-signoff-code-punctuation": "var(--signoff-code-punctuation)",
      "radius-signoff": "var(--signoff-radius)",
      "font-signoff-sans": "var(--signoff-font-sans)",
      "font-signoff-mono": "var(--signoff-font-mono)",
      "animate-signoff-shimmer": "signoff-shimmer 2.2s linear infinite",
      "animate-signoff-ping": "signoff-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite",
      "animate-signoff-spin": "signoff-spin 0.9s linear infinite",
      "animate-signoff-blink": "signoff-blink 1.1s steps(2, start) infinite",
      "animate-signoff-enter": "signoff-enter 0.32s cubic-bezier(0.2, 0.7, 0.2, 1) backwards",
      "animate-signoff-indeterminate": "signoff-indeterminate 1.4s ease-in-out infinite",
      "animate-signoff-expand": "signoff-expand 0.22s cubic-bezier(0.2, 0.7, 0.2, 1)",
      "animate-signoff-collapse": "signoff-collapse 0.18s ease-in"
    },
    "light": {
      "signoff-bg": "#ffffff",
      "signoff-surface": "#f6f7f9",
      "signoff-surface-2": "#eef0f3",
      "signoff-border": "#e2e5ea",
      "signoff-border-strong": "#cdd2d9",
      "signoff-fg": "#0d0f12",
      "signoff-fg-muted": "#4b5563",
      "signoff-fg-subtle": "#5f6875",
      "signoff-accent": "#0e7490",
      "signoff-accent-fg": "#155e75",
      "signoff-on-accent": "#ffffff",
      "signoff-hot": "#be185d",
      "signoff-hot-fg": "#9d174d",
      "signoff-on-hot": "#ffffff",
      "signoff-warn": "#b45309",
      "signoff-warn-fg": "#92400e",
      "signoff-ring": "#0e7490",
      "signoff-add-bg": "rgb(8 145 178 / 0.08)",
      "signoff-add-strong": "rgb(8 145 178 / 0.16)",
      "signoff-del-bg": "rgb(190 24 93 / 0.07)",
      "signoff-del-strong": "rgb(190 24 93 / 0.14)",
      "signoff-chart-input": "#0891b2",
      "signoff-chart-output": "#db2777",
      "signoff-code-keyword": "#155e75",
      "signoff-code-string": "#9a3412",
      "signoff-code-number": "#9d174d",
      "signoff-code-comment": "#525b67",
      "signoff-code-punctuation": "#4b5563",
      "signoff-radius": "0.75rem",
      "signoff-font-sans": "var(--font-sans, var(--font-geist-sans, ui-sans-serif, system-ui, sans-serif))",
      "signoff-font-mono": "var(--font-mono, var(--font-geist-mono, ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace))"
    },
    "dark": {
      "signoff-bg": "#0d0f12",
      "signoff-surface": "#181c22",
      "signoff-surface-2": "#1e232a",
      "signoff-border": "#262b33",
      "signoff-border-strong": "#353c47",
      "signoff-fg": "#e8eaed",
      "signoff-fg-muted": "#a1a9b4",
      "signoff-fg-subtle": "#8b94a0",
      "signoff-accent": "#22d3ee",
      "signoff-accent-fg": "#67e8f9",
      "signoff-on-accent": "#0d0f12",
      "signoff-hot": "#f0468a",
      "signoff-hot-fg": "#f472a8",
      "signoff-on-hot": "#0d0f12",
      "signoff-warn": "#fbbf24",
      "signoff-warn-fg": "#fcd34d",
      "signoff-ring": "#67e8f9",
      "signoff-add-bg": "rgb(34 211 238 / 0.08)",
      "signoff-add-strong": "rgb(34 211 238 / 0.14)",
      "signoff-del-bg": "rgb(240 70 138 / 0.08)",
      "signoff-del-strong": "rgb(240 70 138 / 0.26)",
      "signoff-chart-input": "#11a2bc",
      "signoff-chart-output": "#f0468a",
      "signoff-code-keyword": "#67e8f9",
      "signoff-code-string": "#f5c27a",
      "signoff-code-number": "#f9a8d4",
      "signoff-code-comment": "#a1a9b4",
      "signoff-code-punctuation": "#a1a9b4"
    }
  },
  "css": {
    "@keyframes signoff-shimmer": {
      "from": {
        "background-position": "200% 0"
      },
      "to": {
        "background-position": "-200% 0"
      }
    },
    "@keyframes signoff-ping": {
      "0%": {
        "transform": "scale(1)",
        "opacity": "0.7"
      },
      "80%, 100%": {
        "transform": "scale(2.2)",
        "opacity": "0"
      }
    },
    "@keyframes signoff-spin": {
      "to": {
        "transform": "rotate(360deg)"
      }
    },
    "@keyframes signoff-blink": {
      "to": {
        "visibility": "hidden"
      }
    },
    "@keyframes signoff-enter": {
      "from": {
        "opacity": "0",
        "transform": "translateY(4px)"
      },
      "to": {
        "opacity": "1",
        "transform": "none"
      }
    },
    "@keyframes signoff-expand": {
      "from": {
        "height": "0",
        "opacity": "0"
      },
      "to": {
        "height": "var(--radix-collapsible-content-height)",
        "opacity": "1"
      }
    },
    "@keyframes signoff-collapse": {
      "from": {
        "height": "var(--radix-collapsible-content-height)",
        "opacity": "1"
      },
      "to": {
        "height": "0",
        "opacity": "0"
      }
    },
    "@keyframes signoff-indeterminate": {
      "0%": {
        "transform": "translateX(-100%)"
      },
      "100%": {
        "transform": "translateX(250%)"
      }
    }
  },
  "type": "registry:component",
  "meta": {
    "version": "0.14.0",
    "unreleasedChanges": false
  }
}
