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

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 +1402 -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 +774 -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 +28 -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 +67 -0
  241. package/src/launch-layout.ts +47 -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,322 @@
1
+ /**
2
+ * A non-diff thread (here a plan) still renders a real diff in the Changes view:
3
+ * the controller eagerly captures the live working-tree diff, so repoChanges()
4
+ * carries file contents and rows() derives from the patch - the same shape a
5
+ * cueloop diff pins, just computed live.
6
+ */
7
+
8
+ import { describe, expect, mock, test } from "bun:test";
9
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
10
+ import type { ThreadClient } from "@cueloop/daemon/client";
11
+ import { createReviewController, type ShareTransport } from "./thread-controller";
12
+ import { mergeFromShare } from "./share";
13
+
14
+ const AT = "2026-01-01T00:00:00.000Z";
15
+
16
+ const PATCH = `diff --git a/src/x.ts b/src/x.ts
17
+ --- a/src/x.ts
18
+ +++ b/src/x.ts
19
+ @@ -1,1 +1,1 @@
20
+ -const a = 1;
21
+ +const a = 2;
22
+ `;
23
+
24
+ const FILES = [
25
+ {
26
+ path: "src/x.ts",
27
+ oldContents: "const a = 1;\n",
28
+ newContents: "const a = 2;\n",
29
+ status: "modified" as const,
30
+ },
31
+ ];
32
+
33
+ const shareTransport: ShareTransport = {
34
+ publish: mock(async (session: Thread) => ({
35
+ line: `ssh p_${session.id}@cueloop.dev`,
36
+ copied: true,
37
+ })),
38
+ pull: mock(async () => planSession()),
39
+ push: mock(async () => {}),
40
+ watch: () => () => {},
41
+ revoke: async () => {},
42
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
43
+ formatShareLine: (id: string) => "ssh " + id + "@cueloop.dev",
44
+ collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
45
+ mergeFromShare,
46
+ };
47
+
48
+ function planSession(id = "ses_plan", repoRoot = "/repo"): Thread {
49
+ return {
50
+ schemaVersion: SCHEMA_VERSION,
51
+ id,
52
+ workspace: { repoRoot, branch: "main" },
53
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
54
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: AT }],
55
+ annotations: [],
56
+ message: null,
57
+ status: "pending",
58
+ createdAt: AT,
59
+ };
60
+ }
61
+
62
+ const unimplemented = (member: string) => () =>
63
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
64
+
65
+ function fakeClient(session: Thread): ThreadClient {
66
+ return {
67
+ onEvent: () => () => {},
68
+ subscribe: async () => {},
69
+ sessionGet: async () => session,
70
+ sessionList: async () => [session],
71
+ sessionComment: unimplemented("sessionComment"),
72
+ sessionAnnotate: unimplemented("sessionAnnotate"),
73
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
74
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
75
+ sessionCutBlock: unimplemented("sessionCutBlock"),
76
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
77
+ sessionCurate: unimplemented("sessionCurate"),
78
+ sessionSetAccess: unimplemented("sessionSetAccess"),
79
+ sessionNavigate: unimplemented("sessionNavigate"),
80
+ sessionBranch: unimplemented("sessionBranch"),
81
+ sessionSwitch: unimplemented("sessionSwitch"),
82
+ sessionLabel: unimplemented("sessionLabel"),
83
+ sessionFork: unimplemented("sessionFork"),
84
+ sessionSetViewed: unimplemented("sessionSetViewed"),
85
+ sessionSetTitle: unimplemented("sessionSetTitle"),
86
+ sessionSetShareId: unimplemented("sessionSetShareId"),
87
+ sessionSetShares: unimplemented("sessionSetShares"),
88
+ sessionMergeShared: unimplemented("sessionMergeShared"),
89
+ sessionDelete: unimplemented("sessionDelete"),
90
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
91
+ sessionSendMessage: unimplemented("sessionSendMessage"),
92
+ repoDiff: async () => ({ patch: PATCH, files: FILES }),
93
+ close: () => {},
94
+ } satisfies ThreadClient;
95
+ }
96
+
97
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
98
+
99
+ describe("live working-tree diff for a non-diff thread", () => {
100
+ test("repoChanges carries file contents and rows derive from the patch", async () => {
101
+ const session = planSession();
102
+ const controller = createReviewController({
103
+ sessionId: session.id,
104
+ openClient: async () => fakeClient(session),
105
+ shareTransport,
106
+ });
107
+ controller.connect();
108
+ await tick();
109
+
110
+ // Act - the Changes navigator loads
111
+ const changes = await controller.repoChanges();
112
+
113
+ // Assert - real per-file contents, not empty stubs
114
+ expect(changes.map((file) => file.path)).toEqual(["src/x.ts"]);
115
+ expect(changes[0]!.newContents).toBe("const a = 2;\n");
116
+
117
+ // and the diff rows derive from the live patch, so an opened file tab shows a diff
118
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(true);
119
+
120
+ // the live per-file contents let a non-diff thread expand a folded file too
121
+ expect(controller.canExpandFile("src/x.ts")).toBe(true);
122
+
123
+ controller.close();
124
+ });
125
+
126
+ test("annotating the Changes diff stamps a file target anchored to the diff rows", async () => {
127
+ const session = planSession();
128
+ let sent: Annotation | undefined;
129
+ const client = {
130
+ ...fakeClient(session),
131
+ sessionComment: async (_id: string, annotation: Annotation) => {
132
+ sent = annotation;
133
+
134
+ return { ...session, annotations: [{ ...annotation, createdAt: AT }] };
135
+ },
136
+ } satisfies ThreadClient;
137
+ const controller = createReviewController({
138
+ sessionId: session.id,
139
+ openClient: async () => client,
140
+ shareTransport,
141
+ });
142
+ controller.connect();
143
+ await tick();
144
+ await controller.repoChanges();
145
+
146
+ // Act - comment the added line of the working-tree diff, as the Changes surface does
147
+ const rows = controller.rows();
148
+ const addIndex = rows.findIndex((row) => row.kind === "add");
149
+ const addText = rows[addIndex]!.text.replace(/\n$/, "");
150
+
151
+ controller.annotate("comment", addIndex, 0, addText.length, "eviction?", addIndex, {
152
+ kind: "file",
153
+ path: "",
154
+ rev: "worktree",
155
+ });
156
+
157
+ // Assert - the note carries the file target (path and rev filled from the row) and quotes the diff
158
+ expect(sent?.target).toEqual({ kind: "file", path: "src/x.ts", rev: "worktree" });
159
+ expect(sent?.anchor.quote).toContain("const a = 2");
160
+
161
+ controller.close();
162
+ });
163
+
164
+ test("a late diff response from a previous thread never overwrites the active one", async () => {
165
+ const sessionA = planSession("ses_A", "/repoA");
166
+ const sessionB = planSession("ses_B", "/repoB");
167
+ let resolveA: (diff: { patch: string; files: typeof FILES }) => void = () => {};
168
+ const pendingA = new Promise<{ patch: string; files: typeof FILES }>((resolve) => {
169
+ resolveA = resolve;
170
+ });
171
+ const diffB = {
172
+ patch: PATCH.replace(/x\.ts/g, "y.ts"),
173
+ files: [{ ...FILES[0]!, path: "src/y.ts" }],
174
+ };
175
+ const client = {
176
+ ...fakeClient(sessionA),
177
+ sessionList: async () => [sessionA, sessionB],
178
+ repoDiff: async (cwd: string) => (cwd === "/repoA" ? pendingA : diffB),
179
+ } satisfies ThreadClient;
180
+
181
+ const controller = createReviewController({
182
+ sessionId: sessionA.id,
183
+ openClient: async () => client,
184
+ shareTransport,
185
+ });
186
+ controller.connect();
187
+ await tick();
188
+
189
+ // thread A's diff is still in flight when the user switches to thread B
190
+ const inFlightA = controller.repoChanges();
191
+ controller.open("ses_B");
192
+ await controller.repoChanges();
193
+ // B's diff is showing; now A's stale response lands
194
+ resolveA({ patch: PATCH, files: FILES });
195
+ await inFlightA;
196
+
197
+ // the active thread keeps B's changes; A's late response is dropped
198
+ expect(controller.rows().some((row) => row.file === "src/y.ts")).toBe(true);
199
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(false);
200
+
201
+ controller.close();
202
+ });
203
+ });
204
+
205
+ const STALE_PATCH = `diff --git a/src/stale.ts b/src/stale.ts
206
+ --- a/src/stale.ts
207
+ +++ b/src/stale.ts
208
+ @@ -1,1 +1,1 @@
209
+ -const s = 1;
210
+ +const s = 2;
211
+ `;
212
+
213
+ const STALE_FILES = [
214
+ {
215
+ path: "src/stale.ts",
216
+ oldContents: "const s = 1;\n",
217
+ newContents: "const s = 2;\n",
218
+ status: "modified" as const,
219
+ },
220
+ ];
221
+
222
+ /** A `type:"diff"` thread with a captured snapshot; `meta.workbench` decides frozen vs live. */
223
+ function diffSession(meta: Thread["artifact"]["meta"], id = "ses_diff"): Thread {
224
+ return {
225
+ schemaVersion: SCHEMA_VERSION,
226
+ id,
227
+ workspace: { repoRoot: "/repo", branch: "main" },
228
+ artifact: { type: "diff", content: STALE_PATCH, files: STALE_FILES, meta },
229
+ revisions: [{ revision: 1, content: STALE_PATCH, submittedAt: AT }],
230
+ annotations: [],
231
+ message: null,
232
+ status: "pending",
233
+ createdAt: AT,
234
+ };
235
+ }
236
+
237
+ describe("frozen vs live diff by thread kind", () => {
238
+ test("a workbench thread renders the live working tree, not its captured snapshot", async () => {
239
+ const session = diffSession({ workbench: true, title: "Workbench" });
240
+ const controller = createReviewController({
241
+ sessionId: session.id,
242
+ openClient: async () => fakeClient(session),
243
+ shareTransport,
244
+ });
245
+ controller.connect();
246
+ await tick();
247
+
248
+ // the live repoDiff (src/x.ts) wins over the frozen capture (src/stale.ts)
249
+ const changes = await controller.repoChanges();
250
+
251
+ expect(changes.map((file) => file.path)).toEqual(["src/x.ts"]);
252
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(true);
253
+ expect(controller.rows().some((row) => row.file === "src/stale.ts")).toBe(false);
254
+
255
+ controller.close();
256
+ });
257
+
258
+ test("a plain diff review pins its captured snapshot and never queries the live tree", async () => {
259
+ const session = diffSession({});
260
+ const repoDiff = mock(async () => ({ patch: PATCH, files: FILES }));
261
+ const client = { ...fakeClient(session), repoDiff } satisfies ThreadClient;
262
+ const controller = createReviewController({
263
+ sessionId: session.id,
264
+ openClient: async () => client,
265
+ shareTransport,
266
+ });
267
+ controller.connect();
268
+ await tick();
269
+
270
+ const changes = await controller.repoChanges();
271
+
272
+ expect(changes.map((file) => file.path)).toEqual(["src/stale.ts"]);
273
+ expect(controller.rows().some((row) => row.file === "src/stale.ts")).toBe(true);
274
+ expect(repoDiff).not.toHaveBeenCalled();
275
+
276
+ controller.close();
277
+ });
278
+ });
279
+
280
+ const SERVED_PATCH = `diff --git a/src/served.ts b/src/served.ts
281
+ --- a/src/served.ts
282
+ +++ b/src/served.ts
283
+ @@ -1,1 +1,1 @@
284
+ -const served = 0;
285
+ +const served = 1;
286
+ `;
287
+
288
+ describe("serve mode pins the served thread to a frozen snapshot", () => {
289
+ test("the served artifact wins over the thread's own capture and the live tree", async () => {
290
+ const session = diffSession({ workbench: true });
291
+ const repoDiff = mock(async () => ({ patch: PATCH, files: FILES }));
292
+ const client = { ...fakeClient(session), repoDiff } satisfies ThreadClient;
293
+ const controller = createReviewController({
294
+ sessionId: session.id,
295
+ openClient: async () => client,
296
+ shareTransport,
297
+ servedArtifact: {
298
+ type: "diff",
299
+ content: SERVED_PATCH,
300
+ files: [
301
+ {
302
+ path: "src/served.ts",
303
+ oldContents: "const served = 0;\n",
304
+ newContents: "const served = 1;\n",
305
+ status: "modified",
306
+ },
307
+ ],
308
+ meta: { workbench: true, snapshot: true },
309
+ },
310
+ });
311
+ controller.connect();
312
+ await tick();
313
+
314
+ // the observer sees the frozen served snapshot, not the thread's stale capture nor the live tree
315
+ expect(controller.rows().some((row) => row.file === "src/served.ts")).toBe(true);
316
+ expect(controller.rows().some((row) => row.file === "src/stale.ts")).toBe(false);
317
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(false);
318
+ expect(repoDiff).not.toHaveBeenCalled();
319
+
320
+ controller.close();
321
+ });
322
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
3
+ import { withAnnotationUpserted } from "./thread-controller";
4
+
5
+ function threadFixture(annotations: Annotation[] = []): Thread {
6
+ return {
7
+ schemaVersion: SCHEMA_VERSION,
8
+ id: "ses_1",
9
+ workspace: { repoRoot: "/repo", branch: "main" },
10
+ artifact: { type: "plan", content: "# Plan\n\nShip it.\n", meta: {} },
11
+ revisions: [
12
+ { revision: 1, content: "# Plan\n\nShip it.\n", submittedAt: "2026-01-01T00:00:00.000Z" },
13
+ ],
14
+ annotations,
15
+ message: null,
16
+ status: "pending",
17
+ createdAt: "2026-01-01T00:00:00.000Z",
18
+ };
19
+ }
20
+
21
+ const existing: Annotation = {
22
+ id: "ann_1",
23
+ kind: "comment",
24
+ anchor: { quote: "Ship it.", prefix: "", suffix: "" },
25
+ body: "first",
26
+ createdAt: "2026-01-01T00:00:00.000Z",
27
+ };
28
+
29
+ describe("withAnnotationUpserted", () => {
30
+ test("a new comment is appended so it paints immediately", () => {
31
+ // Arrange
32
+ const session = threadFixture([existing]);
33
+ const wire = { id: "ann_2", kind: "comment", anchor: existing.anchor, body: "second" };
34
+
35
+ // Act
36
+ const next = withAnnotationUpserted(session, wire);
37
+
38
+ // Assert
39
+ expect(next.annotations.map((entry) => entry.id)).toEqual(["ann_1", "ann_2"]);
40
+ expect(next.annotations[1]!.body).toBe("second");
41
+ expect(next.annotations[1]!.createdAt).not.toBe("");
42
+ });
43
+
44
+ test("an edit replaces the note in place and keeps its timestamp", () => {
45
+ // Arrange
46
+ const session = threadFixture([existing]);
47
+ const wire = { id: "ann_1", kind: "comment", anchor: existing.anchor, body: "edited" };
48
+
49
+ // Act
50
+ const next = withAnnotationUpserted(session, wire);
51
+
52
+ // Assert - same position, same createdAt, new body (no reorder or timestamp flash)
53
+ expect(next.annotations.map((entry) => entry.id)).toEqual(["ann_1"]);
54
+ expect(next.annotations[0]!.body).toBe("edited");
55
+ expect(next.annotations[0]!.createdAt).toBe(existing.createdAt);
56
+ });
57
+
58
+ test("the input session is not mutated", () => {
59
+ // Arrange
60
+ const session = threadFixture([existing]);
61
+ const wire = { id: "ann_2", kind: "comment", anchor: existing.anchor, body: "second" };
62
+
63
+ // Act
64
+ withAnnotationUpserted(session, wire);
65
+
66
+ // Assert
67
+ expect(session.annotations).toHaveLength(1);
68
+ });
69
+ });