@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
@@ -7,10 +7,59 @@
7
7
  */
8
8
 
9
9
  import { join } from "node:path";
10
- import type { TestRendererSetup } from "@opentui/core/testing";
10
+ import { cloneElement, isValidElement, type ReactNode } from "react";
11
+ import type { TestRendererOptions, TestRendererSetup } from "@opentui/core/testing";
12
+ import { testRender } from "@opentui/react/test-utils";
13
+ import type { AppProps } from "./App";
11
14
 
12
- /** Pass budget for waits that include daemon or subprocess round-trips. */
13
- export const WAIT_PASSES = { maxPasses: 400 };
15
+ /**
16
+ * A probe for the App's ready signal in an in-process suite: pass `onReady`
17
+ * to the App and await `ready` before the first interaction, instead of
18
+ * waiting for a piece of text and hoping the keyboard is bound by then.
19
+ */
20
+ export interface AppReadyProbe {
21
+ /** Pass as the App's `onReady` prop. */
22
+ onReady: () => void;
23
+ /** Resolves once the App has fired its ready signal. */
24
+ ready: Promise<void>;
25
+ }
26
+
27
+ export function appReadyProbe(): AppReadyProbe {
28
+ let resolve: () => void = () => {};
29
+ const ready = new Promise<void>((done) => {
30
+ resolve = done;
31
+ });
32
+
33
+ return { onReady: () => resolve(), ready };
34
+ }
35
+
36
+ /**
37
+ * Boot an App element and resolve once it has fired its ready signal: the
38
+ * first usable screen is painted and its keyboard handlers are subscribed.
39
+ * Every App suite starts here so no boot waits on a piece of text and hopes.
40
+ */
41
+ export async function renderReadyApp(
42
+ element: ReactNode,
43
+ options: TestRendererOptions,
44
+ ): Promise<TestRendererSetup> {
45
+ if (!isValidElement<AppProps>(element)) throw new Error("renderReadyApp needs an App element");
46
+ const probe = appReadyProbe();
47
+ const setup = await testRender(cloneElement(element, { onReady: probe.onReady }), options);
48
+
49
+ await probe.ready;
50
+
51
+ return setup;
52
+ }
53
+
54
+ /**
55
+ * The CUELOOP_CONFIG path that isolates a test from the developer's real user
56
+ * config: a file inside the test home that does not exist unless the test
57
+ * writes it. Subprocess harnesses set this in the child env; in-process suites
58
+ * use `isolateUserConfig`.
59
+ */
60
+ export function isolatedUserConfigPath(home: string, fileName = "no-config.toml"): string {
61
+ return join(home, fileName);
62
+ }
14
63
 
15
64
  /**
16
65
  * Isolate the user config: point CUELOOP_CONFIG into the test home so
@@ -20,10 +69,10 @@ export const WAIT_PASSES = { maxPasses: 400 };
20
69
  * defaults; pass a file name for suites that write and assert a config of
21
70
  * their own. Call in beforeEach; invoke the returned restore in afterEach.
22
71
  */
23
- export function isolateUserConfig(home: string, fileName = "no-config.toml"): () => void {
72
+ export function isolateUserConfig(home: string, fileName?: string): () => void {
24
73
  const priorUserConfig = process.env.CUELOOP_CONFIG;
25
74
 
26
- process.env.CUELOOP_CONFIG = join(home, fileName);
75
+ process.env.CUELOOP_CONFIG = isolatedUserConfigPath(home, fileName);
27
76
 
28
77
  return () => {
29
78
  if (priorUserConfig === undefined) delete process.env.CUELOOP_CONFIG;
@@ -114,17 +163,27 @@ export interface FrameLocation {
114
163
  column: number;
115
164
  }
116
165
 
117
- /** Locate the first visual line containing the needle: 0-based row and start column. */
118
- export function locateText(setup: TestRendererSetup, needle: string): FrameLocation {
119
- const frame = setup.captureCharFrame();
120
-
166
+ /** Locate the first line of a text frame containing the needle; null when absent. */
167
+ export function locateTextInFrame(frame: string, needle: string): FrameLocation | null {
121
168
  for (const [row, line] of frame.split("\n").entries()) {
122
169
  const column = line.indexOf(needle);
123
170
 
124
171
  if (column !== -1) return { row, column };
125
172
  }
126
173
 
127
- throw new Error(`locateText ${JSON.stringify(needle)} not found.\nframe:\n${frame}`);
174
+ return null;
175
+ }
176
+
177
+ /** Locate the first visual line containing the needle: 0-based row and start column. */
178
+ export function locateText(setup: TestRendererSetup, needle: string): FrameLocation {
179
+ const frame = setup.captureCharFrame();
180
+ const location = locateTextInFrame(frame, needle);
181
+
182
+ if (location === null) {
183
+ throw new Error(`locateText ${JSON.stringify(needle)} not found.\nframe:\n${frame}`);
184
+ }
185
+
186
+ return location;
128
187
  }
129
188
 
130
189
  /** The 0-based visual row of the needle - for relative vertical-layout assertions. */
@@ -50,6 +50,10 @@ const ROSE_PINE_MOON: Theme = {
50
50
  markCommentBackground: "#382a37",
51
51
  insertedForeground: "#9ccfd8",
52
52
  deletedForeground: "#eb6f92",
53
+ insertedBackground: "#243a3f",
54
+ insertedEmphasisBackground: "#31555c",
55
+ deletedBackground: "#3a2530",
56
+ deletedEmphasisBackground: "#563643",
53
57
  backdrop: "transparent",
54
58
  };
55
59
 
@@ -71,6 +75,10 @@ const CATPPUCCIN_MOCHA: Theme = {
71
75
  markCommentBackground: "#33222b",
72
76
  insertedForeground: "#a6e3a1",
73
77
  deletedForeground: "#f38ba8",
78
+ insertedBackground: "#26372b",
79
+ insertedEmphasisBackground: "#375140",
80
+ deletedBackground: "#382530",
81
+ deletedEmphasisBackground: "#553844",
74
82
  backdrop: "transparent",
75
83
  };
76
84
 
@@ -92,6 +100,10 @@ const TOKYO_NIGHT: Theme = {
92
100
  markCommentBackground: "#2f2230",
93
101
  insertedForeground: "#9ece6a",
94
102
  deletedForeground: "#f7768e",
103
+ insertedBackground: "#233028",
104
+ insertedEmphasisBackground: "#33493a",
105
+ deletedBackground: "#32232e",
106
+ deletedEmphasisBackground: "#4d3543",
95
107
  backdrop: "transparent",
96
108
  };
97
109
 
@@ -113,6 +125,10 @@ const GRUVBOX_DARK: Theme = {
113
125
  markCommentBackground: "#3a2823",
114
126
  insertedForeground: "#b8bb26",
115
127
  deletedForeground: "#fb4934",
128
+ insertedBackground: "#333521",
129
+ insertedEmphasisBackground: "#4a4d2c",
130
+ deletedBackground: "#3c2a24",
131
+ deletedEmphasisBackground: "#5a3a30",
116
132
  backdrop: "transparent",
117
133
  };
118
134
 
@@ -134,6 +150,10 @@ const NORD: Theme = {
134
150
  markCommentBackground: "#3a2f33",
135
151
  insertedForeground: "#a3be8c",
136
152
  deletedForeground: "#bf616a",
153
+ insertedBackground: "#333d33",
154
+ insertedEmphasisBackground: "#454f42",
155
+ deletedBackground: "#3c2f33",
156
+ deletedEmphasisBackground: "#544045",
137
157
  backdrop: "transparent",
138
158
  };
139
159
 
package/src/theme.ts CHANGED
@@ -22,6 +22,12 @@ export interface Theme {
22
22
  markCommentBackground: string;
23
23
  insertedForeground: string;
24
24
  deletedForeground: string;
25
+ /** The band behind a whole added line, and the stronger tint under its intra-line changed words. */
26
+ insertedBackground: string;
27
+ insertedEmphasisBackground: string;
28
+ /** The band behind a whole removed line, and the stronger tint under its intra-line changed words. */
29
+ deletedBackground: string;
30
+ deletedEmphasisBackground: string;
25
31
  /** Layer behind centered dialogs; transparent keeps the session visible. */
26
32
  backdrop: string;
27
33
  }
@@ -42,6 +48,10 @@ export function dimmedTheme(theme: Theme): Theme {
42
48
  blue: theme.textDim,
43
49
  insertedForeground: theme.textDim,
44
50
  deletedForeground: theme.textDim,
51
+ insertedBackground: theme.panel,
52
+ insertedEmphasisBackground: theme.elevated,
53
+ deletedBackground: theme.panel,
54
+ deletedEmphasisBackground: theme.elevated,
45
55
  };
46
56
  }
47
57
 
@@ -63,6 +73,10 @@ export const DARK: Theme = {
63
73
  markCommentBackground: "#3d2a2e",
64
74
  insertedForeground: "#62d96b",
65
75
  deletedForeground: "#ff6b6b",
76
+ insertedBackground: "#16321d",
77
+ insertedEmphasisBackground: "#245c30",
78
+ deletedBackground: "#341c1f",
79
+ deletedEmphasisBackground: "#5a2a2e",
66
80
  backdrop: "transparent",
67
81
  };
68
82
 
@@ -89,5 +103,9 @@ export const LIGHT: Theme = {
89
103
  markCommentBackground: "#f2e2e8",
90
104
  insertedForeground: "#2f8a3e",
91
105
  deletedForeground: "#c0392b",
106
+ insertedBackground: "#d8f5dd",
107
+ insertedEmphasisBackground: "#a9e6b6",
108
+ deletedBackground: "#fbdcdc",
109
+ deletedEmphasisBackground: "#f5b7b7",
92
110
  backdrop: "transparent",
93
111
  };
@@ -33,7 +33,7 @@ export const DIFF_CHORD_ENTRIES = [
33
33
  { keys: "⌥x / ⌥X", label: "reject the change / the hunk" },
34
34
  { keys: "⌥u", label: "restore the last rejection" },
35
35
  { keys: "⌥c", label: "collapse the file to its band" },
36
- { keys: "⌥d", label: "unified / split (when zoomed)" },
36
+ { keys: "⌥d", label: "split / stacked (when wide)" },
37
37
  { keys: "⌥k", label: "start the guided walk" },
38
38
  ] as const;
39
39
 
@@ -73,6 +73,12 @@ export interface AnnotationSurfaceOptions {
73
73
  quickActions: QuickAction[];
74
74
  tokens: Theme;
75
75
  observer: boolean;
76
+ /**
77
+ * Whether this surface accepts comments. False for a non-diff thread's live
78
+ * working-tree diff, which is view-only: its rows are diff rows, but the
79
+ * thread anchors comments in plan coordinates, so a draft here would misanchor.
80
+ */
81
+ commentsEnabled?: boolean;
76
82
  /** A verdict is in: no draft may open; the app answers with its read-only status. */
77
83
  resolved: boolean;
78
84
  /** True while a menu, dialog, or overlay owns the keyboard. */
@@ -152,6 +158,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
152
158
  quickActions,
153
159
  tokens,
154
160
  observer,
161
+ commentsEnabled = true,
155
162
  resolved,
156
163
  suspended,
157
164
  onComposingChange,
@@ -283,6 +290,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
283
290
  }));
284
291
 
285
292
  const openCompose = (state: ComposeState): void => {
293
+ // a view-only surface (a non-diff thread's live diff) never opens a draft
294
+ if (!commentsEnabled) return;
286
295
  if (observer) return onObserverBlocked?.("observer");
287
296
  if (resolved) return onObserverBlocked?.("resolved");
288
297
  composerReady.current = false;
@@ -296,7 +305,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
296
305
  setCompose(null);
297
306
  setComposeText("");
298
307
  };
299
- const slashActive = compose !== null && composeText.startsWith("/");
308
+ // open only while typing the token: a leading "/" with no space yet
309
+ const slashActive = compose !== null && /^\/\S*$/.test(composeText);
300
310
  const slashItems = slashActive
301
311
  ? slashFilter(slashItemsFrom(quickActions), composeText.slice(1).trim())
302
312
  : [];
@@ -417,7 +427,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
417
427
  return true;
418
428
  }
419
429
  if (key.name === "return" || key.name === "tab") {
420
- openCompose({ ...activeCompose, seed: slashItems[selected]!.body });
430
+ openCompose({ ...activeCompose, seed: `/${slashItems[selected]!.name} ` });
421
431
 
422
432
  return true;
423
433
  }
@@ -142,9 +142,9 @@ export function useSettingsDialog(params: {
142
142
  },
143
143
  {
144
144
  key: "diffView",
145
- label: "Diff view (when zoomed)",
145
+ label: "Diff view (when wide)",
146
146
  kind: "cycle",
147
- options: ["Unified", "Split"],
147
+ options: ["Split", "Stacked"],
148
148
  },
149
149
  ],
150
150
  },
@@ -191,7 +191,7 @@ export function useSettingsDialog(params: {
191
191
  ];
192
192
  const settingsValues = {
193
193
  autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
194
- diffView: diffView === "split" ? "Split" : "Unified",
194
+ diffView: diffView === "split" ? "Split" : "Stacked",
195
195
  theme: THEME_LABELS[themeName],
196
196
  };
197
197
  const cycleSetting = (rowKey: string): void => {
@@ -201,7 +201,7 @@ export function useSettingsDialog(params: {
201
201
  setAutoClose(next);
202
202
  persistAutoClose(next);
203
203
  } else if (rowKey === "diffView") {
204
- const next: DiffViewMode = diffView === "unified" ? "split" : "unified";
204
+ const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
205
205
 
206
206
  setDiffView(next);
207
207
  persistDiffView(next);
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { diffRowAnchor, diffRows } from "./view-diff";
2
+ import { makeAnchor, type Annotation } from "@cueloop/schema";
3
+ import { diffRowAnchor, diffRowBlocks, diffRows, fileRowRange, fileTargetMarks } from "./view-diff";
3
4
 
4
5
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
5
6
  index 111..222 100644
@@ -67,3 +68,45 @@ describe("diffRowAnchor", () => {
67
68
  expect(anchor.suffix.length).toBeGreaterThan(0);
68
69
  });
69
70
  });
71
+
72
+ describe("fileTargetMarks", () => {
73
+ // two files whose added line is byte-identical; a note on one must never mark the other
74
+ const TWO_FILE = `diff --git a/a.ts b/a.ts
75
+ --- a/a.ts
76
+ +++ b/a.ts
77
+ @@ -1,1 +1,2 @@
78
+ const keep = 0;
79
+ +const dup = 1;
80
+ diff --git a/b.ts b/b.ts
81
+ --- a/b.ts
82
+ +++ b/b.ts
83
+ @@ -1,1 +1,2 @@
84
+ const keep = 0;
85
+ +const dup = 1;
86
+ `;
87
+
88
+ test("a file-target note marks only its own file, never identical text in another", () => {
89
+ // Arrange - anchor the added line inside a.ts, file-relative like the controller does
90
+ const rows = diffRows(TWO_FILE);
91
+ const range = fileRowRange(rows, "a.ts")!;
92
+ const fileRows = rows.slice(range.start, range.end);
93
+ const addRelative = fileRows.findIndex((row) => row.kind === "add");
94
+ const quote = fileRows[addRelative]!.text.replace(/\n$/, "");
95
+ const anchor = makeAnchor(diffRowBlocks(fileRows), addRelative, 0, quote.length, addRelative);
96
+ const annotation: Annotation = {
97
+ id: "a1",
98
+ kind: "comment",
99
+ anchor,
100
+ target: { kind: "file", path: "a.ts", rev: "worktree" },
101
+ body: "note",
102
+ createdAt: "2026-01-01T00:00:00Z",
103
+ };
104
+
105
+ // Act
106
+ const marked = [...fileTargetMarks([annotation], rows).keys()];
107
+
108
+ // Assert - every marked row sits inside a.ts's range, and b.ts's identical line is untouched
109
+ expect(marked.length).toBeGreaterThan(0);
110
+ expect(marked.every((index) => index >= range.start && index < range.end)).toBe(true);
111
+ });
112
+ });
package/src/view-diff.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
9
9
  import {
10
+ annotationTarget,
10
11
  isAddressed,
11
12
  resolveAnchor,
12
13
  type Annotation,
@@ -167,6 +168,86 @@ export function marksByRows(
167
168
  return marksByIndex;
168
169
  }
169
170
 
171
+ /**
172
+ * A whole file rendered as context rows, so the diff sheet can show and annotate a plain file the
173
+ * same way it does a diff: every line is unchanged, numbered on both sides (the file view collapses
174
+ * that to one number). A trailing newline does not add an empty final row.
175
+ */
176
+ export function fileContentsRows(path: string, contents: string): DiffRow[] {
177
+ const lines = contents.replace(/\n$/, "").split("\n");
178
+
179
+ return lines.map((text, index) => ({
180
+ kind: "ctx",
181
+ text,
182
+ file: path,
183
+ oldLine: index + 1,
184
+ newLine: index + 1,
185
+ }));
186
+ }
187
+
188
+ /** The contiguous row range a file occupies in the aggregate diff, or null when it is not shown. */
189
+ export function fileRowRange(rows: DiffRow[], path: string): { start: number; end: number } | null {
190
+ const start = rows.findIndex((row) => row.file === path);
191
+
192
+ if (start === -1) return null;
193
+ let end = start;
194
+
195
+ while (end < rows.length && rows[end]!.file === path) end++;
196
+
197
+ return { start, end };
198
+ }
199
+
200
+ /**
201
+ * Marks for file-target notes on the aggregate working-tree diff. Each note resolves against only
202
+ * its own file's rows, then the row index and span shift back to aggregate coordinates - so a quote
203
+ * never attaches to the same text in another file or on the opposite diff side.
204
+ */
205
+ export function fileTargetMarks(
206
+ annotations: Annotation[],
207
+ rows: DiffRow[],
208
+ focusedId?: string,
209
+ ): Map<number, Mark[]> {
210
+ const byFile = new Map<string, Annotation[]>();
211
+
212
+ for (const annotation of annotations) {
213
+ const target = annotationTarget(annotation);
214
+
215
+ if (target.kind !== "file") continue;
216
+ const forPath = byFile.get(target.path) ?? [];
217
+
218
+ forPath.push(annotation);
219
+ byFile.set(target.path, forPath);
220
+ }
221
+ const result = new Map<number, Mark[]>();
222
+
223
+ for (const [path, fileAnnotations] of byFile) {
224
+ const range = fileRowRange(rows, path);
225
+
226
+ if (!range) continue;
227
+ const base = range.start;
228
+ const fileMarks = marksByRows(fileAnnotations, rows.slice(range.start, range.end), focusedId);
229
+
230
+ for (const [relativeRow, marks] of fileMarks) {
231
+ result.set(
232
+ relativeRow + base,
233
+ marks.map((mark) =>
234
+ mark.span
235
+ ? {
236
+ ...mark,
237
+ span: {
238
+ start: { ...mark.span.start, blockIndex: mark.span.start.blockIndex + base },
239
+ end: { ...mark.span.end, blockIndex: mark.span.end.blockIndex + base },
240
+ },
241
+ }
242
+ : mark,
243
+ ),
244
+ );
245
+ }
246
+ }
247
+
248
+ return result;
249
+ }
250
+
170
251
  /**
171
252
  * Comments per file path across the diff: each discussion counts its comments (root plus
172
253
  * replies) toward the file its span ends in. Feeds the changed-files tree and tab badges, so a
@@ -1,49 +0,0 @@
1
- /**
2
- * The composer's auto-grow row math as a pure function: hard newlines and soft
3
- * wrapping both add rows, growth stops at the cap, and a non-positive width
4
- * (before layout is known) degrades to a hard-newline count. The live textarea
5
- * scrolls internally past the cap, so this only needs to get the growth curve
6
- * and the ceiling right.
7
- */
8
-
9
- import { describe, expect, test } from "bun:test";
10
- import { COMPOSE_MAX_ROWS, composeRowCount } from "./AnnotationCard";
11
-
12
- describe("composeRowCount", () => {
13
- const width = 10;
14
-
15
- test("empty and short text are one row", () => {
16
- expect(composeRowCount("", width)).toBe(1);
17
- expect(composeRowCount("hi", width)).toBe(1);
18
- expect(composeRowCount("exactly-10", width)).toBe(1); // length 10 / width 10
19
- });
20
-
21
- test("hard newlines add rows", () => {
22
- expect(composeRowCount("a\nb", width)).toBe(2);
23
- expect(composeRowCount("a\nb\nc", width)).toBe(3);
24
- // a blank trailing line still occupies a row
25
- expect(composeRowCount("a\n", width)).toBe(2);
26
- });
27
-
28
- test("a soft-wrapped long line grows the box", () => {
29
- expect(composeRowCount("a".repeat(11), width)).toBe(2); // ceil(11/10)
30
- expect(composeRowCount("a".repeat(25), width)).toBe(3); // ceil(25/10)
31
- });
32
-
33
- test("wrapping and hard newlines combine", () => {
34
- // 1 row for "short" plus ceil(21/10)=3 rows for the long line
35
- expect(composeRowCount("short\n" + "b".repeat(21), width)).toBe(4);
36
- });
37
-
38
- test("growth is capped", () => {
39
- expect(composeRowCount("a".repeat(100), width)).toBe(COMPOSE_MAX_ROWS);
40
- expect(composeRowCount("l\ni\nn\ne\ns\nx", width)).toBe(COMPOSE_MAX_ROWS);
41
- expect(composeRowCount("a\nb", width, 1)).toBe(1); // custom cap
42
- });
43
-
44
- test("a non-positive width counts hard newlines only, never divides by zero", () => {
45
- expect(composeRowCount("a".repeat(100), 0)).toBe(1);
46
- expect(composeRowCount("a\nb\nc", 0)).toBe(3);
47
- expect(composeRowCount("a".repeat(100), -5)).toBe(1);
48
- });
49
- });
@@ -1,94 +0,0 @@
1
- import React from "react";
2
- import { DARK } from "../theme";
3
- import type { Story, StoryMeta } from "./story";
4
- import { AnnotationCard } from "./AnnotationCard";
5
-
6
- export const meta: StoryMeta = { title: "Cards/AnnotationCard" };
7
-
8
- const draftCallbacks = { onInput: () => {}, onSave: () => {}, onCancel: () => {} };
9
-
10
- export const CommentDraft: Story = {
11
- render: () => (
12
- <AnnotationCard
13
- kind="comment"
14
- quote="persists sessions"
15
- draft={{ text: "Which daemon?", ...draftCallbacks }}
16
- />
17
- ),
18
- expectedColors: [DARK.blue],
19
- };
20
-
21
- export const SavedSelected: Story = {
22
- render: () => (
23
- <AnnotationCard
24
- kind="comment"
25
- quote="persists sessions"
26
- saved={{
27
- body: "Which daemon owns this?",
28
- isSelected: true,
29
- isOrphan: false,
30
- author: "me",
31
- tone: DARK.blue,
32
- editing: null,
33
- onPress: () => {},
34
- }}
35
- />
36
- ),
37
- expectedColors: [DARK.blue],
38
- };
39
-
40
- export const SavedCollaborator: Story = {
41
- render: () => (
42
- <AnnotationCard
43
- kind="comment"
44
- quote="persists sessions"
45
- saved={{
46
- body: "Which daemon owns this?",
47
- isSelected: false,
48
- isOrphan: false,
49
- author: "Priya",
50
- tone: DARK.blue,
51
- editing: null,
52
- onPress: () => {},
53
- }}
54
- />
55
- ),
56
- expectedColors: [DARK.blue],
57
- };
58
-
59
- export const SavedOrphan: Story = {
60
- render: () => (
61
- <AnnotationCard
62
- kind="comment"
63
- quote="a removed passage"
64
- saved={{
65
- body: "The passage is gone.",
66
- isSelected: false,
67
- isOrphan: true,
68
- author: "me",
69
- tone: DARK.blue,
70
- editing: null,
71
- onPress: () => {},
72
- }}
73
- />
74
- ),
75
- expectedColors: [DARK.blue],
76
- };
77
-
78
- export const SavedEditing: Story = {
79
- render: () => (
80
- <AnnotationCard
81
- kind="comment"
82
- quote="move the store"
83
- saved={{
84
- body: "old body",
85
- isSelected: true,
86
- isOrphan: false,
87
- author: "me",
88
- tone: DARK.blue,
89
- editing: { text: "new body", ...draftCallbacks },
90
- onPress: () => {},
91
- }}
92
- />
93
- ),
94
- };