@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.91

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 (316) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +42 -34
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +259 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +330 -161
  16. package/src/App.prototype-markdown.test.tsx +128 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +264 -0
  23. package/src/App.test.tsx +402 -126
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1406 -1060
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +787 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +577 -0
  35. package/src/app-view-model.ts +168 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/client-extension-registry.test.tsx +61 -0
  39. package/src/client-extension-registry.ts +159 -0
  40. package/src/clipboard.test.ts +23 -0
  41. package/src/clipboard.ts +33 -0
  42. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  43. package/src/components/AnnotatableFileView.test.tsx +102 -0
  44. package/src/components/AnnotatableFileView.tsx +134 -0
  45. package/src/components/AnnotationCards.stories.tsx +86 -0
  46. package/src/components/AnnotationCards.tsx +341 -0
  47. package/src/components/AnsiScreen.stories.tsx +12 -0
  48. package/src/components/AnsiScreen.tsx +28 -0
  49. package/src/components/AppHeader.stories.tsx +50 -0
  50. package/src/components/AppHeader.tsx +83 -0
  51. package/src/components/AppShell.brand.test.tsx +58 -0
  52. package/src/components/AppShell.extensions.test.tsx +191 -0
  53. package/src/components/AppShell.stories.tsx +89 -0
  54. package/src/components/AppShell.tsx +362 -0
  55. package/src/components/AppShell.zoom.test.tsx +135 -0
  56. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  57. package/src/components/BareWorkbenchFileView.tsx +64 -0
  58. package/src/components/Breadcrumb.stories.tsx +19 -0
  59. package/src/components/Breadcrumb.tsx +45 -0
  60. package/src/components/ChangesColumn.stories.tsx +44 -0
  61. package/src/components/ChangesColumn.tsx +199 -0
  62. package/src/components/ChangesFileTree.test.tsx +61 -0
  63. package/src/components/CodeBlock.stories.tsx +42 -0
  64. package/src/components/CodeBlock.tsx +84 -0
  65. package/src/components/CompletionOverlay.stories.tsx +37 -0
  66. package/src/components/CompletionOverlay.tsx +77 -0
  67. package/src/components/ConfirmCard.stories.tsx +37 -0
  68. package/src/components/ConfirmCard.tsx +150 -0
  69. package/src/components/ConfirmDialog.stories.tsx +21 -0
  70. package/src/components/ConfirmDialog.tsx +72 -0
  71. package/src/components/DiffContentView.scroll.test.tsx +149 -0
  72. package/src/components/DiffContentView.split.test.tsx +140 -0
  73. package/src/components/DiffContentView.stories.tsx +80 -0
  74. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  75. package/src/components/DiffContentView.tsx +906 -0
  76. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  77. package/src/components/DiscussionMarkerRail.tsx +186 -0
  78. package/src/components/EditorGrid.stories.tsx +59 -0
  79. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  80. package/src/components/EditorGrid.tsx +426 -0
  81. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  82. package/src/components/ExtensionZone.stories.tsx +28 -0
  83. package/src/components/ExtensionZone.tsx +145 -0
  84. package/src/components/FileContentsView.stories.tsx +16 -0
  85. package/src/components/FileContentsView.test.tsx +29 -0
  86. package/src/components/FileContentsView.tsx +98 -0
  87. package/src/components/GridTabContent.stories.tsx +59 -0
  88. package/src/components/GridTabContent.tsx +182 -0
  89. package/src/components/HandleChipsInput.stories.tsx +24 -0
  90. package/src/components/HandleChipsInput.test.tsx +35 -0
  91. package/src/components/HandleChipsInput.tsx +145 -0
  92. package/src/components/KeybindsDialog.stories.tsx +31 -0
  93. package/src/components/KeybindsDialog.tsx +103 -0
  94. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  95. package/src/components/MarkdownGridBlock.tsx +72 -0
  96. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  97. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  98. package/src/components/MarkdownThreadEditor.tsx +162 -0
  99. package/src/components/MenuBar.stories.tsx +28 -0
  100. package/src/components/MenuBar.tsx +44 -0
  101. package/src/components/NavModeHint.stories.tsx +40 -0
  102. package/src/components/NavModeHint.tsx +31 -0
  103. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  104. package/src/components/OverlayScrollbar.test.tsx +139 -0
  105. package/src/components/OverlayScrollbar.tsx +129 -0
  106. package/src/components/PaneGrid.stories.tsx +84 -0
  107. package/src/components/PaneGrid.tsx +128 -0
  108. package/src/components/PanelColumn.stories.tsx +25 -0
  109. package/src/components/PanelColumn.tsx +98 -0
  110. package/src/components/ProjectTreeView.stories.tsx +25 -0
  111. package/src/components/ProjectTreeView.test.tsx +75 -0
  112. package/src/components/ProjectTreeView.tsx +125 -0
  113. package/src/components/PromptDialog.stories.tsx +36 -0
  114. package/src/components/PromptDialog.test.tsx +61 -0
  115. package/src/components/PromptDialog.tsx +99 -0
  116. package/src/components/PrototypeContentView.stories.tsx +19 -0
  117. package/src/components/PrototypeContentView.tsx +446 -0
  118. package/src/components/RootOverlay.stories.tsx +45 -0
  119. package/src/components/RootOverlay.tsx +48 -0
  120. package/src/components/ScrollArea.stories.tsx +20 -0
  121. package/src/components/ScrollArea.tsx +44 -0
  122. package/src/components/SettingsDialog.stories.tsx +117 -0
  123. package/src/components/SettingsDialog.tsx +186 -0
  124. package/src/components/SettingsRows.stories.tsx +18 -0
  125. package/src/components/SettingsRows.tsx +112 -0
  126. package/src/components/ShareDialog.stories.tsx +121 -0
  127. package/src/components/ShareDialog.tsx +552 -0
  128. package/src/components/ShellHeader.stories.tsx +50 -0
  129. package/src/components/ShellHeader.tsx +137 -0
  130. package/src/components/SlashComposer.stories.tsx +37 -0
  131. package/src/components/SlashComposer.tsx +87 -0
  132. package/src/components/ThreadFooter.stories.tsx +30 -0
  133. package/src/components/ThreadFooter.test.tsx +47 -0
  134. package/src/components/ThreadFooter.tsx +76 -0
  135. package/src/components/ThreadTree.stories.tsx +46 -0
  136. package/src/components/ThreadTree.tsx +255 -0
  137. package/src/components/ThreadView.selection.test.tsx +951 -0
  138. package/src/components/ThreadView.slash.test.tsx +146 -0
  139. package/src/components/ThreadView.stories.tsx +26 -0
  140. package/src/components/ThreadView.tsx +525 -0
  141. package/src/components/Toast.stories.tsx +19 -0
  142. package/src/components/Toast.tsx +52 -0
  143. package/src/components/Tooltip.stories.tsx +26 -0
  144. package/src/components/Tooltip.tsx +72 -0
  145. package/src/components/TreePane.stories.tsx +106 -0
  146. package/src/components/TreePane.tsx +157 -0
  147. package/src/components/WalkWizard.stories.tsx +100 -0
  148. package/src/components/WalkWizard.tsx +150 -0
  149. package/src/components/WelcomePlayground.stories.tsx +12 -0
  150. package/src/components/WelcomePlayground.test.tsx +49 -0
  151. package/src/components/WelcomePlayground.tsx +139 -0
  152. package/src/components/WorkspacePanels.stories.tsx +36 -0
  153. package/src/components/WorkspacePanels.tsx +116 -0
  154. package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
  155. package/src/components/diff-content-view-layout.test.ts +198 -0
  156. package/src/components/diff-content-view-layout.ts +84 -0
  157. package/src/components/editor-grid.test.ts +86 -0
  158. package/src/components/editor-grid.ts +138 -0
  159. package/src/components/file-tree.test.ts +60 -0
  160. package/src/components/file-tree.ts +112 -0
  161. package/src/components/menu-control.stories.tsx +35 -0
  162. package/src/components/menu-control.tsx +43 -0
  163. package/src/components/pages/Workspace.stories.tsx +181 -0
  164. package/src/components/primitives/Button.stories.tsx +32 -0
  165. package/src/components/primitives/Button.tsx +59 -0
  166. package/src/components/primitives/Card.stories.tsx +25 -0
  167. package/src/components/primitives/Card.tsx +63 -0
  168. package/src/components/primitives/Dialog.stories.tsx +24 -0
  169. package/src/components/primitives/Dialog.test.tsx +37 -0
  170. package/src/components/primitives/Dialog.tsx +71 -0
  171. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  172. package/src/components/primitives/DialogActions.tsx +39 -0
  173. package/src/components/primitives/IconButton.stories.tsx +36 -0
  174. package/src/components/primitives/IconButton.tsx +62 -0
  175. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  176. package/src/components/primitives/StatusBar.tsx +23 -0
  177. package/src/components/primitives/Switch.stories.tsx +17 -0
  178. package/src/components/primitives/Switch.tsx +32 -0
  179. package/src/components/primitives/Tabs.stories.tsx +29 -0
  180. package/src/components/primitives/Tabs.tsx +102 -0
  181. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  182. package/src/components/primitives/Toolbar.tsx +14 -0
  183. package/src/components/primitives/Tree.stories.tsx +124 -0
  184. package/src/components/primitives/Tree.tsx +100 -0
  185. package/src/components/primitives/frame.test.ts +12 -0
  186. package/src/components/primitives/frame.ts +17 -0
  187. package/src/components/primitives/icons.ts +61 -0
  188. package/src/components/primitives/tree-model.test.ts +99 -0
  189. package/src/components/primitives/tree-model.ts +137 -0
  190. package/src/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/components/quick-actions-editor.test.tsx +117 -0
  192. package/src/components/quick-actions-editor.tsx +226 -0
  193. package/src/components/share-dialog-store.ts +58 -0
  194. package/src/components/stories-app.tsx +193 -0
  195. package/src/components/stories.test.tsx +74 -0
  196. package/src/components/story-fixtures.ts +102 -0
  197. package/src/components/story-tree.test.ts +108 -0
  198. package/src/components/story.ts +159 -0
  199. package/src/components/syntax-highlight.ts +133 -0
  200. package/src/components/terminal-pane.test.tsx +40 -0
  201. package/src/components/terminal-pane.ts +199 -0
  202. package/src/components/theme-context.stories.tsx +46 -0
  203. package/src/components/theme-context.tsx +37 -0
  204. package/src/components/thread-tree.test.ts +134 -0
  205. package/src/components/thread-tree.ts +109 -0
  206. package/src/components/truncate-text.ts +7 -0
  207. package/src/components/truncate-title.test.ts +31 -0
  208. package/src/components/truncate-title.ts +15 -0
  209. package/src/config-identity.test.ts +78 -0
  210. package/src/config.test.ts +579 -34
  211. package/src/config.ts +533 -57
  212. package/src/diff-fold.test.ts +78 -0
  213. package/src/diff-fold.ts +126 -0
  214. package/src/diff-hunk-curate.test.ts +315 -0
  215. package/src/diff-hunk-curate.ts +153 -0
  216. package/src/diff-intraline.test.ts +238 -0
  217. package/src/diff-intraline.ts +236 -0
  218. package/src/diff-syntax.test.ts +112 -0
  219. package/src/diff-syntax.ts +179 -0
  220. package/src/discussions.ts +79 -0
  221. package/src/editor.test.ts +149 -0
  222. package/src/editor.ts +139 -9
  223. package/src/ghostty-terminal.test.ts +85 -0
  224. package/src/ghostty-terminal.ts +199 -0
  225. package/src/github-identity.test.ts +58 -0
  226. package/src/github-identity.ts +41 -0
  227. package/src/herdr.test.ts +9 -34
  228. package/src/herdr.ts +25 -21
  229. package/src/index.ts +29 -0
  230. package/src/integrations.test.ts +41 -0
  231. package/src/integrations.ts +32 -0
  232. package/src/intent-dispatch.test.ts +724 -0
  233. package/src/intent-dispatch.ts +690 -0
  234. package/src/key-bindings.test.ts +156 -0
  235. package/src/key-bindings.ts +459 -0
  236. package/src/keymap.test.ts +456 -115
  237. package/src/keymap.ts +315 -81
  238. package/src/kitty-image.test.ts +66 -0
  239. package/src/kitty-image.ts +138 -0
  240. package/src/launch-layout.test.ts +78 -0
  241. package/src/launch-layout.ts +62 -0
  242. package/src/mark-runs.ts +121 -0
  243. package/src/markdown-editor-syntax-style.ts +50 -0
  244. package/src/markdown-grid.test.ts +101 -0
  245. package/src/markdown-grid.ts +203 -0
  246. package/src/markdown-highlight.test.ts +61 -0
  247. package/src/markdown-highlight.ts +122 -0
  248. package/src/pasted-image.test.ts +67 -0
  249. package/src/pasted-image.ts +60 -0
  250. package/src/perf/otlp-trace.test.ts +116 -0
  251. package/src/perf/otlp-trace.ts +130 -0
  252. package/src/perf/perf-report.ts +17 -0
  253. package/src/perf/perf-timings.test.ts +18 -0
  254. package/src/perf/perf-timings.ts +38 -0
  255. package/src/prototype-browser.test.ts +81 -0
  256. package/src/prototype-browser.ts +329 -0
  257. package/src/prototype-pixels.tsx +35 -0
  258. package/src/pty.test.ts +81 -0
  259. package/src/pty.ts +245 -0
  260. package/src/ready-signal.test.ts +85 -0
  261. package/src/ready-signal.ts +52 -0
  262. package/src/run.ts +85 -12
  263. package/src/serve.test.ts +24 -12
  264. package/src/serve.ts +52 -10
  265. package/src/share.ts +237 -0
  266. package/src/skills.test.ts +74 -0
  267. package/src/skills.ts +75 -0
  268. package/src/slash-palette.test.ts +103 -0
  269. package/src/slash-palette.ts +129 -0
  270. package/src/split-diff.test.ts +110 -0
  271. package/src/split-diff.ts +149 -0
  272. package/src/test-support.test.ts +264 -0
  273. package/src/test-support.ts +369 -0
  274. package/src/theme-presets.test.ts +51 -0
  275. package/src/theme-presets.ts +219 -0
  276. package/src/theme.ts +88 -19
  277. package/src/thread-chords.test.ts +79 -0
  278. package/src/thread-chords.ts +163 -0
  279. package/src/thread-code-syntax.ts +45 -0
  280. package/src/thread-controller.curate.test.ts +476 -0
  281. package/src/thread-controller.derivation.test.ts +62 -0
  282. package/src/thread-controller.live-diff.test.ts +322 -0
  283. package/src/thread-controller.optimistic.test.ts +69 -0
  284. package/src/thread-controller.share.test.ts +561 -0
  285. package/src/thread-controller.switch-cache.test.ts +176 -0
  286. package/src/thread-controller.tree.test.ts +261 -0
  287. package/src/thread-controller.ts +2050 -0
  288. package/src/thread-selection.test.ts +210 -0
  289. package/src/thread-selection.ts +243 -0
  290. package/src/tree-view.test.ts +128 -0
  291. package/src/tree-view.ts +153 -0
  292. package/src/use-annotation-surface.tsx +975 -0
  293. package/src/use-changes-workbench.ts +190 -0
  294. package/src/use-frame-measure.ts +41 -0
  295. package/src/use-remember-layout.ts +31 -0
  296. package/src/use-repo-changes.ts +87 -0
  297. package/src/use-settings-dialog.tsx +364 -0
  298. package/src/use-terminal-virtualizer.ts +259 -0
  299. package/src/use-thread-body-editing.tsx +58 -0
  300. package/src/version.ts +5 -0
  301. package/src/view-diff.test.ts +279 -9
  302. package/src/view-diff.ts +460 -24
  303. package/src/view-plan.regression.test.ts +60 -0
  304. package/src/view-plan.test.ts +545 -0
  305. package/src/view-plan.ts +1019 -0
  306. package/src/visual/contrast.test.ts +35 -0
  307. package/src/visual/contrast.ts +33 -0
  308. package/src/walk.test.ts +121 -0
  309. package/src/walk.ts +74 -0
  310. package/src/workbench-snapshot.test.ts +99 -0
  311. package/src/workbench-snapshot.ts +59 -0
  312. package/src/session-controller.ts +0 -398
  313. package/src/syntax.test.ts +0 -41
  314. package/src/syntax.ts +0 -88
  315. package/src/view.test.ts +0 -157
  316. package/src/view.ts +0 -364
@@ -0,0 +1,35 @@
1
+ import { expect, test } from "bun:test";
2
+ import { themeForName } from "../theme-presets";
3
+ import { contrastRatio, relativeLuminance, rgbColorFromHex } from "./contrast";
4
+
5
+ test("relativeLuminance ranks black below white", () => {
6
+ expect(relativeLuminance([0, 0, 0])).toBe(0);
7
+ expect(relativeLuminance([255, 255, 255])).toBeCloseTo(1, 5);
8
+ });
9
+
10
+ test("contrastRatio is symmetric and peaks at black on white", () => {
11
+ expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBeCloseTo(21, 0);
12
+ expect(contrastRatio([255, 255, 255], [0, 0, 0])).toBeCloseTo(21, 0);
13
+ expect(contrastRatio([100, 100, 100], [100, 100, 100])).toBeCloseTo(1, 5);
14
+ });
15
+
16
+ // The branded theme is terminal-through, so text sits on a real terminal background, not pure black or
17
+ // white; gate each theme against a representative one so a low-contrast token cannot hide behind an extreme.
18
+ const TERMINAL_BACKGROUND = {
19
+ dark: rgbColorFromHex("#1e1e2e"),
20
+ light: rgbColorFromHex("#f0f0f0"),
21
+ } as const;
22
+ const MIN_NORMAL_TEXT_CONTRAST = 4.5;
23
+
24
+ for (const mode of ["dark", "light"] as const) {
25
+ test(`${mode} theme keeps text, muted text, and accent legible on its terminal`, () => {
26
+ const theme = themeForName("cueloop", mode);
27
+ const background = TERMINAL_BACKGROUND[mode];
28
+
29
+ for (const token of [theme.text, theme.textMuted, theme.accent]) {
30
+ expect(contrastRatio(rgbColorFromHex(token), background)).toBeGreaterThanOrEqual(
31
+ MIN_NORMAL_TEXT_CONTRAST,
32
+ );
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,33 @@
1
+ /** WCAG relative luminance and contrast ratio for the visual-regression accessibility gate. */
2
+
3
+ export type RgbColor = readonly [number, number, number];
4
+
5
+ /** Parse a `#rrggbb` hexadecimal colour into an sRGB triple (channels 0-255). */
6
+ export function rgbColorFromHex(hex: string): RgbColor {
7
+ const value = hex.replace("#", "");
8
+
9
+ return [
10
+ Number.parseInt(value.slice(0, 2), 16),
11
+ Number.parseInt(value.slice(2, 4), 16),
12
+ Number.parseInt(value.slice(4, 6), 16),
13
+ ];
14
+ }
15
+
16
+ /** WCAG relative luminance of an sRGB colour, 0 (black) to 1 (white). */
17
+ export function relativeLuminance(rgb: RgbColor): number {
18
+ const linear = rgb.map((channel) => {
19
+ const ratio = channel / 255;
20
+
21
+ return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
22
+ });
23
+
24
+ return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!;
25
+ }
26
+
27
+ /** WCAG contrast ratio between two sRGB colours, 1 (identical) to 21 (black on white). */
28
+ export function contrastRatio(foreground: RgbColor, background: RgbColor): number {
29
+ const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background));
30
+ const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background));
31
+
32
+ return (lighter + 0.05) / (darker + 0.05);
33
+ }
@@ -0,0 +1,121 @@
1
+ /** Pure walk projections: file steps, resume index, viewed counts, notes. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import type { Annotation } from "@cueloop/schema";
5
+ import { diffRows } from "./view-diff";
6
+ import { firstUnviewedIndex, noteForFile, viewedCount, walkFiles, WALK_PREVIEW_ROWS } from "./walk";
7
+
8
+ const PATCH = `diff --git a/src/a.ts b/src/a.ts
9
+ index 111..222 100644
10
+ --- a/src/a.ts
11
+ +++ b/src/a.ts
12
+ @@ -1,3 +1,4 @@
13
+ export const a = 1;
14
+ -export const b = 2;
15
+ +export const b = 3;
16
+ +export const c = 4;
17
+ diff --git a/src/b.ts b/src/b.ts
18
+ index 333..444 100644
19
+ --- a/src/b.ts
20
+ +++ b/src/b.ts
21
+ @@ -1,2 +1,2 @@
22
+ // header
23
+ -old line
24
+ +new line
25
+ diff --git a/src/c.ts b/src/c.ts
26
+ index 555..666 100644
27
+ --- a/src/c.ts
28
+ +++ b/src/c.ts
29
+ @@ -1,1 +1,8 @@
30
+ -gone
31
+ +one
32
+ +two
33
+ +three
34
+ +four
35
+ +five
36
+ +six
37
+ +seven
38
+ `;
39
+
40
+ const rows = diffRows(PATCH);
41
+ const files = walkFiles(rows);
42
+
43
+ describe("walkFiles", () => {
44
+ test("one step per changed file, in patch order, with counted stats", () => {
45
+ expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
46
+ expect(files[0]).toMatchObject({ added: 2, removed: 1 });
47
+ expect(files[1]).toMatchObject({ added: 1, removed: 1 });
48
+ expect(files[2]).toMatchObject({ added: 7, removed: 1 });
49
+ });
50
+
51
+ test("previews carry signed changed lines and cap at the preview budget", () => {
52
+ expect(files[0]!.preview).toEqual([
53
+ { sign: "-", text: "export const b = 2;" },
54
+ { sign: "+", text: "export const b = 3;" },
55
+ { sign: "+", text: "export const c = 4;" },
56
+ ]);
57
+ expect(files[2]!.preview.length).toBe(WALK_PREVIEW_ROWS);
58
+ expect(files[2]!.preview[0]).toEqual({ sign: "-", text: "gone" });
59
+ });
60
+ });
61
+
62
+ describe("firstUnviewedIndex", () => {
63
+ test("starts at the first file when nothing is viewed", () => {
64
+ expect(firstUnviewedIndex(files, new Set())).toBe(0);
65
+ });
66
+
67
+ test("skips viewed files - a resumed review lands on its first gap", () => {
68
+ expect(firstUnviewedIndex(files, new Set(["src/a.ts"]))).toBe(1);
69
+ expect(firstUnviewedIndex(files, new Set(["src/a.ts", "src/b.ts"]))).toBe(2);
70
+ // a viewed file later in the list does not block the earlier gap
71
+ expect(firstUnviewedIndex(files, new Set(["src/c.ts"]))).toBe(0);
72
+ });
73
+
74
+ test("all viewed resumes on the end card, not the first file", () => {
75
+ expect(firstUnviewedIndex(files, new Set(["src/a.ts", "src/b.ts", "src/c.ts"]))).toBe(
76
+ files.length,
77
+ );
78
+ });
79
+ });
80
+
81
+ describe("viewedCount", () => {
82
+ test("counts only paths present in the current file set", () => {
83
+ expect(viewedCount(files, new Set(["src/a.ts", "src/gone-in-rev-2.ts"]))).toBe(1);
84
+ });
85
+ });
86
+
87
+ describe("noteForFile", () => {
88
+ const note = (path: string, body: string): Annotation => ({
89
+ id: `a_${path}`,
90
+ kind: "note",
91
+ anchor: { quote: path, prefix: "", suffix: "" },
92
+ body,
93
+ createdAt: "now",
94
+ });
95
+
96
+ test("finds the note anchored at the file path", () => {
97
+ // Arrange
98
+ const annotations = [
99
+ note("src/a.ts", "Renames b and adds c."),
100
+ note("src/b.ts", "Swaps the line."),
101
+ ];
102
+
103
+ // Assert
104
+ expect(noteForFile(annotations, "src/b.ts")).toBe("Swaps the line.");
105
+ expect(noteForFile(annotations, "src/c.ts")).toBeUndefined();
106
+ });
107
+
108
+ test("ignores non-note annotations that happen to quote the path", () => {
109
+ // Arrange
110
+ const comment: Annotation = {
111
+ id: "a_1",
112
+ kind: "comment",
113
+ anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
114
+ body: "not a note",
115
+ createdAt: "now",
116
+ };
117
+
118
+ // Assert
119
+ expect(noteForFile([comment], "src/a.ts")).toBeUndefined();
120
+ });
121
+ });
package/src/walk.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Guided-walk projections: pure helpers over the diff row model - no
3
+ * IO, no React. The walk steps through the changed files in patch order;
4
+ * which files were seen lives on the session record (viewedPaths), which
5
+ * file is on screen is view state in the session controller.
6
+ */
7
+
8
+ import type { Annotation } from "@cueloop/schema";
9
+ import type { DiffRow } from "./view-diff";
10
+
11
+ /** One wizard step: a changed file with its stats and a short diff preview. */
12
+ export interface WalkFile {
13
+ path: string;
14
+ added: number;
15
+ removed: number;
16
+ /** The first changed lines, signed (+/-), for the card's diff preview. */
17
+ preview: { sign: "+" | "-"; text: string }[];
18
+ }
19
+
20
+ /** Preview lines per card: enough to recognize the change, never a full diff. */
21
+ export const WALK_PREVIEW_ROWS = 5;
22
+
23
+ /** Collapse the diff rows into one walk step per changed file, in patch order. */
24
+ export function walkFiles(rows: DiffRow[]): WalkFile[] {
25
+ const files: WalkFile[] = [];
26
+ const byPath = new Map<string, WalkFile>();
27
+
28
+ for (const row of rows) {
29
+ let file = byPath.get(row.file);
30
+
31
+ if (!file) {
32
+ file = { path: row.file, added: 0, removed: 0, preview: [] };
33
+ byPath.set(row.file, file);
34
+ files.push(file);
35
+ }
36
+ if (row.kind !== "add" && row.kind !== "del") continue;
37
+ if (row.kind === "add") file.added += 1;
38
+ else file.removed += 1;
39
+ if (file.preview.length < WALK_PREVIEW_ROWS) {
40
+ file.preview.push({
41
+ sign: row.kind === "add" ? "+" : "-",
42
+ text: row.text.replace(/\n$/, ""),
43
+ });
44
+ }
45
+ }
46
+
47
+ return files;
48
+ }
49
+
50
+ /**
51
+ * Where the walk resumes: the first file not yet marked viewed. All files
52
+ * viewed = the end card (index === files.length), so a finished walk reopens
53
+ * on its summary instead of restarting.
54
+ */
55
+ export function firstUnviewedIndex(files: WalkFile[], viewedPaths: ReadonlySet<string>): number {
56
+ const index = files.findIndex((file) => !viewedPaths.has(file.path));
57
+
58
+ return index === -1 ? files.length : index;
59
+ }
60
+
61
+ /** Viewed count against the CURRENT file set - stale paths never inflate it. */
62
+ export function viewedCount(files: WalkFile[], viewedPaths: ReadonlySet<string>): number {
63
+ return files.filter((file) => viewedPaths.has(file.path)).length;
64
+ }
65
+
66
+ /**
67
+ * The agent's note for a file: an annotation with kind "note" whose anchor
68
+ * quote is the file path (the note contract in the shared review core).
69
+ */
70
+ export function noteForFile(annotations: Annotation[], path: string): string | undefined {
71
+ return annotations.find(
72
+ (annotation) => annotation.kind === "note" && annotation.anchor.quote === path,
73
+ )?.body;
74
+ }
@@ -0,0 +1,99 @@
1
+ /** Sharing/serving a workbench thread freezes its live working-tree diff into the artifact so a remote
2
+ * reviewer, who cannot see the owner's tree, reads a stable snapshot; any other thread passes through. */
3
+
4
+ import { describe, expect, mock, test } from "bun:test";
5
+ import { historyFromLinear, viewFollowing, type Thread } from "@cueloop/schema";
6
+ import { snapshotWorkbench } from "./workbench-snapshot";
7
+
8
+ const AT = "2026-01-01T00:00:00.000Z";
9
+
10
+ const STALE_FILES = [
11
+ { path: "src/a.ts", oldContents: "a\n", newContents: "b\n", status: "modified" as const },
12
+ ];
13
+ const LIVE = {
14
+ patch: "FRESH PATCH",
15
+ files: [
16
+ { path: "src/a.ts", oldContents: "a\n", newContents: "c\n", status: "modified" as const },
17
+ ],
18
+ };
19
+
20
+ function diffThread(meta: Thread["artifact"]["meta"]): Thread {
21
+ return {
22
+ schemaVersion: "1",
23
+ id: "ses_wb",
24
+ workspace: { repoRoot: "/repo", branch: "main" },
25
+ artifact: { type: "diff", content: "STALE PATCH", files: STALE_FILES, meta },
26
+ revisions: [{ revision: 1, content: "STALE PATCH", submittedAt: AT }],
27
+ annotations: [
28
+ {
29
+ id: "n1",
30
+ kind: "comment",
31
+ anchor: { quote: "b", prefix: "", suffix: "" },
32
+ body: "why?",
33
+ createdAt: AT,
34
+ },
35
+ ],
36
+ message: null,
37
+ status: "pending",
38
+ createdAt: AT,
39
+ };
40
+ }
41
+
42
+ describe("snapshotWorkbench", () => {
43
+ test("freezes a workbench thread's live diff and drops the workbench flag", async () => {
44
+ const frozen = await snapshotWorkbench(
45
+ diffThread({ workbench: true, title: "Workbench" }),
46
+ async () => LIVE,
47
+ );
48
+
49
+ // the live diff is pinned into the artifact
50
+ expect(frozen.artifact.content).toBe("FRESH PATCH");
51
+ expect(frozen.artifact.files).toEqual(LIVE.files);
52
+ // the snapshot marker pins the render; the workbench marker stays so file-targeted feedback still paints
53
+ expect(frozen.artifact.meta.snapshot).toBe(true);
54
+ expect(frozen.artifact.meta.workbench).toBe(true);
55
+ expect(frozen.artifact.meta.title).toBe("Workbench");
56
+ // the thread is otherwise intact - same id, workspace, and annotations
57
+ expect(frozen.id).toBe("ses_wb");
58
+ expect(frozen.annotations).toHaveLength(1);
59
+ });
60
+
61
+ test("recaptures history so the share path rebuilds the fresh content, not the stale head", async () => {
62
+ // a workbench thread carries branch history whose head is the creation-time capture
63
+ const base = diffThread({ workbench: true });
64
+ const withHistory: Thread = { ...base, history: historyFromLinear(base) };
65
+
66
+ const frozen = await snapshotWorkbench(withHistory, async () => LIVE);
67
+
68
+ // viewFollowing (the share path) rebuilds content from history - it must yield the fresh patch
69
+ expect(viewFollowing(frozen).artifact.content).toBe("FRESH PATCH");
70
+ });
71
+
72
+ test("pins the captured source and revision in a shared workbench", async () => {
73
+ const frozen = await snapshotWorkbench(
74
+ diffThread({ workbench: true, vcs: "jj", vcsRevisionId: "old" }),
75
+ async () => ({
76
+ ...LIVE,
77
+ vcs: "jj",
78
+ source: { changeId: "change", revisionId: "new" },
79
+ }),
80
+ );
81
+
82
+ expect(frozen.artifact.meta.vcsRevisionId).toBe("new");
83
+ expect(frozen.revisions[0]).toMatchObject({
84
+ content: LIVE.patch,
85
+ files: LIVE.files,
86
+ source: { vcs: "jj", changeId: "change", revisionId: "new" },
87
+ });
88
+ });
89
+
90
+ test("passes a plain diff review through untouched and never queries the tree", async () => {
91
+ const session = diffThread({ title: "PR #7" });
92
+ const repoDiff = mock(async () => LIVE);
93
+
94
+ const result = await snapshotWorkbench(session, repoDiff);
95
+
96
+ expect(result).toBe(session);
97
+ expect(repoDiff).not.toHaveBeenCalled();
98
+ });
99
+ });
@@ -0,0 +1,59 @@
1
+ import {
2
+ recaptureMainHead,
3
+ type DiffFileContents,
4
+ type DiffSource,
5
+ type Thread,
6
+ } from "@cueloop/schema";
7
+
8
+ /** Reads the live working-tree diff for a repo root (the daemon's `repo.diff` RPC). */
9
+ export type RepoDiff = (
10
+ repoRoot: string,
11
+ vcs?: string,
12
+ ) => Promise<{
13
+ patch: string;
14
+ files: DiffFileContents[];
15
+ source?: Omit<DiffSource, "vcs">;
16
+ vcs?: string;
17
+ }>;
18
+
19
+ /**
20
+ * Freeze a workbench thread's live working-tree diff into its artifact for a remote reviewer, who cannot
21
+ * see the owner's tree. The `snapshot` marker pins the render to this captured diff while keeping the
22
+ * workbench marker, so the reviewer still sees the file-targeted feedback already on the thread. Any other
23
+ * thread is already stable and passes through; the owner's own session keeps the original live thread.
24
+ */
25
+ export async function snapshotWorkbench(session: Thread, repoDiff: RepoDiff): Promise<Thread> {
26
+ if (session.artifact.type !== "diff" || session.artifact.meta.workbench !== true) return session;
27
+
28
+ const { patch, files, source, vcs } = await repoDiff(
29
+ session.workspace.repoRoot,
30
+ session.artifact.meta.vcs,
31
+ );
32
+ const provider = vcs ?? session.artifact.meta.vcs;
33
+ const artifact = {
34
+ ...session.artifact,
35
+ content: patch,
36
+ files,
37
+ meta: {
38
+ ...session.artifact.meta,
39
+ snapshot: true,
40
+ vcs: provider,
41
+ vcsChangeId: source?.changeId,
42
+ vcsRevisionId: source?.revisionId,
43
+ },
44
+ };
45
+ // the share path rebuilds content from history; recapture the branch head so it matches the fresh patch
46
+ const history = session.history ? recaptureMainHead(session.history, patch) : session.history;
47
+ const revisions = session.revisions.map((revision, index) =>
48
+ index === session.revisions.length - 1
49
+ ? {
50
+ ...revision,
51
+ content: patch,
52
+ files,
53
+ source: provider && source ? { vcs: provider, ...source } : undefined,
54
+ }
55
+ : revision,
56
+ );
57
+
58
+ return { ...session, artifact, history, revisions };
59
+ }