@cueloop/client 0.1.0-alpha.8 → 0.1.0-alpha.81

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 (307) 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 +40 -32
  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 +176 -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 +428 -0
  16. package/src/App.prototype-markdown.test.tsx +92 -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 +222 -0
  23. package/src/App.test.tsx +333 -131
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1338 -608
  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 +701 -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 +574 -0
  35. package/src/app-view-model.ts +162 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/clipboard.test.ts +23 -0
  39. package/src/clipboard.ts +33 -0
  40. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  41. package/src/components/AnnotatableFileView.test.tsx +102 -0
  42. package/src/components/AnnotatableFileView.tsx +134 -0
  43. package/src/components/AnnotationCards.stories.tsx +86 -0
  44. package/src/components/AnnotationCards.tsx +341 -0
  45. package/src/components/AnsiScreen.stories.tsx +12 -0
  46. package/src/components/AnsiScreen.tsx +28 -0
  47. package/src/components/AppHeader.stories.tsx +50 -0
  48. package/src/components/AppHeader.tsx +83 -0
  49. package/src/components/AppShell.brand.test.tsx +58 -0
  50. package/src/components/AppShell.stories.tsx +89 -0
  51. package/src/components/AppShell.tsx +329 -0
  52. package/src/components/AppShell.zoom.test.tsx +135 -0
  53. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  54. package/src/components/BareWorkbenchFileView.tsx +64 -0
  55. package/src/components/Breadcrumb.stories.tsx +19 -0
  56. package/src/components/Breadcrumb.tsx +45 -0
  57. package/src/components/ChangesColumn.stories.tsx +44 -0
  58. package/src/components/ChangesColumn.tsx +199 -0
  59. package/src/components/ChangesFileTree.test.tsx +61 -0
  60. package/src/components/CodeBlock.stories.tsx +42 -0
  61. package/src/components/CodeBlock.tsx +84 -0
  62. package/src/components/CompletionOverlay.stories.tsx +37 -0
  63. package/src/components/CompletionOverlay.tsx +77 -0
  64. package/src/components/ConfirmCard.stories.tsx +37 -0
  65. package/src/components/ConfirmCard.tsx +153 -0
  66. package/src/components/ConfirmDialog.stories.tsx +21 -0
  67. package/src/components/ConfirmDialog.tsx +72 -0
  68. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  69. package/src/components/DiffContentView.split.test.tsx +140 -0
  70. package/src/components/DiffContentView.stories.tsx +80 -0
  71. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  72. package/src/components/DiffContentView.tsx +876 -0
  73. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  74. package/src/components/DiscussionMarkerRail.tsx +186 -0
  75. package/src/components/EditorGrid.stories.tsx +59 -0
  76. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  77. package/src/components/EditorGrid.tsx +426 -0
  78. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  79. package/src/components/FileContentsView.stories.tsx +16 -0
  80. package/src/components/FileContentsView.test.tsx +29 -0
  81. package/src/components/FileContentsView.tsx +98 -0
  82. package/src/components/GridTabContent.stories.tsx +59 -0
  83. package/src/components/GridTabContent.tsx +182 -0
  84. package/src/components/HandleChipsInput.stories.tsx +24 -0
  85. package/src/components/HandleChipsInput.test.tsx +35 -0
  86. package/src/components/HandleChipsInput.tsx +145 -0
  87. package/src/components/KeybindsDialog.stories.tsx +31 -0
  88. package/src/components/KeybindsDialog.tsx +103 -0
  89. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  90. package/src/components/MarkdownGridBlock.tsx +72 -0
  91. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  92. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  93. package/src/components/MarkdownThreadEditor.tsx +162 -0
  94. package/src/components/MenuBar.stories.tsx +28 -0
  95. package/src/components/MenuBar.tsx +44 -0
  96. package/src/components/NavModeHint.stories.tsx +40 -0
  97. package/src/components/NavModeHint.tsx +31 -0
  98. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  99. package/src/components/OverlayScrollbar.test.tsx +139 -0
  100. package/src/components/OverlayScrollbar.tsx +129 -0
  101. package/src/components/PaneGrid.stories.tsx +84 -0
  102. package/src/components/PaneGrid.tsx +128 -0
  103. package/src/components/PanelColumn.stories.tsx +25 -0
  104. package/src/components/PanelColumn.tsx +98 -0
  105. package/src/components/ProjectTreeView.stories.tsx +25 -0
  106. package/src/components/ProjectTreeView.test.tsx +59 -0
  107. package/src/components/ProjectTreeView.tsx +111 -0
  108. package/src/components/PromptDialog.stories.tsx +36 -0
  109. package/src/components/PromptDialog.test.tsx +61 -0
  110. package/src/components/PromptDialog.tsx +99 -0
  111. package/src/components/PrototypeContentView.stories.tsx +19 -0
  112. package/src/components/PrototypeContentView.tsx +446 -0
  113. package/src/components/RootOverlay.stories.tsx +45 -0
  114. package/src/components/RootOverlay.tsx +48 -0
  115. package/src/components/ScrollArea.stories.tsx +20 -0
  116. package/src/components/ScrollArea.tsx +44 -0
  117. package/src/components/SettingsDialog.stories.tsx +117 -0
  118. package/src/components/SettingsDialog.tsx +186 -0
  119. package/src/components/SettingsRows.stories.tsx +18 -0
  120. package/src/components/SettingsRows.tsx +112 -0
  121. package/src/components/ShareDialog.stories.tsx +121 -0
  122. package/src/components/ShareDialog.tsx +552 -0
  123. package/src/components/ShellHeader.stories.tsx +50 -0
  124. package/src/components/ShellHeader.tsx +137 -0
  125. package/src/components/SlashComposer.stories.tsx +37 -0
  126. package/src/components/SlashComposer.tsx +87 -0
  127. package/src/components/ThreadFooter.stories.tsx +30 -0
  128. package/src/components/ThreadFooter.test.tsx +47 -0
  129. package/src/components/ThreadFooter.tsx +76 -0
  130. package/src/components/ThreadTree.stories.tsx +46 -0
  131. package/src/components/ThreadTree.tsx +255 -0
  132. package/src/components/ThreadView.selection.test.tsx +899 -0
  133. package/src/components/ThreadView.slash.test.tsx +146 -0
  134. package/src/components/ThreadView.stories.tsx +26 -0
  135. package/src/components/ThreadView.tsx +472 -0
  136. package/src/components/Toast.stories.tsx +19 -0
  137. package/src/components/Toast.tsx +52 -0
  138. package/src/components/Tooltip.stories.tsx +26 -0
  139. package/src/components/Tooltip.tsx +72 -0
  140. package/src/components/TreePane.stories.tsx +106 -0
  141. package/src/components/TreePane.tsx +157 -0
  142. package/src/components/WalkWizard.stories.tsx +100 -0
  143. package/src/components/WalkWizard.tsx +150 -0
  144. package/src/components/WelcomePlayground.stories.tsx +12 -0
  145. package/src/components/WelcomePlayground.test.tsx +47 -0
  146. package/src/components/WelcomePlayground.tsx +142 -0
  147. package/src/components/__snapshots__/stories.test.tsx.snap +2238 -0
  148. package/src/components/diff-content-view-layout.test.ts +198 -0
  149. package/src/components/diff-content-view-layout.ts +84 -0
  150. package/src/components/editor-grid.test.ts +86 -0
  151. package/src/components/editor-grid.ts +138 -0
  152. package/src/components/file-tree.test.ts +60 -0
  153. package/src/components/file-tree.ts +112 -0
  154. package/src/components/menu-control.stories.tsx +35 -0
  155. package/src/components/menu-control.tsx +43 -0
  156. package/src/components/pages/Workspace.stories.tsx +181 -0
  157. package/src/components/primitives/Button.stories.tsx +32 -0
  158. package/src/components/primitives/Button.tsx +56 -0
  159. package/src/components/primitives/Card.stories.tsx +25 -0
  160. package/src/components/primitives/Card.tsx +63 -0
  161. package/src/components/primitives/Dialog.stories.tsx +24 -0
  162. package/src/components/primitives/Dialog.test.tsx +37 -0
  163. package/src/components/primitives/Dialog.tsx +71 -0
  164. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  165. package/src/components/primitives/DialogActions.tsx +39 -0
  166. package/src/components/primitives/IconButton.stories.tsx +36 -0
  167. package/src/components/primitives/IconButton.tsx +62 -0
  168. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  169. package/src/components/primitives/StatusBar.tsx +23 -0
  170. package/src/components/primitives/Switch.stories.tsx +17 -0
  171. package/src/components/primitives/Switch.tsx +32 -0
  172. package/src/components/primitives/Tabs.stories.tsx +29 -0
  173. package/src/components/primitives/Tabs.tsx +102 -0
  174. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  175. package/src/components/primitives/Toolbar.tsx +14 -0
  176. package/src/components/primitives/Tree.stories.tsx +124 -0
  177. package/src/components/primitives/Tree.tsx +100 -0
  178. package/src/components/primitives/frame.test.ts +12 -0
  179. package/src/components/primitives/frame.ts +17 -0
  180. package/src/components/primitives/icons.ts +61 -0
  181. package/src/components/primitives/tree-model.test.ts +99 -0
  182. package/src/components/primitives/tree-model.ts +137 -0
  183. package/src/components/quick-actions-editor.stories.tsx +58 -0
  184. package/src/components/quick-actions-editor.test.tsx +117 -0
  185. package/src/components/quick-actions-editor.tsx +226 -0
  186. package/src/components/share-dialog-store.ts +58 -0
  187. package/src/components/stories-app.tsx +193 -0
  188. package/src/components/stories.test.tsx +74 -0
  189. package/src/components/story-fixtures.ts +102 -0
  190. package/src/components/story-tree.test.ts +108 -0
  191. package/src/components/story.ts +159 -0
  192. package/src/components/syntax-highlight.ts +133 -0
  193. package/src/components/terminal-pane.test.tsx +40 -0
  194. package/src/components/terminal-pane.ts +199 -0
  195. package/src/components/theme-context.stories.tsx +46 -0
  196. package/src/components/theme-context.tsx +37 -0
  197. package/src/components/thread-tree.test.ts +134 -0
  198. package/src/components/thread-tree.ts +109 -0
  199. package/src/components/truncate-text.ts +7 -0
  200. package/src/components/truncate-title.test.ts +31 -0
  201. package/src/components/truncate-title.ts +15 -0
  202. package/src/config-identity.test.ts +78 -0
  203. package/src/config.test.ts +517 -33
  204. package/src/config.ts +467 -57
  205. package/src/diff-fold.test.ts +78 -0
  206. package/src/diff-fold.ts +126 -0
  207. package/src/diff-hunk-curate.test.ts +315 -0
  208. package/src/diff-hunk-curate.ts +153 -0
  209. package/src/diff-intraline.test.ts +238 -0
  210. package/src/diff-intraline.ts +236 -0
  211. package/src/diff-syntax.test.ts +112 -0
  212. package/src/diff-syntax.ts +179 -0
  213. package/src/discussions.ts +79 -0
  214. package/src/editor.test.ts +149 -0
  215. package/src/editor.ts +139 -9
  216. package/src/ghostty-terminal.test.ts +85 -0
  217. package/src/ghostty-terminal.ts +199 -0
  218. package/src/github-identity.test.ts +58 -0
  219. package/src/github-identity.ts +41 -0
  220. package/src/herdr.test.ts +9 -34
  221. package/src/herdr.ts +25 -21
  222. package/src/index.ts +13 -0
  223. package/src/integrations.test.ts +41 -0
  224. package/src/integrations.ts +32 -0
  225. package/src/intent-dispatch.test.ts +723 -0
  226. package/src/intent-dispatch.ts +692 -0
  227. package/src/key-bindings.test.ts +156 -0
  228. package/src/key-bindings.ts +459 -0
  229. package/src/keymap.test.ts +461 -113
  230. package/src/keymap.ts +321 -79
  231. package/src/kitty-image.test.ts +66 -0
  232. package/src/kitty-image.ts +138 -0
  233. package/src/launch-layout.test.ts +52 -0
  234. package/src/launch-layout.ts +42 -0
  235. package/src/mark-runs.ts +121 -0
  236. package/src/markdown-editor-syntax-style.ts +50 -0
  237. package/src/markdown-grid.test.ts +101 -0
  238. package/src/markdown-grid.ts +203 -0
  239. package/src/markdown-highlight.test.ts +61 -0
  240. package/src/markdown-highlight.ts +122 -0
  241. package/src/pasted-image.test.ts +67 -0
  242. package/src/pasted-image.ts +60 -0
  243. package/src/perf/otlp-trace.test.ts +116 -0
  244. package/src/perf/otlp-trace.ts +130 -0
  245. package/src/perf/perf-report.ts +17 -0
  246. package/src/perf/perf-timings.test.ts +18 -0
  247. package/src/perf/perf-timings.ts +38 -0
  248. package/src/prototype-browser.test.ts +64 -0
  249. package/src/prototype-browser.ts +329 -0
  250. package/src/prototype-pixels.tsx +35 -0
  251. package/src/pty.test.ts +81 -0
  252. package/src/pty.ts +245 -0
  253. package/src/ready-signal.test.ts +85 -0
  254. package/src/ready-signal.ts +52 -0
  255. package/src/run.ts +78 -13
  256. package/src/serve.test.ts +24 -12
  257. package/src/serve.ts +52 -10
  258. package/src/share.ts +237 -0
  259. package/src/skills.test.ts +74 -0
  260. package/src/skills.ts +75 -0
  261. package/src/slash-palette.test.ts +103 -0
  262. package/src/slash-palette.ts +129 -0
  263. package/src/split-diff.test.ts +110 -0
  264. package/src/split-diff.ts +149 -0
  265. package/src/test-support.test.ts +264 -0
  266. package/src/test-support.ts +369 -0
  267. package/src/theme-presets.test.ts +51 -0
  268. package/src/theme-presets.ts +214 -0
  269. package/src/theme.ts +84 -19
  270. package/src/thread-chords.test.ts +79 -0
  271. package/src/thread-chords.ts +163 -0
  272. package/src/thread-controller.curate.test.ts +450 -0
  273. package/src/thread-controller.derivation.test.ts +62 -0
  274. package/src/thread-controller.live-diff.test.ts +322 -0
  275. package/src/thread-controller.optimistic.test.ts +69 -0
  276. package/src/thread-controller.share.test.ts +561 -0
  277. package/src/thread-controller.switch-cache.test.ts +176 -0
  278. package/src/thread-controller.tree.test.ts +261 -0
  279. package/src/thread-controller.ts +1893 -0
  280. package/src/thread-selection.test.ts +210 -0
  281. package/src/thread-selection.ts +243 -0
  282. package/src/tree-view.test.ts +128 -0
  283. package/src/tree-view.ts +148 -0
  284. package/src/use-annotation-surface.tsx +915 -0
  285. package/src/use-changes-workbench.ts +190 -0
  286. package/src/use-frame-measure.ts +41 -0
  287. package/src/use-remember-layout.ts +31 -0
  288. package/src/use-settings-dialog.tsx +320 -0
  289. package/src/use-terminal-virtualizer.ts +233 -0
  290. package/src/use-thread-body-editing.tsx +58 -0
  291. package/src/version.ts +5 -0
  292. package/src/view-diff.test.ts +137 -9
  293. package/src/view-diff.ts +269 -23
  294. package/src/view-plan.regression.test.ts +60 -0
  295. package/src/view-plan.test.ts +456 -0
  296. package/src/view-plan.ts +719 -0
  297. package/src/visual/contrast.test.ts +35 -0
  298. package/src/visual/contrast.ts +33 -0
  299. package/src/walk.test.ts +121 -0
  300. package/src/walk.ts +74 -0
  301. package/src/workbench-snapshot.test.ts +81 -0
  302. package/src/workbench-snapshot.ts +29 -0
  303. package/src/session-controller.ts +0 -345
  304. package/src/syntax.test.ts +0 -41
  305. package/src/syntax.ts +0 -88
  306. package/src/view.test.ts +0 -157
  307. package/src/view.ts +0 -312
@@ -0,0 +1,450 @@
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 { ThreadClient } 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
+ message: 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): ThreadClient {
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
+ sessionSetAccess: unimplemented("sessionSetAccess"),
113
+ sessionNavigate: unimplemented("sessionNavigate"),
114
+ sessionBranch: unimplemented("sessionBranch"),
115
+ sessionSwitch: unimplemented("sessionSwitch"),
116
+ sessionLabel: unimplemented("sessionLabel"),
117
+ sessionFork: unimplemented("sessionFork"),
118
+ sessionSetViewed: unimplemented("sessionSetViewed"),
119
+ sessionSetTitle: unimplemented("sessionSetTitle"),
120
+ sessionSetShareId: unimplemented("sessionSetShareId"),
121
+ sessionSetShares: unimplemented("sessionSetShares"),
122
+ sessionMergeShared: unimplemented("sessionMergeShared"),
123
+ sessionDelete: unimplemented("sessionDelete"),
124
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
125
+ sessionSendMessage: unimplemented("sessionSendMessage"),
126
+ close: () => {},
127
+ };
128
+ }
129
+
130
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
131
+
132
+ async function connected(session: Thread) {
133
+ const sink: WorkingCopySink = {};
134
+ const client = fakeClient(session, sink);
135
+ const controller = createReviewController({
136
+ sessionId: session.id,
137
+ openClient: async () => client,
138
+ });
139
+
140
+ controller.connect();
141
+ await tick();
142
+
143
+ return { controller, client, sink };
144
+ }
145
+
146
+ // rows for PATCH: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
147
+ describe("diff hunk curation", () => {
148
+ test("rejecting the only change writes an empty curated working copy", async () => {
149
+ // Arrange
150
+ const { controller, sink } = await connected(diffSession(FILES));
151
+
152
+ // Act - reject the change on the added line
153
+ controller.toggleRejectChange(4);
154
+ await tick();
155
+
156
+ // Assert
157
+ expect(controller.getSnapshot().status).toContain("change rejected");
158
+ expect(sink.workingCopy).toBe("");
159
+ });
160
+
161
+ test("both the deletion and addition rows of the change dim", async () => {
162
+ // Arrange
163
+ const { controller } = await connected(diffSession(FILES));
164
+
165
+ // Act
166
+ controller.toggleRejectChange(4);
167
+ await tick();
168
+
169
+ // Assert - the change spans the del row and the add row
170
+ expect([...controller.rejectedRows()].sort((a, b) => a - b)).toEqual([3, 4]);
171
+ });
172
+
173
+ test("toggling the same change twice restores the full diff", async () => {
174
+ // Arrange
175
+ const { controller, sink } = await connected(diffSession(FILES));
176
+
177
+ // Act - reject then restore
178
+ controller.toggleRejectChange(4);
179
+ await tick();
180
+ controller.toggleRejectChange(4);
181
+ await tick();
182
+
183
+ // Assert - no decisions left, so the working copy reverts to undefined
184
+ expect(controller.getSnapshot().status).toContain("change restored");
185
+ expect(sink.workingCopy).toBeUndefined();
186
+ expect(controller.rejectedRows().size).toBe(0);
187
+ });
188
+
189
+ test("whole-hunk reject drops the hunk from the working copy", async () => {
190
+ // Arrange
191
+ const { controller, sink } = await connected(diffSession(FILES));
192
+
193
+ // Act - reject the whole hunk from any of its rows
194
+ controller.toggleRejectHunk(3);
195
+ await tick();
196
+
197
+ // Assert - the single hunk is the whole diff, so nothing remains
198
+ expect(controller.getSnapshot().status).toContain("hunk rejected");
199
+ expect(sink.workingCopy).toBe("");
200
+ });
201
+
202
+ test("a rejected change becomes a removal card with a source, id, and preview", async () => {
203
+ // Arrange
204
+ const { controller } = await connected(diffSession(FILES));
205
+
206
+ // Act
207
+ controller.toggleRejectChange(4);
208
+ await tick();
209
+
210
+ // Assert - one item: diff source, revealing the change's row, both lines previewed
211
+ const items = controller.curationItems();
212
+
213
+ expect(items.length).toBe(1);
214
+ expect(items[0]!.source).toBe("diff");
215
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#1");
216
+ expect(items[0]!.revealIndex).toBe(3);
217
+ expect(items[0]!.preview).toEqual([
218
+ "- private items = [];",
219
+ "+ private items = new Map();",
220
+ ]);
221
+ });
222
+
223
+ test("a rejected hunk becomes a removal card with the whole-hunk id", async () => {
224
+ // Arrange
225
+ const { controller } = await connected(diffSession(FILES));
226
+
227
+ // Act
228
+ controller.toggleRejectHunk(3);
229
+ await tick();
230
+
231
+ // Assert - the whole hunk, id diff:path#hunk#hunk
232
+ const items = controller.curationItems();
233
+
234
+ expect(items.length).toBe(1);
235
+ expect(items[0]!.source).toBe("diff");
236
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#hunk");
237
+ });
238
+
239
+ test("restoreCuration drops the diff removal and reverts the working copy", async () => {
240
+ // Arrange - one change rejected
241
+ const { controller, sink } = await connected(diffSession(FILES));
242
+
243
+ controller.toggleRejectChange(4);
244
+ await tick();
245
+ const [item] = controller.curationItems();
246
+
247
+ // Act
248
+ controller.restoreCuration(item!.id);
249
+ await tick();
250
+
251
+ // Assert - the list empties and the working copy reverts to the full diff
252
+ expect(controller.getSnapshot().status).toContain("removal restored");
253
+ expect(controller.curationItems().length).toBe(0);
254
+ expect(sink.workingCopy).toBeUndefined();
255
+ expect(controller.rejectedRows().size).toBe(0);
256
+ });
257
+
258
+ test("restoreCuration ignores an unknown id", async () => {
259
+ // Arrange - one change rejected
260
+ const { controller } = await connected(diffSession(FILES));
261
+
262
+ controller.toggleRejectChange(4);
263
+ await tick();
264
+
265
+ // Act
266
+ controller.restoreCuration("diff:nope#0#0");
267
+ await tick();
268
+
269
+ // Assert - the item stays
270
+ expect(controller.curationItems().length).toBe(1);
271
+ });
272
+
273
+ test("a second decision before the daemon answers builds on the first", async () => {
274
+ // Arrange
275
+ const { controller, client } = await connected(diffSession(FILES));
276
+
277
+ // Act - reject, then restore, with no answer in between
278
+ controller.toggleRejectChange(4);
279
+ controller.toggleRejectChange(4);
280
+ await tick();
281
+
282
+ // Assert - the second request restores; a stale snapshot would reject again
283
+ expect(client.sessionCurate).toHaveBeenCalledTimes(2);
284
+ expect(client.sessionCurate).toHaveBeenNthCalledWith(1, "ses_diff", [
285
+ { path: "src/store.ts", hunkIndex: 0, changeIndex: 1 },
286
+ ]);
287
+ expect(client.sessionCurate).toHaveBeenNthCalledWith(2, "ses_diff", []);
288
+ });
289
+
290
+ test("curation is disabled without full file contents", async () => {
291
+ // Arrange - a legacy diff with no artifact.files
292
+ const { controller, sink } = await connected(diffSession(undefined));
293
+
294
+ // Act
295
+ controller.toggleRejectChange(4);
296
+ await tick();
297
+
298
+ // Assert
299
+ expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
300
+ expect(sink.workingCopy).toBeUndefined();
301
+ });
302
+
303
+ test("curation is disabled when a revision cleared its file snapshots", async () => {
304
+ const { controller, client } = await connected(diffSession([]));
305
+
306
+ controller.toggleRejectChange(4);
307
+ await tick();
308
+
309
+ expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
310
+ expect(client.sessionCurate).not.toHaveBeenCalled();
311
+ });
312
+ });
313
+
314
+ const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
315
+ // the working copy with the "Second paragraph." block cut out
316
+ const PLAN_CUT = "# Title\n\nFirst paragraph.\n";
317
+
318
+ function planSession(workingCopy?: string): Thread {
319
+ return {
320
+ schemaVersion: SCHEMA_VERSION,
321
+ id: "ses_plan",
322
+ workspace: { repoRoot: "/repo", branch: "main" },
323
+ artifact: { type: "plan", content: PLAN_CONTENT, meta: {} },
324
+ revisions: [{ revision: 1, content: PLAN_CONTENT, submittedAt: "2026-01-01T00:00:00.000Z" }],
325
+ annotations: [],
326
+ message: null,
327
+ status: "pending",
328
+ createdAt: "2026-01-01T00:00:00.000Z",
329
+ workingCopy,
330
+ };
331
+ }
332
+
333
+ describe("plan cut removals", () => {
334
+ test("a cut block becomes a plan removal card previewing its content", async () => {
335
+ // Arrange - a plan whose working copy dropped the second paragraph
336
+ const { controller } = await connected(planSession(PLAN_CUT));
337
+
338
+ // Act
339
+ const items = controller.curationItems();
340
+
341
+ // Assert - one plan-source removal, keyed on the base line range, content previewed
342
+ expect(items.length).toBe(1);
343
+ expect(items[0]!.source).toBe("plan");
344
+ expect(items[0]!.id).toBe("plan:4-4");
345
+ expect(items[0]!.preview).toEqual(["Second paragraph."]);
346
+ });
347
+
348
+ test("restoreCuration re-inserts the cut block and returns to pristine", async () => {
349
+ // Arrange
350
+ const { controller, sink } = await connected(planSession(PLAN_CUT));
351
+ const [item] = controller.curationItems();
352
+
353
+ // Act
354
+ controller.restoreCuration(item!.id);
355
+ await tick();
356
+
357
+ // Assert - restoring the only cut round-trips to the submitted revision
358
+ expect(controller.getSnapshot().status).toContain("removal restored");
359
+ expect(sink.workingCopy).toBeUndefined();
360
+ });
361
+
362
+ test("a second cut before the daemon answers indexes the already-cut copy", async () => {
363
+ // Arrange - pristine plan: title(0), first(1), second(2)
364
+ const { controller, client, sink } = await connected(planSession());
365
+
366
+ // Act - cut the first paragraph, then the second, with no answer in between
367
+ controller.cut(1);
368
+ controller.cut(2);
369
+ await tick();
370
+
371
+ // Assert - the second paragraph is block 1 once the first is gone
372
+ expect(client.sessionCutBlock).toHaveBeenNthCalledWith(1, "ses_plan", 1);
373
+ expect(client.sessionCutBlock).toHaveBeenNthCalledWith(2, "ses_plan", 1);
374
+ expect(sink.workingCopy).toBe("# Title\n");
375
+ });
376
+ });
377
+
378
+ // a file whose hunk touches line 2 only, so lines 1/3/4 are unchanged tail/context
379
+ const TAIL_PATCH = `diff --git a/src/store.ts b/src/store.ts
380
+ --- a/src/store.ts
381
+ +++ b/src/store.ts
382
+ @@ -2,1 +2,1 @@
383
+ - private items = [];
384
+ + private items = new Map();
385
+ `;
386
+
387
+ function tailSession(): Thread {
388
+ return {
389
+ ...diffSession(),
390
+ artifact: {
391
+ type: "diff",
392
+ content: TAIL_PATCH,
393
+ meta: {},
394
+ files: [
395
+ {
396
+ path: "src/store.ts",
397
+ oldContents: "export class Store {\n private items = [];\n}\nexport const x = 1;\n",
398
+ newContents:
399
+ "export class Store {\n private items = new Map();\n}\nexport const x = 1;\n",
400
+ status: "modified",
401
+ },
402
+ ],
403
+ },
404
+ };
405
+ }
406
+
407
+ describe("diff file fold", () => {
408
+ test("collapse leaves only the file band; expand restores the body", async () => {
409
+ // Arrange
410
+ const { controller } = await connected(tailSession());
411
+ const bodyRows = () =>
412
+ controller.rows().filter((row) => row.file === "src/store.ts" && row.kind !== "file").length;
413
+
414
+ // Assert - starts unfolded with a body
415
+ expect(bodyRows()).toBeGreaterThan(0);
416
+
417
+ // Act / Assert - collapse hides the body
418
+ controller.setFileCollapsed("src/store.ts", true);
419
+ expect(bodyRows()).toBe(0);
420
+ expect(controller.isFileCollapsed("src/store.ts")).toBe(true);
421
+
422
+ // Act / Assert - unfold brings it back
423
+ controller.setFileCollapsed("src/store.ts", false);
424
+ expect(bodyRows()).toBeGreaterThan(0);
425
+ });
426
+
427
+ test("expand weaves the file's unchanged tail line the hunk never showed", async () => {
428
+ // Arrange
429
+ const { controller } = await connected(tailSession());
430
+ const hasTail = () => controller.rows().some((row) => row.text.includes("export const x = 1"));
431
+
432
+ // Assert - the hunk view does not show the tail line
433
+ expect(controller.canExpandFile("src/store.ts")).toBe(true);
434
+ expect(hasTail()).toBe(false);
435
+
436
+ // Act - weave the full file
437
+ controller.setFileExpanded("src/store.ts", true);
438
+
439
+ // Assert
440
+ expect(hasTail()).toBe(true);
441
+ });
442
+
443
+ test("a diff without full contents cannot be expanded", async () => {
444
+ // Arrange - no artifact.files
445
+ const { controller } = await connected(diffSession(undefined));
446
+
447
+ // Assert
448
+ expect(controller.canExpandFile("src/store.ts")).toBe(false);
449
+ });
450
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
3
+ import { sameDerivationInputs } from "./thread-controller";
4
+
5
+ function threadFixture(overrides: Partial<Thread> = {}): 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
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ const annotation: Annotation = {
23
+ id: "ann_1",
24
+ kind: "comment",
25
+ anchor: { quote: "Ship it.", prefix: "", suffix: "" },
26
+ body: "nit",
27
+ createdAt: "2026-01-01T00:00:00.000Z",
28
+ };
29
+
30
+ describe("sameDerivationInputs", () => {
31
+ test("an annotation-only update reuses the projection (a fresh record, identical inputs)", () => {
32
+ // Arrange - the daemon re-serializes the whole thread on any change, so the record is all-new
33
+ const before = threadFixture();
34
+ const after = threadFixture({ annotations: [annotation] });
35
+
36
+ // Assert - the artifact and working copy are unchanged, so the projection can be reused
37
+ expect(sameDerivationInputs(before, after)).toBe(true);
38
+ });
39
+
40
+ test("a content edit forces a re-derivation", () => {
41
+ const before = threadFixture();
42
+ const after = threadFixture({
43
+ artifact: { type: "plan", content: "# Plan\n\nShip it soon.\n", meta: {} },
44
+ });
45
+
46
+ expect(sameDerivationInputs(before, after)).toBe(false);
47
+ });
48
+
49
+ test("a working-copy change forces a re-derivation", () => {
50
+ const before = threadFixture();
51
+ const after = threadFixture({ workingCopy: "# Plan\n\nShip it.\n\nedited\n" });
52
+
53
+ expect(sameDerivationInputs(before, after)).toBe(false);
54
+ });
55
+
56
+ test("a different thread is never a reuse", () => {
57
+ const before = threadFixture();
58
+ const after = threadFixture({ id: "ses_2" });
59
+
60
+ expect(sameDerivationInputs(before, after)).toBe(false);
61
+ });
62
+ });