@cueloop/client 0.1.0-alpha.65 → 0.1.0-alpha.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +79 -112
  4. package/src/App.plan-review.test.tsx +23 -205
  5. package/src/App.prototype.test.tsx +1 -8
  6. package/src/App.readonly.test.tsx +9 -27
  7. package/src/App.submit-confirm.test.tsx +19 -72
  8. package/src/App.test.tsx +136 -31
  9. package/src/App.tsx +491 -322
  10. package/src/App.walk.test.tsx +15 -16
  11. package/src/App.workbench.test.tsx +390 -0
  12. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  13. package/src/annotation-palette.ts +61 -0
  14. package/src/app-screens.tsx +197 -77
  15. package/src/app-view-model.ts +9 -69
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +1 -1
  18. package/src/components/AnnotationCards.stories.tsx +87 -0
  19. package/src/components/AnnotationCards.tsx +303 -0
  20. package/src/components/AppHeader.stories.tsx +50 -0
  21. package/src/components/AppHeader.tsx +83 -0
  22. package/src/components/AppShell.stories.tsx +89 -0
  23. package/src/components/AppShell.tsx +239 -0
  24. package/src/components/AppShell.zoom.test.tsx +94 -0
  25. package/src/components/Breadcrumb.stories.tsx +1 -1
  26. package/src/components/ChangesColumn.stories.tsx +39 -0
  27. package/src/components/ChangesColumn.tsx +170 -0
  28. package/src/components/CodeBlock.stories.tsx +1 -1
  29. package/src/components/CompletionOverlay.stories.tsx +1 -1
  30. package/src/components/ConfirmCard.stories.tsx +1 -1
  31. package/src/components/ConfirmCard.tsx +3 -4
  32. package/src/components/ConfirmDialog.stories.tsx +1 -1
  33. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  34. package/src/components/DiffSheet.split.test.tsx +140 -0
  35. package/src/components/DiffSheet.stories.tsx +56 -54
  36. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  37. package/src/components/DiffSheet.tsx +607 -276
  38. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  39. package/src/components/DiscussionMarkerRail.tsx +181 -0
  40. package/src/components/EditorGrid.stories.tsx +59 -0
  41. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  42. package/src/components/EditorGrid.tsx +338 -0
  43. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  44. package/src/components/FileContentsView.stories.tsx +16 -0
  45. package/src/components/FileContentsView.test.tsx +29 -0
  46. package/src/components/FileContentsView.tsx +78 -0
  47. package/src/components/InboxList.stories.tsx +23 -8
  48. package/src/components/InboxList.tsx +188 -33
  49. package/src/components/KeybindsDialog.stories.tsx +1 -1
  50. package/src/components/MarkerPopover.stories.tsx +1 -1
  51. package/src/components/MenuBar.stories.tsx +3 -12
  52. package/src/components/MenuBar.tsx +25 -56
  53. package/src/components/PaneGrid.stories.tsx +84 -0
  54. package/src/components/PaneGrid.tsx +128 -0
  55. package/src/components/PanelColumn.stories.tsx +25 -0
  56. package/src/components/PanelColumn.tsx +95 -0
  57. package/src/components/ProjectTreeView.stories.tsx +25 -0
  58. package/src/components/ProjectTreeView.test.tsx +32 -0
  59. package/src/components/ProjectTreeView.tsx +82 -0
  60. package/src/components/PromptDialog.stories.tsx +1 -1
  61. package/src/components/PromptDialog.tsx +8 -1
  62. package/src/components/PrototypeSheet.stories.tsx +1 -1
  63. package/src/components/RootOverlay.stories.tsx +44 -0
  64. package/src/components/RootOverlay.tsx +35 -0
  65. package/src/components/SettingsDialog.stories.tsx +7 -1
  66. package/src/components/SettingsDialog.tsx +114 -67
  67. package/src/components/SettingsRows.stories.tsx +1 -1
  68. package/src/components/ShellHeader.stories.tsx +50 -0
  69. package/src/components/ShellHeader.tsx +137 -0
  70. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  71. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  72. package/src/components/SurfaceScrollbar.tsx +93 -0
  73. package/src/components/ThreadFooter.stories.tsx +30 -0
  74. package/src/components/ThreadFooter.test.tsx +31 -0
  75. package/src/components/ThreadFooter.tsx +54 -0
  76. package/src/components/ThreadView.slash.test.tsx +113 -0
  77. package/src/components/ThreadView.stories.tsx +1 -1
  78. package/src/components/ThreadView.tsx +62 -1352
  79. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  80. package/src/components/ThreadsSidebar.tsx +70 -0
  81. package/src/components/Toast.stories.tsx +1 -1
  82. package/src/components/Tooltip.stories.tsx +26 -0
  83. package/src/components/Tooltip.tsx +72 -0
  84. package/src/components/TreePane.stories.tsx +1 -1
  85. package/src/components/WalkWizard.stories.tsx +1 -1
  86. package/src/components/WelcomeSurface.stories.tsx +12 -0
  87. package/src/components/WelcomeSurface.tsx +52 -0
  88. package/src/components/__snapshots__/stories.test.tsx.snap +732 -572
  89. package/src/components/agent-launcher.stories.tsx +1 -1
  90. package/src/components/diff-sheet-layout.test.ts +125 -55
  91. package/src/components/diff-sheet-layout.ts +3 -103
  92. package/src/components/editor-grid.test.ts +86 -0
  93. package/src/components/editor-grid.ts +138 -0
  94. package/src/components/fade-title.test.ts +49 -0
  95. package/src/components/fade-title.ts +61 -0
  96. package/src/components/file-tree.test.ts +60 -0
  97. package/src/components/file-tree.ts +112 -0
  98. package/src/components/pages/Workspace.stories.tsx +181 -0
  99. package/src/components/primitives/Button.stories.tsx +1 -1
  100. package/src/components/primitives/Card.stories.tsx +1 -1
  101. package/src/components/primitives/Dialog.stories.tsx +1 -1
  102. package/src/components/primitives/IconButton.stories.tsx +36 -0
  103. package/src/components/primitives/IconButton.tsx +62 -0
  104. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  105. package/src/components/primitives/Tabs.stories.tsx +1 -1
  106. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  107. package/src/components/primitives/Tree.stories.tsx +124 -0
  108. package/src/components/primitives/Tree.tsx +96 -0
  109. package/src/components/primitives/icons.ts +61 -0
  110. package/src/components/primitives/tree-model.test.ts +99 -0
  111. package/src/components/primitives/tree-model.ts +137 -0
  112. package/src/components/quick-actions-editor.stories.tsx +1 -1
  113. package/src/components/session-tree.test.ts +134 -0
  114. package/src/components/session-tree.ts +108 -0
  115. package/src/components/stories-app.tsx +142 -82
  116. package/src/components/story-tree.test.ts +100 -0
  117. package/src/components/story.ts +55 -0
  118. package/src/components/theme-context.stories.tsx +1 -1
  119. package/src/config.test.ts +20 -49
  120. package/src/config.ts +26 -28
  121. package/src/diff-fold.test.ts +78 -0
  122. package/src/diff-fold.ts +126 -0
  123. package/src/diff-syntax.test.ts +16 -0
  124. package/src/diff-syntax.ts +6 -3
  125. package/src/discussions.ts +79 -0
  126. package/src/intent-dispatch.test.ts +31 -11
  127. package/src/intent-dispatch.ts +93 -48
  128. package/src/key-bindings.test.ts +1 -1
  129. package/src/keymap.test.ts +3 -25
  130. package/src/keymap.ts +17 -18
  131. package/src/mark-runs.ts +120 -0
  132. package/src/pty.ts +10 -2
  133. package/src/run.ts +17 -7
  134. package/src/serve.test.ts +4 -8
  135. package/src/session-controller.curate.test.ts +75 -0
  136. package/src/session-controller.share.test.ts +1 -0
  137. package/src/session-controller.tree.test.ts +26 -0
  138. package/src/session-controller.ts +160 -31
  139. package/src/slash-palette.ts +83 -0
  140. package/src/split-diff.test.ts +110 -0
  141. package/src/split-diff.ts +149 -0
  142. package/src/test-support.test.ts +3 -3
  143. package/src/test-support.ts +39 -8
  144. package/src/theme.ts +4 -4
  145. package/src/thread-chords.test.ts +25 -9
  146. package/src/thread-chords.ts +33 -8
  147. package/src/use-annotation-surface.tsx +837 -0
  148. package/src/use-changes-workbench.ts +151 -0
  149. package/src/use-frame-measure.ts +38 -0
  150. package/src/use-settings-dialog.tsx +23 -24
  151. package/src/use-terminal-virtualizer.ts +233 -0
  152. package/src/view-diff.ts +114 -0
  153. package/src/App.review-panel.test.tsx +0 -140
  154. package/src/App.tree.test.tsx +0 -224
  155. package/src/components/ReviewPanel.stories.tsx +0 -100
  156. package/src/components/ReviewPanel.tsx +0 -103
  157. package/src/components/ReviewRail.stories.tsx +0 -232
  158. package/src/components/ReviewRail.tsx +0 -327
  159. package/src/review-panel.test.ts +0 -113
  160. package/src/review-panel.ts +0 -83
@@ -1,36 +1,33 @@
1
1
  /**
2
- * The diff review sheet: a thin renderer over diff-sheet-layout. Rows render (no
3
- * window cap) in a scrollbox with file/hunk headers and contiguous code chunks.
4
- * A chunk draws through the native line-number gutter - custom numbers per row,
5
- * the cursor as a gutter sign, annotation markers as line signs - and closes
6
- * after an annotated row so the annotation body sits under its line. A
7
- * curated-out row renders struck through.
2
+ * The diff review sheet: the code surface where discussions live inline under
3
+ * the lines they mark, exactly as the plan thread view does for prose. The caret,
4
+ * click/drag marks, type-to-comment, the "/" palette, and the discussion cards
5
+ * come from the shared annotation surface; this sheet paints the diff's own rows -
6
+ * file bands, hunk headers, a line-number gutter with the change sign, syntax and
7
+ * intra-line colors, curated-out lines struck through - one text per visual line
8
+ * so a drag can hit-test any row, unified or side by side.
8
9
  */
9
10
 
10
11
  import React, { useEffect, useMemo, useRef, useState } from "react";
11
- import {
12
- createTextAttributes,
13
- type LineNumberRenderable,
14
- type LineSign,
15
- type ScrollBoxRenderable,
16
- } from "@opentui/core";
17
- import type { Annotation } from "@cueloop/schema";
18
- import type { DiffRow } from "../view-diff";
12
+ import { createTextAttributes, type ScrollBoxRenderable } from "@opentui/core";
13
+ import type { ReviewSession } from "@cueloop/schema";
14
+ import { diffRowText, fileChangeCounts, type DiffRow, type Mark } from "../view-diff";
15
+ import type { TextSpan } from "../thread-selection";
16
+ import type { QuickAction } from "../config";
19
17
  import type { Theme } from "../theme";
20
18
  import { useComponentTheme } from "./theme-context";
21
- import { truncateToSingleLine } from "./truncate-text";
22
- import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
23
- import { FRAME_BORDER_STYLE } from "./primitives/frame";
19
+ import { IconButton } from "./primitives/IconButton";
20
+ import { NERD } from "./primitives/icons";
24
21
  import { intralineRunsByRow, type IntralineRun } from "../diff-intraline";
25
22
  import { highlightDiffRows, type SyntaxSpan } from "../diff-syntax";
26
- import {
27
- annotatedRowsByIndex,
28
- coloredRowSpans,
29
- rowContentOffsets,
30
- rowLine,
31
- segmentRows,
32
- type DiffSegment,
33
- } from "./diff-sheet-layout";
23
+ import { splitDiffRows, type SplitLine, type SplitRow } from "../split-diff";
24
+ import { UNDERLINE, type AnnotationPalette } from "../annotation-palette";
25
+ import { lineMarkRanges, runsFor, wrapLines, type MarkRange } from "../mark-runs";
26
+ import { useFrameMeasure } from "../use-frame-measure";
27
+ import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
28
+ import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
29
+ import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
30
+ import { coloredRowSpans } from "./diff-sheet-layout";
34
31
 
35
32
  /** Shared empty map so an unresolved/stale highlight state is a stable value. */
36
33
  const EMPTY_SYNTAX: Map<number, SyntaxSpan[]> = new Map();
@@ -41,195 +38,318 @@ const EMPTY_REJECTED: Set<number> = new Set();
41
38
  /** A rejected (curated-out) change row renders struck through and dimmed. */
42
39
  const REJECTED_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
43
40
 
44
- export interface DiffComposeState {
45
- kind: "comment";
46
- rowIndex: number;
47
- quote: string;
48
- draft: AnnotationDraft;
41
+ /** A file band renders its name in bold between an equal rule above and below. */
42
+ const FILE_HEADER_ATTRIBUTES = createTextAttributes({ bold: true });
43
+
44
+ /** The gutter before a code line: caret bar, four-digit line number, a space, the sign, a space. */
45
+ const GUTTER_COLUMNS = 8;
46
+
47
+ /** The per-file fold controls the file band renders; absent in read-only story renders. */
48
+ export interface DiffFoldControls {
49
+ isCollapsed: (file: string) => boolean;
50
+ isExpanded: (file: string) => boolean;
51
+ canExpand: (file: string) => boolean;
52
+ onToggleCollapse: (file: string) => void;
53
+ onToggleExpand: (file: string) => void;
54
+ onCopyPath: (file: string) => void;
49
55
  }
50
56
 
51
57
  export interface DiffSheetProps {
52
58
  rows: DiffRow[];
53
- cursor: number;
54
- annotations: Annotation[];
59
+ session: ReviewSession;
60
+ /** Annotations resolved onto rows (marksByRows), with char ranges and spans. */
61
+ marks: Map<number, Mark[]>;
62
+ quickActions: QuickAction[];
63
+ observer: boolean;
64
+ /** A verdict is in: no draft may open; the app answers with its read-only status. */
65
+ resolved?: boolean;
66
+ /** True while a menu, dialog, or overlay owns the keyboard. */
67
+ suspended?: boolean;
68
+ /** Reports whether a composer is open, so session chords can yield to typing. */
69
+ onComposingChange?: (composing: boolean) => void;
70
+ /** An observer or a resolved review refused a draft; the app shows why. */
71
+ onObserverBlocked?: (reason: "observer" | "resolved") => void;
72
+ /** Reports the caret's row, so row-level primitives (reject, fold) act where the caret is. */
73
+ onCursorChange?: (rowIndex: number) => void;
74
+ /** The rail's focused card; the discussion holding it takes focus here. */
55
75
  focusedAnnotationId?: string;
76
+ /** Reports the focused discussion's root comment, so the rail follows. */
77
+ onFocusAnnotation?: (annotationId: string | undefined) => void;
78
+ onAnnotate: (span: TextSpan, body: string) => void;
79
+ onReply: (rootAnnotationId: string, body: string) => void;
80
+ onUpdateAnnotation: (id: string, body: string) => void;
81
+ onExit: () => void;
56
82
  /** Row indices the owner rejected during curation; drawn struck through. */
57
83
  rejectedRows?: Set<number>;
58
- compose?: DiffComposeState | null;
84
+ /** File-band chevron/copy/unfold actions; when absent the band shows no controls. */
85
+ fold?: DiffFoldControls;
86
+ /** Per-file +/- counts from the base rows, so a collapsed file keeps its badge; else computed here. */
87
+ fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
88
+ /** Render old|new side by side instead of one inline column; the App gates this on zoom. */
89
+ split?: boolean;
59
90
  theme?: Theme;
60
91
  }
61
92
 
62
- /** The sign, base color, and background a row draws with. */
63
- function rowStyle(row: DiffRow, isCursorRow: boolean, isAnnotatedRow: boolean, tokens: Theme) {
64
- const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
65
- const baseColor =
66
- row.kind === "add"
67
- ? tokens.insertedForeground
68
- : row.kind === "del"
69
- ? tokens.deletedForeground
70
- : tokens.textMuted;
71
- const background = isCursorRow
72
- ? tokens.cursorBackground
73
- : isAnnotatedRow
74
- ? tokens.markCommentBackground
75
- : undefined;
76
-
77
- return { sign, baseColor, background };
93
+ /** A row the caret can rest on and a comment can anchor to. */
94
+ function isCodeRow(row: DiffRow | undefined): boolean {
95
+ return row?.kind === "ctx" || row?.kind === "add" || row?.kind === "del";
78
96
  }
79
97
 
80
- function DiffChunk({
81
- segment,
82
- cursor,
83
- focusedAnnotationId,
84
- intralineByRow,
85
- syntaxByRow,
86
- rejectedRows,
87
- theme,
98
+ /** The green +N red -N badge pinned to the file band's right edge. */
99
+ function FileCountsBadge({
100
+ stats,
101
+ tokens,
102
+ marginRight,
88
103
  }: {
89
- segment: Extract<DiffSegment, { kind: "chunk" }>;
90
- cursor: number;
91
- focusedAnnotationId?: string;
92
- intralineByRow: Map<number, IntralineRun[]>;
93
- syntaxByRow: Map<number, SyntaxSpan[]>;
94
- rejectedRows: Set<number>;
95
- theme?: Theme;
104
+ stats: { additions: number; deletions: number };
105
+ tokens: Theme;
106
+ marginRight: number;
96
107
  }): React.ReactNode {
97
- const tokens = useComponentTheme(theme);
98
- const gutterRef = useRef<LineNumberRenderable | null>(null);
99
- const cursorInChunk = cursor - segment.firstRowIndex;
108
+ if (stats.additions === 0 && stats.deletions === 0) return null;
100
109
 
101
- const lineNumbers = useMemo(() => {
102
- const numbers = new Map<number, number>();
110
+ return (
111
+ <text style={{ flexShrink: 0, wrapMode: "none", marginRight }}>
112
+ {stats.additions > 0 ? (
113
+ <span fg={tokens.insertedForeground}>{`+${stats.additions}`}</span>
114
+ ) : null}
115
+ {stats.additions > 0 && stats.deletions > 0 ? <span> </span> : null}
116
+ {stats.deletions > 0 ? (
117
+ <span fg={tokens.deletedForeground}>{`-${stats.deletions}`}</span>
118
+ ) : null}
119
+ </text>
120
+ );
121
+ }
103
122
 
104
- segment.rows.forEach((row, lineIndex) => {
105
- const gutterNumber = row.kind === "del" ? row.oldLine : row.newLine;
123
+ /**
124
+ * A file band: chevron, bold name, and the added/removed counts between an equal rule above and
125
+ * below. `fold` absent (story renders) drops the controls.
126
+ */
127
+ function FileBand({
128
+ row,
129
+ stats,
130
+ fold,
131
+ tokens,
132
+ }: {
133
+ row: DiffRow;
134
+ stats: { additions: number; deletions: number } | undefined;
135
+ fold: DiffFoldControls | undefined;
136
+ tokens: Theme;
137
+ }): React.ReactNode {
138
+ const file = row.file;
139
+ const collapsed = fold?.isCollapsed(file) ?? false;
140
+ const expanded = fold?.isExpanded(file) ?? false;
141
+ const canExpand = fold?.canExpand(file) ?? false;
106
142
 
107
- if (gutterNumber !== undefined) numbers.set(lineIndex, gutterNumber);
108
- });
143
+ return (
144
+ <box style={{ borderStyle: "single", border: ["top", "bottom"], borderColor: tokens.border }}>
145
+ <box style={{ flexDirection: "row", justifyContent: "space-between" }}>
146
+ <box style={{ flexDirection: "row", flexShrink: 1, minWidth: 0 }}>
147
+ {fold ? (
148
+ <IconButton
149
+ glyph={collapsed ? NERD.chevronRight : NERD.chevronDown}
150
+ onPress={() => fold.onToggleCollapse(file)}
151
+ tip={collapsed ? "Expand file" : "Collapse file"}
152
+ marginRight={1}
153
+ />
154
+ ) : null}
155
+ <text
156
+ fg={tokens.text}
157
+ attributes={FILE_HEADER_ATTRIBUTES}
158
+ style={{ flexShrink: 1, minWidth: 0, wrapMode: "none" }}
159
+ >
160
+ {diffRowText(row)}
161
+ </text>
162
+ </box>
163
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
164
+ {stats ? (
165
+ <FileCountsBadge stats={stats} tokens={tokens} marginRight={fold ? 1 : 0} />
166
+ ) : null}
167
+ {fold ? (
168
+ <IconButton
169
+ glyph={NERD.copy}
170
+ onPress={() => fold.onCopyPath(file)}
171
+ tip="Copy path"
172
+ marginRight={canExpand ? 1 : 0}
173
+ />
174
+ ) : null}
175
+ {fold && canExpand ? (
176
+ <IconButton
177
+ glyph={NERD.unfold}
178
+ onPress={() => fold.onToggleExpand(file)}
179
+ tip={expanded ? "Collapse to changes" : "Expand all lines"}
180
+ />
181
+ ) : null}
182
+ </box>
183
+ </box>
184
+ </box>
185
+ );
186
+ }
109
187
 
110
- return numbers;
111
- }, [segment]);
188
+ /** One code row rendered: its visual line rows, and the cards that hang under them. */
189
+ interface CodeRowNodes {
190
+ lines: React.ReactNode[];
191
+ cards: React.ReactNode[];
192
+ }
112
193
 
113
- const lineSigns = useMemo(() => {
114
- const signs = new Map<number, LineSign>();
194
+ /** One side of a split pair rendered: its column of lines with its cards under them, or filler. */
195
+ interface SplitSideNodes {
196
+ node: React.ReactNode;
197
+ }
115
198
 
116
- if (cursorInChunk >= 0 && cursorInChunk < segment.rows.length) {
117
- signs.set(cursorInChunk, { before: "▎", beforeColor: tokens.accent });
118
- }
119
- if (segment.annotation) {
120
- signs.set(segment.rows.length - 1, { after: "◆", afterColor: tokens.accent });
121
- }
199
+ /**
200
+ * One item of the sheet's vertical layout: a base row (unified) or a split row, with the
201
+ * visual lines it occupies. Cards under a materialized row add to the real height; the
202
+ * model only has to be right enough for spacers and the caret reveal.
203
+ */
204
+ interface LayoutItem {
205
+ /** Index into the unified rows, or into the split rows when side by side. */
206
+ index: number;
207
+ height: number;
208
+ }
209
+
210
+ interface SheetLayout {
211
+ items: LayoutItem[];
212
+ /** Visual-line offset of each item from the top of the content. */
213
+ offsets: number[];
214
+ total: number;
215
+ /** The layout item that shows a given base row, for the caret reveal. */
216
+ itemOfRow: number[];
217
+ }
218
+
219
+ /** Rows this many visual lines beyond the viewport stay materialized, so a scroll step never shows a gap. */
220
+ const OVERSCAN_LINES = 30;
221
+
222
+ /** A file band is three rows (rule, name, rule); a hunk header one. */
223
+ function headerHeight(row: DiffRow): number {
224
+ return row.kind === "file" ? 3 : 1;
225
+ }
226
+
227
+ function codeRowHeight(row: DiffRow, textWidth: number): number {
228
+ return wrapLines(diffRowText(row), textWidth).length;
229
+ }
230
+
231
+ function buildLayout(items: LayoutItem[], itemOfRow: number[]): SheetLayout {
232
+ const offsets: number[] = [];
233
+ let total = 0;
234
+
235
+ for (const item of items) {
236
+ offsets.push(total);
237
+ total += item.height;
238
+ }
239
+
240
+ return { items, offsets, total, itemOfRow };
241
+ }
242
+
243
+ function unifiedLayout(rows: DiffRow[], textWidth: number): SheetLayout {
244
+ const items = rows.map((row, index) => ({
245
+ index,
246
+ height: isCodeRow(row) ? codeRowHeight(row, textWidth) : headerHeight(row),
247
+ }));
122
248
 
123
- return signs;
124
- }, [segment, cursorInChunk, tokens]);
249
+ return buildLayout(
250
+ items,
251
+ rows.map((_row, index) => index),
252
+ );
253
+ }
254
+
255
+ function splitLayout(splitRows: SplitRow[], rows: DiffRow[], textWidth: number): SheetLayout {
256
+ const itemOfRow: number[] = [];
257
+ const items = splitRows.map((pair, index) => {
258
+ if (pair.kind !== "pair") {
259
+ const row = rows[pair.rowIndex ?? -1];
125
260
 
126
- const lineColors = useMemo(() => {
127
- // the renderable's config parser requires the gutter key to be present
128
- const colors = new Map<number, { gutter: string | undefined; content: string }>();
261
+ if (pair.rowIndex !== undefined) itemOfRow[pair.rowIndex] = index;
129
262
 
130
- if (segment.annotation) {
131
- colors.set(segment.rows.length - 1, {
132
- gutter: undefined,
133
- content: tokens.markCommentBackground,
134
- });
263
+ return { index, height: row ? headerHeight(row) : 1 };
135
264
  }
136
- if (cursorInChunk >= 0 && cursorInChunk < segment.rows.length) {
137
- colors.set(cursorInChunk, {
138
- gutter: tokens.cursorBackground,
139
- content: tokens.cursorBackground,
140
- });
265
+ const sideHeight = (line: SplitLine | undefined): number =>
266
+ line ? codeRowHeight(line.row, textWidth) : 0;
267
+
268
+ if (pair.left) itemOfRow[pair.left.rowIndex] = index;
269
+ if (pair.right) itemOfRow[pair.right.rowIndex] = index;
270
+
271
+ return { index, height: Math.max(1, sideHeight(pair.left), sideHeight(pair.right)) };
272
+ });
273
+
274
+ return buildLayout(items, itemOfRow);
275
+ }
276
+
277
+ /** One painted stretch of a code line: its text with foreground, backdrop, and attributes. */
278
+ interface CodeSpan {
279
+ text: string;
280
+ fg: string;
281
+ bg: string | undefined;
282
+ attributes: number;
283
+ }
284
+
285
+ /**
286
+ * The spans of one visual line: per character, the syntax or intra-line color under
287
+ * the mark treatment (backdrop + underline) or the caret cell, coalesced where equal.
288
+ * A rejected row paints one dim struck-through run, so it reads as excluded regardless
289
+ * of its colors; a mark still shows through it.
290
+ */
291
+ function codeLineSpans(
292
+ lineText: string,
293
+ lineStart: number,
294
+ lineRanges: MarkRange[],
295
+ fgByColumn: string[],
296
+ rejected: boolean,
297
+ tokens: Theme,
298
+ palette: AnnotationPalette,
299
+ ): CodeSpan[] {
300
+ const spans: CodeSpan[] = [];
301
+ let column = 0;
302
+
303
+ for (const run of runsFor(lineText, lineRanges)) {
304
+ const bg = run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined;
305
+ const attributes = (run.marked ? UNDERLINE : 0) | (rejected ? REJECTED_ATTRIBUTES : 0);
306
+
307
+ for (const character of run.text) {
308
+ const fg = rejected ? tokens.textDim : (fgByColumn[lineStart + column] ?? tokens.textMuted);
309
+ const previous = spans[spans.length - 1];
310
+
311
+ if (
312
+ previous &&
313
+ previous.fg === fg &&
314
+ previous.bg === bg &&
315
+ previous.attributes === attributes
316
+ ) {
317
+ previous.text += character;
318
+ } else spans.push({ text: character, fg, bg, attributes });
319
+ column++;
141
320
  }
321
+ }
142
322
 
143
- return colors;
144
- }, [segment, cursorInChunk, tokens]);
323
+ return spans;
324
+ }
145
325
 
146
- // the gutter renderable has no prop setters for these maps, so they land via an effect
147
- useEffect(() => {
148
- const gutter = gutterRef.current;
326
+ /** Per-column foreground of a row's text: intra-line change color over syntax over the base. */
327
+ function foregroundColumns(
328
+ text: string,
329
+ baseColor: string,
330
+ intraline: IntralineRun[] | undefined,
331
+ syntax: SyntaxSpan[] | undefined,
332
+ tokens: Theme,
333
+ ): string[] {
334
+ const columns: string[] = [];
335
+
336
+ for (const span of coloredRowSpans(text, intraline, syntax, baseColor, tokens)) {
337
+ for (let index = 0; index < span.text.length; index++) columns.push(span.foreground);
338
+ }
339
+
340
+ return columns;
341
+ }
149
342
 
150
- if (!gutter) return;
151
- gutter.setLineNumbers(lineNumbers);
152
- gutter.setLineSigns(lineSigns);
153
- gutter.clearAllLineColors();
154
- for (const [line, colorConfig] of lineColors) gutter.setLineColor(line, colorConfig);
155
- }, [lineNumbers, lineSigns, lineColors]);
343
+ function rowSign(row: DiffRow): string {
344
+ return row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
345
+ }
156
346
 
157
- return (
158
- <box style={{ flexDirection: "column" }}>
159
- <line-number
160
- ref={gutterRef}
161
- fg={tokens.textDim}
162
- bg={tokens.background}
163
- minWidth={4}
164
- paddingRight={1}
165
- lineNumbers={lineNumbers}
166
- lineSigns={lineSigns}
167
- >
168
- <text style={{ wrapMode: "none" }} selectable={false}>
169
- {segment.rows.map((row, lineIndex) => {
170
- const isCursorRow = lineIndex === cursorInChunk;
171
- const isAnnotatedRow =
172
- segment.annotation !== null && lineIndex === segment.rows.length - 1;
173
- const { sign, baseColor, background } = rowStyle(
174
- row,
175
- isCursorRow,
176
- isAnnotatedRow,
177
- tokens,
178
- );
179
- const prefix = (lineIndex > 0 ? "\n" : "") + sign;
180
- const absoluteRowIndex = segment.firstRowIndex + lineIndex;
181
-
182
- // A rejected change collapses to one struck-through, dimmed span, so
183
- // it reads as excluded regardless of its syntax or intra-line colors.
184
- if (rejectedRows.has(absoluteRowIndex)) {
185
- return (
186
- <span
187
- key={lineIndex}
188
- fg={tokens.textDim}
189
- bg={background}
190
- attributes={REJECTED_ATTRIBUTES}
191
- >
192
- {prefix + rowLine(row)}
193
- </span>
194
- );
195
- }
196
-
197
- const spans = coloredRowSpans(
198
- rowLine(row),
199
- intralineByRow.get(absoluteRowIndex),
200
- syntaxByRow.get(absoluteRowIndex),
201
- baseColor,
202
- tokens,
203
- );
204
-
205
- return (
206
- <React.Fragment key={lineIndex}>
207
- <span fg={baseColor} bg={background}>
208
- {prefix}
209
- </span>
210
- {spans.map((span, spanIndex) => (
211
- <span key={spanIndex} fg={span.foreground} bg={background}>
212
- {span.text}
213
- </span>
214
- ))}
215
- </React.Fragment>
216
- );
217
- })}
218
- </text>
219
- </line-number>
220
-
221
- {segment.annotation ? (
222
- // one row exactly: wrapMode none keeps the model's 1-line-per-card offset
223
- // honest, so the cursor-follow scroll never drifts past a wrapped card
224
- <text style={{ wrapMode: "none" }}>
225
- <span fg={tokens.textDim}>{" "}</span>
226
- <span fg={segment.annotation.id === focusedAnnotationId ? tokens.text : tokens.accent}>
227
- ◆ {truncateToSingleLine(segment.annotation.body, 70)}
228
- </span>
229
- </text>
230
- ) : null}
231
- </box>
232
- );
347
+ function rowBaseColor(row: DiffRow, tokens: Theme): string {
348
+ return row.kind === "add"
349
+ ? tokens.insertedForeground
350
+ : row.kind === "del"
351
+ ? tokens.deletedForeground
352
+ : tokens.textMuted;
233
353
  }
234
354
 
235
355
  /** Async tree-sitter highlights, discarded when they belong to superseded rows. */
@@ -259,111 +379,322 @@ function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
259
379
 
260
380
  export function DiffSheet({
261
381
  rows,
262
- cursor,
263
- annotations,
382
+ session,
383
+ marks,
384
+ quickActions,
385
+ observer,
386
+ resolved = false,
387
+ suspended = false,
388
+ onComposingChange,
389
+ onObserverBlocked,
390
+ onCursorChange,
264
391
  focusedAnnotationId,
392
+ onFocusAnnotation,
393
+ onAnnotate,
394
+ onReply,
395
+ onUpdateAnnotation,
396
+ onExit,
265
397
  rejectedRows = EMPTY_REJECTED,
266
- compose,
398
+ fold,
399
+ fileStats: fileStatsProp,
400
+ split = false,
267
401
  theme,
268
402
  }: DiffSheetProps): React.ReactNode {
269
403
  const tokens = useComponentTheme(theme);
270
404
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
271
-
272
- const annotatedByRow = useMemo(
273
- () => annotatedRowsByIndex(rows, annotations),
274
- [rows, annotations],
275
- );
276
- const segments = useMemo(
277
- () => segmentRows(rows, annotatedByRow, compose?.rowIndex),
278
- [rows, annotatedByRow, compose?.rowIndex],
279
- );
405
+ const source: LineSource = {
406
+ count: rows.length,
407
+ textAt: (rowIndex) => diffRowText(rows[rowIndex]!),
408
+ annotatable: (rowIndex) => isCodeRow(rows[rowIndex]),
409
+ };
410
+ const surface = useAnnotationSurface({
411
+ source,
412
+ session,
413
+ marks,
414
+ quickActions,
415
+ tokens,
416
+ observer,
417
+ resolved,
418
+ suspended,
419
+ onComposingChange,
420
+ onObserverBlocked,
421
+ onCursorChange,
422
+ focusedAnnotationId,
423
+ onFocusAnnotation,
424
+ onAnnotate,
425
+ onReply,
426
+ onUpdateAnnotation,
427
+ onExit,
428
+ });
429
+ const { palette } = surface;
280
430
  const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
281
431
  const syntaxByRow = useSyntaxHighlights(rows);
282
- const rowOffsets = useMemo(() => rowContentOffsets(segments), [segments]);
432
+ const splitRows = useMemo(() => (split ? splitDiffRows(rows) : []), [split, rows]);
433
+ const localStats = useMemo(() => fileChangeCounts(rows), [rows]);
434
+ // base-row counts survive a collapse (folded rows drop the file's add/del rows); fall back locally
435
+ const fileStats = fileStatsProp ?? localStats;
436
+ const viewWidth = useFrameMeasure(
437
+ () => scrollRef.current?.content?.width ?? 0,
438
+ (left, right) => left === right,
439
+ 0,
440
+ );
441
+ // two columns and a one-cell divider share the width; each column keeps its own gutter
442
+ const textWidth = split
443
+ ? Math.max(0, Math.floor((viewWidth - 1) / 2) - GUTTER_COLUMNS)
444
+ : Math.max(0, viewWidth - GUTTER_COLUMNS);
445
+
446
+ // Every visual line is its own text renderable (so a drag can hit-test it), and a large
447
+ // diff has tens of thousands of them - more native text buffers than the renderer can
448
+ // hold. So the sheet is virtual: only the viewport plus an overscan is mounted, each row
449
+ // estimated at its wrapped line count until its box (cards included) is measured.
450
+ const layout = useMemo(
451
+ () => (split ? splitLayout(splitRows, rows, textWidth) : unifiedLayout(rows, textWidth)),
452
+ [split, splitRows, rows, textWidth],
453
+ );
454
+ const virtual = useTerminalVirtualizer({
455
+ scrollbox: scrollRef,
456
+ count: layout.items.length,
457
+ estimateSize: (index) => layout.items[index]?.height ?? 1,
458
+ overscan: OVERSCAN_LINES,
459
+ });
283
460
 
284
- // follow the cursor: keep it a couple of rows inside the viewport
285
- useEffect(() => {
286
- const scrollbox = scrollRef.current;
287
- const cursorOffset = rowOffsets[cursor];
461
+ // an opening card shifts the layout, so the row it belongs to is revealed again; a
462
+ // discussion focused from the rail is scrolled into view the same way
463
+ const revealItem = layout.itemOfRow[surface.revealBlockIndex];
288
464
 
289
- if (!scrollbox || cursorOffset === undefined) return;
290
- const viewportHeight = Math.max(1, scrollbox.height);
465
+ useEffect(() => {
466
+ if (revealItem !== undefined) virtual.scrollToIndex(revealItem);
467
+ // eslint-disable-next-line react-hooks/exhaustive-deps
468
+ }, [surface.revealBlockIndex]);
469
+
470
+ /**
471
+ * The visual lines of one code row painted with gutter, colors, and marks; cards collected after.
472
+ * `paintMarks` false renders the line plain (no caret, no mark backdrop) - the split view uses it so
473
+ * a context row, shared by both columns, only carries the caret and marks on the change side.
474
+ */
475
+ const codeRowLines = (
476
+ row: DiffRow,
477
+ rowIndex: number,
478
+ textWidth: number,
479
+ keyPrefix: string,
480
+ paintMarks = true,
481
+ ): CodeRowNodes => {
482
+ const text = diffRowText(row);
483
+ const lines = wrapLines(text, textWidth);
484
+ const ranges = paintMarks ? surface.rangesFor(rowIndex) : [];
485
+ const fgByColumn = foregroundColumns(
486
+ text,
487
+ rowBaseColor(row, tokens),
488
+ intralineByRow.get(rowIndex),
489
+ syntaxByRow.get(rowIndex),
490
+ tokens,
491
+ );
492
+ const rejected = rejectedRows.has(rowIndex);
493
+ const isCaretRow = paintMarks && surface.head.blockIndex === rowIndex;
494
+ const lineNumber = row.kind === "del" ? row.oldLine : row.newLine;
495
+ const gutter = `${isCaretRow ? "▎" : " "}${String(lineNumber ?? "").padStart(4, " ")} ${rowSign(row)} `;
496
+ const lineNodes: React.ReactNode[] = [];
497
+ const cards: React.ReactNode[] = [];
498
+
499
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
500
+ const line = lines[lineIndex]!;
501
+ const isLastLine = lineIndex === lines.length - 1;
502
+
503
+ lineNodes.push(
504
+ <box key={`${keyPrefix}-line-${lineIndex}`} style={{ flexDirection: "row" }}>
505
+ <text
506
+ selectable={false}
507
+ fg={isCaretRow && lineIndex === 0 ? tokens.accent : tokens.textDim}
508
+ style={{ flexShrink: 0, wrapMode: "none" }}
509
+ >
510
+ {lineIndex === 0 ? (
511
+ <>
512
+ <span fg={isCaretRow ? tokens.accent : tokens.textDim}>{gutter.slice(0, 6)}</span>
513
+ <span fg={rowBaseColor(row, tokens)}>{gutter.slice(6)}</span>
514
+ </>
515
+ ) : (
516
+ " ".repeat(GUTTER_COLUMNS)
517
+ )}
518
+ </text>
519
+ <text
520
+ selectable={false}
521
+ style={{ flexGrow: 1, flexShrink: 1, wrapMode: "none" }}
522
+ ref={surface.registerLine(rowIndex, lineIndex, line)}
523
+ onMouseDown={surface.onLineMouseDown}
524
+ >
525
+ {codeLineSpans(
526
+ text.slice(line.start, line.end),
527
+ line.start,
528
+ lineMarkRanges(ranges, line),
529
+ fgByColumn,
530
+ rejected,
531
+ tokens,
532
+ palette,
533
+ ).map((span, spanIndex) => (
534
+ <span key={spanIndex} fg={span.fg} bg={span.bg} attributes={span.attributes}>
535
+ {span.text}
536
+ </span>
537
+ ))}
538
+ </text>
539
+ </box>,
540
+ );
541
+ // cards render on the side that owns the row, so a shared context row's card is not duplicated
542
+ if (paintMarks) cards.push(...surface.cardsAfterLine(rowIndex, line, isLastLine));
543
+ }
291
544
 
292
- if (cursorOffset < scrollbox.scrollTop + 2) {
293
- scrollbox.scrollTo({ x: 0, y: Math.max(0, cursorOffset - 2) });
294
- } else if (cursorOffset > scrollbox.scrollTop + viewportHeight - 3) {
295
- scrollbox.scrollTo({ x: 0, y: cursorOffset - viewportHeight + 3 });
545
+ return { lines: lineNodes, cards };
546
+ };
547
+
548
+ const headerNode = (row: DiffRow, rowIndex: number): React.ReactNode =>
549
+ row.kind === "file" ? (
550
+ <box key={rowIndex} id={`diff-row-${rowIndex}`}>
551
+ <FileBand row={row} stats={fileStats.get(row.file)} fold={fold} tokens={tokens} />
552
+ </box>
553
+ ) : (
554
+ <text
555
+ key={rowIndex}
556
+ id={`diff-row-${rowIndex}`}
557
+ fg={tokens.blue}
558
+ style={{ wrapMode: "none" }}
559
+ >
560
+ {` ${diffRowText(row)}`}
561
+ </text>
562
+ );
563
+
564
+ const unifiedItem = (rowIndex: number): React.ReactNode => {
565
+ const row = rows[rowIndex]!;
566
+
567
+ if (!isCodeRow(row)) return headerNode(row, rowIndex);
568
+ const { lines, cards } = codeRowLines(row, rowIndex, textWidth, `row-${rowIndex}`);
569
+
570
+ return (
571
+ <box key={rowIndex} id={`diff-row-${rowIndex}`} style={{ flexDirection: "column" }}>
572
+ {lines}
573
+ {cards}
574
+ </box>
575
+ );
576
+ };
577
+
578
+ /** One side of a split pair: the row's visual lines, or blank filler that reads as absent. */
579
+ const splitSide = (
580
+ line: SplitLine | undefined,
581
+ side: "left" | "right",
582
+ textWidth: number,
583
+ keyPrefix: string,
584
+ ): SplitSideNodes => {
585
+ if (!line) {
586
+ const filler = (
587
+ <box style={{ flexGrow: 1, flexBasis: 0, minWidth: 0, backgroundColor: tokens.panel }} />
588
+ );
589
+
590
+ return { node: filler };
591
+ }
592
+ // the change side (right: additions and context) owns the caret and marks; the left carries them
593
+ // only on its deletions, so a context row shared by both columns is not marked twice
594
+ const paintMarks = side === "right" || line.kind === "del";
595
+ const { lines, cards } = codeRowLines(
596
+ line.row,
597
+ line.rowIndex,
598
+ textWidth,
599
+ keyPrefix,
600
+ paintMarks,
601
+ );
602
+ // a discussion stays in the pane of the side it annotates, under that side's lines
603
+ const column = (
604
+ <box style={{ flexDirection: "column", flexGrow: 1, flexBasis: 0, minWidth: 0 }}>
605
+ {lines}
606
+ {cards}
607
+ </box>
608
+ );
609
+
610
+ return { node: column };
611
+ };
612
+
613
+ const splitItem = (pairIndex: number): React.ReactNode => {
614
+ const pair: SplitRow = splitRows[pairIndex]!;
615
+
616
+ if (pair.kind !== "pair") {
617
+ const rowIndex = pair.rowIndex ?? -1;
618
+
619
+ return headerNode(
620
+ rows[rowIndex] ?? { kind: pair.kind, text: pair.file, file: pair.file },
621
+ rowIndex,
622
+ );
623
+ }
624
+ const left = splitSide(pair.left, "left", textWidth, `pair-${pairIndex}-left`);
625
+ const right = splitSide(pair.right, "right", textWidth, `pair-${pairIndex}-right`);
626
+ // both sides answer to the pair's id, so a reveal of either base row lands here
627
+ const anchorRow = pair.right?.rowIndex ?? pair.left?.rowIndex ?? -1;
628
+ const otherRow = pair.left?.rowIndex;
629
+
630
+ return (
631
+ <box
632
+ key={`pair-${pairIndex}`}
633
+ id={`diff-row-${anchorRow}`}
634
+ style={{ flexDirection: "column" }}
635
+ >
636
+ {/* stretch so the divider box grows to the taller side's wrapped height, leaving no gap */}
637
+ <box
638
+ id={otherRow !== undefined && otherRow !== anchorRow ? `diff-row-${otherRow}` : undefined}
639
+ style={{ flexDirection: "row", alignItems: "stretch" }}
640
+ >
641
+ {left.node}
642
+ <box
643
+ style={{
644
+ flexShrink: 0,
645
+ borderStyle: "single",
646
+ border: ["left"],
647
+ borderColor: tokens.border,
648
+ }}
649
+ />
650
+ {right.node}
651
+ </box>
652
+ </box>
653
+ );
654
+ };
655
+
656
+ const materialized: React.ReactNode[] = [];
657
+ const firstItem = virtual.items[0];
658
+ const lastItem = virtual.items[virtual.items.length - 1];
659
+
660
+ if (firstItem && lastItem) {
661
+ const below = virtual.totalSize - lastItem.end;
662
+
663
+ if (firstItem.start > 0) {
664
+ materialized.push(<box key="spacer-above" style={{ height: firstItem.start }} />);
665
+ }
666
+ for (const item of virtual.items) {
667
+ // the wrapper is what gets measured, so a row's cards count toward its height
668
+ materialized.push(
669
+ <box key={`item-${item.index}`} ref={virtual.measureRef(item.index)}>
670
+ {split ? splitItem(item.index) : unifiedItem(item.index)}
671
+ </box>,
672
+ );
296
673
  }
297
- }, [cursor, rowOffsets]);
674
+ if (below > 0) materialized.push(<box key="spacer-below" style={{ height: below }} />);
675
+ }
298
676
 
299
677
  return (
300
- <box
301
- style={{
302
- flexGrow: 1,
303
- flexDirection: "column",
304
- border: true,
305
- borderStyle: FRAME_BORDER_STYLE,
306
- borderColor: tokens.text,
307
- paddingLeft: 1,
308
- paddingTop: 0,
309
- }}
310
- >
311
- <scrollbox id="diff-scroll" ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
312
- {segments.map((segment, segmentIndex) => {
313
- if (segment.kind === "header") {
314
- const isCursor = segment.rowIndex === cursor;
315
-
316
- if (segment.row.kind === "file") {
317
- return (
318
- <text
319
- key={segmentIndex}
320
- fg={tokens.text}
321
- bg={isCursor ? tokens.cursorBackground : tokens.panel}
322
- style={{ wrapMode: "none" }}
323
- >
324
- {isCursor ? "▎" : " "}■ {rowLine(segment.row)}
325
- </text>
326
- );
327
- }
328
-
329
- return (
330
- <text
331
- key={segmentIndex}
332
- fg={tokens.blue}
333
- bg={isCursor ? tokens.cursorBackground : undefined}
334
- style={{ wrapMode: "none" }}
335
- >
336
- {isCursor ? "▎" : " "}
337
- {rowLine(segment.row)}
338
- </text>
339
- );
340
- }
341
-
342
- const lastRowIndex = segment.firstRowIndex + segment.rows.length - 1;
343
-
344
- return (
345
- <React.Fragment key={segmentIndex}>
346
- <DiffChunk
347
- segment={segment}
348
- cursor={cursor}
349
- focusedAnnotationId={focusedAnnotationId}
350
- intralineByRow={intralineByRow}
351
- syntaxByRow={syntaxByRow}
352
- rejectedRows={rejectedRows}
353
- theme={theme}
354
- />
355
- {compose && compose.rowIndex === lastRowIndex ? (
356
- <AnnotationCard
357
- kind={compose.kind}
358
- quote={compose.quote}
359
- draft={compose.draft}
360
- theme={theme}
361
- />
362
- ) : null}
363
- </React.Fragment>
364
- );
365
- })}
366
- </scrollbox>
678
+ <box style={{ flexGrow: 1, flexDirection: "row" }} {...surface.rootMouseProps}>
679
+ <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 0 }}>
680
+ {/* the rail draws this box's scrollbar past the dots, so the built-in one stays hidden */}
681
+ <scrollbox
682
+ id="diff-scroll"
683
+ ref={scrollRef}
684
+ style={{ flexGrow: 1 }}
685
+ focused={false}
686
+ verticalScrollbarOptions={{ visible: false }}
687
+ >
688
+ {materialized}
689
+ </scrollbox>
690
+ </box>
691
+ <DiscussionMarkerRail
692
+ discussions={surface.discussions}
693
+ spanQuote={surface.spanQuote}
694
+ onJump={surface.jumpToDiscussion}
695
+ scrollbox={scrollRef}
696
+ theme={theme}
697
+ />
367
698
  </box>
368
699
  );
369
700
  }