@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41

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 (138) hide show
  1. package/native/build.sh +56 -0
  2. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  3. package/native/src/shim.c +107 -0
  4. package/package.json +34 -24
  5. package/src/App.autoclose.test.tsx +137 -0
  6. package/src/App.diff.test.tsx +158 -22
  7. package/src/App.export.test.tsx +44 -37
  8. package/src/App.plan-review.test.tsx +632 -0
  9. package/src/App.readonly.test.tsx +60 -36
  10. package/src/App.rendering.test.tsx +132 -0
  11. package/src/App.review-panel.test.tsx +136 -0
  12. package/src/App.submit-confirm.test.tsx +220 -0
  13. package/src/App.test.tsx +128 -69
  14. package/src/App.toast.test.tsx +75 -0
  15. package/src/App.tsx +0 -0
  16. package/src/App.walk.test.tsx +288 -0
  17. package/src/attribution.test.ts +31 -0
  18. package/src/attribution.ts +38 -0
  19. package/src/clipboard.test.ts +23 -0
  20. package/src/clipboard.ts +30 -0
  21. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  22. package/src/components/AnnotationCard.stories.tsx +94 -0
  23. package/src/components/AnnotationCard.tsx +228 -0
  24. package/src/components/Breadcrumb.stories.tsx +19 -0
  25. package/src/components/Breadcrumb.tsx +44 -0
  26. package/src/components/CodeBlock.stories.tsx +42 -0
  27. package/src/components/CodeBlock.tsx +83 -0
  28. package/src/components/CompletionOverlay.stories.tsx +23 -0
  29. package/src/components/CompletionOverlay.tsx +59 -0
  30. package/src/components/ConfirmCard.stories.tsx +56 -0
  31. package/src/components/ConfirmCard.tsx +135 -0
  32. package/src/components/ConfirmDialog.stories.tsx +21 -0
  33. package/src/components/ConfirmDialog.tsx +64 -0
  34. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  35. package/src/components/DiffSheet.stories.tsx +78 -0
  36. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  37. package/src/components/DiffSheet.tsx +355 -0
  38. package/src/components/InboxList.stories.tsx +31 -0
  39. package/src/components/InboxList.tsx +62 -0
  40. package/src/components/KeybindsDialog.stories.tsx +31 -0
  41. package/src/components/KeybindsDialog.tsx +58 -0
  42. package/src/components/MarkerPopover.stories.tsx +58 -0
  43. package/src/components/MarkerPopover.tsx +102 -0
  44. package/src/components/MenuBar.stories.tsx +37 -0
  45. package/src/components/MenuBar.tsx +74 -0
  46. package/src/components/PlanSheet.stories.tsx +113 -0
  47. package/src/components/PlanSheet.tsx +389 -0
  48. package/src/components/PromptDialog.stories.tsx +36 -0
  49. package/src/components/PromptDialog.tsx +78 -0
  50. package/src/components/ReviewPanel.stories.tsx +86 -0
  51. package/src/components/ReviewPanel.tsx +101 -0
  52. package/src/components/ReviewRail.stories.tsx +220 -0
  53. package/src/components/ReviewRail.tsx +324 -0
  54. package/src/components/SettingsDialog.stories.tsx +111 -0
  55. package/src/components/SettingsDialog.tsx +141 -0
  56. package/src/components/SettingsRows.stories.tsx +18 -0
  57. package/src/components/SettingsRows.tsx +108 -0
  58. package/src/components/Toast.stories.tsx +12 -0
  59. package/src/components/Toast.tsx +44 -0
  60. package/src/components/WalkWizard.stories.tsx +100 -0
  61. package/src/components/WalkWizard.tsx +152 -0
  62. package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
  63. package/src/components/agent-launcher.stories.tsx +26 -0
  64. package/src/components/agent-launcher.test.tsx +21 -0
  65. package/src/components/agent-launcher.tsx +180 -0
  66. package/src/components/diff-sheet-layout.test.ts +127 -0
  67. package/src/components/diff-sheet-layout.ts +167 -0
  68. package/src/components/primitives/Button.stories.tsx +32 -0
  69. package/src/components/primitives/Button.tsx +48 -0
  70. package/src/components/primitives/Card.stories.tsx +25 -0
  71. package/src/components/primitives/Card.tsx +59 -0
  72. package/src/components/primitives/Dialog.stories.tsx +24 -0
  73. package/src/components/primitives/Dialog.tsx +64 -0
  74. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  75. package/src/components/primitives/StatusBar.tsx +22 -0
  76. package/src/components/primitives/Tabs.stories.tsx +29 -0
  77. package/src/components/primitives/Tabs.tsx +98 -0
  78. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  79. package/src/components/primitives/Toolbar.tsx +14 -0
  80. package/src/components/primitives/frame.test.ts +12 -0
  81. package/src/components/primitives/frame.ts +14 -0
  82. package/src/components/quick-actions-editor.stories.tsx +54 -0
  83. package/src/components/quick-actions-editor.tsx +136 -0
  84. package/src/components/stories-app.tsx +123 -0
  85. package/src/components/stories.test.tsx +62 -0
  86. package/src/components/story-fixtures.ts +98 -0
  87. package/src/components/story.ts +69 -0
  88. package/src/components/syntax-highlight.ts +92 -0
  89. package/src/components/terminal-pane.test.tsx +40 -0
  90. package/src/components/terminal-pane.ts +174 -0
  91. package/src/components/theme-context.stories.tsx +46 -0
  92. package/src/components/theme-context.tsx +36 -0
  93. package/src/components/truncate-text.ts +6 -0
  94. package/src/config.test.ts +430 -31
  95. package/src/config.ts +304 -26
  96. package/src/diff-hunk-curate.test.ts +315 -0
  97. package/src/diff-hunk-curate.ts +233 -0
  98. package/src/diff-intraline.test.ts +164 -0
  99. package/src/diff-intraline.ts +132 -0
  100. package/src/diff-syntax.test.ts +96 -0
  101. package/src/diff-syntax.ts +154 -0
  102. package/src/editor.test.ts +143 -0
  103. package/src/editor.ts +123 -9
  104. package/src/ghostty-terminal.test.ts +45 -0
  105. package/src/ghostty-terminal.ts +168 -0
  106. package/src/herdr.test.ts +41 -0
  107. package/src/herdr.ts +27 -0
  108. package/src/index.ts +10 -0
  109. package/src/integrations.test.ts +41 -0
  110. package/src/integrations.ts +30 -0
  111. package/src/intent-dispatch.test.ts +492 -0
  112. package/src/intent-dispatch.ts +428 -0
  113. package/src/key-bindings.test.ts +152 -0
  114. package/src/key-bindings.ts +424 -0
  115. package/src/keymap.test.ts +622 -0
  116. package/src/keymap.ts +339 -0
  117. package/src/review-panel.test.ts +113 -0
  118. package/src/review-panel.ts +83 -0
  119. package/src/run.ts +9 -5
  120. package/src/serve.test.ts +24 -9
  121. package/src/serve.ts +7 -7
  122. package/src/session-controller.curate.test.ts +253 -0
  123. package/src/session-controller.share.test.ts +309 -0
  124. package/src/session-controller.ts +900 -0
  125. package/src/share.ts +113 -0
  126. package/src/test-support.test.ts +55 -0
  127. package/src/test-support.ts +187 -0
  128. package/src/theme-presets.ts +168 -0
  129. package/src/theme.ts +36 -15
  130. package/src/version.ts +5 -0
  131. package/src/view-diff.test.ts +19 -8
  132. package/src/view-diff.ts +34 -23
  133. package/src/view-plan.test.ts +294 -0
  134. package/src/view-plan.ts +408 -0
  135. package/src/walk.test.ts +121 -0
  136. package/src/walk.ts +70 -0
  137. package/src/view.test.ts +0 -157
  138. package/src/view.ts +0 -0
@@ -0,0 +1,253 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type DiffFileContents, type ReviewSession } from "@cueloop/schema";
3
+ import type { SessionClient } from "@cueloop/daemon/client";
4
+ import { createReviewController } from "./session-controller";
5
+
6
+ const PATCH = `diff --git a/src/store.ts b/src/store.ts
7
+ index 111..222 100644
8
+ --- a/src/store.ts
9
+ +++ b/src/store.ts
10
+ @@ -1,4 +1,4 @@
11
+ export class Store {
12
+ - private items = [];
13
+ + private items = new Map();
14
+ }
15
+ `;
16
+
17
+ const FILES: DiffFileContents[] = [
18
+ {
19
+ path: "src/store.ts",
20
+ oldContents: "export class Store {\n private items = [];\n}\n",
21
+ newContents: "export class Store {\n private items = new Map();\n}\n",
22
+ status: "modified",
23
+ },
24
+ ];
25
+
26
+ function diffSession(files?: DiffFileContents[]): ReviewSession {
27
+ return {
28
+ schemaVersion: SCHEMA_VERSION,
29
+ id: "ses_diff",
30
+ workspace: { repoRoot: "/repo", branch: "main" },
31
+ artifact: { type: "diff", content: PATCH, meta: {}, files },
32
+ revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00.000Z" }],
33
+ annotations: [],
34
+ verdict: null,
35
+ status: "pending",
36
+ createdAt: "2026-01-01T00:00:00.000Z",
37
+ };
38
+ }
39
+
40
+ /** A fake client that records the working copy the controller writes. */
41
+ function fakeClient(session: ReviewSession, sink: { workingCopy?: string }): SessionClient {
42
+ return {
43
+ onEvent: () => () => {},
44
+ subscribe: async () => {},
45
+ sessionGet: async () => session,
46
+ sessionList: async () => [session],
47
+ sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
48
+ sink.workingCopy = content;
49
+ return { ...session, workingCopy: content };
50
+ }),
51
+ close: () => {},
52
+ } as unknown as SessionClient;
53
+ }
54
+
55
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
56
+
57
+ async function connected(session: ReviewSession) {
58
+ const sink: { workingCopy?: string } = {};
59
+ const client = fakeClient(session, sink);
60
+ const controller = createReviewController({
61
+ sessionId: session.id,
62
+ openClient: async () => client,
63
+ });
64
+ controller.connect();
65
+ await tick();
66
+ return { controller, client, sink };
67
+ }
68
+
69
+ // rows for PATCH: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
70
+ describe("diff hunk curation", () => {
71
+ test("rejecting the only change writes an empty curated working copy", async () => {
72
+ // Arrange
73
+ const { controller, sink } = await connected(diffSession(FILES));
74
+
75
+ // Act - reject the change on the added line
76
+ controller.toggleRejectChange(4);
77
+ await tick();
78
+
79
+ // Assert
80
+ expect(controller.getSnapshot().status).toContain("change rejected");
81
+ expect(sink.workingCopy).toBe("");
82
+ });
83
+
84
+ test("both the deletion and addition rows of the change dim", async () => {
85
+ // Arrange
86
+ const { controller } = await connected(diffSession(FILES));
87
+
88
+ // Act
89
+ controller.toggleRejectChange(4);
90
+ await tick();
91
+
92
+ // Assert - the change spans the del row and the add row
93
+ expect([...controller.rejectedRows()].sort((a, b) => a - b)).toEqual([3, 4]);
94
+ });
95
+
96
+ test("toggling the same change twice restores the full diff", async () => {
97
+ // Arrange
98
+ const { controller, sink } = await connected(diffSession(FILES));
99
+
100
+ // Act - reject then restore
101
+ controller.toggleRejectChange(4);
102
+ await tick();
103
+ controller.toggleRejectChange(4);
104
+ await tick();
105
+
106
+ // Assert - no decisions left, so the working copy reverts to undefined
107
+ expect(controller.getSnapshot().status).toContain("change restored");
108
+ expect(sink.workingCopy).toBeUndefined();
109
+ expect(controller.rejectedRows().size).toBe(0);
110
+ });
111
+
112
+ test("whole-hunk reject drops the hunk from the working copy", async () => {
113
+ // Arrange
114
+ const { controller, sink } = await connected(diffSession(FILES));
115
+
116
+ // Act - reject the whole hunk from any of its rows
117
+ controller.toggleRejectHunk(3);
118
+ await tick();
119
+
120
+ // Assert - the single hunk is the whole diff, so nothing remains
121
+ expect(controller.getSnapshot().status).toContain("hunk rejected");
122
+ expect(sink.workingCopy).toBe("");
123
+ });
124
+
125
+ test("a rejected change becomes a removal card with a source, id, and preview", async () => {
126
+ // Arrange
127
+ const { controller } = await connected(diffSession(FILES));
128
+
129
+ // Act
130
+ controller.toggleRejectChange(4);
131
+ await tick();
132
+
133
+ // Assert - one item: diff source, revealing the change's row, both lines previewed
134
+ const items = controller.curationItems();
135
+ expect(items.length).toBe(1);
136
+ expect(items[0]!.source).toBe("diff");
137
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#1");
138
+ expect(items[0]!.revealIndex).toBe(3);
139
+ expect(items[0]!.preview).toEqual([
140
+ "- private items = [];",
141
+ "+ private items = new Map();",
142
+ ]);
143
+ });
144
+
145
+ test("a rejected hunk becomes a removal card with the whole-hunk id", async () => {
146
+ // Arrange
147
+ const { controller } = await connected(diffSession(FILES));
148
+
149
+ // Act
150
+ controller.toggleRejectHunk(3);
151
+ await tick();
152
+
153
+ // Assert - the whole hunk, id diff:path#hunk#hunk
154
+ const items = controller.curationItems();
155
+ expect(items.length).toBe(1);
156
+ expect(items[0]!.source).toBe("diff");
157
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#hunk");
158
+ });
159
+
160
+ test("restoreCuration drops the diff removal and reverts the working copy", async () => {
161
+ // Arrange - one change rejected
162
+ const { controller, sink } = await connected(diffSession(FILES));
163
+ controller.toggleRejectChange(4);
164
+ await tick();
165
+ const [item] = controller.curationItems();
166
+
167
+ // Act
168
+ controller.restoreCuration(item!.id);
169
+ await tick();
170
+
171
+ // Assert - the list empties and the working copy reverts to the full diff
172
+ expect(controller.getSnapshot().status).toContain("removal restored");
173
+ expect(controller.curationItems().length).toBe(0);
174
+ expect(sink.workingCopy).toBeUndefined();
175
+ expect(controller.rejectedRows().size).toBe(0);
176
+ });
177
+
178
+ test("restoreCuration ignores an unknown id", async () => {
179
+ // Arrange - one change rejected
180
+ const { controller } = await connected(diffSession(FILES));
181
+ controller.toggleRejectChange(4);
182
+ await tick();
183
+
184
+ // Act
185
+ controller.restoreCuration("diff:nope#0#0");
186
+ await tick();
187
+
188
+ // Assert - the item stays
189
+ expect(controller.curationItems().length).toBe(1);
190
+ });
191
+
192
+ test("curation is disabled without full file contents", async () => {
193
+ // Arrange - a legacy diff with no artifact.files
194
+ const { controller, sink } = await connected(diffSession(undefined));
195
+
196
+ // Act
197
+ controller.toggleRejectChange(4);
198
+ await tick();
199
+
200
+ // Assert
201
+ expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
202
+ expect(sink.workingCopy).toBeUndefined();
203
+ });
204
+ });
205
+
206
+ const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
207
+ // the working copy with the "Second paragraph." block cut out
208
+ const PLAN_CUT = "# Title\n\nFirst paragraph.\n";
209
+
210
+ function planSession(workingCopy?: string): ReviewSession {
211
+ return {
212
+ schemaVersion: SCHEMA_VERSION,
213
+ id: "ses_plan",
214
+ workspace: { repoRoot: "/repo", branch: "main" },
215
+ artifact: { type: "plan", content: PLAN_CONTENT, meta: {} },
216
+ revisions: [{ revision: 1, content: PLAN_CONTENT, submittedAt: "2026-01-01T00:00:00.000Z" }],
217
+ annotations: [],
218
+ verdict: null,
219
+ status: "pending",
220
+ createdAt: "2026-01-01T00:00:00.000Z",
221
+ workingCopy,
222
+ };
223
+ }
224
+
225
+ describe("plan cut removals", () => {
226
+ test("a cut block becomes a plan removal card previewing its content", async () => {
227
+ // Arrange - a plan whose working copy dropped the second paragraph
228
+ const { controller } = await connected(planSession(PLAN_CUT));
229
+
230
+ // Act
231
+ const items = controller.curationItems();
232
+
233
+ // Assert - one plan-source removal, keyed on the base line range, content previewed
234
+ expect(items.length).toBe(1);
235
+ expect(items[0]!.source).toBe("plan");
236
+ expect(items[0]!.id).toBe("plan:4-4");
237
+ expect(items[0]!.preview).toEqual(["Second paragraph."]);
238
+ });
239
+
240
+ test("restoreCuration re-inserts the cut block and returns to pristine", async () => {
241
+ // Arrange
242
+ const { controller, sink } = await connected(planSession(PLAN_CUT));
243
+ const [item] = controller.curationItems();
244
+
245
+ // Act
246
+ controller.restoreCuration(item!.id);
247
+ await tick();
248
+
249
+ // Assert - restoring the only cut round-trips to the submitted revision
250
+ expect(controller.getSnapshot().status).toContain("removal restored");
251
+ expect(sink.workingCopy).toBeUndefined();
252
+ });
253
+ });
@@ -0,0 +1,309 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { ManualClock } from "@opentui/core/testing";
3
+ import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
4
+ import type { SessionClient } from "@cueloop/daemon/client";
5
+
6
+ // Stub the ssh transport so no subprocess spawns; the controller under test
7
+ // imports these from "./share".
8
+ const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
9
+ let remote: ReviewSession;
10
+ const pullShare = mock(async () => remote);
11
+ const pushShare = mock(
12
+ async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
13
+ );
14
+ mock.module("./share", () => ({
15
+ publishShare,
16
+ pullShare,
17
+ pushShare,
18
+ shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
19
+ collaboratorAnnotations: (session: ReviewSession) =>
20
+ session.annotations.filter((annotation) => annotation.author),
21
+ }));
22
+
23
+ const { createReviewController, SHARE_POLL_MS } = await import("./session-controller");
24
+
25
+ function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
26
+ return {
27
+ schemaVersion: SCHEMA_VERSION,
28
+ id: "ses_1",
29
+ workspace: { repoRoot: "/repo", branch: "main" },
30
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
31
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
32
+ annotations: [],
33
+ verdict: null,
34
+ status: "pending",
35
+ createdAt: "2026-01-01T00:00:00.000Z",
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ function annotation(id: string, author: string): Annotation {
41
+ return {
42
+ id,
43
+ kind: "comment",
44
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
45
+ body: "note",
46
+ author,
47
+ createdAt: "2026-01-01T00:00:00.000Z",
48
+ };
49
+ }
50
+
51
+ function fakeClient(session: ReviewSession): SessionClient {
52
+ return {
53
+ onEvent: () => () => {},
54
+ subscribe: async () => {},
55
+ sessionGet: async () => session,
56
+ sessionList: async () => [session],
57
+ sessionAnnotate: mock(async () => session),
58
+ sessionSetShareId: mock(
59
+ async (_id: string, shareId: string) => ((session.shareId = shareId), session),
60
+ ),
61
+ sessionMergeShared: mock(async (_id: string, incoming: { annotations: Annotation[] }) => {
62
+ const known = new Set(session.annotations.map((existing) => existing.id));
63
+ for (const note of incoming.annotations)
64
+ if (!known.has(note.id)) session.annotations.push(note);
65
+ return session;
66
+ }),
67
+ close: () => {},
68
+ } as unknown as SessionClient;
69
+ }
70
+
71
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
72
+
73
+ async function connectedController(
74
+ session: ReviewSession,
75
+ clock?: ManualClock,
76
+ ): Promise<{ controller: ReturnType<typeof createReviewController>; client: SessionClient }> {
77
+ const client = fakeClient(session);
78
+ const controller = createReviewController({
79
+ sessionId: session.id,
80
+ openClient: async () => client,
81
+ clock,
82
+ });
83
+ controller.connect();
84
+ await tick();
85
+ return { controller, client };
86
+ }
87
+
88
+ describe("share", () => {
89
+ test("stamps the returned share id back on the session", async () => {
90
+ // Arrange
91
+ const { controller, client } = await connectedController(sessionFixture());
92
+
93
+ // Act
94
+ controller.share();
95
+ await tick();
96
+
97
+ // Assert
98
+ expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
99
+ });
100
+
101
+ test("surfaces the ssh line as a toast, not an inline status", async () => {
102
+ // Arrange
103
+ const { controller } = await connectedController(sessionFixture());
104
+
105
+ // Act
106
+ controller.share();
107
+ await tick();
108
+
109
+ // Assert
110
+ expect(controller.getSnapshot().toast).toEqual({
111
+ body: "ssh p_abc123xy@cueloop.dev",
112
+ title: "share link copied",
113
+ });
114
+ expect(controller.getSnapshot().status).not.toContain("ssh p_abc123xy@cueloop.dev");
115
+ });
116
+ });
117
+
118
+ describe("pullShared", () => {
119
+ test("unions collaborator notes and identities in when the plan was shared", async () => {
120
+ // Arrange
121
+ remote = sessionFixture({
122
+ annotations: [annotation("a1", "SHA256:mate")],
123
+ participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
124
+ });
125
+ const { controller, client } = await connectedController(
126
+ sessionFixture({ shareId: "p_abc123xy" }),
127
+ );
128
+
129
+ // Act
130
+ controller.pullShared();
131
+ await tick();
132
+
133
+ // Assert
134
+ expect(pullShare).toHaveBeenCalledWith("p_abc123xy");
135
+ expect(client.sessionMergeShared).toHaveBeenCalledWith("ses_1", {
136
+ annotations: remote.annotations,
137
+ participants: remote.participants,
138
+ });
139
+ });
140
+
141
+ test("does nothing for a plan that was never shared", async () => {
142
+ // Arrange
143
+ pullShare.mockClear();
144
+ const { controller, client } = await connectedController(sessionFixture());
145
+
146
+ // Act
147
+ controller.pullShared();
148
+ await tick();
149
+
150
+ // Assert
151
+ expect(pullShare).not.toHaveBeenCalled();
152
+ expect(client.sessionMergeShared).not.toHaveBeenCalled();
153
+ });
154
+ });
155
+
156
+ describe("mirror on annotate", () => {
157
+ test("pushes an edited note up when the plan is shared", async () => {
158
+ // Arrange
159
+ pushShare.mockClear();
160
+ const { controller } = await connectedController(
161
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
162
+ );
163
+
164
+ // Act
165
+ controller.updateAnnotation("a1", "revised body");
166
+ await tick();
167
+
168
+ // Assert
169
+ expect(pushShare).toHaveBeenCalledTimes(1);
170
+ expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
171
+ expect(pushShare.mock.calls[0]?.[1]).toEqual([
172
+ {
173
+ id: "a1",
174
+ kind: "comment",
175
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
176
+ body: "revised body",
177
+ },
178
+ ]);
179
+ });
180
+
181
+ test("does not push when the plan was never shared", async () => {
182
+ // Arrange
183
+ pushShare.mockClear();
184
+ const { controller } = await connectedController(
185
+ sessionFixture({ annotations: [annotation("a1", "SHA256:me")] }),
186
+ );
187
+
188
+ // Act
189
+ controller.updateAnnotation("a1", "revised body");
190
+ await tick();
191
+
192
+ // Assert
193
+ expect(pushShare).not.toHaveBeenCalled();
194
+ });
195
+
196
+ test("does not push when the local write is rejected", async () => {
197
+ // Arrange - the daemon write fails (e.g. a resolved session)
198
+ pushShare.mockClear();
199
+ const { controller, client } = await connectedController(
200
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
201
+ );
202
+ (client.sessionAnnotate as ReturnType<typeof mock>).mockImplementationOnce(async () => {
203
+ throw new Error("session is resolved");
204
+ });
205
+
206
+ // Act
207
+ controller.updateAnnotation("a1", "revised body");
208
+ await tick();
209
+
210
+ // Assert - a failed local write must never leak to the share
211
+ expect(pushShare).not.toHaveBeenCalled();
212
+ });
213
+ });
214
+
215
+ describe("startSharePoll", () => {
216
+ test("pulls now and again each interval until stopped", async () => {
217
+ // Arrange
218
+ pullShare.mockClear();
219
+ remote = sessionFixture({ annotations: [] });
220
+ const clock = new ManualClock();
221
+ const { controller } = await connectedController(
222
+ sessionFixture({ shareId: "p_abc123xy" }),
223
+ clock,
224
+ );
225
+
226
+ // Act - immediate pull
227
+ const stop = controller.startSharePoll();
228
+ await tick();
229
+
230
+ // Assert
231
+ expect(pullShare).toHaveBeenCalledTimes(1);
232
+
233
+ // Act - one interval later, it pulls again
234
+ clock.advance(SHARE_POLL_MS);
235
+ await tick();
236
+
237
+ // Assert
238
+ expect(pullShare).toHaveBeenCalledTimes(2);
239
+
240
+ // Act - stop, then advance: no further pulls
241
+ stop();
242
+ clock.advance(SHARE_POLL_MS * 3);
243
+ await tick();
244
+
245
+ // Assert
246
+ expect(pullShare).toHaveBeenCalledTimes(2);
247
+ });
248
+
249
+ test("a stop during an in-flight pull leaves no zombie timer", async () => {
250
+ // Arrange - the first pull hangs until released
251
+ pullShare.mockClear();
252
+ remote = sessionFixture({ annotations: [] });
253
+ let release: () => void = () => {};
254
+ pullShare.mockImplementationOnce(
255
+ () => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
256
+ );
257
+ const clock = new ManualClock();
258
+ const { controller } = await connectedController(
259
+ sessionFixture({ shareId: "p_abc123xy" }),
260
+ clock,
261
+ );
262
+
263
+ // Act - start (pull is in flight), leave, then let the pull settle
264
+ const stop = controller.startSharePoll();
265
+ await tick();
266
+ expect(pullShare).toHaveBeenCalledTimes(1);
267
+ stop();
268
+ release();
269
+ await tick();
270
+ clock.advance(SHARE_POLL_MS * 3);
271
+ await tick();
272
+
273
+ // Assert - the settled pull's finally must not re-arm the timer
274
+ expect(pullShare).toHaveBeenCalledTimes(1);
275
+ });
276
+
277
+ test("a restart during an in-flight pull does not double the poll", async () => {
278
+ // Arrange - run A's first pull hangs; later pulls resolve immediately
279
+ pullShare.mockClear();
280
+ remote = sessionFixture({ annotations: [] });
281
+ let release: () => void = () => {};
282
+ pullShare.mockImplementationOnce(
283
+ () => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
284
+ );
285
+ const clock = new ManualClock();
286
+ const { controller } = await connectedController(
287
+ sessionFixture({ shareId: "p_abc123xy" }),
288
+ clock,
289
+ );
290
+
291
+ // Act - run A starts (pull hangs), then run B restarts while A is in flight
292
+ controller.startSharePoll();
293
+ await tick();
294
+ expect(pullShare).toHaveBeenCalledTimes(1);
295
+ const stopB = controller.startSharePoll();
296
+ await tick();
297
+ expect(pullShare).toHaveBeenCalledTimes(2);
298
+
299
+ // A's stale pull settles - it must not re-arm; then one interval fires only run B
300
+ release();
301
+ await tick();
302
+ clock.advance(SHARE_POLL_MS);
303
+ await tick();
304
+
305
+ // Assert - exactly one more pull (run B), not two
306
+ expect(pullShare).toHaveBeenCalledTimes(3);
307
+ stopB();
308
+ });
309
+ });