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

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 +1043 -798
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +495 -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 +290 -0
  41. package/src/components/AppShell.zoom.test.tsx +135 -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 +153 -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,472 @@
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 Thread } from "@cueloop/schema";
4
+ import type { SessionClient } 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 (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
18
+ );
19
+
20
+ const shareTransport: ShareTransport = {
21
+ publish: publishShare,
22
+ pull: pullShare,
23
+ push: pushShare,
24
+ watch: () => () => {},
25
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
26
+ collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
27
+ mergeFromShare,
28
+ };
29
+
30
+ function sessionFixture(overrides: Partial<Thread> = {}): Thread {
31
+ return {
32
+ schemaVersion: SCHEMA_VERSION,
33
+ id: "ses_1",
34
+ workspace: { repoRoot: "/repo", branch: "main" },
35
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
36
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
37
+ annotations: [],
38
+ verdict: null,
39
+ status: "pending",
40
+ createdAt: "2026-01-01T00:00:00.000Z",
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ function annotation(id: string, author: string): Annotation {
46
+ return {
47
+ id,
48
+ kind: "comment",
49
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
50
+ body: "note",
51
+ author,
52
+ createdAt: "2026-01-01T00:00:00.000Z",
53
+ };
54
+ }
55
+
56
+ interface FakeSessionClient extends SessionClient {
57
+ sessionComment: Mock<SessionClient["sessionComment"]>;
58
+ }
59
+
60
+ const unimplemented = (member: string) => () =>
61
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
62
+
63
+ function fakeClient(session: Thread): FakeSessionClient {
64
+ return {
65
+ onEvent: () => () => {},
66
+ subscribe: async () => {},
67
+ sessionGet: async () => session,
68
+ sessionList: async () => [session],
69
+ sessionComment: mock<SessionClient["sessionComment"]>(async () => session),
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: mock(
84
+ async (_id: string, shareId: string) => ((session.shareId = shareId), session),
85
+ ),
86
+ sessionMergeShared: mock(async (_id: string, incoming: { annotations: Annotation[] }) => {
87
+ const known = new Set(session.annotations.map((existing) => existing.id));
88
+
89
+ for (const note of incoming.annotations)
90
+ if (!known.has(note.id)) session.annotations.push(note);
91
+
92
+ return session;
93
+ }),
94
+ sessionDelete: unimplemented("sessionDelete"),
95
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
96
+ sessionResolve: unimplemented("sessionResolve"),
97
+ close: () => {},
98
+ };
99
+ }
100
+
101
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
102
+
103
+ async function connectedController(
104
+ session: Thread,
105
+ clock?: ManualClock,
106
+ transport: ShareTransport = shareTransport,
107
+ ): Promise<{ controller: ReturnType<typeof createReviewController>; client: FakeSessionClient }> {
108
+ const client = fakeClient(session);
109
+ const controller = createReviewController({
110
+ sessionId: session.id,
111
+ openClient: async () => client,
112
+ clock,
113
+ shareTransport: transport,
114
+ });
115
+
116
+ controller.connect();
117
+ await tick();
118
+
119
+ return { controller, client };
120
+ }
121
+
122
+ describe("share", () => {
123
+ test("stamps the returned share id back on the session", async () => {
124
+ // Arrange
125
+ const { controller, client } = await connectedController(sessionFixture());
126
+
127
+ // Act
128
+ controller.share();
129
+ await tick();
130
+
131
+ // Assert
132
+ expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
133
+ });
134
+
135
+ test("surfaces the ssh line as a toast, not an inline status", async () => {
136
+ // Arrange
137
+ const { controller } = await connectedController(sessionFixture());
138
+
139
+ // Act
140
+ controller.share();
141
+ await tick();
142
+
143
+ // Assert
144
+ expect(controller.getSnapshot().toast).toEqual({
145
+ body: "ssh p_abc123xy@cueloop.dev",
146
+ title: "share link copied",
147
+ });
148
+ expect(controller.getSnapshot().status).not.toContain("ssh p_abc123xy@cueloop.dev");
149
+ });
150
+ });
151
+
152
+ describe("pullShared", () => {
153
+ test("unions collaborator notes and identities in when the plan was shared", async () => {
154
+ // Arrange
155
+ remote = sessionFixture({
156
+ annotations: [annotation("a1", "SHA256:mate")],
157
+ participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
158
+ });
159
+ const { controller, client } = await connectedController(
160
+ sessionFixture({ shareId: "p_abc123xy" }),
161
+ );
162
+
163
+ // Act
164
+ controller.pullShared();
165
+ await tick();
166
+
167
+ // Assert
168
+ expect(pullShare).toHaveBeenCalledWith("p_abc123xy");
169
+ expect(client.sessionMergeShared).toHaveBeenCalledWith("ses_1", {
170
+ annotations: remote.annotations,
171
+ participants: remote.participants,
172
+ });
173
+ });
174
+
175
+ test("does nothing for a plan that was never shared", async () => {
176
+ // Arrange
177
+ pullShare.mockClear();
178
+ const { controller, client } = await connectedController(sessionFixture());
179
+
180
+ // Act
181
+ controller.pullShared();
182
+ await tick();
183
+
184
+ // Assert
185
+ expect(pullShare).not.toHaveBeenCalled();
186
+ expect(client.sessionMergeShared).not.toHaveBeenCalled();
187
+ });
188
+ });
189
+
190
+ describe("mirror on annotate", () => {
191
+ test("pushes an edited note up when the plan is shared", async () => {
192
+ // Arrange
193
+ pushShare.mockClear();
194
+ const { controller } = await connectedController(
195
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
196
+ );
197
+
198
+ // Act
199
+ controller.updateAnnotation("a1", "revised body");
200
+ await tick();
201
+
202
+ // Assert
203
+ expect(pushShare).toHaveBeenCalledTimes(1);
204
+ expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
205
+ expect(pushShare.mock.calls[0]?.[1]).toEqual([
206
+ {
207
+ id: "a1",
208
+ kind: "comment",
209
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
210
+ body: "revised body",
211
+ },
212
+ ]);
213
+ });
214
+
215
+ test("does not push when the plan was never shared", async () => {
216
+ // Arrange
217
+ pushShare.mockClear();
218
+ const { controller } = await connectedController(
219
+ sessionFixture({ annotations: [annotation("a1", "SHA256:me")] }),
220
+ );
221
+
222
+ // Act
223
+ controller.updateAnnotation("a1", "revised body");
224
+ await tick();
225
+
226
+ // Assert
227
+ expect(pushShare).not.toHaveBeenCalled();
228
+ });
229
+
230
+ test("does not push when the local write is rejected", async () => {
231
+ // Arrange - the daemon write fails (e.g. a resolved session)
232
+ pushShare.mockClear();
233
+ const { controller, client } = await connectedController(
234
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
235
+ );
236
+
237
+ client.sessionComment.mockImplementationOnce(async () => {
238
+ throw new Error("session is resolved");
239
+ });
240
+
241
+ // Act
242
+ controller.updateAnnotation("a1", "revised body");
243
+ await tick();
244
+
245
+ // Assert - a failed local write must never leak to the share
246
+ expect(pushShare).not.toHaveBeenCalled();
247
+ });
248
+ });
249
+
250
+ describe("reply", () => {
251
+ test("a reply shares the root's anchor, names it in replyTo, and mirrors up", async () => {
252
+ // Arrange
253
+ pushShare.mockClear();
254
+ const { controller, client } = await connectedController(
255
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:ana")] }),
256
+ );
257
+
258
+ // Act
259
+ const id = controller.reply("a1", "agreed");
260
+ await tick();
261
+
262
+ // Assert
263
+ const wire = client.sessionComment.mock.calls.at(-1)![1];
264
+
265
+ expect(wire).toEqual({
266
+ id: id!,
267
+ kind: "comment",
268
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
269
+ body: "agreed",
270
+ replyTo: "a1",
271
+ });
272
+ expect(pushShare.mock.calls[0]?.[1]).toEqual([wire]);
273
+ });
274
+
275
+ test("editing a reply keeps its reply link", async () => {
276
+ // Arrange
277
+ const { controller, client } = await connectedController(
278
+ sessionFixture({
279
+ annotations: [
280
+ annotation("a1", "SHA256:ana"),
281
+ { ...annotation("a2", "SHA256:me"), replyTo: "a1" },
282
+ ],
283
+ }),
284
+ );
285
+
286
+ // Act
287
+ controller.updateAnnotation("a2", "revised reply");
288
+ await tick();
289
+
290
+ // Assert
291
+ expect(client.sessionComment.mock.calls.at(-1)?.[1]).toMatchObject({
292
+ id: "a2",
293
+ body: "revised reply",
294
+ replyTo: "a1",
295
+ });
296
+ });
297
+
298
+ test("a reply to a reply hangs off the discussion's root comment", async () => {
299
+ // Arrange
300
+ const { controller, client } = await connectedController(
301
+ sessionFixture({
302
+ annotations: [
303
+ annotation("a1", "SHA256:ana"),
304
+ { ...annotation("a2", "SHA256:bob"), replyTo: "a1" },
305
+ ],
306
+ }),
307
+ );
308
+
309
+ // Act
310
+ controller.reply("a2", "same thread");
311
+ await tick();
312
+
313
+ // Assert
314
+ expect(client.sessionComment.mock.calls.at(-1)?.[1]).toMatchObject({ replyTo: "a1" });
315
+ });
316
+
317
+ test("replying to an unknown annotation does nothing", async () => {
318
+ // Arrange
319
+ const { controller, client } = await connectedController(sessionFixture());
320
+
321
+ // Act + Assert
322
+ expect(controller.reply("missing", "x")).toBeUndefined();
323
+ expect(client.sessionComment).not.toHaveBeenCalled();
324
+ });
325
+ });
326
+
327
+ describe("startShareSync", () => {
328
+ /** A fake watch stream: records handlers so a test can push a session or drop the link. */
329
+ const streams: Array<{ shareId: string; handlers: ShareWatchHandlers; stopped: boolean }> = [];
330
+ const watchShare = mock((shareId: string, handlers: ShareWatchHandlers) => {
331
+ const stream = { shareId, handlers, stopped: false };
332
+
333
+ streams.push(stream);
334
+
335
+ return () => {
336
+ stream.stopped = true;
337
+ };
338
+ });
339
+ const liveTransport: ShareTransport = { ...shareTransport, watch: watchShare };
340
+
341
+ beforeEach(() => {
342
+ streams.length = 0;
343
+ watchShare.mockClear();
344
+ pullShare.mockClear();
345
+ remote = sessionFixture({ annotations: [] });
346
+ });
347
+
348
+ test("connect pulls once to catch up, then follows the stream and merges each session it delivers", async () => {
349
+ // Arrange
350
+ const clock = new ManualClock();
351
+ const { controller, client } = await connectedController(
352
+ sessionFixture({ shareId: "p_abc123xy" }),
353
+ clock,
354
+ liveTransport,
355
+ );
356
+
357
+ // Act
358
+ controller.startShareSync();
359
+ await tick();
360
+
361
+ // Assert - one catch-up pull, one open stream on the share
362
+ expect(pullShare).toHaveBeenCalledTimes(1);
363
+ expect(streams).toHaveLength(1);
364
+ expect(streams[0]!.shareId).toBe("p_abc123xy");
365
+
366
+ // Act - the gateway pushes a change carrying Ana's note
367
+ streams[0]!.handlers.onSession(
368
+ sessionFixture({ annotations: [annotation("a9", "SHA256:ana")] }),
369
+ );
370
+ await tick();
371
+
372
+ // Assert - merged without another pull
373
+ expect(client.sessionMergeShared).toHaveBeenLastCalledWith(
374
+ "ses_1",
375
+ expect.objectContaining({
376
+ annotations: [expect.objectContaining({ id: "a9", author: "SHA256:ana" })],
377
+ }),
378
+ );
379
+ expect(pullShare).toHaveBeenCalledTimes(1);
380
+ });
381
+
382
+ test("a dropped stream reconnects with doubling backoff and catches up on each connect", async () => {
383
+ // Arrange
384
+ const clock = new ManualClock();
385
+ const { controller } = await connectedController(
386
+ sessionFixture({ shareId: "p_abc123xy" }),
387
+ clock,
388
+ liveTransport,
389
+ );
390
+
391
+ controller.startShareSync();
392
+ await tick();
393
+
394
+ // Act - the link drops: nothing until the floor delay has passed
395
+ streams[0]!.handlers.onClose("ssh exited 255");
396
+ clock.advance(SHARE_RECONNECT_MIN_MS - 1);
397
+ await tick();
398
+ expect(streams).toHaveLength(1);
399
+ clock.advance(1);
400
+ await tick();
401
+
402
+ // Assert - reconnected, with a fresh catch-up pull
403
+ expect(streams).toHaveLength(2);
404
+ expect(pullShare).toHaveBeenCalledTimes(2);
405
+
406
+ // Act - drops again: the wait doubles
407
+ streams[1]!.handlers.onClose("ssh exited 255");
408
+ clock.advance(SHARE_RECONNECT_MIN_MS);
409
+ await tick();
410
+ expect(streams).toHaveLength(2);
411
+ clock.advance(SHARE_RECONNECT_MIN_MS);
412
+ await tick();
413
+ expect(streams).toHaveLength(3);
414
+
415
+ // Act - a delivered session resets the backoff to the floor
416
+ streams[2]!.handlers.onSession(remote);
417
+ streams[2]!.handlers.onClose("ssh exited 255");
418
+ clock.advance(SHARE_RECONNECT_MIN_MS);
419
+ await tick();
420
+
421
+ // Assert
422
+ expect(streams).toHaveLength(4);
423
+ });
424
+
425
+ test("stop closes the stream and a late close never reconnects", async () => {
426
+ // Arrange
427
+ const clock = new ManualClock();
428
+ const { controller } = await connectedController(
429
+ sessionFixture({ shareId: "p_abc123xy" }),
430
+ clock,
431
+ liveTransport,
432
+ );
433
+ const stop = controller.startShareSync();
434
+
435
+ await tick();
436
+
437
+ // Act
438
+ stop();
439
+ streams[0]!.handlers.onClose("killed");
440
+ clock.advance(SHARE_RECONNECT_MAX_MS * 2);
441
+ await tick();
442
+
443
+ // Assert
444
+ expect(streams[0]!.stopped).toBe(true);
445
+ expect(streams).toHaveLength(1);
446
+ });
447
+
448
+ test("a restart replaces the stream instead of doubling it", async () => {
449
+ // Arrange
450
+ const clock = new ManualClock();
451
+ const { controller } = await connectedController(
452
+ sessionFixture({ shareId: "p_abc123xy" }),
453
+ clock,
454
+ liveTransport,
455
+ );
456
+
457
+ controller.startShareSync();
458
+ await tick();
459
+
460
+ // Act
461
+ controller.startShareSync();
462
+ await tick();
463
+ streams[0]!.handlers.onClose("stale");
464
+ clock.advance(SHARE_RECONNECT_MAX_MS * 2);
465
+ await tick();
466
+
467
+ // Assert - the first stream was stopped and its close re-armed nothing
468
+ expect(streams[0]!.stopped).toBe(true);
469
+ expect(streams).toHaveLength(2);
470
+ expect(streams[1]!.stopped).toBe(false);
471
+ });
472
+ });