@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70

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 (248) 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 +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1039 -799
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +461 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +286 -0
  41. package/src/components/AppShell.zoom.test.tsx +94 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +151 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
@@ -0,0 +1,438 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ cutBlock,
4
+ parseBlocks,
5
+ restoreBlock,
6
+ SCHEMA_VERSION,
7
+ type DiffFileContents,
8
+ type HunkRejection,
9
+ type Thread,
10
+ } from "@cueloop/schema";
11
+ import { curateDiff } from "@cueloop/daemon/curate";
12
+ import type { SessionClient } from "@cueloop/daemon/client";
13
+ import { createReviewController } from "./thread-controller";
14
+
15
+ const PATCH = `diff --git a/src/store.ts b/src/store.ts
16
+ index 111..222 100644
17
+ --- a/src/store.ts
18
+ +++ b/src/store.ts
19
+ @@ -1,4 +1,4 @@
20
+ export class Store {
21
+ - private items = [];
22
+ + private items = new Map();
23
+ }
24
+ `;
25
+
26
+ const FILES: DiffFileContents[] = [
27
+ {
28
+ path: "src/store.ts",
29
+ oldContents: "export class Store {\n private items = [];\n}\n",
30
+ newContents: "export class Store {\n private items = new Map();\n}\n",
31
+ status: "modified",
32
+ },
33
+ ];
34
+
35
+ function diffSession(files?: DiffFileContents[]): Thread {
36
+ return {
37
+ schemaVersion: SCHEMA_VERSION,
38
+ id: "ses_diff",
39
+ workspace: { repoRoot: "/repo", branch: "main" },
40
+ artifact: { type: "diff", content: PATCH, meta: {}, files },
41
+ revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00.000Z" }],
42
+ annotations: [],
43
+ verdict: null,
44
+ status: "pending",
45
+ createdAt: "2026-01-01T00:00:00.000Z",
46
+ };
47
+ }
48
+
49
+ interface WorkingCopySink {
50
+ workingCopy?: string;
51
+ }
52
+
53
+ const unimplemented = (member: string) => () =>
54
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
55
+
56
+ /** A fake client that records the working copy the controller writes. */
57
+ function fakeClient(initial: Thread, sink: WorkingCopySink): SessionClient {
58
+ let session = initial;
59
+
60
+ return {
61
+ onEvent: () => () => {},
62
+ subscribe: async () => {},
63
+ sessionGet: async () => session,
64
+ sessionList: async () => [session],
65
+ sessionComment: unimplemented("sessionComment"),
66
+ sessionAnnotate: unimplemented("sessionAnnotate"),
67
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
68
+ sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
69
+ sink.workingCopy = content;
70
+
71
+ return { ...session, workingCopy: content };
72
+ }),
73
+ // the daemon's block primitives, stood in for with the same pure helpers it uses
74
+ sessionCutBlock: mock(async (_id: string, blockIndex: number) => {
75
+ const working = session.workingCopy ?? session.artifact.content;
76
+ const content = cutBlock(working, parseBlocks(working)[blockIndex]!);
77
+
78
+ sink.workingCopy = content;
79
+ session = { ...session, workingCopy: content };
80
+
81
+ return session;
82
+ }),
83
+ sessionRestoreBlock: mock(async (_id: string, baseBlockIndex: number, line?: number) => {
84
+ const base = session.artifact.content;
85
+ const working = session.workingCopy ?? base;
86
+ const content = restoreBlock(
87
+ base,
88
+ working,
89
+ parseBlocks(base)[baseBlockIndex]!,
90
+ line ?? working.split("\n").length,
91
+ );
92
+
93
+ sink.workingCopy = content;
94
+ session = { ...session, workingCopy: content };
95
+ if (content === undefined) delete session.workingCopy;
96
+
97
+ return session;
98
+ }),
99
+ // the daemon's curation, stood in for: the record carries the decisions and the patch they leave
100
+ sessionCurate: mock(async (_id: string, rejections: HunkRejection[]) => {
101
+ const content = rejections.length
102
+ ? curateDiff(session.artifact.files!, rejections)
103
+ : undefined;
104
+
105
+ sink.workingCopy = content;
106
+ session = { ...session, workingCopy: content };
107
+ if (rejections.length) session.curation = rejections;
108
+ else delete session.curation;
109
+
110
+ return session;
111
+ }),
112
+ sessionNavigate: unimplemented("sessionNavigate"),
113
+ sessionBranch: unimplemented("sessionBranch"),
114
+ sessionSwitch: unimplemented("sessionSwitch"),
115
+ sessionLabel: unimplemented("sessionLabel"),
116
+ sessionFork: unimplemented("sessionFork"),
117
+ sessionSetViewed: unimplemented("sessionSetViewed"),
118
+ sessionSetTitle: unimplemented("sessionSetTitle"),
119
+ sessionSetShareId: unimplemented("sessionSetShareId"),
120
+ sessionMergeShared: unimplemented("sessionMergeShared"),
121
+ sessionDelete: unimplemented("sessionDelete"),
122
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
123
+ sessionResolve: unimplemented("sessionResolve"),
124
+ close: () => {},
125
+ };
126
+ }
127
+
128
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
129
+
130
+ async function connected(session: Thread) {
131
+ const sink: WorkingCopySink = {};
132
+ const client = fakeClient(session, sink);
133
+ const controller = createReviewController({
134
+ sessionId: session.id,
135
+ openClient: async () => client,
136
+ });
137
+
138
+ controller.connect();
139
+ await tick();
140
+
141
+ return { controller, client, sink };
142
+ }
143
+
144
+ // rows for PATCH: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
145
+ describe("diff hunk curation", () => {
146
+ test("rejecting the only change writes an empty curated working copy", async () => {
147
+ // Arrange
148
+ const { controller, sink } = await connected(diffSession(FILES));
149
+
150
+ // Act - reject the change on the added line
151
+ controller.toggleRejectChange(4);
152
+ await tick();
153
+
154
+ // Assert
155
+ expect(controller.getSnapshot().status).toContain("change rejected");
156
+ expect(sink.workingCopy).toBe("");
157
+ });
158
+
159
+ test("both the deletion and addition rows of the change dim", async () => {
160
+ // Arrange
161
+ const { controller } = await connected(diffSession(FILES));
162
+
163
+ // Act
164
+ controller.toggleRejectChange(4);
165
+ await tick();
166
+
167
+ // Assert - the change spans the del row and the add row
168
+ expect([...controller.rejectedRows()].sort((a, b) => a - b)).toEqual([3, 4]);
169
+ });
170
+
171
+ test("toggling the same change twice restores the full diff", async () => {
172
+ // Arrange
173
+ const { controller, sink } = await connected(diffSession(FILES));
174
+
175
+ // Act - reject then restore
176
+ controller.toggleRejectChange(4);
177
+ await tick();
178
+ controller.toggleRejectChange(4);
179
+ await tick();
180
+
181
+ // Assert - no decisions left, so the working copy reverts to undefined
182
+ expect(controller.getSnapshot().status).toContain("change restored");
183
+ expect(sink.workingCopy).toBeUndefined();
184
+ expect(controller.rejectedRows().size).toBe(0);
185
+ });
186
+
187
+ test("whole-hunk reject drops the hunk from the working copy", async () => {
188
+ // Arrange
189
+ const { controller, sink } = await connected(diffSession(FILES));
190
+
191
+ // Act - reject the whole hunk from any of its rows
192
+ controller.toggleRejectHunk(3);
193
+ await tick();
194
+
195
+ // Assert - the single hunk is the whole diff, so nothing remains
196
+ expect(controller.getSnapshot().status).toContain("hunk rejected");
197
+ expect(sink.workingCopy).toBe("");
198
+ });
199
+
200
+ test("a rejected change becomes a removal card with a source, id, and preview", async () => {
201
+ // Arrange
202
+ const { controller } = await connected(diffSession(FILES));
203
+
204
+ // Act
205
+ controller.toggleRejectChange(4);
206
+ await tick();
207
+
208
+ // Assert - one item: diff source, revealing the change's row, both lines previewed
209
+ const items = controller.curationItems();
210
+
211
+ expect(items.length).toBe(1);
212
+ expect(items[0]!.source).toBe("diff");
213
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#1");
214
+ expect(items[0]!.revealIndex).toBe(3);
215
+ expect(items[0]!.preview).toEqual([
216
+ "- private items = [];",
217
+ "+ private items = new Map();",
218
+ ]);
219
+ });
220
+
221
+ test("a rejected hunk becomes a removal card with the whole-hunk id", async () => {
222
+ // Arrange
223
+ const { controller } = await connected(diffSession(FILES));
224
+
225
+ // Act
226
+ controller.toggleRejectHunk(3);
227
+ await tick();
228
+
229
+ // Assert - the whole hunk, id diff:path#hunk#hunk
230
+ const items = controller.curationItems();
231
+
232
+ expect(items.length).toBe(1);
233
+ expect(items[0]!.source).toBe("diff");
234
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#hunk");
235
+ });
236
+
237
+ test("restoreCuration drops the diff removal and reverts the working copy", async () => {
238
+ // Arrange - one change rejected
239
+ const { controller, sink } = await connected(diffSession(FILES));
240
+
241
+ controller.toggleRejectChange(4);
242
+ await tick();
243
+ const [item] = controller.curationItems();
244
+
245
+ // Act
246
+ controller.restoreCuration(item!.id);
247
+ await tick();
248
+
249
+ // Assert - the list empties and the working copy reverts to the full diff
250
+ expect(controller.getSnapshot().status).toContain("removal restored");
251
+ expect(controller.curationItems().length).toBe(0);
252
+ expect(sink.workingCopy).toBeUndefined();
253
+ expect(controller.rejectedRows().size).toBe(0);
254
+ });
255
+
256
+ test("restoreCuration ignores an unknown id", async () => {
257
+ // Arrange - one change rejected
258
+ const { controller } = await connected(diffSession(FILES));
259
+
260
+ controller.toggleRejectChange(4);
261
+ await tick();
262
+
263
+ // Act
264
+ controller.restoreCuration("diff:nope#0#0");
265
+ await tick();
266
+
267
+ // Assert - the item stays
268
+ expect(controller.curationItems().length).toBe(1);
269
+ });
270
+
271
+ test("a second decision before the daemon answers builds on the first", async () => {
272
+ // Arrange
273
+ const { controller, client } = await connected(diffSession(FILES));
274
+
275
+ // Act - reject, then restore, with no answer in between
276
+ controller.toggleRejectChange(4);
277
+ controller.toggleRejectChange(4);
278
+ await tick();
279
+
280
+ // Assert - the second request restores; a stale snapshot would reject again
281
+ expect(client.sessionCurate).toHaveBeenCalledTimes(2);
282
+ expect(client.sessionCurate).toHaveBeenNthCalledWith(1, "ses_diff", [
283
+ { path: "src/store.ts", hunkIndex: 0, changeIndex: 1 },
284
+ ]);
285
+ expect(client.sessionCurate).toHaveBeenNthCalledWith(2, "ses_diff", []);
286
+ });
287
+
288
+ test("curation is disabled without full file contents", async () => {
289
+ // Arrange - a legacy diff with no artifact.files
290
+ const { controller, sink } = await connected(diffSession(undefined));
291
+
292
+ // Act
293
+ controller.toggleRejectChange(4);
294
+ await tick();
295
+
296
+ // Assert
297
+ expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
298
+ expect(sink.workingCopy).toBeUndefined();
299
+ });
300
+ });
301
+
302
+ const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
303
+ // the working copy with the "Second paragraph." block cut out
304
+ const PLAN_CUT = "# Title\n\nFirst paragraph.\n";
305
+
306
+ function planSession(workingCopy?: string): Thread {
307
+ return {
308
+ schemaVersion: SCHEMA_VERSION,
309
+ id: "ses_plan",
310
+ workspace: { repoRoot: "/repo", branch: "main" },
311
+ artifact: { type: "plan", content: PLAN_CONTENT, meta: {} },
312
+ revisions: [{ revision: 1, content: PLAN_CONTENT, submittedAt: "2026-01-01T00:00:00.000Z" }],
313
+ annotations: [],
314
+ verdict: null,
315
+ status: "pending",
316
+ createdAt: "2026-01-01T00:00:00.000Z",
317
+ workingCopy,
318
+ };
319
+ }
320
+
321
+ describe("plan cut removals", () => {
322
+ test("a cut block becomes a plan removal card previewing its content", async () => {
323
+ // Arrange - a plan whose working copy dropped the second paragraph
324
+ const { controller } = await connected(planSession(PLAN_CUT));
325
+
326
+ // Act
327
+ const items = controller.curationItems();
328
+
329
+ // Assert - one plan-source removal, keyed on the base line range, content previewed
330
+ expect(items.length).toBe(1);
331
+ expect(items[0]!.source).toBe("plan");
332
+ expect(items[0]!.id).toBe("plan:4-4");
333
+ expect(items[0]!.preview).toEqual(["Second paragraph."]);
334
+ });
335
+
336
+ test("restoreCuration re-inserts the cut block and returns to pristine", async () => {
337
+ // Arrange
338
+ const { controller, sink } = await connected(planSession(PLAN_CUT));
339
+ const [item] = controller.curationItems();
340
+
341
+ // Act
342
+ controller.restoreCuration(item!.id);
343
+ await tick();
344
+
345
+ // Assert - restoring the only cut round-trips to the submitted revision
346
+ expect(controller.getSnapshot().status).toContain("removal restored");
347
+ expect(sink.workingCopy).toBeUndefined();
348
+ });
349
+
350
+ test("a second cut before the daemon answers indexes the already-cut copy", async () => {
351
+ // Arrange - pristine plan: title(0), first(1), second(2)
352
+ const { controller, client, sink } = await connected(planSession());
353
+
354
+ // Act - cut the first paragraph, then the second, with no answer in between
355
+ controller.cut(1);
356
+ controller.cut(2);
357
+ await tick();
358
+
359
+ // Assert - the second paragraph is block 1 once the first is gone
360
+ expect(client.sessionCutBlock).toHaveBeenNthCalledWith(1, "ses_plan", 1);
361
+ expect(client.sessionCutBlock).toHaveBeenNthCalledWith(2, "ses_plan", 1);
362
+ expect(sink.workingCopy).toBe("# Title\n");
363
+ });
364
+ });
365
+
366
+ // a file whose hunk touches line 2 only, so lines 1/3/4 are unchanged tail/context
367
+ const TAIL_PATCH = `diff --git a/src/store.ts b/src/store.ts
368
+ --- a/src/store.ts
369
+ +++ b/src/store.ts
370
+ @@ -2,1 +2,1 @@
371
+ - private items = [];
372
+ + private items = new Map();
373
+ `;
374
+
375
+ function tailSession(): Thread {
376
+ return {
377
+ ...diffSession(),
378
+ artifact: {
379
+ type: "diff",
380
+ content: TAIL_PATCH,
381
+ meta: {},
382
+ files: [
383
+ {
384
+ path: "src/store.ts",
385
+ oldContents: "export class Store {\n private items = [];\n}\nexport const x = 1;\n",
386
+ newContents:
387
+ "export class Store {\n private items = new Map();\n}\nexport const x = 1;\n",
388
+ status: "modified",
389
+ },
390
+ ],
391
+ },
392
+ };
393
+ }
394
+
395
+ describe("diff file fold", () => {
396
+ test("collapse leaves only the file band; expand restores the body", async () => {
397
+ // Arrange
398
+ const { controller } = await connected(tailSession());
399
+ const bodyRows = () =>
400
+ controller.rows().filter((row) => row.file === "src/store.ts" && row.kind !== "file").length;
401
+
402
+ // Assert - starts unfolded with a body
403
+ expect(bodyRows()).toBeGreaterThan(0);
404
+
405
+ // Act / Assert - collapse hides the body
406
+ controller.setFileCollapsed("src/store.ts", true);
407
+ expect(bodyRows()).toBe(0);
408
+ expect(controller.isFileCollapsed("src/store.ts")).toBe(true);
409
+
410
+ // Act / Assert - unfold brings it back
411
+ controller.setFileCollapsed("src/store.ts", false);
412
+ expect(bodyRows()).toBeGreaterThan(0);
413
+ });
414
+
415
+ test("expand weaves the file's unchanged tail line the hunk never showed", async () => {
416
+ // Arrange
417
+ const { controller } = await connected(tailSession());
418
+ const hasTail = () => controller.rows().some((row) => row.text.includes("export const x = 1"));
419
+
420
+ // Assert - the hunk view does not show the tail line
421
+ expect(controller.canExpandFile("src/store.ts")).toBe(true);
422
+ expect(hasTail()).toBe(false);
423
+
424
+ // Act - weave the full file
425
+ controller.setFileExpanded("src/store.ts", true);
426
+
427
+ // Assert
428
+ expect(hasTail()).toBe(true);
429
+ });
430
+
431
+ test("a diff without full contents cannot be expanded", async () => {
432
+ // Arrange - no artifact.files
433
+ const { controller } = await connected(diffSession(undefined));
434
+
435
+ // Assert
436
+ expect(controller.canExpandFile("src/store.ts")).toBe(false);
437
+ });
438
+ });
@@ -0,0 +1,199 @@
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 { SessionClient } 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
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
42
+ collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
43
+ mergeFromShare,
44
+ };
45
+
46
+ function planSession(id = "ses_plan", repoRoot = "/repo"): Thread {
47
+ return {
48
+ schemaVersion: SCHEMA_VERSION,
49
+ id,
50
+ workspace: { repoRoot, branch: "main" },
51
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
52
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: AT }],
53
+ annotations: [],
54
+ verdict: null,
55
+ status: "pending",
56
+ createdAt: AT,
57
+ };
58
+ }
59
+
60
+ const unimplemented = (member: string) => () =>
61
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
62
+
63
+ function fakeClient(session: Thread): SessionClient {
64
+ return {
65
+ onEvent: () => () => {},
66
+ subscribe: async () => {},
67
+ sessionGet: async () => session,
68
+ sessionList: async () => [session],
69
+ sessionComment: unimplemented("sessionComment"),
70
+ sessionAnnotate: unimplemented("sessionAnnotate"),
71
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
72
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
73
+ sessionCutBlock: unimplemented("sessionCutBlock"),
74
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
75
+ sessionCurate: unimplemented("sessionCurate"),
76
+ sessionNavigate: unimplemented("sessionNavigate"),
77
+ sessionBranch: unimplemented("sessionBranch"),
78
+ sessionSwitch: unimplemented("sessionSwitch"),
79
+ sessionLabel: unimplemented("sessionLabel"),
80
+ sessionFork: unimplemented("sessionFork"),
81
+ sessionSetViewed: unimplemented("sessionSetViewed"),
82
+ sessionSetTitle: unimplemented("sessionSetTitle"),
83
+ sessionSetShareId: unimplemented("sessionSetShareId"),
84
+ sessionMergeShared: unimplemented("sessionMergeShared"),
85
+ sessionDelete: unimplemented("sessionDelete"),
86
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
87
+ sessionResolve: unimplemented("sessionResolve"),
88
+ repoDiff: async () => ({ patch: PATCH, files: FILES }),
89
+ close: () => {},
90
+ } satisfies SessionClient;
91
+ }
92
+
93
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
94
+
95
+ describe("live working-tree diff for a non-diff thread", () => {
96
+ test("repoChanges carries file contents and rows derive from the patch", async () => {
97
+ const session = planSession();
98
+ const controller = createReviewController({
99
+ sessionId: session.id,
100
+ openClient: async () => fakeClient(session),
101
+ shareTransport,
102
+ });
103
+ controller.connect();
104
+ await tick();
105
+
106
+ // Act - the Changes navigator loads
107
+ const changes = await controller.repoChanges();
108
+
109
+ // Assert - real per-file contents, not empty stubs
110
+ expect(changes.map((file) => file.path)).toEqual(["src/x.ts"]);
111
+ expect(changes[0]!.newContents).toBe("const a = 2;\n");
112
+
113
+ // and the diff rows derive from the live patch, so an opened file tab shows a diff
114
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(true);
115
+
116
+ // the live per-file contents let a non-diff thread expand a folded file too
117
+ expect(controller.canExpandFile("src/x.ts")).toBe(true);
118
+
119
+ controller.close();
120
+ });
121
+
122
+ test("annotating the Changes diff stamps a file target anchored to the diff rows", async () => {
123
+ const session = planSession();
124
+ let sent: Annotation | undefined;
125
+ const client = {
126
+ ...fakeClient(session),
127
+ sessionComment: async (_id: string, annotation: Annotation) => {
128
+ sent = annotation;
129
+
130
+ return { ...session, annotations: [{ ...annotation, createdAt: AT }] };
131
+ },
132
+ } satisfies SessionClient;
133
+ const controller = createReviewController({
134
+ sessionId: session.id,
135
+ openClient: async () => client,
136
+ shareTransport,
137
+ });
138
+ controller.connect();
139
+ await tick();
140
+ await controller.repoChanges();
141
+
142
+ // Act - comment the added line of the working-tree diff, as the Changes surface does
143
+ const rows = controller.rows();
144
+ const addIndex = rows.findIndex((row) => row.kind === "add");
145
+ const addText = rows[addIndex]!.text.replace(/\n$/, "");
146
+
147
+ controller.annotate("comment", addIndex, 0, addText.length, "eviction?", addIndex, {
148
+ kind: "file",
149
+ path: "",
150
+ rev: "worktree",
151
+ });
152
+
153
+ // Assert - the note carries the file target (path and rev filled from the row) and quotes the diff
154
+ expect(sent?.target).toEqual({ kind: "file", path: "src/x.ts", rev: "worktree" });
155
+ expect(sent?.anchor.quote).toContain("const a = 2");
156
+
157
+ controller.close();
158
+ });
159
+
160
+ test("a late diff response from a previous thread never overwrites the active one", async () => {
161
+ const sessionA = planSession("ses_A", "/repoA");
162
+ const sessionB = planSession("ses_B", "/repoB");
163
+ let resolveA: (diff: { patch: string; files: typeof FILES }) => void = () => {};
164
+ const pendingA = new Promise<{ patch: string; files: typeof FILES }>((resolve) => {
165
+ resolveA = resolve;
166
+ });
167
+ const diffB = {
168
+ patch: PATCH.replace(/x\.ts/g, "y.ts"),
169
+ files: [{ ...FILES[0]!, path: "src/y.ts" }],
170
+ };
171
+ const client = {
172
+ ...fakeClient(sessionA),
173
+ sessionList: async () => [sessionA, sessionB],
174
+ repoDiff: async (cwd: string) => (cwd === "/repoA" ? pendingA : diffB),
175
+ } satisfies SessionClient;
176
+
177
+ const controller = createReviewController({
178
+ sessionId: sessionA.id,
179
+ openClient: async () => client,
180
+ shareTransport,
181
+ });
182
+ controller.connect();
183
+ await tick();
184
+
185
+ // thread A's diff is still in flight when the user switches to thread B
186
+ const inFlightA = controller.repoChanges();
187
+ controller.open("ses_B");
188
+ await controller.repoChanges();
189
+ // B's diff is showing; now A's stale response lands
190
+ resolveA({ patch: PATCH, files: FILES });
191
+ await inFlightA;
192
+
193
+ // the active thread keeps B's changes; A's late response is dropped
194
+ expect(controller.rows().some((row) => row.file === "src/y.ts")).toBe(true);
195
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(false);
196
+
197
+ controller.close();
198
+ });
199
+ });