@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,176 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ SCHEMA_VERSION,
4
+ type DiffFileContents,
5
+ type HunkRejection,
6
+ type Thread,
7
+ } from "@cueloop/schema";
8
+ import { curateDiff } from "@cueloop/daemon/curate";
9
+ import type { ThreadClient } from "@cueloop/daemon/client";
10
+ import { createReviewController } from "./thread-controller";
11
+
12
+ const PATCH_A = `diff --git a/src/a.ts b/src/a.ts
13
+ index 111..222 100644
14
+ --- a/src/a.ts
15
+ +++ b/src/a.ts
16
+ @@ -1,3 +1,3 @@
17
+ export class A {
18
+ - value = 1;
19
+ + value = 2;
20
+ }
21
+ `;
22
+
23
+ const PATCH_B = `diff --git a/src/b.ts b/src/b.ts
24
+ index 333..444 100644
25
+ --- a/src/b.ts
26
+ +++ b/src/b.ts
27
+ @@ -1,3 +1,3 @@
28
+ export class B {
29
+ - flag = false;
30
+ + flag = true;
31
+ }
32
+ `;
33
+
34
+ function diffSession(id: string, patch: string, files: DiffFileContents[]): Thread {
35
+ return {
36
+ schemaVersion: SCHEMA_VERSION,
37
+ id,
38
+ workspace: { repoRoot: "/repo", branch: "main" },
39
+ artifact: { type: "diff", content: patch, meta: { title: id }, files },
40
+ revisions: [{ revision: 1, content: patch, submittedAt: "2026-01-01T00:00:00.000Z" }],
41
+ annotations: [],
42
+ message: null,
43
+ status: "pending",
44
+ createdAt: "2026-01-01T00:00:00.000Z",
45
+ };
46
+ }
47
+
48
+ const FILES_A: DiffFileContents[] = [
49
+ {
50
+ path: "src/a.ts",
51
+ oldContents: "export class A {\n value = 1;\n}\n",
52
+ newContents: "export class A {\n value = 2;\n}\n",
53
+ status: "modified",
54
+ },
55
+ ];
56
+ const FILES_B: DiffFileContents[] = [
57
+ {
58
+ path: "src/b.ts",
59
+ oldContents: "export class B {\n flag = false;\n}\n",
60
+ newContents: "export class B {\n flag = true;\n}\n",
61
+ status: "modified",
62
+ },
63
+ ];
64
+
65
+ const unimplemented = (member: string) => () =>
66
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
67
+
68
+ /** A fake client over a fixed set of sessions, so open(id) can switch between them from the inbox. */
69
+ function fakeClient(sessions: Thread[]): ThreadClient {
70
+ const byId = new Map(sessions.map((session) => [session.id, session]));
71
+
72
+ return {
73
+ onEvent: () => () => {},
74
+ subscribe: async () => {},
75
+ sessionGet: async (id: string) => byId.get(id)!,
76
+ sessionList: async () => [...byId.values()],
77
+ sessionCurate: mock(async (id: string, rejections: HunkRejection[]) => {
78
+ const session = byId.get(id)!;
79
+ const content = rejections.length
80
+ ? curateDiff(session.artifact.files!, rejections)
81
+ : undefined;
82
+ const next = {
83
+ ...session,
84
+ workingCopy: content,
85
+ curation: rejections.length ? rejections : undefined,
86
+ };
87
+
88
+ byId.set(id, next);
89
+
90
+ return next;
91
+ }),
92
+ sessionComment: unimplemented("sessionComment"),
93
+ sessionAnnotate: unimplemented("sessionAnnotate"),
94
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
95
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
96
+ sessionCutBlock: unimplemented("sessionCutBlock"),
97
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
98
+ sessionSetAccess: unimplemented("sessionSetAccess"),
99
+ sessionNavigate: unimplemented("sessionNavigate"),
100
+ sessionBranch: unimplemented("sessionBranch"),
101
+ sessionSwitch: unimplemented("sessionSwitch"),
102
+ sessionLabel: unimplemented("sessionLabel"),
103
+ sessionFork: unimplemented("sessionFork"),
104
+ sessionSetViewed: unimplemented("sessionSetViewed"),
105
+ sessionSetTitle: unimplemented("sessionSetTitle"),
106
+ sessionSetShareId: unimplemented("sessionSetShareId"),
107
+ sessionSetShares: unimplemented("sessionSetShares"),
108
+ sessionMergeShared: unimplemented("sessionMergeShared"),
109
+ sessionDelete: unimplemented("sessionDelete"),
110
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
111
+ sessionSendMessage: unimplemented("sessionSendMessage"),
112
+ close: () => {},
113
+ };
114
+ }
115
+
116
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
117
+
118
+ // rows for each single-change patch: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
119
+ describe("switching threads keeps the cached projection curatable", () => {
120
+ test("curation still works after switching away and back to a diff thread", async () => {
121
+ // Arrange - two diff threads in one inbox
122
+ const controller = createReviewController({
123
+ sessionId: "ses_a",
124
+ openClient: async () =>
125
+ fakeClient([
126
+ diffSession("ses_a", PATCH_A, FILES_A),
127
+ diffSession("ses_b", PATCH_B, FILES_B),
128
+ ]),
129
+ });
130
+
131
+ controller.connect();
132
+ await tick();
133
+
134
+ // Act - visit B, then return to A (A's projection now comes from the per-thread cache)
135
+ controller.open("ses_b");
136
+ await tick();
137
+ controller.open("ses_a");
138
+ await tick();
139
+
140
+ // Assert - the returned thread's file model parses lazily and curation lands
141
+ controller.toggleRejectChange(4);
142
+ await tick();
143
+
144
+ expect(controller.getSnapshot().toast).toBeNull();
145
+ expect(controller.getSnapshot().session?.workingCopy).toBe("");
146
+ });
147
+
148
+ test("returning to a diff thread shows the daemon's latest, not the stale sidebar copy", async () => {
149
+ // The sidebar list trails the daemon for a hot-reloading diff (its session.updated
150
+ // refreshes the open thread, not the list). Returning to it must revalidate.
151
+ const staleA = diffSession("ses_a", PATCH_A, FILES_A);
152
+ const freshA = diffSession("ses_a", PATCH_B, FILES_B);
153
+ const other = diffSession("ses_b", PATCH_B, FILES_B);
154
+ const base = fakeClient([staleA, other]);
155
+ const controller = createReviewController({
156
+ sessionId: "ses_a",
157
+ openClient: async () => ({
158
+ ...base,
159
+ // the sidebar keeps the stale copy; the daemon serves the fresh one
160
+ sessionList: async () => [staleA, other],
161
+ sessionGet: async (id: string) => (id === "ses_a" ? freshA : other),
162
+ }),
163
+ });
164
+
165
+ controller.connect();
166
+ await tick();
167
+
168
+ // Visit B, then return to A - open() paints the stale sidebar copy, then revalidates
169
+ controller.open("ses_b");
170
+ await tick();
171
+ controller.open("ses_a");
172
+ await tick();
173
+
174
+ expect(controller.getSnapshot().session?.artifact.content).toBe(PATCH_B);
175
+ });
176
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * The controller's tree primitives over a fake client: a move shows its path
3
+ * before the daemon answers, refusals surface as status, a fork opens the fork,
4
+ * and fork-and-share shares the fork while the current session stays put.
5
+ */
6
+
7
+ import { describe, expect, mock, test } from "bun:test";
8
+ import {
9
+ appendEntry,
10
+ createBranch,
11
+ historyFromLinear,
12
+ SCHEMA_VERSION,
13
+ switchBranch,
14
+ type Annotation,
15
+ type Thread,
16
+ } from "@cueloop/schema";
17
+ import type { ThreadClient } from "@cueloop/daemon/client";
18
+ import { createReviewController, type ShareTransport } from "./thread-controller";
19
+ import { mergeFromShare } from "./share";
20
+
21
+ const AT = "2026-01-01T00:00:00.000Z";
22
+
23
+ function annotation(id: string, body: string): Annotation {
24
+ return {
25
+ id,
26
+ kind: "comment",
27
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
28
+ body,
29
+ createdAt: AT,
30
+ };
31
+ }
32
+
33
+ /** A plan with one comment on main and its history. */
34
+ function sessionFixture(): Thread {
35
+ const session: Thread = {
36
+ schemaVersion: SCHEMA_VERSION,
37
+ id: "ses_1",
38
+ workspace: { repoRoot: "/repo", branch: "main" },
39
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
40
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: AT }],
41
+ annotations: [annotation("a1", "first")],
42
+ message: null,
43
+ status: "pending",
44
+ createdAt: AT,
45
+ };
46
+
47
+ session.history = historyFromLinear(session);
48
+
49
+ return session;
50
+ }
51
+
52
+ const publish = mock(async (session: Thread) => ({
53
+ line: `ssh p_${session.id}@cueloop.dev`,
54
+ copied: true,
55
+ }));
56
+ const shareTransport: ShareTransport = {
57
+ publish,
58
+ pull: mock(async () => sessionFixture()),
59
+ push: mock(async () => {}),
60
+ watch: () => () => {},
61
+ revoke: async () => {},
62
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
63
+ formatShareLine: (id: string) => "ssh " + id + "@cueloop.dev",
64
+ collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
65
+ mergeFromShare,
66
+ };
67
+
68
+ const unimplemented = (member: string) => () =>
69
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
70
+
71
+ /** Tree requests never resolve here: what the controller shows is its own guess. */
72
+ const pending = () => new Promise<Thread>(() => {});
73
+
74
+ function fakeClient(session: Thread) {
75
+ return {
76
+ onEvent: () => () => {},
77
+ subscribe: async () => {},
78
+ sessionGet: async () => session,
79
+ sessionList: async () => [session],
80
+ sessionComment: unimplemented("sessionComment"),
81
+ sessionAnnotate: unimplemented("sessionAnnotate"),
82
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
83
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
84
+ sessionCutBlock: unimplemented("sessionCutBlock"),
85
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
86
+ sessionCurate: unimplemented("sessionCurate"),
87
+ sessionSetAccess: unimplemented("sessionSetAccess"),
88
+ sessionNavigate: mock(pending),
89
+ sessionBranch: mock(pending),
90
+ sessionSwitch: mock(pending),
91
+ sessionLabel: mock(pending),
92
+ sessionFork: mock(async (id: string) => ({
93
+ ...sessionFixture(),
94
+ id: `${id}_fork`,
95
+ parentSessionId: id,
96
+ })),
97
+ sessionSetViewed: unimplemented("sessionSetViewed"),
98
+ sessionSetTitle: unimplemented("sessionSetTitle"),
99
+ sessionSetShareId: mock(async (_id: string, _shareId: string) => session),
100
+ sessionSetShares: mock(async () => session),
101
+ sessionMergeShared: unimplemented("sessionMergeShared"),
102
+ sessionDelete: unimplemented("sessionDelete"),
103
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
104
+ sessionSendMessage: unimplemented("sessionSendMessage"),
105
+ close: () => {},
106
+ } satisfies ThreadClient;
107
+ }
108
+
109
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
110
+
111
+ async function connected(session = sessionFixture()) {
112
+ const client = fakeClient(session);
113
+ const controller = createReviewController({
114
+ sessionId: session.id,
115
+ openClient: async () => client,
116
+ shareTransport,
117
+ });
118
+
119
+ controller.connect();
120
+ await tick();
121
+
122
+ return { controller, client, session };
123
+ }
124
+
125
+ describe("read-only guards", () => {
126
+ test("an observer's rename and delete never reach the daemon", async () => {
127
+ // Arrange - a read-only controller over a client whose mutations would throw
128
+ const session = sessionFixture();
129
+ const client = fakeClient(session);
130
+ const controller = createReviewController({
131
+ sessionId: session.id,
132
+ openClient: async () => client,
133
+ readOnly: true,
134
+ shareTransport,
135
+ });
136
+
137
+ controller.connect();
138
+ await tick();
139
+
140
+ // Act - the sidebar's rename and delete, invoked directly
141
+ controller.renameSession(session.id, "hijacked");
142
+ controller.deleteSession(session.id);
143
+ await tick();
144
+
145
+ // Assert - both answer read-only and the throwing client stubs were never called
146
+ expect(controller.getSnapshot().status).toBe("observer - read-only");
147
+ });
148
+ });
149
+
150
+ describe("tree primitives", () => {
151
+ test("a move back shows the earlier path at once and asks the daemon for the same move", async () => {
152
+ // Arrange
153
+ const { controller, client, session } = await connected();
154
+ const revision = session.history!.entries[0]!.id;
155
+
156
+ // Act
157
+ controller.goToEntry(revision, "too soon");
158
+
159
+ // Assert: the comment is off the shown path before any answer; the summary travels
160
+ expect(controller.getSnapshot().session!.annotations).toEqual([]);
161
+ expect(controller.getSnapshot().session!.shelvedAnnotations?.map((entry) => entry.id)).toEqual([
162
+ "a1",
163
+ ]);
164
+ expect(controller.treeRows().find((row) => row.glyph === "↩")?.isCurrentTip).toBe(true);
165
+ expect(client.sessionNavigate).toHaveBeenCalledWith("ses_1", revision, "too soon", undefined);
166
+ });
167
+
168
+ test("a move on another branch is one request that names the branch", async () => {
169
+ // Arrange: alt with its own comment entry; the owner stands on main
170
+ const session = sessionFixture();
171
+ let history = createBranch(session.history!, "alt");
172
+
173
+ history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
174
+ history = appendEntry(history, { type: "comment", annotationId: "b2", createdAt: AT }).history;
175
+ session.history = switchBranch(history, "main");
176
+ session.shelvedAnnotations = [annotation("b1", "alt one"), annotation("b2", "alt two")];
177
+ const { controller, client } = await connected(session);
178
+ const altFirst = history.entries.find(
179
+ (entry) => entry.type === "comment" && entry.annotationId === "b1",
180
+ )!;
181
+
182
+ // Act
183
+ controller.goToEntry(altFirst.id);
184
+
185
+ // Assert
186
+ expect(client.sessionSwitch).not.toHaveBeenCalled();
187
+ expect(client.sessionNavigate).toHaveBeenCalledWith("ses_1", altFirst.id, undefined, "alt");
188
+ expect(controller.getSnapshot().session!.annotations.map((entry) => entry.id)).toEqual([
189
+ "a1",
190
+ "b1",
191
+ ]);
192
+ });
193
+
194
+ test("a refused move gives way to the daemon's record", async () => {
195
+ // Arrange: the daemon refuses; a re-read hands back the untouched record
196
+ const session = sessionFixture();
197
+ const { controller, client } = await connected(session);
198
+
199
+ client.sessionNavigate.mockImplementationOnce(() => Promise.reject(new Error("resolved")));
200
+
201
+ // Act
202
+ controller.goToEntry(session.history!.entries[0]!.id);
203
+ const guessed = controller.getSnapshot().session!.annotations.length;
204
+
205
+ await tick();
206
+
207
+ // Assert: the guess showed, then the record came back
208
+ expect(guessed).toBe(0);
209
+ expect(controller.getSnapshot().session!.annotations.map((entry) => entry.id)).toEqual(["a1"]);
210
+ expect(controller.getSnapshot().status).toBe("resolved");
211
+ });
212
+
213
+ test("branch and label refuse an empty name and a taken branch name", async () => {
214
+ // Arrange
215
+ const { controller, client } = await connected();
216
+
217
+ // Act
218
+ controller.branch(" ");
219
+ controller.branch("main");
220
+ controller.labelTip("");
221
+
222
+ // Assert
223
+ expect(client.sessionBranch).not.toHaveBeenCalled();
224
+ expect(client.sessionLabel).not.toHaveBeenCalled();
225
+ expect(controller.getSnapshot().status).toBe("a checkpoint needs a name");
226
+ });
227
+
228
+ test("a branch shows up on the tip and becomes the current branch", async () => {
229
+ // Arrange
230
+ const { controller, client } = await connected();
231
+
232
+ // Act
233
+ controller.branch("alt");
234
+
235
+ // Assert
236
+ expect(controller.getSnapshot().session!.history!.branch).toBe("alt");
237
+ expect(controller.treeRows().find((row) => row.isCurrentTip)?.tips).toEqual(["main", "alt"]);
238
+ expect(client.sessionBranch).toHaveBeenCalledWith("ses_1", "alt");
239
+ });
240
+
241
+ test("fork opens the fork; fork-and-share shares the fork and stays on the original", async () => {
242
+ // Arrange
243
+ const forked = await connected();
244
+ const shared = await connected();
245
+
246
+ // Act
247
+ forked.controller.fork();
248
+ shared.controller.forkAndShare();
249
+ await tick();
250
+ await tick();
251
+
252
+ // Assert
253
+ expect(forked.controller.getSnapshot().session!.id).toBe("ses_1_fork");
254
+ expect(shared.controller.getSnapshot().session!.id).toBe("ses_1");
255
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({ id: "ses_1_fork" }));
256
+ expect(shared.client.sessionSetShares).toHaveBeenCalledWith("ses_1_fork", [
257
+ { id: "p_ses_1_fork", requireAuth: false, allowlist: [], shareBranch: "main" },
258
+ ]);
259
+ expect(shared.controller.getSnapshot().toast?.title).toBe("fork shared - link copied");
260
+ });
261
+ });