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

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 (85) hide show
  1. package/package.json +7 -6
  2. package/src/App.appearance.test.tsx +8 -6
  3. package/src/App.diff.test.tsx +3 -3
  4. package/src/App.export.test.tsx +9 -3
  5. package/src/App.plan-review.test.tsx +10 -8
  6. package/src/App.prototype-markdown.test.tsx +92 -0
  7. package/src/App.prototype.test.tsx +17 -28
  8. package/src/App.readonly.test.tsx +3 -3
  9. package/src/App.rendering.test.tsx +3 -4
  10. package/src/App.test.tsx +14 -18
  11. package/src/App.tsx +117 -42
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/App.workbench.test.tsx +85 -14
  14. package/src/app-screens.tsx +26 -10
  15. package/src/app-view-model.ts +17 -3
  16. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  17. package/src/components/AnnotatableFileView.test.tsx +102 -0
  18. package/src/components/AnnotatableFileView.tsx +126 -0
  19. package/src/components/AnnotationCards.tsx +6 -6
  20. package/src/components/AppShell.tsx +62 -15
  21. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  22. package/src/components/BareWorkbenchFileView.tsx +69 -0
  23. package/src/components/ChangesColumn.tsx +3 -2
  24. package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
  25. package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
  26. package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
  27. package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
  28. package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
  29. package/src/components/DiscussionMarkerRail.tsx +2 -2
  30. package/src/components/EditorGrid.tabs.test.tsx +17 -4
  31. package/src/components/EditorGrid.tsx +49 -19
  32. package/src/components/FileContentsView.test.tsx +3 -3
  33. package/src/components/FileContentsView.tsx +37 -17
  34. package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
  35. package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
  36. package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
  37. package/src/components/ProjectTreeView.tsx +3 -2
  38. package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
  39. package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
  40. package/src/components/ScrollArea.stories.tsx +20 -0
  41. package/src/components/ScrollArea.tsx +44 -0
  42. package/src/components/SettingsDialog.tsx +3 -2
  43. package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
  44. package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
  45. package/src/components/ThreadsSidebar.tsx +5 -4
  46. package/src/components/TreePane.tsx +3 -2
  47. package/src/components/WelcomePlayground.stories.tsx +12 -0
  48. package/src/components/WelcomePlayground.test.tsx +47 -0
  49. package/src/components/WelcomePlayground.tsx +140 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
  51. package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
  52. package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
  53. package/src/config.test.ts +19 -6
  54. package/src/config.ts +26 -6
  55. package/src/ghostty-terminal.test.ts +2 -1
  56. package/src/ghostty-terminal.ts +27 -4
  57. package/src/intent-dispatch.test.ts +2 -0
  58. package/src/intent-dispatch.ts +1 -1
  59. package/src/key-bindings.test.ts +1 -1
  60. package/src/key-bindings.ts +5 -1
  61. package/src/keymap.ts +1 -1
  62. package/src/prototype-browser.ts +8 -0
  63. package/src/prototype-pixels.tsx +35 -0
  64. package/src/ready-signal.test.ts +85 -0
  65. package/src/ready-signal.ts +52 -0
  66. package/src/session-controller.live-diff.test.ts +198 -0
  67. package/src/session-controller.ts +141 -26
  68. package/src/slash-palette.test.ts +54 -0
  69. package/src/slash-palette.ts +32 -18
  70. package/src/test-support.test.ts +12 -0
  71. package/src/test-support.ts +69 -10
  72. package/src/theme-presets.ts +20 -0
  73. package/src/theme.ts +18 -0
  74. package/src/thread-chords.ts +1 -1
  75. package/src/use-annotation-surface.tsx +12 -2
  76. package/src/use-settings-dialog.tsx +4 -4
  77. package/src/view-diff.test.ts +44 -1
  78. package/src/view-diff.ts +81 -0
  79. package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
  80. package/src/components/AnnotationCard.stories.tsx +0 -94
  81. package/src/components/AnnotationCard.tsx +0 -250
  82. package/src/components/MarkerPopover.stories.tsx +0 -58
  83. package/src/components/MarkerPopover.tsx +0 -109
  84. package/src/components/WelcomeSurface.stories.tsx +0 -12
  85. package/src/components/WelcomeSurface.tsx +0 -52
@@ -3,6 +3,7 @@
3
3
  // so the diff reclaims the width. Mirrors the left Threads column on the far side.
4
4
  // Co-located here: the state hook that opens it by context and the header toggle.
5
5
 
6
+ import { ScrollArea } from "./ScrollArea";
6
7
  import React, { useMemo, useState } from "react";
7
8
  import type { DiffFileContents, ReviewSession } from "@cueloop/schema";
8
9
  import type { Theme } from "../theme";
@@ -115,7 +116,7 @@ export function ChangesFileTree({
115
116
  }, [nodes, collapsedIds]);
116
117
 
117
118
  return (
118
- <scrollbox style={{ flexGrow: 1 }} focused={false}>
119
+ <ScrollArea>
119
120
  <Tree
120
121
  nodes={nodes}
121
122
  expandedIds={expandedIds}
@@ -135,7 +136,7 @@ export function ChangesFileTree({
135
136
  }
136
137
  theme={theme}
137
138
  />
138
- </scrollbox>
139
+ </ScrollArea>
139
140
  );
140
141
  }
141
142
 
@@ -5,7 +5,7 @@ import React from "react";
5
5
  import { testRender } from "@opentui/react/test-utils";
6
6
  import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
7
7
  import type { Annotation } from "@cueloop/schema";
8
- import { DiffSheet } from "./DiffSheet";
8
+ import { DiffContentView } from "./DiffContentView";
9
9
  import { diffRows, marksByRows } from "../view-diff";
10
10
  import { DARK } from "../theme";
11
11
  import { annotationPaletteFor } from "../annotation-palette";
@@ -63,7 +63,7 @@ test("walking the caret down past wrapped discussion cards keeps it on screen an
63
63
  createdAt: "2026-01-01T00:00:00Z",
64
64
  }));
65
65
  const setup = await testRender(
66
- <DiffSheet
66
+ <DiffContentView
67
67
  rows={rows}
68
68
  session={fixtureDiffSession({ annotations })}
69
69
  marks={marksByRows(annotations, rows)}
@@ -7,7 +7,7 @@
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { testRender } from "@opentui/react/test-utils";
9
9
  import React from "react";
10
- import { DiffSheet } from "./DiffSheet";
10
+ import { DiffContentView } from "./DiffContentView";
11
11
  import { diffRows } from "../view-diff";
12
12
  import { allowEventLoopUpdates } from "../test-support";
13
13
  import { fixtureDiffSession } from "./story-fixtures";
@@ -37,7 +37,7 @@ const WRAPPING_PATCH = `diff --git a/app.ts b/app.ts
37
37
 
38
38
  async function splitFrameLines(width: number): Promise<string[]> {
39
39
  const setup = await testRender(
40
- <DiffSheet
40
+ <DiffContentView
41
41
  rows={diffRows(WRAPPING_PATCH)}
42
42
  session={fixtureDiffSession()}
43
43
  marks={new Map()}
@@ -65,7 +65,7 @@ describe("split diff rendering", () => {
65
65
  test("the caret marks only the change side, not both columns", async () => {
66
66
  // Arrange - the caret opens on a context row, which is shown in both columns
67
67
  const setup = await testRender(
68
- <DiffSheet
68
+ <DiffContentView
69
69
  rows={diffRows(WRAPPING_PATCH)}
70
70
  session={fixtureDiffSession()}
71
71
  marks={new Map()}
@@ -5,10 +5,10 @@ import { annotationPaletteFor } from "../annotation-palette";
5
5
  import { DEFAULT_QUICK_ACTIONS } from "../config";
6
6
  import { marksByRows } from "../view-diff";
7
7
  import type { Story, StoryMeta } from "./story";
8
- import { DiffSheet } from "./DiffSheet";
8
+ import { DiffContentView } from "./DiffContentView";
9
9
  import { fixtureDiffRows, fixtureDiffSession } from "./story-fixtures";
10
10
 
11
- export const meta: StoryMeta = { title: "Surfaces/DiffSheet" };
11
+ export const meta: StoryMeta = { title: "Surfaces/DiffContentView" };
12
12
 
13
13
  const palette = annotationPaletteFor(DARK);
14
14
  const noop = (): void => {};
@@ -27,7 +27,7 @@ function sheet(annotations: Annotation[], split = false): React.ReactNode {
27
27
  const session = fixtureDiffSession({ annotations });
28
28
 
29
29
  return (
30
- <DiffSheet
30
+ <DiffContentView
31
31
  rows={rows}
32
32
  session={session}
33
33
  marks={marksByRows(annotations, rows)}
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test";
2
2
  import { testRender } from "@opentui/react/test-utils";
3
3
  import type { RGBA } from "@opentui/core";
4
4
  import React from "react";
5
- import { DiffSheet } from "./DiffSheet";
5
+ import { DiffContentView } from "./DiffContentView";
6
6
  import { fixtureDiffSession } from "./story-fixtures";
7
7
  import { diffRows } from "../view-diff";
8
8
  import { DARK } from "../theme";
@@ -31,7 +31,7 @@ test("renders tree-sitter colors, with the changed word in the diff color on top
31
31
  // Arrange
32
32
  const noop = (): void => {};
33
33
  const setup = await testRender(
34
- <DiffSheet
34
+ <DiffContentView
35
35
  rows={diffRows(PATCH)}
36
36
  session={fixtureDiffSession()}
37
37
  marks={new Map()}
@@ -27,7 +27,7 @@ import { useFrameMeasure } from "../use-frame-measure";
27
27
  import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
28
28
  import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
29
29
  import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
30
- import { coloredRowSpans } from "./diff-sheet-layout";
30
+ import { coloredRowSpans } from "./diff-content-view-layout";
31
31
 
32
32
  /** Shared empty map so an unresolved/stale highlight state is a stable value. */
33
33
  const EMPTY_SYNTAX: Map<number, SyntaxSpan[]> = new Map();
@@ -41,9 +41,24 @@ const REJECTED_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: tru
41
41
  /** A file band renders its name in bold between an equal rule above and below. */
42
42
  const FILE_HEADER_ATTRIBUTES = createTextAttributes({ bold: true });
43
43
 
44
- /** The gutter before a code line: caret bar, four-digit line number, a space, the sign, a space. */
44
+ /** The split-view gutter before a code line: caret bar, four-digit line number, a space, the sign, a space. */
45
45
  const GUTTER_COLUMNS = 8;
46
46
 
47
+ /** The stacked gutter around its two line-number columns: caret bar, the space between them, a space, the sign, a space. */
48
+ const UNIFIED_GUTTER_CHROME = 5;
49
+
50
+ /** The widest line number in the diff, so the stacked gutter's two columns stay as narrow as the file allows. */
51
+ function lineNumberWidth(rows: DiffRow[]): number {
52
+ let width = 1;
53
+
54
+ for (const row of rows) {
55
+ if (row.oldLine !== undefined) width = Math.max(width, String(row.oldLine).length);
56
+ if (row.newLine !== undefined) width = Math.max(width, String(row.newLine).length);
57
+ }
58
+
59
+ return width;
60
+ }
61
+
47
62
  /** The per-file fold controls the file band renders; absent in read-only story renders. */
48
63
  export interface DiffFoldControls {
49
64
  isCollapsed: (file: string) => boolean;
@@ -54,13 +69,15 @@ export interface DiffFoldControls {
54
69
  onCopyPath: (file: string) => void;
55
70
  }
56
71
 
57
- export interface DiffSheetProps {
72
+ export interface DiffContentViewProps {
58
73
  rows: DiffRow[];
59
74
  session: ReviewSession;
60
75
  /** Annotations resolved onto rows (marksByRows), with char ranges and spans. */
61
76
  marks: Map<number, Mark[]>;
62
77
  quickActions: QuickAction[];
63
78
  observer: boolean;
79
+ /** Whether comments can be drafted here; false for a non-diff thread's view-only live diff. */
80
+ commentsEnabled?: boolean;
64
81
  /** A verdict is in: no draft may open; the app answers with its read-only status. */
65
82
  resolved?: boolean;
66
83
  /** True while a menu, dialog, or overlay owns the keyboard. */
@@ -87,6 +104,8 @@ export interface DiffSheetProps {
87
104
  fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
88
105
  /** Render old|new side by side instead of one inline column; the App gates this on zoom. */
89
106
  split?: boolean;
107
+ /** A plain-file view (all context rows): one line-number gutter, no +/- sign. */
108
+ fileView?: boolean;
90
109
  theme?: Theme;
91
110
  }
92
111
 
@@ -120,6 +139,26 @@ function FileCountsBadge({
120
139
  );
121
140
  }
122
141
 
142
+ /** The file band's expand-all-lines toggle: the word "expand" stays put, its color carries the state
143
+ * (active white when the file is expanded, inactive gray when collapsed to changes) so nothing shifts. */
144
+ function ExpandToggle({
145
+ expanded,
146
+ onToggle,
147
+ tokens,
148
+ }: {
149
+ expanded: boolean;
150
+ onToggle: () => void;
151
+ tokens: Theme;
152
+ }): React.ReactNode {
153
+ return (
154
+ <box onMouseUp={onToggle} style={{ alignSelf: "center" }}>
155
+ <text fg={expanded ? tokens.text : tokens.textDim} style={{ wrapMode: "none" }}>
156
+ expand
157
+ </text>
158
+ </box>
159
+ );
160
+ }
161
+
123
162
  /**
124
163
  * A file band: chevron, bold name, and the added/removed counts between an equal rule above and
125
164
  * below. `fold` absent (story renders) drops the controls.
@@ -173,10 +212,10 @@ function FileBand({
173
212
  />
174
213
  ) : null}
175
214
  {fold && canExpand ? (
176
- <IconButton
177
- glyph={NERD.unfold}
178
- onPress={() => fold.onToggleExpand(file)}
179
- tip={expanded ? "Collapse to changes" : "Expand all lines"}
215
+ <ExpandToggle
216
+ expanded={expanded}
217
+ onToggle={() => fold.onToggleExpand(file)}
218
+ tokens={tokens}
180
219
  />
181
220
  ) : null}
182
221
  </box>
@@ -293,6 +332,7 @@ function codeLineSpans(
293
332
  lineStart: number,
294
333
  lineRanges: MarkRange[],
295
334
  fgByColumn: string[],
335
+ emphasisBgByColumn: Array<string | undefined>,
296
336
  rejected: boolean,
297
337
  tokens: Theme,
298
338
  palette: AnnotationPalette,
@@ -301,11 +341,18 @@ function codeLineSpans(
301
341
  let column = 0;
302
342
 
303
343
  for (const run of runsFor(lineText, lineRanges)) {
304
- const bg = run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined;
344
+ // a caret or a comment mark owns the whole run's backdrop; otherwise the changed-word tint (if any)
345
+ // shows per column and the row box paints the soft band behind the rest
346
+ const runBackground = run.caretOnly
347
+ ? palette.caretCell
348
+ : run.marked
349
+ ? palette.markBackdrop
350
+ : undefined;
305
351
  const attributes = (run.marked ? UNDERLINE : 0) | (rejected ? REJECTED_ATTRIBUTES : 0);
306
352
 
307
353
  for (const character of run.text) {
308
354
  const fg = rejected ? tokens.textDim : (fgByColumn[lineStart + column] ?? tokens.textMuted);
355
+ const bg = runBackground ?? emphasisBgByColumn[lineStart + column];
309
356
  const previous = spans[spans.length - 1];
310
357
 
311
358
  if (
@@ -352,6 +399,45 @@ function rowBaseColor(row: DiffRow, tokens: Theme): string {
352
399
  : tokens.textMuted;
353
400
  }
354
401
 
402
+ /** The soft band behind a whole added/removed row; context rows carry no band. */
403
+ function rowBackground(row: DiffRow, tokens: Theme): string | undefined {
404
+ return row.kind === "add"
405
+ ? tokens.insertedBackground
406
+ : row.kind === "del"
407
+ ? tokens.deletedBackground
408
+ : undefined;
409
+ }
410
+
411
+ /** The stronger tint behind an add/del row's gutter chip and its intra-line changed words. */
412
+ function rowEmphasisBackground(row: DiffRow, tokens: Theme): string | undefined {
413
+ return row.kind === "add"
414
+ ? tokens.insertedEmphasisBackground
415
+ : row.kind === "del"
416
+ ? tokens.deletedEmphasisBackground
417
+ : undefined;
418
+ }
419
+
420
+ /** Per-column emphasis backdrop: the changed words of a modified line, else none. */
421
+ function emphasisBackgroundColumns(
422
+ text: string,
423
+ intraline: IntralineRun[] | undefined,
424
+ emphasisBackground: string | undefined,
425
+ ): Array<string | undefined> {
426
+ const columns: Array<string | undefined> = Array.from({ length: text.length }, () => undefined);
427
+
428
+ if (!intraline || !emphasisBackground) return columns;
429
+ let offset = 0;
430
+
431
+ for (const run of intraline) {
432
+ if (run.changed)
433
+ for (let index = 0; index < run.text.length && offset + index < text.length; index++)
434
+ columns[offset + index] = emphasisBackground;
435
+ offset += run.text.length;
436
+ }
437
+
438
+ return columns;
439
+ }
440
+
355
441
  /** Async tree-sitter highlights, discarded when they belong to superseded rows. */
356
442
  function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
357
443
  const [highlighted, setHighlighted] = useState<{
@@ -365,9 +451,16 @@ function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
365
451
  useEffect(() => {
366
452
  let active = true;
367
453
 
368
- void highlightDiffRows(rows).then((byRow) => {
369
- if (active) setHighlighted({ rows, byRow });
370
- });
454
+ highlightDiffRows(rows).then(
455
+ (byRow) => {
456
+ if (active) setHighlighted({ rows, byRow });
457
+ },
458
+ (error) => {
459
+ // a highlighter torn down mid-init rejects after unmount (active === false): expected, silent.
460
+ // a rejection while still mounted is a real failure - surface it and leave the rows unhighlighted
461
+ if (active) console.error("diff syntax highlighting failed", error);
462
+ },
463
+ );
371
464
 
372
465
  return () => {
373
466
  active = false;
@@ -377,12 +470,13 @@ function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
377
470
  return highlighted.rows === rows ? highlighted.byRow : EMPTY_SYNTAX;
378
471
  }
379
472
 
380
- export function DiffSheet({
473
+ export function DiffContentView({
381
474
  rows,
382
475
  session,
383
476
  marks,
384
477
  quickActions,
385
478
  observer,
479
+ commentsEnabled = true,
386
480
  resolved = false,
387
481
  suspended = false,
388
482
  onComposingChange,
@@ -398,8 +492,9 @@ export function DiffSheet({
398
492
  fold,
399
493
  fileStats: fileStatsProp,
400
494
  split = false,
495
+ fileView = false,
401
496
  theme,
402
- }: DiffSheetProps): React.ReactNode {
497
+ }: DiffContentViewProps): React.ReactNode {
403
498
  const tokens = useComponentTheme(theme);
404
499
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
405
500
  const source: LineSource = {
@@ -414,6 +509,7 @@ export function DiffSheet({
414
509
  quickActions,
415
510
  tokens,
416
511
  observer,
512
+ commentsEnabled,
417
513
  resolved,
418
514
  suspended,
419
515
  onComposingChange,
@@ -427,6 +523,8 @@ export function DiffSheet({
427
523
  onExit,
428
524
  });
429
525
  const { palette } = surface;
526
+ const numberWidth = useMemo(() => lineNumberWidth(rows), [rows]);
527
+ const unifiedGutterColumns = numberWidth * 2 + UNIFIED_GUTTER_CHROME;
430
528
  const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
431
529
  const syntaxByRow = useSyntaxHighlights(rows);
432
530
  const splitRows = useMemo(() => (split ? splitDiffRows(rows) : []), [split, rows]);
@@ -438,10 +536,12 @@ export function DiffSheet({
438
536
  (left, right) => left === right,
439
537
  0,
440
538
  );
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);
539
+ // a file view shows one number; split gives each column its own gutter; stacked shows old and new
540
+ const textWidth = fileView
541
+ ? Math.max(0, viewWidth - (numberWidth + 2))
542
+ : split
543
+ ? Math.max(0, Math.floor((viewWidth - 1) / 2) - GUTTER_COLUMNS)
544
+ : Math.max(0, viewWidth - unifiedGutterColumns);
445
545
 
446
546
  // Every visual line is its own text renderable (so a drag can hit-test it), and a large
447
547
  // diff has tens of thousands of them - more native text buffers than the renderer can
@@ -489,10 +589,72 @@ export function DiffSheet({
489
589
  syntaxByRow.get(rowIndex),
490
590
  tokens,
491
591
  );
592
+ const emphasisBgByColumn = emphasisBackgroundColumns(
593
+ text,
594
+ intralineByRow.get(rowIndex),
595
+ rowEmphasisBackground(row, tokens),
596
+ );
597
+ const rowBg = rowBackground(row, tokens);
492
598
  const rejected = rejectedRows.has(rowIndex);
493
599
  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)} `;
600
+ // split view carries one number per side; the stacked view shows the old and new numbers together
601
+ const splitLineNumber = row.kind === "del" ? row.oldLine : row.newLine;
602
+ const splitGutter = `${isCaretRow ? "▎" : " "}${String(splitLineNumber ?? "").padStart(4, " ")} ${rowSign(row)} `;
603
+ const barChar = isCaretRow ? "▎" : " ";
604
+ const barColor = isCaretRow ? tokens.accent : tokens.textDim;
605
+ const oldNumber =
606
+ row.kind === "add"
607
+ ? " ".repeat(numberWidth)
608
+ : String(row.oldLine ?? "").padStart(numberWidth);
609
+ const newNumber =
610
+ row.kind === "del"
611
+ ? " ".repeat(numberWidth)
612
+ : String(row.newLine ?? "").padStart(numberWidth);
613
+ const gutterFor = (lineIndex: number): React.ReactNode => {
614
+ if (fileView) {
615
+ // a plain file has no old/new sides, so one line number reads like an ordinary file viewer
616
+ return lineIndex === 0 ? (
617
+ <>
618
+ <span fg={barColor}>{barChar}</span>
619
+ <span fg={tokens.textDim}>{String(row.newLine ?? "").padStart(numberWidth)}</span>
620
+ <span> </span>
621
+ </>
622
+ ) : (
623
+ " ".repeat(numberWidth + 2)
624
+ );
625
+ }
626
+ if (split) {
627
+ return lineIndex === 0 ? (
628
+ <>
629
+ <span fg={isCaretRow ? tokens.accent : tokens.textDim}>{splitGutter.slice(0, 6)}</span>
630
+ <span fg={rowBaseColor(row, tokens)}>{splitGutter.slice(6)}</span>
631
+ </>
632
+ ) : (
633
+ " ".repeat(GUTTER_COLUMNS)
634
+ );
635
+ }
636
+ // stacked: caret bar, old new (each tinted red/green on its side), then the change sign;
637
+ // the numbers sit on the row's soft band, only the changed code carries the brighter backdrop
638
+ return lineIndex === 0 ? (
639
+ <>
640
+ <span fg={barColor}>{barChar}</span>
641
+ <span fg={row.kind === "del" ? tokens.deletedForeground : tokens.textDim}>
642
+ {oldNumber}
643
+ </span>
644
+ <span fg={tokens.textDim}> </span>
645
+ <span fg={row.kind === "add" ? tokens.insertedForeground : tokens.textDim}>
646
+ {newNumber}
647
+ </span>
648
+ <span> </span>
649
+ <span fg={rowBaseColor(row, tokens)}>{`${rowSign(row)} `}</span>
650
+ </>
651
+ ) : (
652
+ <>
653
+ <span fg={barColor}>{barChar}</span>
654
+ {" ".repeat(unifiedGutterColumns - 1)}
655
+ </>
656
+ );
657
+ };
496
658
  const lineNodes: React.ReactNode[] = [];
497
659
  const cards: React.ReactNode[] = [];
498
660
 
@@ -501,20 +663,16 @@ export function DiffSheet({
501
663
  const isLastLine = lineIndex === lines.length - 1;
502
664
 
503
665
  lineNodes.push(
504
- <box key={`${keyPrefix}-line-${lineIndex}`} style={{ flexDirection: "row" }}>
666
+ <box
667
+ key={`${keyPrefix}-line-${lineIndex}`}
668
+ style={{ flexDirection: "row", backgroundColor: rowBg }}
669
+ >
505
670
  <text
506
671
  selectable={false}
507
672
  fg={isCaretRow && lineIndex === 0 ? tokens.accent : tokens.textDim}
508
673
  style={{ flexShrink: 0, wrapMode: "none" }}
509
674
  >
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
- )}
675
+ {gutterFor(lineIndex)}
518
676
  </text>
519
677
  <text
520
678
  selectable={false}
@@ -527,6 +685,7 @@ export function DiffSheet({
527
685
  line.start,
528
686
  lineMarkRanges(ranges, line),
529
687
  fgByColumn,
688
+ emphasisBgByColumn,
530
689
  rejected,
531
690
  tokens,
532
691
  palette,
@@ -14,7 +14,7 @@ import type { Discussion } from "../discussions";
14
14
  import { useFrameMeasure } from "../use-frame-measure";
15
15
  import { useComponentTheme } from "./theme-context";
16
16
  import { useRootOverlay } from "./RootOverlay";
17
- import { SurfaceScrollbar } from "./SurfaceScrollbar";
17
+ import { OverlayScrollbar } from "./OverlayScrollbar";
18
18
 
19
19
  interface HoveredMarker {
20
20
  key: string;
@@ -175,7 +175,7 @@ export function DiscussionMarkerRail({
175
175
  onHover={setHovered}
176
176
  onJump={onJump}
177
177
  />
178
- {scrollbox ? <SurfaceScrollbar scrollbox={scrollbox} theme={theme} /> : null}
178
+ {scrollbox ? <OverlayScrollbar scrollbox={scrollbox} theme={theme} /> : null}
179
179
  </>
180
180
  );
181
181
  }
@@ -39,11 +39,24 @@ describe("visibleTabWindow", () => {
39
39
  // Act / Assert
40
40
  expect(visibleTabWindow(wide, 0, 0, 10)).toEqual({ first: 0, end: 1 });
41
41
  });
42
+
43
+ test("the comment badge counts toward a tab's width, so a commented run does not overflow", () => {
44
+ // Arrange - three 9-cell tabs fit a 27-cell strip exactly with no badge
45
+ const three = ["aaaa", "bbbb", "cccc"].map((name) => fileTab(name, name, "diff"));
46
+
47
+ // Act - without badges all three fit; a ` ● 2` badge on each widens them past the strip
48
+ const bare = visibleTabWindow(three, 0, 0, 27);
49
+ const badged = visibleTabWindow(three, 0, 0, 27, () => 2);
50
+
51
+ // Assert - the badge width narrows the window rather than clipping the last tab
52
+ expect(bare.end).toBe(3);
53
+ expect(badged.end).toBeLessThan(3);
54
+ });
42
55
  });
43
56
 
44
57
  const NAMES = [
45
58
  "AppShell.tsx",
46
- "DiffSheet.tsx",
59
+ "DiffContentView.tsx",
47
60
  "IconButton.tsx",
48
61
  "EditorGrid.zoom.test.tsx",
49
62
  "ThreadView.tsx",
@@ -89,9 +102,9 @@ describe("editor tab strip", () => {
89
102
  // Assert - no squeezed labels: every visible tab reads in full up to the clip edge
90
103
  expect(row).toContain("Changes ");
91
104
  expect(row).toContain("AppShell.tsx ");
92
- expect(row).toContain("DiffSheet.tsx ");
93
- // and the header controls sit at the right edge, past the clipped strip
94
- expect(row.trimEnd().endsWith(`search ${NERD.zoom}`)).toBe(true);
105
+ expect(row).toContain("DiffContentView.tsx ");
106
+ // and the header controls sit at the right edge, past the clipped strip (search is out for now)
107
+ expect(row.trimEnd().endsWith(NERD.zoom)).toBe(true);
95
108
  });
96
109
 
97
110
  test("the strip scrolls to reveal the active tab", async () => {
@@ -91,9 +91,11 @@ function EditorTabButton({
91
91
  );
92
92
  }
93
93
 
94
- /** Cells a tab occupies: its padding, the label, the close cell and its padding, the right rule. */
95
- function tabCellWidth(tab: EditorTab): number {
96
- return tab.label.length + 5;
94
+ /** Cells a tab occupies: padding, label, the ` ● N` comment badge when present, the close cell, the right rule. */
95
+ export function tabCellWidth(tab: EditorTab, commentCount = 0): number {
96
+ const badge = commentCount > 0 ? 3 + String(commentCount).length : 0;
97
+
98
+ return tab.label.length + 5 + badge;
97
99
  }
98
100
 
99
101
  /** The run of tabs `[first, end)` the strip shows. */
@@ -108,9 +110,11 @@ export function visibleTabWindow(
108
110
  firstVisible: number,
109
111
  activeIndex: number,
110
112
  stripWidth: number,
113
+ commentCount: (tab: EditorTab) => number = () => 0,
111
114
  ): TabWindow {
112
115
  // unmeasured strip: show everything, the header clips until the width is known
113
116
  if (stripWidth <= 0) return { first: 0, end: tabs.length };
117
+ const width = (index: number): number => tabCellWidth(tabs[index]!, commentCount(tabs[index]!));
114
118
  const fitEnd = (first: number): number => {
115
119
  let used = first > 0 ? 1 : 0;
116
120
  let end = first;
@@ -118,8 +122,8 @@ export function visibleTabWindow(
118
122
  while (end < tabs.length) {
119
123
  const remaining = end + 1 < tabs.length ? 1 : 0;
120
124
 
121
- if (used + tabCellWidth(tabs[end]!) + remaining > stripWidth) break;
122
- used += tabCellWidth(tabs[end]!);
125
+ if (used + width(end) + remaining > stripWidth) break;
126
+ used += width(end);
123
127
  end++;
124
128
  }
125
129
 
@@ -192,12 +196,22 @@ function EditorGroupPane({
192
196
  0,
193
197
  );
194
198
  const [firstVisible, setFirstVisible] = useState(0);
199
+ // a zoom re-anchors the strip to the leftmost tab, so the new width fills from the left
200
+ // and the step markers disappear when every tab now fits
201
+ const [wasZoomed, setWasZoomed] = useState(props.zoomed);
202
+
203
+ if (wasZoomed !== props.zoomed) {
204
+ setWasZoomed(props.zoomed);
205
+ setFirstVisible(0);
206
+ }
195
207
  const activeIndex = Math.max(
196
208
  0,
197
209
  group.tabs.findIndex((tab) => tab.id === active?.id),
198
210
  );
199
211
  // derived every render: the manual offset is a floor the active tab can pull the window past
200
- const tabWindow = visibleTabWindow(group.tabs, firstVisible, activeIndex, stripWidth);
212
+ const tabWindow = visibleTabWindow(group.tabs, firstVisible, activeIndex, stripWidth, (tab) =>
213
+ tab.path ? (props.commentCounts?.get(tab.path) ?? 0) : 0,
214
+ );
201
215
 
202
216
  return (
203
217
  <box
@@ -227,8 +241,16 @@ function EditorGroupPane({
227
241
  style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0 }}
228
242
  >
229
243
  {tabWindow.first > 0 ? (
230
- <box onMouseUp={() => setFirstVisible(tabWindow.first - 1)} style={{ flexShrink: 0 }}>
231
- <text fg={tokens.textDim}>{"‹"}</text>
244
+ <box
245
+ onMouseUp={(event) => {
246
+ event.stopPropagation();
247
+ setFirstVisible(tabWindow.first - 1);
248
+ }}
249
+ style={{ flexShrink: 0 }}
250
+ >
251
+ <text fg={tokens.textDim} selectable={false}>
252
+ {"‹"}
253
+ </text>
232
254
  </box>
233
255
  ) : null}
234
256
  {group.tabs.slice(tabWindow.first, tabWindow.end).map((tab) => (
@@ -243,29 +265,37 @@ function EditorGroupPane({
243
265
  />
244
266
  ))}
245
267
  {tabWindow.end < group.tabs.length ? (
246
- <box onMouseUp={() => setFirstVisible(tabWindow.first + 1)} style={{ flexShrink: 0 }}>
247
- <text fg={tokens.textDim}>{"›"}</text>
268
+ <box
269
+ onMouseUp={(event) => {
270
+ event.stopPropagation();
271
+ setFirstVisible(tabWindow.first + 1);
272
+ }}
273
+ style={{ flexShrink: 0 }}
274
+ >
275
+ <text fg={tokens.textDim} selectable={false}>
276
+ {"›"}
277
+ </text>
248
278
  </box>
249
279
  ) : null}
250
280
  </box>
251
281
  <box style={{ flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
252
- <IconButton glyph="search" onPress={() => {}} marginRight={2} theme={tokens} />
253
- <IconButton
254
- glyph={NERD.zoom}
255
- active={props.zoomed}
256
- onPress={props.onZoom}
257
- tip={props.zoomed ? "Zoom Out" : "Zoom In"}
258
- marginRight={isFile ? 2 : 0}
259
- theme={tokens}
260
- />
282
+ {/* search sits out until it does something; split takes its place */}
261
283
  {isFile ? (
262
284
  <IconButton
263
285
  glyph="split"
264
286
  active={menuOpen}
265
287
  onPress={() => setMenuOpen((open) => !open)}
288
+ marginRight={2}
266
289
  theme={tokens}
267
290
  />
268
291
  ) : null}
292
+ <IconButton
293
+ glyph={NERD.zoom}
294
+ active={props.zoomed}
295
+ onPress={props.onZoom}
296
+ tip={props.zoomed ? "Zoom Out" : "Zoom In"}
297
+ theme={tokens}
298
+ />
269
299
  </box>
270
300
  </box>
271
301
  {menuOpen ? (