@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
package/src/config.ts CHANGED
@@ -2,8 +2,7 @@
2
2
  * Layered TOML config: built-in defaults → user config → trusted repo
3
3
  * config → env. Sections: [keys] action = "combo" (every action rebindable),
4
4
  * [theme] per-token overrides, [ui] auto_close + editor + theme (a named
5
- * preset) + the review-panel layout (review_width + review_state),
6
- * [integrations.obsidian] notes-vault export.
5
+ * preset) + pins, [integrations.obsidian] notes-vault export.
7
6
  */
8
7
 
9
8
  import { existsSync, readFileSync } from "node:fs";
@@ -15,7 +14,6 @@ import { dirname } from "node:path";
15
14
  import * as v from "valibot";
16
15
  import { DARK, type Theme } from "./theme";
17
16
  import { DEFAULT_THEME_NAME, isThemeName, themeForName, type ThemeName } from "./theme-presets";
18
- import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
19
17
 
20
18
  export interface KeymapConfig {
21
19
  [action: string]: string | string[];
@@ -28,6 +26,9 @@ export interface IntegrationsConfig {
28
26
  /** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
29
27
  export type AutoClose = "off" | number;
30
28
 
29
+ /** How the Changes diff renders: one inline column, or old|new side by side (only when zoomed). */
30
+ export type DiffViewMode = "unified" | "split";
31
+
31
32
  /** One marker-popover quick action: a preset comment body, plus optional extra lines. */
32
33
  export interface QuickAction {
33
34
  prompt: string;
@@ -93,17 +94,15 @@ export interface CueloopConfig {
93
94
  theme: Theme;
94
95
  /** The `[theme]` per-token overrides alone, so a live theme switch can re-compose them onto a new preset. */
95
96
  themeOverrides: Partial<Theme>;
96
- /**
97
- * ui.reviewState / ui.reviewWidth are CLIENT VIEW STATE: the review panel's
98
- * collapse mode and expanded-rail width, persisted so they survive restarts.
99
- */
100
97
  ui: {
101
98
  autoClose: AutoClose;
102
99
  editor?: string;
103
- reviewState: ReviewPanelMode;
104
- reviewWidth: number;
105
100
  /** The selected theme preset name; its tokens are the base for `theme`, before any `[theme]` overrides. */
106
101
  theme: ThemeName;
102
+ /** How the Changes diff renders when zoomed: one inline column or old|new side by side. */
103
+ diffView: DiffViewMode;
104
+ /** Session ids the user has pinned to the top of the sidebar; client-local view state. */
105
+ pins: string[];
107
106
  };
108
107
  /** Planner-local author renames: identity id → display name ([authors] table). */
109
108
  authors: Record<string, string>;
@@ -123,6 +122,9 @@ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
123
122
  cut: ["x"],
124
123
  reject_hunk: ["X"],
125
124
  restore_curation: ["u"],
125
+ collapse_file: ["right"],
126
+ expand_file: ["left"],
127
+ split_diff: ["s"],
126
128
  edit: ["e"],
127
129
  next_annotation: ["n"],
128
130
  prev_annotation: ["p"],
@@ -132,9 +134,6 @@ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
132
134
  share: ["S"],
133
135
  quit: ["q"],
134
136
  walk: ["w"],
135
- review_cycle: ["b"],
136
- review_wider: ["]"],
137
- review_narrower: ["["],
138
137
  };
139
138
 
140
139
  const ConfigDocumentSchema = v.object({
@@ -165,9 +164,9 @@ const UiSchema = v.object({
165
164
  undefined,
166
165
  ),
167
166
  editor: v.fallback(v.optional(v.string()), undefined),
168
- review_width: v.fallback(v.optional(v.pipe(v.number(), v.finite())), undefined),
169
- review_state: v.fallback(v.optional(v.picklist(["expanded", "compact", "hidden"])), undefined),
170
167
  theme: v.fallback(v.optional(v.string()), undefined),
168
+ diff_view: v.fallback(v.optional(v.picklist(["unified", "split"])), undefined),
169
+ pins: v.fallback(v.optional(v.array(v.string())), undefined),
171
170
  });
172
171
  const ObsidianSchema = v.object({
173
172
  vault: v.fallback(v.optional(v.string()), undefined),
@@ -252,9 +251,8 @@ function layer(
252
251
  if (ui.success) {
253
252
  if (ui.output.auto_close !== undefined) out.ui.autoClose = ui.output.auto_close;
254
253
  if (ui.output.editor?.trim()) out.ui.editor = ui.output.editor.trim();
255
- if (ui.output.review_width !== undefined)
256
- out.ui.reviewWidth = clampWidth(ui.output.review_width);
257
- if (ui.output.review_state !== undefined) out.ui.reviewState = ui.output.review_state;
254
+ if (ui.output.diff_view !== undefined) out.ui.diffView = ui.output.diff_view;
255
+ if (ui.output.pins !== undefined) out.ui.pins = ui.output.pins;
258
256
  }
259
257
  if (integrations.success && integrations.output.obsidian) {
260
258
  mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
@@ -272,9 +270,9 @@ export function loadConfig(
272
270
  themeOverrides: {},
273
271
  ui: {
274
272
  autoClose: "off",
275
- reviewState: "expanded",
276
- reviewWidth: REVIEW_DEFAULT_WIDTH,
277
273
  theme: DEFAULT_THEME_NAME,
274
+ diffView: "unified",
275
+ pins: [],
278
276
  },
279
277
  authors: {},
280
278
  actions: [...DEFAULT_QUICK_ACTIONS],
@@ -403,19 +401,19 @@ export function persistAutoClose(value: AutoClose, userConfigPath?: string): voi
403
401
  persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
404
402
  }
405
403
 
406
- /** Persist the expanded-rail width (`[ui] review_width`) into the user config. */
407
- export function persistReviewWidth(width: number, userConfigPath?: string): void {
408
- persistUiSetting("review_width", String(clampWidth(width)), userConfigPath);
404
+ /** Persist the selected theme preset (`[ui] theme`) into the user config. */
405
+ export function persistTheme(name: ThemeName, userConfigPath?: string): void {
406
+ persistUiSetting("theme", `"${name}"`, userConfigPath);
409
407
  }
410
408
 
411
- /** Persist the review-panel collapse mode (`[ui] review_state`) into the config. */
412
- export function persistReviewState(state: ReviewPanelMode, userConfigPath?: string): void {
413
- persistUiSetting("review_state", `"${state}"`, userConfigPath);
409
+ /** Persist the diff-view choice (`[ui] diff_view`) into the user config. */
410
+ export function persistDiffView(mode: DiffViewMode, userConfigPath?: string): void {
411
+ persistUiSetting("diff_view", `"${mode}"`, userConfigPath);
414
412
  }
415
413
 
416
- /** Persist the selected theme preset (`[ui] theme`) into the user config. */
417
- export function persistTheme(name: ThemeName, userConfigPath?: string): void {
418
- persistUiSetting("theme", `"${name}"`, userConfigPath);
414
+ /** Persist the pinned-thread ids (`[ui] pins`) into the user config. */
415
+ export function persistPins(ids: readonly string[], userConfigPath?: string): void {
416
+ persistUiSetting("pins", `[${ids.map(tomlString).join(", ")}]`, userConfigPath);
419
417
  }
420
418
 
421
419
  function escapeRegExp(text: string): string {
@@ -0,0 +1,78 @@
1
+ /** Per-file collapse and full-file weave over the flat diff rows. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import type { DiffRow } from "./view-diff";
5
+ import { applyFold, weaveFullFileRows } from "./diff-fold";
6
+
7
+ function row(kind: DiffRow["kind"], text: string, extra: Partial<DiffRow> = {}): DiffRow {
8
+ return { kind, text, file: "a.ts", ...extra };
9
+ }
10
+
11
+ // a two-line change in a four-line file: line 2 swapped, lines 1/3/4 unchanged
12
+ const base: DiffRow[] = [
13
+ row("file", "a.ts"),
14
+ row("hunk", "@@ -1,3 +1,3 @@"),
15
+ row("ctx", "line one", { oldLine: 1, newLine: 1 }),
16
+ row("del", "old two", { oldLine: 2 }),
17
+ row("add", "new two", { newLine: 2 }),
18
+ row("ctx", "line three", { oldLine: 3, newLine: 3 }),
19
+ ];
20
+
21
+ const contents = {
22
+ path: "a.ts",
23
+ oldContents: "line one\nold two\nline three\nline four\n",
24
+ newContents: "line one\nnew two\nline three\nline four\n",
25
+ };
26
+
27
+ describe("applyFold", () => {
28
+ test("with no fold state it returns the base rows unchanged", () => {
29
+ expect(applyFold(base, new Set(), new Set(), [contents])).toBe(base);
30
+ });
31
+
32
+ test("a collapsed file keeps only its file band", () => {
33
+ // Act
34
+ const folded = applyFold(base, new Set(["a.ts"]), new Set(), [contents]);
35
+
36
+ // Assert
37
+ expect(folded).toHaveLength(1);
38
+ expect(folded[0]).toEqual(row("file", "a.ts"));
39
+ });
40
+
41
+ test("an expanded file weaves the unchanged tail line the hunk never showed", () => {
42
+ // Act
43
+ const folded = applyFold(base, new Set(), new Set(["a.ts"]), [contents]);
44
+
45
+ // Assert - the hunk header is gone and "line four" (outside the hunk) now shows
46
+ expect(folded.some((r) => r.kind === "hunk")).toBe(false);
47
+ const four = folded.find((r) => r.text === "line four");
48
+
49
+ expect(four).toEqual(row("ctx", "line four", { oldLine: 4, newLine: 4 }));
50
+ });
51
+
52
+ test("expand is a no-op without the file's full contents", () => {
53
+ // Act - no contents entry, so the file keeps its hunks
54
+ const folded = applyFold(base, new Set(), new Set(["a.ts"]), undefined);
55
+
56
+ // Assert
57
+ expect(folded).toEqual(base);
58
+ });
59
+ });
60
+
61
+ describe("weaveFullFileRows", () => {
62
+ test("keeps the change rows and fills every unchanged line as context in order", () => {
63
+ // Arrange - the file's code rows (no headers)
64
+ const codeRows = base.slice(2);
65
+
66
+ // Act
67
+ const woven = weaveFullFileRows("a.ts", codeRows, contents.newContents);
68
+
69
+ // Assert - full file: ctx1, del, add, ctx3, ctx4
70
+ expect(woven.map((r) => `${r.kind}:${r.text}`)).toEqual([
71
+ "ctx:line one",
72
+ "del:old two",
73
+ "add:new two",
74
+ "ctx:line three",
75
+ "ctx:line four",
76
+ ]);
77
+ });
78
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Per-file fold state for the diff sheet, applied over the flat diff rows so the
3
+ * cursor, curation, and rendering all read one array. A collapsed file shows only
4
+ * its file band; an expanded file shows its whole contents with the diff woven in
5
+ * (unchanged lines become context rows), matching VSCode's "expand all lines".
6
+ */
7
+
8
+ import type { DiffRow } from "./view-diff";
9
+
10
+ /** The full old/new contents a diff carries per file (curatable diffs do). */
11
+ export interface FileContents {
12
+ path: string;
13
+ newContents: string;
14
+ oldContents: string;
15
+ }
16
+
17
+ /** Split file contents into lines, dropping the single trailing newline the file carries. */
18
+ function contentLines(contents: string): string[] {
19
+ if (contents === "") return [];
20
+
21
+ return contents.replace(/\n$/, "").split("\n");
22
+ }
23
+
24
+ /**
25
+ * Weave a file's hunk rows into its full contents: unchanged lines between and
26
+ * around the hunks become context rows, so the whole file renders with the diff
27
+ * inline. `codeRows` are the file's ctx/add/del rows in order (no headers).
28
+ */
29
+ export function weaveFullFileRows(
30
+ fileName: string,
31
+ codeRows: DiffRow[],
32
+ newContents: string,
33
+ ): DiffRow[] {
34
+ const newLines = contentLines(newContents);
35
+ const woven: DiffRow[] = [];
36
+ // the next unchanged old/new line numbers to emit while filling gaps between hunks
37
+ let newCursor = 1;
38
+ let oldCursor = 1;
39
+ const fillTo = (newLine: number): void => {
40
+ while (newCursor < newLine) {
41
+ woven.push({
42
+ kind: "ctx",
43
+ text: newLines[newCursor - 1] ?? "",
44
+ file: fileName,
45
+ oldLine: oldCursor,
46
+ newLine: newCursor,
47
+ });
48
+ newCursor += 1;
49
+ oldCursor += 1;
50
+ }
51
+ };
52
+
53
+ for (const row of codeRows) {
54
+ if (row.newLine !== undefined) {
55
+ fillTo(row.newLine);
56
+ woven.push(row);
57
+ newCursor = row.newLine + 1;
58
+ if (row.kind === "ctx") oldCursor = (row.oldLine ?? oldCursor) + 1;
59
+ } else {
60
+ // a deletion consumes an old line only; it keeps its place before the additions
61
+ woven.push(row);
62
+ oldCursor = (row.oldLine ?? oldCursor) + 1;
63
+ }
64
+ }
65
+ // the unchanged tail after the last hunk
66
+ while (newCursor <= newLines.length) {
67
+ woven.push({
68
+ kind: "ctx",
69
+ text: newLines[newCursor - 1] ?? "",
70
+ file: fileName,
71
+ oldLine: oldCursor,
72
+ newLine: newCursor,
73
+ });
74
+ newCursor += 1;
75
+ oldCursor += 1;
76
+ }
77
+
78
+ return woven;
79
+ }
80
+
81
+ /**
82
+ * Rebuild the diff rows with per-file fold state. A collapsed file keeps only its
83
+ * file band; an expanded file with known contents shows its whole file woven; every
84
+ * other file keeps its hunks. Row order and the file bands themselves are preserved.
85
+ */
86
+ export function applyFold(
87
+ base: DiffRow[],
88
+ collapsed: ReadonlySet<string>,
89
+ expanded: ReadonlySet<string>,
90
+ files: readonly FileContents[] | undefined,
91
+ ): DiffRow[] {
92
+ if (collapsed.size === 0 && expanded.size === 0) return base;
93
+ const contentsByPath = new Map((files ?? []).map((file) => [file.path, file]));
94
+ const out: DiffRow[] = [];
95
+ let index = 0;
96
+
97
+ while (index < base.length) {
98
+ const row = base[index]!;
99
+
100
+ if (row.kind !== "file") {
101
+ out.push(row);
102
+ index += 1;
103
+ continue;
104
+ }
105
+ out.push(row);
106
+ const file = row.file;
107
+ let end = index + 1;
108
+
109
+ while (end < base.length && base[end]!.kind !== "file") end += 1;
110
+ const body = base.slice(index + 1, end);
111
+ const contents = contentsByPath.get(file);
112
+
113
+ if (collapsed.has(file)) {
114
+ // only the file band; the body is hidden
115
+ } else if (expanded.has(file) && contents) {
116
+ const codeRows = body.filter((bodyRow) => bodyRow.kind !== "hunk");
117
+
118
+ out.push(...weaveFullFileRows(file, codeRows, contents.newContents));
119
+ } else {
120
+ out.push(...body);
121
+ }
122
+ index = end;
123
+ }
124
+
125
+ return out;
126
+ }
@@ -41,6 +41,22 @@ describe("spansByLine", () => {
41
41
  expect(lineSpans[1]).toEqual([{ start: 0, end: 2, group: "comment" }]);
42
42
  });
43
43
 
44
+ test("a capture ending past the source clamps to its end instead of walking forever", () => {
45
+ // Arrange - offsets past the string, as byte offsets over a multi-byte line report
46
+ const source = "a│b\ncc";
47
+ const highlights: SimpleHighlight[] = [
48
+ [0, 9, "string"],
49
+ [12, 20, "comment"],
50
+ ];
51
+
52
+ // Act
53
+ const lineSpans = spansByLine(source, highlights);
54
+
55
+ // Assert - the covered text is colored to the end and the second capture paints nothing
56
+ expect(lineSpans[0]).toEqual([{ start: 0, end: 3, group: "string" }]);
57
+ expect(lineSpans[1]).toEqual([{ start: 0, end: 2, group: "string" }]);
58
+ });
59
+
44
60
  test("resolves overlapping captures with later winning", () => {
45
61
  // Arrange
46
62
  const source = "foo";
@@ -111,10 +111,13 @@ export function spansByLine(source: string, highlights: SimpleHighlight[]): Synt
111
111
  return Array.from({ length: Math.max(0, end - start) }, () => undefined);
112
112
  });
113
113
 
114
- for (const [start, end, group] of highlights) {
114
+ for (const [start, rawEnd, group] of highlights) {
115
115
  // A capture can span newlines (block comments, template literals); color the
116
- // covered columns on every line it crosses, not just the starting line.
117
- let cursor = start;
116
+ // covered columns on every line it crosses, not just the starting line. Offsets
117
+ // past the source (byte offsets from a multi-byte line) clamp to its end, or the
118
+ // walk below would sit on the last line forever.
119
+ const end = Math.min(rawEnd, source.length);
120
+ let cursor = Math.max(0, start);
118
121
 
119
122
  while (cursor < end) {
120
123
  const line = lineOf(cursor);
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Discussions: the conversations an annotated surface hosts inline. A root
3
+ * annotation, its replies, and the span they mark, grouped so two roots on the
4
+ * very same text read as one conversation. Pure over the resolved marks, so the
5
+ * plan thread view and the diff sheet group the same way.
6
+ */
7
+
8
+ import type { Annotation, ReviewSession } from "@cueloop/schema";
9
+ import type { Mark } from "./view-plan";
10
+ import { comparePositions, type TextSpan } from "./thread-selection";
11
+
12
+ /** One conversation: a root annotation, its replies, and the span they mark. */
13
+ export interface Discussion {
14
+ key: string;
15
+ /** The annotation replies attach to. */
16
+ rootId: string;
17
+ /** The block the card renders under: where the span ends. */
18
+ blockIndex: number;
19
+ span: TextSpan;
20
+ annotations: Annotation[];
21
+ }
22
+
23
+ export function spanKey(span: TextSpan): string {
24
+ return `${span.start.blockIndex}:${span.start.char}:${span.end.blockIndex}:${span.end.char}`;
25
+ }
26
+
27
+ /**
28
+ * Roots group by the span they resolved to (two roots on the very same text
29
+ * read as one conversation); replies join the discussion their replyTo names, or
30
+ * stand as roots when that root is gone.
31
+ */
32
+ export function discussionsFrom(session: ReviewSession, marks: Map<number, Mark[]>): Discussion[] {
33
+ const byId = new Map(session.annotations.map((annotation) => [annotation.id, annotation]));
34
+ const spanOf = new Map<string, TextSpan>();
35
+
36
+ for (const [displayIndex, blockMarks] of marks) {
37
+ for (const mark of blockMarks) {
38
+ if (!mark.annotationId || spanOf.has(mark.annotationId)) continue;
39
+ spanOf.set(
40
+ mark.annotationId,
41
+ mark.span ?? {
42
+ start: { blockIndex: displayIndex, char: mark.start },
43
+ end: { blockIndex: displayIndex, char: mark.end },
44
+ },
45
+ );
46
+ }
47
+ }
48
+ const resolved = [...spanOf.keys()].map((id) => byId.get(id)!).filter(Boolean);
49
+ const isRoot = (annotation: Annotation): boolean =>
50
+ annotation.replyTo === undefined || !spanOf.has(annotation.replyTo);
51
+ const grouped = new Map<string, Discussion>();
52
+ const discussionOfRoot = new Map<string, Discussion>();
53
+
54
+ for (const annotation of resolved.filter(isRoot)) {
55
+ const span = spanOf.get(annotation.id)!;
56
+ const key = spanKey(span);
57
+ const discussion = grouped.get(key) ?? {
58
+ key,
59
+ rootId: annotation.id,
60
+ blockIndex: span.end.blockIndex,
61
+ span,
62
+ annotations: [],
63
+ };
64
+
65
+ discussion.annotations.push(annotation);
66
+ grouped.set(key, discussion);
67
+ discussionOfRoot.set(annotation.id, discussion);
68
+ }
69
+ for (const annotation of resolved.filter((candidate) => !isRoot(candidate))) {
70
+ discussionOfRoot.get(annotation.replyTo!)?.annotations.push(annotation);
71
+ }
72
+ for (const discussion of grouped.values()) {
73
+ discussion.annotations.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
74
+ }
75
+
76
+ return [...grouped.values()].toSorted((left, right) =>
77
+ comparePositions(left.span.start, right.span.start),
78
+ );
79
+ }
@@ -71,10 +71,23 @@ function baseController(): ReviewController {
71
71
  dismissToast: mock(),
72
72
  display: mock(() => []),
73
73
  rows: mock(() => []),
74
+ setFileCollapsed: mock(),
75
+ isFileCollapsed: mock(() => false),
76
+ setFileExpanded: mock(),
77
+ isFileExpanded: mock(() => false),
78
+ canExpandFile: mock(() => false),
79
+ copyFilePath: mock(),
80
+ fileStats: mock(() => new Map()),
74
81
  files: mock(() => []),
75
82
  working: mock(() => ""),
83
+ projectFiles: mock(() => Promise.resolve<string[]>([])),
84
+ readFile: mock(() => Promise.resolve<string | null>(null)),
85
+ repoFiles: mock(() => Promise.resolve<string[]>([])),
86
+ repoReadFile: mock(() => Promise.resolve<string | null>(null)),
87
+ repoChanges: mock(() => Promise.resolve([])),
76
88
  open: mock(),
77
89
  deleteSession: mock(),
90
+ renameSession: mock(),
78
91
  setSelfName: mock(),
79
92
  cut: mock(),
80
93
  toggleRejectHunk: mock(),
@@ -106,7 +119,6 @@ function baseController(): ReviewController {
106
119
  finishReview: mock(),
107
120
  dismissCompletion: mock(),
108
121
  optInAutoClose: mock(),
109
- saveReviewPanel: mock(),
110
122
  };
111
123
  }
112
124
 
@@ -123,9 +135,6 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
123
135
  inboxCursor: 0,
124
136
  mode: { type: "normal" },
125
137
  session: null,
126
- reviewMode: "expanded",
127
- reviewWidth: 34,
128
- terminalWidth: 120,
129
138
  focusedAnnotationId: undefined,
130
139
  selectedCurationId: undefined,
131
140
  railTab: "review",
@@ -133,13 +142,11 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
133
142
  authorNames: {},
134
143
  quickActions: [],
135
144
  renameAuthor: mock(),
145
+ renameThread: mock(),
136
146
  liveInput: { current: "" },
137
- reviewWidthRef: { current: 34 },
138
147
  setCursor: mock(),
139
148
  setInboxCursor: mock(),
140
149
  setMode: mock(),
141
- setReviewMode: mock(),
142
- setReviewWidth: mock(),
143
150
  setRailTab: mock(),
144
151
  setSelectedEntryId: mock(),
145
152
  setFocusedAnnotationId: mock(),
@@ -148,6 +155,7 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
148
155
  selectCardFromDocument: mock(),
149
156
  runEditorHandOff: mock(),
150
157
  openCardEdit: mock(),
158
+ toggleDiffView: mock(),
151
159
  ...overrides,
152
160
  };
153
161
  }
@@ -349,17 +357,15 @@ describe("marker-actions popover", () => {
349
357
  });
350
358
 
351
359
  describe("openSubmit", () => {
352
- test("force-opens the review rail so the confirm card can never be hidden", () => {
360
+ test("opens the submit confirm with the default verdict", () => {
353
361
  // Arrange
354
- const deps = makeDeps({ session: sessionWith([]), reviewMode: "hidden" });
362
+ const deps = makeDeps({ session: sessionWith([]) });
355
363
  const dispatch = createIntentDispatch(deps);
356
364
 
357
365
  // Act
358
366
  dispatch({ type: "openSubmit" });
359
367
 
360
368
  // Assert
361
- expect(deps.setReviewMode).toHaveBeenCalledWith("expanded");
362
- expect(deps.setRailTab).toHaveBeenCalledWith("review");
363
369
  expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "approve", summary: "" });
364
370
  });
365
371
 
@@ -436,6 +442,20 @@ describe("restoreCuration", () => {
436
442
  });
437
443
  });
438
444
 
445
+ describe("toggleDiffView", () => {
446
+ test("delegates to the App-owned toggle", () => {
447
+ // Arrange
448
+ const deps = makeDeps();
449
+ const dispatch = createIntentDispatch(deps);
450
+
451
+ // Act
452
+ dispatch({ type: "toggleDiffView" });
453
+
454
+ // Assert
455
+ expect(deps.toggleDiffView).toHaveBeenCalled();
456
+ });
457
+ });
458
+
439
459
  describe("exit", () => {
440
460
  test("calls the exit hook with 0", () => {
441
461
  // Arrange