@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,561 @@
1
+ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
2
+ import { ManualClock } from "@opentui/core/testing";
3
+ import { SCHEMA_VERSION, type Annotation, type ShareAccess, type Thread } from "@cueloop/schema";
4
+ import type { ThreadClient } from "@cueloop/daemon/client";
5
+ import {
6
+ createReviewController,
7
+ SHARE_RECONNECT_MAX_MS,
8
+ SHARE_RECONNECT_MIN_MS,
9
+ type ShareTransport,
10
+ } from "./thread-controller";
11
+ import { mergeFromShare, type ShareWatchHandlers } from "./share";
12
+
13
+ const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
14
+ let remote: Thread;
15
+ const pullShare = mock(async () => remote);
16
+ const pushShare = mock(
17
+ async (
18
+ _shareId: string,
19
+ _annotations: Array<Omit<Annotation, "createdAt">>,
20
+ _access?: ShareAccess | "public",
21
+ ) => {},
22
+ );
23
+
24
+ const revokeShare = mock(async (_shareId: string) => {});
25
+
26
+ const shareTransport: ShareTransport = {
27
+ publish: publishShare,
28
+ pull: pullShare,
29
+ push: pushShare,
30
+ watch: () => () => {},
31
+ revoke: revokeShare,
32
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
33
+ formatShareLine: (id: string) => "ssh " + id + "@cueloop.dev",
34
+ collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
35
+ mergeFromShare,
36
+ };
37
+
38
+ function sessionFixture(overrides: Partial<Thread> = {}): Thread {
39
+ return {
40
+ schemaVersion: SCHEMA_VERSION,
41
+ id: "ses_1",
42
+ workspace: { repoRoot: "/repo", branch: "main" },
43
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
44
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
45
+ annotations: [],
46
+ message: null,
47
+ status: "pending",
48
+ createdAt: "2026-01-01T00:00:00.000Z",
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ function annotation(id: string, author: string): Annotation {
54
+ return {
55
+ id,
56
+ kind: "comment",
57
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
58
+ body: "note",
59
+ author,
60
+ createdAt: "2026-01-01T00:00:00.000Z",
61
+ };
62
+ }
63
+
64
+ interface FakeThreadClient extends ThreadClient {
65
+ sessionComment: Mock<ThreadClient["sessionComment"]>;
66
+ }
67
+
68
+ const unimplemented = (member: string) => () =>
69
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
70
+
71
+ function fakeClient(session: Thread): FakeThreadClient {
72
+ return {
73
+ onEvent: () => () => {},
74
+ subscribe: async () => {},
75
+ sessionGet: async () => session,
76
+ sessionList: async () => [session],
77
+ sessionComment: mock<ThreadClient["sessionComment"]>(async () => session),
78
+ sessionAnnotate: unimplemented("sessionAnnotate"),
79
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
80
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
81
+ sessionCutBlock: unimplemented("sessionCutBlock"),
82
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
83
+ sessionCurate: unimplemented("sessionCurate"),
84
+ sessionSetAccess: mock(async () => session),
85
+ sessionNavigate: unimplemented("sessionNavigate"),
86
+ sessionBranch: unimplemented("sessionBranch"),
87
+ sessionSwitch: unimplemented("sessionSwitch"),
88
+ sessionLabel: unimplemented("sessionLabel"),
89
+ sessionFork: unimplemented("sessionFork"),
90
+ sessionSetViewed: unimplemented("sessionSetViewed"),
91
+ sessionSetTitle: unimplemented("sessionSetTitle"),
92
+ sessionSetShareId: mock(
93
+ async (_id: string, shareId: string) => ((session.shareId = shareId), session),
94
+ ),
95
+ sessionSetShares: mock(
96
+ async (_id: string, shares: import("@cueloop/schema").ShareLink[]) => (
97
+ (session.shares = shares),
98
+ session
99
+ ),
100
+ ),
101
+ sessionMergeShared: mock(async (_id: string, incoming: { annotations: Annotation[] }) => {
102
+ const known = new Set(session.annotations.map((existing) => existing.id));
103
+
104
+ for (const note of incoming.annotations)
105
+ if (!known.has(note.id)) session.annotations.push(note);
106
+
107
+ return session;
108
+ }),
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
+ async function connectedController(
119
+ session: Thread,
120
+ clock?: ManualClock,
121
+ transport: ShareTransport = shareTransport,
122
+ ): Promise<{ controller: ReturnType<typeof createReviewController>; client: FakeThreadClient }> {
123
+ const client = fakeClient(session);
124
+ const controller = createReviewController({
125
+ sessionId: session.id,
126
+ openClient: async () => client,
127
+ clock,
128
+ shareTransport: transport,
129
+ });
130
+
131
+ controller.connect();
132
+ await tick();
133
+
134
+ return { controller, client };
135
+ }
136
+
137
+ describe("share", () => {
138
+ test("publishes a public link and records it on the session", async () => {
139
+ // Arrange
140
+ const { controller, client } = await connectedController(sessionFixture());
141
+
142
+ // Act
143
+ controller.share();
144
+ await tick();
145
+
146
+ // Assert - a public link (no auth) is persisted with the returned id
147
+ expect(client.sessionSetShares).toHaveBeenCalledWith("ses_1", [
148
+ { id: "p_abc123xy", name: undefined, requireAuth: false, allowlist: [], shareBranch: "main" },
149
+ ]);
150
+ });
151
+
152
+ test("surfaces the ssh line as a toast, not an inline status", async () => {
153
+ // Arrange
154
+ const { controller } = await connectedController(sessionFixture());
155
+
156
+ // Act
157
+ controller.share();
158
+ await tick();
159
+
160
+ // Assert
161
+ expect(controller.getSnapshot().toast).toEqual({
162
+ body: "ssh p_abc123xy@cueloop.dev",
163
+ title: "link copied",
164
+ });
165
+ expect(controller.getSnapshot().status).not.toContain("ssh p_abc123xy@cueloop.dev");
166
+ });
167
+ });
168
+
169
+ describe("setShareAccess", () => {
170
+ test("pushes the allowlist up to the gateway when the plan is already shared", async () => {
171
+ // Arrange
172
+ pushShare.mockClear();
173
+ const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
174
+
175
+ // Act
176
+ controller.setShareAccess(["octocat"]);
177
+ await tick();
178
+
179
+ // Assert - the existing link must enforce the new access, so it rides the push
180
+ expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
181
+ expect(pushShare.mock.calls[0]?.[2]).toEqual({ githubLogins: ["octocat"] });
182
+ });
183
+
184
+ test("does not push when the plan was never shared", async () => {
185
+ // Arrange
186
+ pushShare.mockClear();
187
+ const { controller } = await connectedController(sessionFixture());
188
+
189
+ // Act
190
+ controller.setShareAccess(["octocat"]);
191
+ await tick();
192
+
193
+ // Assert
194
+ expect(pushShare).not.toHaveBeenCalled();
195
+ });
196
+ });
197
+
198
+ describe("pullShared", () => {
199
+ test("unions collaborator notes and identities in when the plan was shared", async () => {
200
+ // Arrange
201
+ remote = sessionFixture({
202
+ annotations: [annotation("a1", "SHA256:mate")],
203
+ participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
204
+ });
205
+ const { controller, client } = await connectedController(
206
+ sessionFixture({ shareId: "p_abc123xy" }),
207
+ );
208
+
209
+ // Act
210
+ controller.pullShared();
211
+ await tick();
212
+
213
+ // Assert
214
+ expect(pullShare).toHaveBeenCalledWith("p_abc123xy");
215
+ expect(client.sessionMergeShared).toHaveBeenCalledWith("ses_1", {
216
+ annotations: remote.annotations,
217
+ participants: remote.participants,
218
+ });
219
+ });
220
+
221
+ test("does nothing for a plan that was never shared", async () => {
222
+ // Arrange
223
+ pullShare.mockClear();
224
+ const { controller, client } = await connectedController(sessionFixture());
225
+
226
+ // Act
227
+ controller.pullShared();
228
+ await tick();
229
+
230
+ // Assert
231
+ expect(pullShare).not.toHaveBeenCalled();
232
+ expect(client.sessionMergeShared).not.toHaveBeenCalled();
233
+ });
234
+ });
235
+
236
+ describe("mirror on annotate", () => {
237
+ test("pushes an edited note up when the plan is shared", async () => {
238
+ // Arrange
239
+ pushShare.mockClear();
240
+ const { controller } = await connectedController(
241
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
242
+ );
243
+
244
+ // Act
245
+ controller.updateAnnotation("a1", "revised body");
246
+ await tick();
247
+
248
+ // Assert
249
+ expect(pushShare).toHaveBeenCalledTimes(1);
250
+ expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
251
+ expect(pushShare.mock.calls[0]?.[1]).toEqual([
252
+ {
253
+ id: "a1",
254
+ kind: "comment",
255
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
256
+ body: "revised body",
257
+ },
258
+ ]);
259
+ });
260
+
261
+ test("does not push when the plan was never shared", async () => {
262
+ // Arrange
263
+ pushShare.mockClear();
264
+ const { controller } = await connectedController(
265
+ sessionFixture({ annotations: [annotation("a1", "SHA256:me")] }),
266
+ );
267
+
268
+ // Act
269
+ controller.updateAnnotation("a1", "revised body");
270
+ await tick();
271
+
272
+ // Assert
273
+ expect(pushShare).not.toHaveBeenCalled();
274
+ });
275
+
276
+ test("does not push when the local write is rejected", async () => {
277
+ // Arrange - the daemon write fails (e.g. a resolved session)
278
+ pushShare.mockClear();
279
+ const { controller, client } = await connectedController(
280
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
281
+ );
282
+
283
+ client.sessionComment.mockImplementationOnce(async () => {
284
+ throw new Error("session is resolved");
285
+ });
286
+
287
+ // Act
288
+ controller.updateAnnotation("a1", "revised body");
289
+ await tick();
290
+
291
+ // Assert - a failed local write must never leak to the share
292
+ expect(pushShare).not.toHaveBeenCalled();
293
+ });
294
+ });
295
+
296
+ describe("reply", () => {
297
+ test("a reply shares the root's anchor, names it in replyTo, and mirrors up", async () => {
298
+ // Arrange
299
+ pushShare.mockClear();
300
+ const { controller, client } = await connectedController(
301
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:ana")] }),
302
+ );
303
+
304
+ // Act
305
+ const id = controller.reply("a1", "agreed");
306
+ await tick();
307
+
308
+ // Assert
309
+ const wire = client.sessionComment.mock.calls.at(-1)![1];
310
+
311
+ expect(wire).toEqual({
312
+ id: id!,
313
+ kind: "comment",
314
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
315
+ body: "agreed",
316
+ replyTo: "a1",
317
+ });
318
+ expect(pushShare.mock.calls[0]?.[1]).toEqual([wire]);
319
+ });
320
+
321
+ test("editing a reply keeps its reply link", async () => {
322
+ // Arrange
323
+ const { controller, client } = await connectedController(
324
+ sessionFixture({
325
+ annotations: [
326
+ annotation("a1", "SHA256:ana"),
327
+ { ...annotation("a2", "SHA256:me"), replyTo: "a1" },
328
+ ],
329
+ }),
330
+ );
331
+
332
+ // Act
333
+ controller.updateAnnotation("a2", "revised reply");
334
+ await tick();
335
+
336
+ // Assert
337
+ expect(client.sessionComment.mock.calls.at(-1)?.[1]).toMatchObject({
338
+ id: "a2",
339
+ body: "revised reply",
340
+ replyTo: "a1",
341
+ });
342
+ });
343
+
344
+ test("a reply to a reply hangs off the discussion's root comment", async () => {
345
+ // Arrange
346
+ const { controller, client } = await connectedController(
347
+ sessionFixture({
348
+ annotations: [
349
+ annotation("a1", "SHA256:ana"),
350
+ { ...annotation("a2", "SHA256:bob"), replyTo: "a1" },
351
+ ],
352
+ }),
353
+ );
354
+
355
+ // Act
356
+ controller.reply("a2", "same thread");
357
+ await tick();
358
+
359
+ // Assert
360
+ expect(client.sessionComment.mock.calls.at(-1)?.[1]).toMatchObject({ replyTo: "a1" });
361
+ });
362
+
363
+ test("replying to an unknown annotation does nothing", async () => {
364
+ // Arrange
365
+ const { controller, client } = await connectedController(sessionFixture());
366
+
367
+ // Act + Assert
368
+ expect(controller.reply("missing", "x")).toBeUndefined();
369
+ expect(client.sessionComment).not.toHaveBeenCalled();
370
+ });
371
+ });
372
+
373
+ describe("startShareSync", () => {
374
+ /** A fake watch stream: records handlers so a test can push a session or drop the link. */
375
+ const streams: Array<{ shareId: string; handlers: ShareWatchHandlers; stopped: boolean }> = [];
376
+ const watchShare = mock((shareId: string, handlers: ShareWatchHandlers) => {
377
+ const stream = { shareId, handlers, stopped: false };
378
+
379
+ streams.push(stream);
380
+
381
+ return () => {
382
+ stream.stopped = true;
383
+ };
384
+ });
385
+ const liveTransport: ShareTransport = { ...shareTransport, watch: watchShare };
386
+
387
+ beforeEach(() => {
388
+ streams.length = 0;
389
+ watchShare.mockClear();
390
+ pullShare.mockClear();
391
+ remote = sessionFixture({ annotations: [] });
392
+ });
393
+
394
+ test("connect pulls once to catch up, then follows the stream and merges each session it delivers", async () => {
395
+ // Arrange
396
+ const clock = new ManualClock();
397
+ const { controller, client } = await connectedController(
398
+ sessionFixture({ shareId: "p_abc123xy" }),
399
+ clock,
400
+ liveTransport,
401
+ );
402
+
403
+ // Act
404
+ controller.startShareSync();
405
+ await tick();
406
+
407
+ // Assert - one catch-up pull, one open stream on the share
408
+ expect(pullShare).toHaveBeenCalledTimes(1);
409
+ expect(streams).toHaveLength(1);
410
+ expect(streams[0]!.shareId).toBe("p_abc123xy");
411
+
412
+ // Act - the gateway pushes a change carrying Ana's note
413
+ streams[0]!.handlers.onSession(
414
+ sessionFixture({ annotations: [annotation("a9", "SHA256:ana")] }),
415
+ );
416
+ await tick();
417
+
418
+ // Assert - merged without another pull
419
+ expect(client.sessionMergeShared).toHaveBeenLastCalledWith(
420
+ "ses_1",
421
+ expect.objectContaining({
422
+ annotations: [expect.objectContaining({ id: "a9", author: "SHA256:ana" })],
423
+ }),
424
+ );
425
+ expect(pullShare).toHaveBeenCalledTimes(1);
426
+ });
427
+
428
+ test("a dropped stream reconnects with doubling backoff and catches up on each connect", async () => {
429
+ // Arrange
430
+ const clock = new ManualClock();
431
+ const { controller } = await connectedController(
432
+ sessionFixture({ shareId: "p_abc123xy" }),
433
+ clock,
434
+ liveTransport,
435
+ );
436
+
437
+ controller.startShareSync();
438
+ await tick();
439
+
440
+ // Act - the link drops: nothing until the floor delay has passed
441
+ streams[0]!.handlers.onClose("ssh exited 255");
442
+ clock.advance(SHARE_RECONNECT_MIN_MS - 1);
443
+ await tick();
444
+ expect(streams).toHaveLength(1);
445
+ clock.advance(1);
446
+ await tick();
447
+
448
+ // Assert - reconnected, with a fresh catch-up pull
449
+ expect(streams).toHaveLength(2);
450
+ expect(pullShare).toHaveBeenCalledTimes(2);
451
+
452
+ // Act - drops again: the wait doubles
453
+ streams[1]!.handlers.onClose("ssh exited 255");
454
+ clock.advance(SHARE_RECONNECT_MIN_MS);
455
+ await tick();
456
+ expect(streams).toHaveLength(2);
457
+ clock.advance(SHARE_RECONNECT_MIN_MS);
458
+ await tick();
459
+ expect(streams).toHaveLength(3);
460
+
461
+ // Act - a delivered session resets the backoff to the floor
462
+ streams[2]!.handlers.onSession(remote);
463
+ streams[2]!.handlers.onClose("ssh exited 255");
464
+ clock.advance(SHARE_RECONNECT_MIN_MS);
465
+ await tick();
466
+
467
+ // Assert
468
+ expect(streams).toHaveLength(4);
469
+ });
470
+
471
+ test("stop closes the stream and a late close never reconnects", async () => {
472
+ // Arrange
473
+ const clock = new ManualClock();
474
+ const { controller } = await connectedController(
475
+ sessionFixture({ shareId: "p_abc123xy" }),
476
+ clock,
477
+ liveTransport,
478
+ );
479
+ const stop = controller.startShareSync();
480
+
481
+ await tick();
482
+
483
+ // Act
484
+ stop();
485
+ streams[0]!.handlers.onClose("killed");
486
+ clock.advance(SHARE_RECONNECT_MAX_MS * 2);
487
+ await tick();
488
+
489
+ // Assert
490
+ expect(streams[0]!.stopped).toBe(true);
491
+ expect(streams).toHaveLength(1);
492
+ });
493
+
494
+ test("a restart replaces the stream instead of doubling it", async () => {
495
+ // Arrange
496
+ const clock = new ManualClock();
497
+ const { controller } = await connectedController(
498
+ sessionFixture({ shareId: "p_abc123xy" }),
499
+ clock,
500
+ liveTransport,
501
+ );
502
+
503
+ controller.startShareSync();
504
+ await tick();
505
+
506
+ // Act
507
+ controller.startShareSync();
508
+ await tick();
509
+ streams[0]!.handlers.onClose("stale");
510
+ clock.advance(SHARE_RECONNECT_MAX_MS * 2);
511
+ await tick();
512
+
513
+ // Assert - the first stream was stopped and its close re-armed nothing
514
+ expect(streams[0]!.stopped).toBe(true);
515
+ expect(streams).toHaveLength(2);
516
+ expect(streams[1]!.stopped).toBe(false);
517
+ });
518
+ });
519
+
520
+ describe("revoke", () => {
521
+ test("unshare revokes the gateway blob for the shared thread", async () => {
522
+ revokeShare.mockClear();
523
+ const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
524
+
525
+ controller.unshare();
526
+ await tick();
527
+
528
+ expect(revokeShare).toHaveBeenCalledWith("p_abc123xy");
529
+ });
530
+
531
+ test("unshare on an unshared thread is a no-op", async () => {
532
+ revokeShare.mockClear();
533
+ const { controller } = await connectedController(sessionFixture());
534
+
535
+ controller.unshare();
536
+ await tick();
537
+
538
+ expect(revokeShare).not.toHaveBeenCalled();
539
+ });
540
+
541
+ test("deleting a shared thread revokes its share first", async () => {
542
+ revokeShare.mockClear();
543
+ const session = sessionFixture({ shareId: "p_abc123xy" });
544
+ const client = fakeClient(session);
545
+
546
+ client.sessionDelete = mock(async () => {});
547
+ const controller = createReviewController({
548
+ sessionId: session.id,
549
+ openClient: async () => client,
550
+ shareTransport,
551
+ });
552
+
553
+ controller.connect();
554
+ await tick();
555
+ controller.deleteSession(session.id);
556
+ await tick();
557
+
558
+ expect(revokeShare).toHaveBeenCalledWith("p_abc123xy");
559
+ expect(client.sessionDelete).toHaveBeenCalledWith(session.id);
560
+ });
561
+ });