@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,15 +1,18 @@
1
- /** Keyboard scroll is smooth: cursor-follow never drifts past wrapped cards. */
1
+ /** Keyboard scroll is smooth: the caret-follow reveal never scrolls the caret off screen, nor rebounds. */
2
2
 
3
3
  import { expect, test } from "bun:test";
4
- import React, { useState } from "react";
4
+ import React from "react";
5
5
  import { testRender } from "@opentui/react/test-utils";
6
- import { useKeyboard } from "@opentui/react";
7
6
  import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
8
7
  import type { Annotation } from "@cueloop/schema";
9
8
  import { DiffSheet } from "./DiffSheet";
10
- import { diffRows, type DiffRow } from "../view-diff";
9
+ import { diffRows, marksByRows } from "../view-diff";
11
10
  import { DARK } from "../theme";
11
+ import { annotationPaletteFor } from "../annotation-palette";
12
12
  import { press } from "../test-support";
13
+ import { fixtureDiffSession } from "./story-fixtures";
14
+
15
+ const noop = (): void => {};
13
16
 
14
17
  /** Find a renderable by id anywhere in the tree (getRenderable is not recursive). */
15
18
  function findById(node: Renderable, id: string): Renderable | undefined {
@@ -46,26 +49,9 @@ function tallPatch(count: number): string {
46
49
  ].join("\n");
47
50
  }
48
51
 
49
- /** DiffSheet with j-driven cursor, mirroring the App's down-navigation. */
50
- function ScrollHarness({
51
- rows,
52
- annotations,
53
- }: {
54
- rows: DiffRow[];
55
- annotations: Annotation[];
56
- }): React.ReactNode {
57
- const [cursor, setCursor] = useState(0);
58
-
59
- useKeyboard((key) => {
60
- if (key.name === "j") setCursor((current) => Math.min(rows.length - 1, current + 1));
61
- });
62
-
63
- return <DiffSheet rows={rows} cursor={cursor} annotations={annotations} />;
64
- }
65
-
66
- test("scrolling down past wrapped annotation cards keeps the cursor pinned and the scroll monotonic", async () => {
67
- // Arrange - a tall diff whose annotation bodies would wrap at this width; the
68
- // wrapped card is exactly the layout the cursor-follow model used to mis-measure
52
+ test("walking the caret down past wrapped discussion cards keeps it on screen and the scroll monotonic", async () => {
53
+ // Arrange - a tall diff whose comment bodies wrap at this width, so the cards
54
+ // between rows are taller than one line
69
55
  const rows = diffRows(tallPatch(40));
70
56
  const longBody =
71
57
  "this is a deliberately long annotation body that would wrap across the narrow test viewport";
@@ -73,39 +59,52 @@ test("scrolling down past wrapped annotation cards keeps the cursor pinned and t
73
59
  id: `a${rowIndex}`,
74
60
  kind: "comment",
75
61
  body: longBody,
76
- anchor: { quote: rows[rowIndex]!.text, prefix: "", suffix: "" },
62
+ anchor: { quote: rows[rowIndex]!.text.replace(/\n$/, ""), prefix: "", suffix: "" },
77
63
  createdAt: "2026-01-01T00:00:00Z",
78
64
  }));
79
- const setup = await testRender(<ScrollHarness rows={rows} annotations={annotations} />, {
80
- width: 60,
81
- height: 12,
82
- });
65
+ const setup = await testRender(
66
+ <DiffSheet
67
+ rows={rows}
68
+ session={fixtureDiffSession({ annotations })}
69
+ marks={marksByRows(annotations, rows)}
70
+ quickActions={[]}
71
+ observer={false}
72
+ onAnnotate={noop}
73
+ onReply={noop}
74
+ onUpdateAnnotation={noop}
75
+ onExit={noop}
76
+ />,
77
+ { width: 60, height: 12 },
78
+ );
83
79
 
84
80
  await setup.waitForVisualIdle();
85
81
  const found = findById(setup.renderer.root, "diff-scroll");
86
82
 
87
83
  if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
88
84
  const scrollbox = found;
85
+ const caretCell = annotationPaletteFor(DARK).caretCell;
89
86
 
90
- // the cursor row is the one painted with the cursor background
91
- const cursorScreenRow = (): number => {
87
+ // the caret row is the one painting the caret cell
88
+ const caretScreenRow = (): number => {
92
89
  const lines = setup.captureSpans().lines;
93
90
 
94
91
  for (let index = 0; index < lines.length; index++) {
95
- if (lines[index]!.spans.some((span) => hex(span.bg) === DARK.cursorBackground)) return index;
92
+ if (lines[index]!.spans.some((span) => hex(span.bg) === caretCell)) return index;
96
93
  }
97
94
 
98
95
  return -1;
99
96
  };
100
97
 
101
- // Act - walk the cursor down through the whole diff, sampling the scroll each step
98
+ // Act - walk the caret down through every code row, sampling the scroll each step
102
99
  const scrollTops: number[] = [];
103
100
  const screenRows: number[] = [];
101
+ const codeRowCount = rows.filter((row) => row.kind === "add").length;
104
102
 
105
- for (let step = 0; step < rows.length; step++) {
103
+ for (let step = 0; step < codeRowCount; step++) {
104
+ await setup.waitForVisualIdle();
106
105
  scrollTops.push(scrollbox.scrollTop);
107
- screenRows.push(cursorScreenRow());
108
- await press(setup, "j");
106
+ screenRows.push(caretScreenRow());
107
+ await press(setup, "down");
109
108
  }
110
109
 
111
110
  // Assert - scrollTop only ever grows going down (never rebounds/jitters)
@@ -113,26 +112,10 @@ test("scrolling down past wrapped annotation cards keeps the cursor pinned and t
113
112
  expect(scrollTops[step]!).toBeGreaterThanOrEqual(scrollTops[step - 1]!);
114
113
  }
115
114
 
116
- // Assert - the cursor never scrolls off screen (the pre-fix bug walked it off
117
- // the viewport as it passed each wrapped card)
115
+ // Assert - the caret never scrolls off screen while it walks past each wrapped card
118
116
  expect(screenRows.every((row) => row >= 0)).toBe(true);
119
-
120
- // Assert - through the steady scroll region (not the initial fill, not the
121
- // final clamp at the content end) the cursor holds ONE stable row near the
122
- // bottom; pre-fix it drifted down a row per wrapped card
123
- const maxScroll = scrollbox.scrollHeight - scrollbox.height;
124
- const steadyRows = screenRows.filter(
125
- (_, step) => scrollTops[step]! > 0 && scrollTops[step]! < maxScroll,
126
- );
127
-
128
- expect(steadyRows.length).toBeGreaterThan(0);
129
- expect(new Set(steadyRows).size).toBe(1);
130
- const pinnedRow = steadyRows[0]!;
131
- const viewportBottom = scrollbox.height;
132
-
133
- // the cursor sits a couple of rows inside the bottom edge, per the follow rule
134
- expect(viewportBottom - pinnedRow).toBeGreaterThanOrEqual(2);
135
- expect(viewportBottom - pinnedRow).toBeLessThanOrEqual(3);
117
+ // and the walk did scroll: the tall diff does not fit the viewport
118
+ expect(scrollTops[scrollTops.length - 1]!).toBeGreaterThan(0);
136
119
 
137
120
  setup.renderer.destroy();
138
121
  }, 25000);
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Visual regression for the side-by-side split: the divider must be one unbroken
3
+ * column with no holes on wrapped continuation lines, and the two columns must not
4
+ * merge into each other. Rendered in a virtual terminal and asserted on the char frame.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import React from "react";
10
+ import { DiffSheet } from "./DiffSheet";
11
+ import { diffRows } from "../view-diff";
12
+ import { allowEventLoopUpdates } from "../test-support";
13
+ import { fixtureDiffSession } from "./story-fixtures";
14
+ import { annotationPaletteFor } from "../annotation-palette";
15
+ import { DARK } from "../theme";
16
+
17
+ const noop = (): void => {};
18
+
19
+ function hex(color: { toInts(): [number, number, number, number] }): string {
20
+ const [red, green, blue] = color.toInts();
21
+
22
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
23
+ }
24
+
25
+ // spaced words so each side word-wraps several visual lines, plus an unbalanced block (filler);
26
+ // spaced (not one long token) so a whole word lands on a continuation line for the assertions
27
+ const WRAPPING_PATCH = `diff --git a/app.ts b/app.ts
28
+ --- a/app.ts
29
+ +++ b/app.ts
30
+ @@ -1,4 +1,5 @@
31
+ const alpha = the quick brown fox jumps over the lazy dog and then keeps running past the far edge
32
+ -const beta = old value that used to sit on this single line before the review had changed it wholly
33
+ +const beta = new value that now sits upon this single line after the review has changed it entirely
34
+ +const gamma = an added line with no deletion partner so the whole left column stays blank filler now
35
+ export { alpha, beta };
36
+ `;
37
+
38
+ async function splitFrameLines(width: number): Promise<string[]> {
39
+ const setup = await testRender(
40
+ <DiffSheet
41
+ rows={diffRows(WRAPPING_PATCH)}
42
+ session={fixtureDiffSession()}
43
+ marks={new Map()}
44
+ quickActions={[]}
45
+ observer={false}
46
+ onAnnotate={noop}
47
+ onReply={noop}
48
+ onUpdateAnnotation={noop}
49
+ onExit={noop}
50
+ split
51
+ />,
52
+ { width, height: 18 },
53
+ );
54
+
55
+ allowEventLoopUpdates();
56
+ await setup.waitForVisualIdle();
57
+ const frame = setup.captureCharFrame();
58
+
59
+ setup.renderer.destroy();
60
+
61
+ return frame.split("\n");
62
+ }
63
+
64
+ describe("split diff rendering", () => {
65
+ test("the caret marks only the change side, not both columns", async () => {
66
+ // Arrange - the caret opens on a context row, which is shown in both columns
67
+ const setup = await testRender(
68
+ <DiffSheet
69
+ rows={diffRows(WRAPPING_PATCH)}
70
+ session={fixtureDiffSession()}
71
+ marks={new Map()}
72
+ quickActions={[]}
73
+ observer={false}
74
+ onAnnotate={noop}
75
+ onReply={noop}
76
+ onUpdateAnnotation={noop}
77
+ onExit={noop}
78
+ split
79
+ />,
80
+ { width: 100, height: 18 },
81
+ );
82
+
83
+ allowEventLoopUpdates();
84
+ await setup.waitForVisualIdle();
85
+ const caretCell = annotationPaletteFor(DARK).caretCell;
86
+ let cells = 0;
87
+
88
+ for (const line of setup.captureSpans().lines) {
89
+ for (const span of line.spans) {
90
+ if (span.bg && hex(span.bg) === caretCell) cells += span.text.length;
91
+ }
92
+ }
93
+
94
+ // Assert - one caret cell (the change side), not one per column
95
+ expect(cells).toBe(1);
96
+ setup.renderer.destroy();
97
+ });
98
+
99
+ test("the divider is one unbroken column across wrapped lines", async () => {
100
+ // Act
101
+ const lines = await splitFrameLines(80);
102
+ const dividerLines = lines.filter((line) => line.includes("│"));
103
+ const dividerColumns = new Set(dividerLines.map((line) => line.indexOf("│")));
104
+
105
+ // Assert - every divider sits in the same column (the columns never drift or merge)
106
+ expect(dividerColumns.size).toBe(1);
107
+ // and the code region is contiguous: no code row is missing its divider
108
+ const firstDivider = lines.findIndex((line) => line.includes("│"));
109
+ const lastDivider = lines.map((line) => line.includes("│")).lastIndexOf(true);
110
+
111
+ for (let index = firstDivider; index <= lastDivider; index++) {
112
+ expect(lines[index]).toContain("│");
113
+ }
114
+ });
115
+
116
+ test("wrapped continuation lines carry the divider, not just the first line", async () => {
117
+ // Act - the alpha line wraps, so a late word sits on a continuation line (no gutter number)
118
+ const lines = await splitFrameLines(80);
119
+ const continuation = lines.find(
120
+ (line) => line.includes("running") && !line.includes("const alpha"),
121
+ );
122
+
123
+ // Assert - the continuation still shows the divider
124
+ expect(continuation).toBeDefined();
125
+ expect(continuation).toContain("│");
126
+ });
127
+
128
+ test("an addition with no deletion partner keeps the divider over blank left filler", async () => {
129
+ // Act - the gamma line has no left partner, so its left column is blank filler
130
+ const lines = await splitFrameLines(80);
131
+ const fillerRow = lines.find((line) => line.includes("no deletion partner"));
132
+
133
+ // Assert - the row still has a divider, and the left of it is blank (no merged text)
134
+ expect(fillerRow).toBeDefined();
135
+ const dividerAt = fillerRow!.indexOf("│");
136
+
137
+ expect(dividerAt).toBeGreaterThan(0);
138
+ expect(fillerRow!.slice(0, dividerAt).trim()).toBe("");
139
+ });
140
+ });
@@ -1,13 +1,49 @@
1
1
  import React from "react";
2
+ import type { Annotation } from "@cueloop/schema";
2
3
  import { DARK } from "../theme";
4
+ import { annotationPaletteFor } from "../annotation-palette";
5
+ import { DEFAULT_QUICK_ACTIONS } from "../config";
6
+ import { marksByRows } from "../view-diff";
3
7
  import type { Story, StoryMeta } from "./story";
4
8
  import { DiffSheet } from "./DiffSheet";
5
- import { FIXTURE_PATCH, fixtureDiffRows } from "./story-fixtures";
9
+ import { fixtureDiffRows, fixtureDiffSession } from "./story-fixtures";
6
10
 
7
- export const meta: StoryMeta = { title: "DiffSheet" };
11
+ export const meta: StoryMeta = { title: "Surfaces/DiffSheet" };
12
+
13
+ const palette = annotationPaletteFor(DARK);
14
+ const noop = (): void => {};
15
+
16
+ /** A comment on the added line, anchored the way the surface anchors a marked word. */
17
+ const LINE_COMMENT: Annotation = {
18
+ id: "a_diff_1",
19
+ kind: "comment",
20
+ anchor: { quote: "new Map()", prefix: " private items = ", suffix: ";" },
21
+ body: "Map needs an eviction story.",
22
+ createdAt: "2026-01-01T00:00:00Z",
23
+ };
24
+
25
+ function sheet(annotations: Annotation[], split = false): React.ReactNode {
26
+ const rows = fixtureDiffRows();
27
+ const session = fixtureDiffSession({ annotations });
28
+
29
+ return (
30
+ <DiffSheet
31
+ rows={rows}
32
+ session={session}
33
+ marks={marksByRows(annotations, rows)}
34
+ quickActions={DEFAULT_QUICK_ACTIONS}
35
+ observer={false}
36
+ onAnnotate={noop}
37
+ onReply={noop}
38
+ onUpdateAnnotation={noop}
39
+ onExit={noop}
40
+ split={split}
41
+ />
42
+ );
43
+ }
8
44
 
9
45
  export const SignedRows: Story = {
10
- render: () => <DiffSheet rows={fixtureDiffRows()} cursor={0} annotations={[]} />,
46
+ render: () => sheet([]),
11
47
  expectedColors: [DARK.insertedForeground, DARK.deletedForeground],
12
48
  size: { width: 90, height: 16 },
13
49
  };
@@ -15,64 +51,30 @@ export const SignedRows: Story = {
15
51
  // A modified line keeps the diff color on the changed words and dims the
16
52
  // unchanged prefix to the dim token, so the intra-line change stands out.
17
53
  export const IntralineWordDiff: Story = {
18
- render: () => <DiffSheet rows={fixtureDiffRows()} cursor={0} annotations={[]} />,
54
+ render: () => sheet([]),
19
55
  expectedColors: [DARK.insertedForeground, DARK.deletedForeground, DARK.textDim],
20
56
  size: { width: 90, height: 16 },
21
57
  };
22
58
 
23
- export const CursorOnAddedLine: Story = {
24
- render: () => <DiffSheet rows={fixtureDiffRows()} cursor={4} annotations={[]} />,
25
- expectedColors: [DARK.cursorBackground],
59
+ // The caret opens on the first code line: its cell wears the caret backdrop.
60
+ export const CaretOnFirstLine: Story = {
61
+ render: () => sheet([]),
62
+ expectedColors: [palette.caretCell],
26
63
  size: { width: 90, height: 16 },
27
64
  };
28
65
 
29
- export const ComposingOnLine: Story = {
30
- render: () => (
31
- <DiffSheet
32
- rows={fixtureDiffRows()}
33
- cursor={4}
34
- annotations={[]}
35
- compose={{
36
- kind: "comment",
37
- rowIndex: 4,
38
- quote: " private items = new Map();",
39
- draft: {
40
- text: "Map needs an eviction story.",
41
- onInput: () => {},
42
- onSave: () => {},
43
- onCancel: () => {},
44
- },
45
- }}
46
- />
47
- ),
48
- expectedColors: [DARK.accent],
49
- size: { width: 90, height: 16 },
66
+ // A saved comment marks its words (backdrop + underline) and hangs its discussion
67
+ // card under the line, exactly as the thread view does for prose.
68
+ export const AnnotatedLine: Story = {
69
+ render: () => sheet([LINE_COMMENT]),
70
+ expectedColors: [palette.markBackdrop, palette.cardEdge],
71
+ size: { width: 90, height: 18 },
50
72
  };
51
73
 
52
- export const AnnotatedLine: Story = {
53
- render: () => (
54
- <DiffSheet
55
- rows={fixtureDiffRows()}
56
- cursor={0}
57
- annotations={[
58
- {
59
- id: "a_story_diff",
60
- kind: "comment",
61
- anchor: {
62
- // diff anchors quote the row text verbatim, trailing newline included
63
- quote:
64
- FIXTURE_PATCH.split("\n")
65
- .find((line) => line.startsWith("+ "))!
66
- .slice(1) + "\n",
67
- prefix: "",
68
- suffix: "",
69
- },
70
- body: "Map needs an eviction story.",
71
- createdAt: "2026-01-01T00:00:00Z",
72
- },
73
- ]}
74
- />
75
- ),
76
- expectedColors: [DARK.markCommentBackground],
77
- size: { width: 90, height: 16 },
74
+ // Side by side: old on the left, new on the right, divided by a thin rule; the
75
+ // comment card spans the full width under its pair.
76
+ export const SplitView: Story = {
77
+ render: () => sheet([LINE_COMMENT], true),
78
+ expectedColors: [DARK.insertedForeground, DARK.deletedForeground, palette.markBackdrop],
79
+ size: { width: 120, height: 18 },
78
80
  };
@@ -3,6 +3,7 @@ import { testRender } from "@opentui/react/test-utils";
3
3
  import type { RGBA } from "@opentui/core";
4
4
  import React from "react";
5
5
  import { DiffSheet } from "./DiffSheet";
6
+ import { fixtureDiffSession } from "./story-fixtures";
6
7
  import { diffRows } from "../view-diff";
7
8
  import { DARK } from "../theme";
8
9
  import { allowEventLoopUpdates } from "../test-support";
@@ -28,10 +29,21 @@ afterEach(() => allowEventLoopUpdates());
28
29
 
29
30
  test("renders tree-sitter colors, with the changed word in the diff color on top", async () => {
30
31
  // Arrange
31
- const setup = await testRender(<DiffSheet rows={diffRows(PATCH)} cursor={0} annotations={[]} />, {
32
- width: 60,
33
- height: 12,
34
- });
32
+ const noop = (): void => {};
33
+ const setup = await testRender(
34
+ <DiffSheet
35
+ rows={diffRows(PATCH)}
36
+ session={fixtureDiffSession()}
37
+ marks={new Map()}
38
+ quickActions={[]}
39
+ observer={false}
40
+ onAnnotate={noop}
41
+ onReply={noop}
42
+ onUpdateAnnotation={noop}
43
+ onExit={noop}
44
+ />,
45
+ { width: 60, height: 12 },
46
+ );
35
47
 
36
48
  // Act
37
49
  // syntax highlighting resolves off the render path; pump until a keyword paints
@@ -40,10 +52,15 @@ test("renders tree-sitter colors, with the changed word in the diff color on top
40
52
  for (let attempt = 0; attempt < 60 && !keywordAccent; attempt++) {
41
53
  await setup.renderOnce();
42
54
  await new Promise((resolve) => setTimeout(resolve, 20));
55
+ // the caret cell splits the keyword's first character into its own span, so read the
56
+ // accent-colored text of a whole line rather than one span
43
57
  for (const line of setup.captureSpans().lines) {
44
- for (const span of line.spans) {
45
- if (span.text.includes("export") && hex(span.fg) === DARK.accent) keywordAccent = true;
46
- }
58
+ const accentText = line.spans
59
+ .filter((span) => hex(span.fg) === DARK.accent)
60
+ .map((span) => span.text)
61
+ .join("");
62
+
63
+ if (accentText.includes("export")) keywordAccent = true;
47
64
  }
48
65
  }
49
66