@cueloop/client 0.1.0-alpha.23 → 0.1.0-alpha.26

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.
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.23",
3
+ "version": "0.1.0-alpha.26",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@cueloop/daemon": "0.1.0-alpha.23",
11
- "@cueloop/extension-api": "0.1.0-alpha.23",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.23",
13
- "@cueloop/schema": "0.1.0-alpha.23",
10
+ "@cueloop/daemon": "0.1.0-alpha.26",
11
+ "@cueloop/extension-api": "0.1.0-alpha.26",
12
+ "@cueloop/integration-obsidian": "0.1.0-alpha.26",
13
+ "@cueloop/schema": "0.1.0-alpha.26",
14
14
  "@opentui/core": "^0.5.1",
15
15
  "@opentui/keymap": "0.5.1",
16
16
  "@opentui/react": "^0.5.1",
@@ -69,7 +69,7 @@ describe("diff review", () => {
69
69
  await press(setup, "c");
70
70
 
71
71
  // Assert
72
- expect(setup.captureCharFrame()).toContain("COMMENT ON");
72
+ await waitForText(setup, "COMMENT ON");
73
73
 
74
74
  // Act
75
75
  await setup.mockInput.typeText("Map needs an eviction story.");
@@ -101,6 +101,6 @@ describe("diff review", () => {
101
101
  await press(setup, "x");
102
102
 
103
103
  // Assert
104
- expect(setup.captureCharFrame()).toContain("plan-only verb");
104
+ await waitForText(setup, "plan-only verb");
105
105
  });
106
106
  });
@@ -54,7 +54,7 @@ describe("obsidian export on resolve", () => {
54
54
  await press(setup, "enter"); // open the submit overlay, keep the default verdict (approve)
55
55
 
56
56
  // Assert
57
- expect(setup.captureCharFrame()).toContain("verdict");
57
+ await waitForText(setup, "verdict");
58
58
 
59
59
  // Act
60
60
  await press(setup, "enter"); // submit
@@ -76,12 +76,38 @@ async function toContextParagraph(setup: Setup): Promise<void> {
76
76
  }
77
77
 
78
78
  describe("share button", () => {
79
- test("the owner's rail shows the one-click Share button", async () => {
79
+ test("the owner's plan header shows the Share button next to Edit", async () => {
80
80
  // Arrange / Act
81
81
  const setup = await renderApp();
82
82
 
83
83
  // Assert
84
- expect(setup.captureCharFrame()).toContain("Share plan");
84
+ // both word-buttons ride the same header row, so Share sits next to Edit
85
+ const headerLine = setup.captureCharFrame().split("\n").find((line) => line.includes("submitted by"));
86
+ expect(headerLine).toContain("Edit");
87
+ expect(headerLine).toContain("Share");
88
+ });
89
+
90
+ test("a read-only viewer (a plan shared over ssh) never sees the Share button", async () => {
91
+ // Arrange / Act
92
+ const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, { width: 120, height: 32 });
93
+ await waitForText(viewer, "cueloop");
94
+
95
+ // Assert
96
+ expect(viewer.captureCharFrame()).not.toContain("Share");
97
+ });
98
+
99
+ test("a resolved plan hides Edit and Share (no re-sharing a finished review)", async () => {
100
+ // Arrange - resolve the session before opening it
101
+ server.core.sessionResolve(session.id, "approve", "");
102
+
103
+ // Act
104
+ const setup = await renderApp();
105
+ await waitForText(setup, "submitted by");
106
+
107
+ // Assert - the owner toolbar is gone once the review is resolved
108
+ const headerLine = setup.captureCharFrame().split("\n").find((line) => line.includes("submitted by"));
109
+ expect(headerLine).not.toContain("Edit");
110
+ expect(headerLine).not.toContain("Share");
85
111
  });
86
112
  });
87
113
 
@@ -116,7 +142,7 @@ describe("selection feeds the annotation quote", () => {
116
142
  await press(setup, "c");
117
143
 
118
144
  // Assert
119
- expect(setup.captureCharFrame()).toContain('comment on "The daemon"');
145
+ await waitForText(setup, 'comment on "The daemon"');
120
146
 
121
147
  // Act
122
148
  await type(setup, "Which daemon?");
@@ -143,7 +169,7 @@ describe("selection feeds the annotation quote", () => {
143
169
  await press(setup, "c");
144
170
 
145
171
  // Assert
146
- expect(setup.captureCharFrame()).toContain('comment on "persists sessions"');
172
+ await waitForText(setup, 'comment on "persists sessions"');
147
173
 
148
174
  // Act
149
175
  await type(setup, "sessions plural?");
@@ -170,7 +196,7 @@ describe("inline compose keeps the anchor painted", () => {
170
196
  // Assert
171
197
  // compose open: the anchor is painted selection-style. The box can render
172
198
  // a frame before the anchor repaint settles, so wait on the color itself.
173
- expect(setup.captureCharFrame()).toContain("Save ⏎");
199
+ await waitForText(setup, "Save ⏎");
174
200
  expect(setup.captureCharFrame()).toContain("Cancel esc");
175
201
  await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
176
202
  expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
@@ -197,8 +223,9 @@ describe("inline compose keeps the anchor painted", () => {
197
223
  expect(stored.annotations.length).toBe(1);
198
224
  expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBg);
199
225
  // renderApp plus this many frame-waits grazes the 5s default on a loaded CI
200
- // runner; the sibling walk resume test carries the same budget.
201
- }, 15_000);
226
+ // runner, and the whole-suite publish lane has timed even 15s out; give the
227
+ // heaviest frame-wait chain generous headroom so runner load cannot flake it.
228
+ }, 30_000);
202
229
  });
203
230
 
204
231
  describe("compose newline convention", () => {
@@ -363,7 +390,7 @@ describe("addressed annotations leave the open list", () => {
363
390
  });
364
391
 
365
392
  describe("sheet header", () => {
366
- test("shows the submitting agent, the revision, and the Edit word-button", async () => {
393
+ test("shows the submitting agent, the revision, and the Edit and Share word-buttons", async () => {
367
394
  // Arrange
368
395
  const setup = await renderApp();
369
396
 
@@ -371,6 +398,7 @@ describe("sheet header", () => {
371
398
  const frame = setup.captureCharFrame();
372
399
  expect(frame).toContain("submitted by agent/worker-3 · revision 1");
373
400
  expect(frame).toContain("Edit");
401
+ expect(frame).toContain("Share");
374
402
  });
375
403
 
376
404
  test("the Agent tab shows the submitter, status, and revision", async () => {
@@ -385,6 +413,7 @@ describe("sheet header", () => {
385
413
  await setup.renderOnce();
386
414
 
387
415
  // Assert
416
+ await waitForText(setup, "status: pending");
388
417
  const frame = setup.captureCharFrame();
389
418
  expect(frame).toContain("agent/worker-3");
390
419
  expect(frame).toContain("status: pending");
@@ -142,6 +142,6 @@ describe("observer navigation still works", () => {
142
142
  await setup.renderOnce();
143
143
 
144
144
  // Assert
145
- expect(setup.captureCharFrame()).toContain("▸ COMMENT");
145
+ await waitForText(setup, "▸ COMMENT");
146
146
  });
147
147
  });
@@ -10,7 +10,7 @@ import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
12
  import { loadConfig } from "./config";
13
- import { press, waitForText } from "./test-support";
13
+ import { press, waitForText, waitForTextGone } from "./test-support";
14
14
 
15
15
  const PLAN = "# Plan\n\nShip the thing.\n";
16
16
 
@@ -54,6 +54,7 @@ describe("review panel", () => {
54
54
  await press(setup, "b");
55
55
 
56
56
  // Assert
57
+ await waitForText(setup, "«");
57
58
  let frame = setup.captureCharFrame();
58
59
  expect(frame).toContain("«"); // compact strip shows the expand chevron
59
60
  expect(frame).not.toContain("Submit review");
@@ -62,7 +63,7 @@ describe("review panel", () => {
62
63
  await press(setup, "b");
63
64
 
64
65
  // Assert
65
- frame = setup.captureCharFrame();
66
+ frame = await waitForTextGone(setup, "«");
66
67
  expect(frame).not.toContain("«"); // hidden: no strip, no divider
67
68
  expect(frame).not.toContain("│");
68
69
 
@@ -70,7 +71,7 @@ describe("review panel", () => {
70
71
  await press(setup, "b");
71
72
 
72
73
  // Assert
73
- expect(setup.captureCharFrame()).toContain("Submit review"); // back to expanded
74
+ await waitForText(setup, "Submit review"); // back to expanded
74
75
  });
75
76
 
76
77
  test("the collapse mode persists to the user config", async () => {
@@ -104,12 +105,14 @@ describe("review panel", () => {
104
105
  await press(setup, "b"); // -> hidden
105
106
 
106
107
  // Assert
107
- expect(setup.captureCharFrame()).not.toContain("Submit review"); // rail is gone
108
+ await waitForTextGone(setup, "Submit review"); // rail is gone
109
+ expect(setup.captureCharFrame()).not.toContain("Submit review");
108
110
 
109
111
  // Act
110
112
  await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
111
113
 
112
114
  // Assert
115
+ await waitForText(setup, "submit review");
113
116
  const frame = setup.captureCharFrame();
114
117
  expect(frame).toContain("submit review"); // the confirm card is visible again
115
118
  expect(frame).toContain("[Approve]");
@@ -77,6 +77,7 @@ describe("rail submit confirm", () => {
77
77
  await press(setup, "enter");
78
78
 
79
79
  // Assert
80
+ await waitForText(setup, "submit review");
80
81
  const frame = setup.captureCharFrame();
81
82
  // the card: title, honest counts, verdict selector, word-buttons
82
83
  expect(frame).toContain("submit review");
@@ -96,25 +97,25 @@ describe("rail submit confirm", () => {
96
97
  await press(setup, "enter");
97
98
 
98
99
  // Assert
99
- expect(setup.captureCharFrame()).toContain("[Approve]");
100
+ await waitForText(setup, "[Approve]");
100
101
 
101
102
  // Act
102
103
  await press(setup, "right");
103
104
 
104
105
  // Assert
105
- expect(setup.captureCharFrame()).toContain("[Changes]");
106
+ await waitForText(setup, "[Changes]");
106
107
 
107
108
  // Act
108
109
  await press(setup, "right");
109
110
 
110
111
  // Assert
111
- expect(setup.captureCharFrame()).toContain("[Comment]");
112
+ await waitForText(setup, "[Comment]");
112
113
 
113
114
  // Act
114
115
  await press(setup, "left");
115
116
 
116
117
  // Assert
117
- expect(setup.captureCharFrame()).toContain("[Changes]");
118
+ await waitForText(setup, "[Changes]");
118
119
  });
119
120
 
120
121
  test("esc cancels the card and restores the plain Submit button", async () => {
@@ -123,7 +124,7 @@ describe("rail submit confirm", () => {
123
124
  await press(setup, "enter");
124
125
 
125
126
  // Assert
126
- expect(setup.captureCharFrame()).toContain("[Approve]");
127
+ await waitForText(setup, "[Approve]");
127
128
 
128
129
  // Act
129
130
  await press(setup, "escape");
@@ -144,6 +145,7 @@ describe("rail submit confirm", () => {
144
145
  await press(setup, "enter");
145
146
 
146
147
  // Assert
148
+ await waitForText(setup, "1 annotations · 0 blocking");
147
149
  const frame = setup.captureCharFrame();
148
150
  expect(frame).toContain("1 annotations · 0 blocking");
149
151
  expect(frame).toContain("[Changes]"); // pending items: request changes default
@@ -170,19 +172,16 @@ describe("rail submit confirm", () => {
170
172
  for (let index = 0; index < 12; index++) await press(setup, "n");
171
173
 
172
174
  // Assert
173
- const scrolled = setup.captureCharFrame();
174
- expect(scrolled).toContain("note 12");
175
- expect(scrolled).not.toContain("note 01");
175
+ await waitForText(setup, "note 12");
176
+ expect(setup.captureCharFrame()).not.toContain("note 01");
176
177
 
177
178
  // Act
178
179
  await press(setup, "enter");
179
180
 
180
181
  // Assert
181
- const opened = setup.captureCharFrame();
182
- // the card is pinned outside the scrollbox; the stack above stays
183
- // scrolled away from the top when the card takes its space
184
- expect(opened).toContain("12 annotations · 0 blocking");
185
- expect(opened).not.toContain("note 01");
182
+ await waitForText(setup, "12 annotations · 0 blocking");
183
+ // the card is pinned outside the scrollbox; the stack stays scrolled off the top
184
+ expect(setup.captureCharFrame()).not.toContain("note 01");
186
185
 
187
186
  // Act
188
187
  // the stack still scrolls with the card open: wheel over the rail
@@ -191,8 +190,8 @@ describe("rail submit confirm", () => {
191
190
  await settle(setup);
192
191
 
193
192
  // Assert
193
+ await waitForText(setup, "note 12");
194
194
  const scrolledWithCard = setup.captureCharFrame();
195
- expect(scrolledWithCard).toContain("note 12");
196
195
  expect(scrolledWithCard).toContain("12 annotations · 0 blocking");
197
196
  expect(scrolledWithCard).not.toContain("note 01");
198
197
  });
@@ -206,6 +205,7 @@ describe("rail submit confirm", () => {
206
205
  await press(setup, "enter");
207
206
 
208
207
  // Assert
208
+ await waitForText(setup, "observer - read-only");
209
209
  const frame = setup.captureCharFrame();
210
210
  expect(frame).toContain("observer - read-only");
211
211
  expect(frame).not.toContain("1 annotations");
package/src/App.test.tsx CHANGED
@@ -94,7 +94,7 @@ describe("keyboard grammar", () => {
94
94
  await setup.renderOnce();
95
95
 
96
96
  // Assert
97
- expect(setup.captureCharFrame()).toContain('comment on "The daemon');
97
+ await waitForText(setup, 'comment on "The daemon');
98
98
 
99
99
  // Act
100
100
  await type(setup, "Define atomically.");
@@ -182,7 +182,7 @@ describe("submit", () => {
182
182
  await press(setup, "enter"); // open submit
183
183
 
184
184
  // Assert
185
- expect(setup.captureCharFrame()).toContain("[Changes]");
185
+ await waitForText(setup, "[Changes]");
186
186
 
187
187
  // Act
188
188
  await type(setup, "Expand the steps.");
@@ -206,7 +206,7 @@ describe("submit", () => {
206
206
  await press(setup, "enter");
207
207
 
208
208
  // Assert
209
- expect(setup.captureCharFrame()).toContain("[Approve]"); // no pending items → approve default
209
+ await waitForText(setup, "[Approve]"); // no pending items → approve default
210
210
 
211
211
  // Act
212
212
  await press(setup, "enter");
package/src/App.tsx CHANGED
@@ -92,6 +92,12 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
92
92
  controller.getSnapshot,
93
93
  controller.getSnapshot,
94
94
  );
95
+ // A shared plan you own polls for collaborator notes while it is open; the
96
+ // merge refreshes through the normal event path. Stops on leave.
97
+ useEffect(() => {
98
+ if (!isOwner || !session?.shareId) return;
99
+ return controller.startSharePoll();
100
+ }, [isOwner, session?.id, session?.shareId, controller]);
95
101
  const renderer = useRenderer();
96
102
  const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
97
103
 
@@ -366,7 +372,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
366
372
  runEditorHandOff();
367
373
  };
368
374
 
369
- // clicking the rail Share button: publish the plan, copy the ssh line
375
+ // clicking the header Share button: publish the plan, copy the ssh line
370
376
  const onShareRequest = (): void => {
371
377
  if (!isOwner) return controller.setStatus("only the plan owner can share");
372
378
  controller.share();
@@ -493,6 +499,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
493
499
  editOrphanCount={editOrphanCount}
494
500
  onLineActivate={onLineActivate}
495
501
  onEditRequest={onEditRequest}
502
+ onShareRequest={onShareRequest}
496
503
  canEdit={isOwner}
497
504
  />
498
505
  )}
@@ -516,7 +523,6 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
516
523
  onSelectCard: selectCardFromRail,
517
524
  onActivateCard: openCardEdit,
518
525
  onSubmitRequest,
519
- onShareRequest: isOwner ? onShareRequest : undefined,
520
526
  }}
521
527
  />
522
528
  </box>
@@ -184,8 +184,10 @@ describe("the guided walk", () => {
184
184
  // Assert
185
185
  await waitForText(second, "file 3 of 3 · 2 viewed");
186
186
  expect(second.captureCharFrame()).toContain("src/c.ts");
187
- // two full App boots with daemon round-trips need more than the default budget
188
- }, 15_000);
187
+ // two full App boots with daemon round-trips need more than the default
188
+ // budget, and the whole-suite publish lane has timed even 15s out; give the
189
+ // double-boot chain generous headroom so runner load cannot flake it.
190
+ }, 30_000);
189
191
 
190
192
  test("the agent-note block renders only for files carrying a note", async () => {
191
193
  // Arrange
@@ -6,7 +6,7 @@ import { fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtur
6
6
 
7
7
  export const meta: StoryMeta = { title: "PlanSheet" };
8
8
 
9
- const callbacks = { onLineActivate: () => {}, onEditRequest: () => {}, canEdit: true };
9
+ const callbacks = { onLineActivate: () => {}, onEditRequest: () => {}, onShareRequest: () => {}, canEdit: true };
10
10
 
11
11
  export const AnnotatedPlan: Story = {
12
12
  render: () => (
@@ -64,7 +64,8 @@ export interface PlanSheetProps {
64
64
  editOrphanCount: number;
65
65
  onLineActivate: (displayIndex: number) => void;
66
66
  onEditRequest: () => void;
67
- /** Owner-only: show the Edit affordance. A share viewer never sees it. */
67
+ onShareRequest: () => void;
68
+ /** Owner-only: show the Edit and Share affordances. A share viewer never sees either. */
68
69
  canEdit: boolean;
69
70
  theme?: Theme;
70
71
  }
@@ -99,6 +100,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
99
100
  editOrphanCount,
100
101
  onLineActivate,
101
102
  onEditRequest,
103
+ onShareRequest,
102
104
  canEdit,
103
105
  theme,
104
106
  }: PlanSheetProps,
@@ -218,7 +220,13 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
218
220
 
219
221
  return (
220
222
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
221
- <SheetHeader session={session} onEditRequest={onEditRequest} canEdit={canEdit} theme={theme} />
223
+ <SheetHeader
224
+ session={session}
225
+ onEditRequest={onEditRequest}
226
+ onShareRequest={onShareRequest}
227
+ canEdit={canEdit}
228
+ theme={theme}
229
+ />
222
230
  {editOrphanCount > 0 ? (
223
231
  <box style={{ height: 1, backgroundColor: tokens.markCommentBg, paddingLeft: 2 }}>
224
232
  <text fg={tokens.red}>
@@ -233,15 +241,17 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
233
241
  );
234
242
  });
235
243
 
236
- /** Sheet chrome: submitted-by + revision delta left, the Edit word-button right. */
244
+ /** Sheet chrome: submitted-by + revision delta left, the Edit and Share word-buttons right. */
237
245
  function SheetHeader({
238
246
  session,
239
247
  onEditRequest,
248
+ onShareRequest,
240
249
  canEdit,
241
250
  theme,
242
251
  }: {
243
252
  session: ReviewSession;
244
253
  onEditRequest: () => void;
254
+ onShareRequest: () => void;
245
255
  canEdit: boolean;
246
256
  theme?: Theme;
247
257
  }): React.ReactNode {
@@ -260,11 +270,14 @@ function SheetHeader({
260
270
  ) : null}
261
271
  </text>
262
272
  <box style={{ flexGrow: 1 }} />
263
- {canEdit ? (
273
+ {canEdit && session.status !== "resolved" ? (
264
274
  <Toolbar>
265
275
  <Button onPress={onEditRequest} theme={theme}>
266
276
  {" Edit "}
267
277
  </Button>
278
+ <Button onPress={onShareRequest} theme={theme}>
279
+ {" Share "}
280
+ </Button>
268
281
  </Toolbar>
269
282
  ) : null}
270
283
  </box>
@@ -40,8 +40,6 @@ export interface ReviewRailProps {
40
40
  onSelectCard: (id: string) => void;
41
41
  onActivateCard: (id: string) => void;
42
42
  onSubmitRequest: () => void;
43
- /** Publish the plan as a share; omitted for non-owners (hides the button). */
44
- onShareRequest?: () => void;
45
43
  /** Rail column width; the app derives it from the persisted review layout. */
46
44
  width?: number;
47
45
  /**
@@ -70,7 +68,6 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
70
68
  onSelectCard,
71
69
  onActivateCard,
72
70
  onSubmitRequest,
73
- onShareRequest,
74
71
  width = 34,
75
72
  onCollapse,
76
73
  theme,
@@ -152,11 +149,6 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
152
149
  {`Submit review (${pendingCount}) ⏎`}
153
150
  </Button>
154
151
  )}
155
- {onShareRequest && session.status !== "resolved" ? (
156
- <Button variant="plain" onPress={onShareRequest} theme={theme}>
157
- {"Share plan ⇧S"}
158
- </Button>
159
- ) : null}
160
152
  </>
161
153
  )}
162
154
  {/* the collapse chevron: muted, left-bound, one column from the divider.
@@ -489,7 +489,7 @@ exports[`stories catalog InboxList/TwoPending renders, snapshots, and carries it
489
489
  `;
490
490
 
491
491
  exports[`stories catalog PlanSheet/AnnotatedPlan renders, snapshots, and carries its colors: PlanSheet/AnnotatedPlan 1`] = `
492
- " submitted by agent/worker-3 · revision 1 Edit
492
+ " submitted by agent/worker-3 · revision 1 Edit Share
493
493
 
494
494
  Migration Plan
495
495
 
@@ -521,7 +521,7 @@ exports[`stories catalog PlanSheet/AnnotatedPlan renders, snapshots, and carries
521
521
  `;
522
522
 
523
523
  exports[`stories catalog PlanSheet/InlineCompose renders, snapshots, and carries its colors: PlanSheet/InlineCompose 1`] = `
524
- " submitted by agent/worker-3 · revision 1 Edit
524
+ " submitted by agent/worker-3 · revision 1 Edit Share
525
525
 
526
526
  Migration Plan
527
527
 
@@ -555,7 +555,7 @@ exports[`stories catalog PlanSheet/InlineCompose renders, snapshots, and carries
555
555
  `;
556
556
 
557
557
  exports[`stories catalog PlanSheet/OrphanBanner renders, snapshots, and carries its colors: PlanSheet/OrphanBanner 1`] = `
558
- " submitted by agent/worker-3 · revision 1 Edit
558
+ " submitted by agent/worker-3 · revision 1 Edit Share
559
559
  1 annotation no longer match - the passage was removed.
560
560
 
561
561
  ▎ Migration Plan
@@ -587,7 +587,7 @@ exports[`stories catalog PlanSheet/OrphanBanner renders, snapshots, and carries
587
587
  `;
588
588
 
589
589
  exports[`stories catalog PlanSheet/SpanSelection renders, snapshots, and carries its colors: PlanSheet/SpanSelection 1`] = `
590
- " submitted by agent/worker-3 · revision 1 Edit
590
+ " submitted by agent/worker-3 · revision 1 Edit Share
591
591
 
592
592
  Migration Plan
593
593
 
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { runClient, type RunClientOptions } from "./run";
2
2
  export { serveClient, type ServeHandle, type ServeOptions } from "./serve";
3
3
  export { App, type AppProps } from "./App";
4
- export { publishShare, type ShareResult, type ShareTarget } from "./share";
4
+ export { collaboratorAnnotations, publishShare, pullShare, pushShare, shareIdFromLine, type ShareResult, type ShareTarget } from "./share";
@@ -0,0 +1,242 @@
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(async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {});
12
+ mock.module("./share", () => ({
13
+ publishShare,
14
+ pullShare,
15
+ pushShare,
16
+ shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
17
+ collaboratorAnnotations: (session: ReviewSession) => session.annotations.filter((annotation) => annotation.author),
18
+ }));
19
+
20
+ const { createReviewController, SHARE_POLL_MS } = await import("./session-controller");
21
+
22
+ function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
23
+ return {
24
+ schemaVersion: SCHEMA_VERSION,
25
+ id: "ses_1",
26
+ workspace: { repoRoot: "/repo", branch: "main" },
27
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
28
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
29
+ annotations: [],
30
+ verdict: null,
31
+ status: "pending",
32
+ createdAt: "2026-01-01T00:00:00.000Z",
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function annotation(id: string, author: string): Annotation {
38
+ return { id, kind: "comment", anchor: { quote: "Plan", prefix: "", suffix: "" }, body: "note", author, createdAt: "2026-01-01T00:00:00.000Z" };
39
+ }
40
+
41
+ function fakeClient(session: ReviewSession): SessionClient {
42
+ return {
43
+ onEvent: () => () => {},
44
+ subscribe: async () => {},
45
+ sessionGet: async () => session,
46
+ sessionList: async () => [session],
47
+ sessionAnnotate: mock(async () => session),
48
+ sessionSetShareId: mock(async (_id: string, shareId: string) => ((session.shareId = shareId), session)),
49
+ sessionMergeAnnotations: mock(async (_id: string, incoming: Annotation[]) => {
50
+ const known = new Set(session.annotations.map((existing) => existing.id));
51
+ for (const note of incoming) if (!known.has(note.id)) session.annotations.push(note);
52
+ return session;
53
+ }),
54
+ close: () => {},
55
+ } as unknown as SessionClient;
56
+ }
57
+
58
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
59
+
60
+ async function connectedController(
61
+ session: ReviewSession,
62
+ clock?: ManualClock,
63
+ ): Promise<{ controller: ReturnType<typeof createReviewController>; client: SessionClient }> {
64
+ const client = fakeClient(session);
65
+ const controller = createReviewController({ sessionId: session.id, openClient: async () => client, clock });
66
+ controller.connect();
67
+ await tick();
68
+ return { controller, client };
69
+ }
70
+
71
+ describe("share", () => {
72
+ test("stamps the returned share id back on the session", async () => {
73
+ // Arrange
74
+ const { controller, client } = await connectedController(sessionFixture());
75
+
76
+ // Act
77
+ controller.share();
78
+ await tick();
79
+
80
+ // Assert
81
+ expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
82
+ });
83
+ });
84
+
85
+ describe("pullShared", () => {
86
+ test("unions collaborator notes in when the plan was shared", async () => {
87
+ // Arrange
88
+ remote = sessionFixture({ annotations: [annotation("a1", "SHA256:mate")] });
89
+ const { controller, client } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
90
+
91
+ // Act
92
+ controller.pullShared();
93
+ await tick();
94
+
95
+ // Assert
96
+ expect(pullShare).toHaveBeenCalledWith("p_abc123xy");
97
+ expect(client.sessionMergeAnnotations).toHaveBeenCalledWith("ses_1", remote.annotations);
98
+ });
99
+
100
+ test("does nothing for a plan that was never shared", async () => {
101
+ // Arrange
102
+ pullShare.mockClear();
103
+ const { controller, client } = await connectedController(sessionFixture());
104
+
105
+ // Act
106
+ controller.pullShared();
107
+ await tick();
108
+
109
+ // Assert
110
+ expect(pullShare).not.toHaveBeenCalled();
111
+ expect(client.sessionMergeAnnotations).not.toHaveBeenCalled();
112
+ });
113
+ });
114
+
115
+ describe("mirror on annotate", () => {
116
+ test("pushes an edited note up when the plan is shared", async () => {
117
+ // Arrange
118
+ pushShare.mockClear();
119
+ const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }));
120
+
121
+ // Act
122
+ controller.updateAnnotation("a1", "revised body");
123
+ await tick();
124
+
125
+ // Assert
126
+ expect(pushShare).toHaveBeenCalledTimes(1);
127
+ expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
128
+ expect(pushShare.mock.calls[0]?.[1]).toEqual([{ id: "a1", kind: "comment", anchor: { quote: "Plan", prefix: "", suffix: "" }, body: "revised body" }]);
129
+ });
130
+
131
+ test("does not push when the plan was never shared", async () => {
132
+ // Arrange
133
+ pushShare.mockClear();
134
+ const { controller } = await connectedController(sessionFixture({ annotations: [annotation("a1", "SHA256:me")] }));
135
+
136
+ // Act
137
+ controller.updateAnnotation("a1", "revised body");
138
+ await tick();
139
+
140
+ // Assert
141
+ expect(pushShare).not.toHaveBeenCalled();
142
+ });
143
+
144
+ test("does not push when the local write is rejected", async () => {
145
+ // Arrange - the daemon write fails (e.g. a resolved session)
146
+ pushShare.mockClear();
147
+ const { controller, client } = await connectedController(sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }));
148
+ (client.sessionAnnotate as ReturnType<typeof mock>).mockImplementationOnce(async () => {
149
+ throw new Error("session is resolved");
150
+ });
151
+
152
+ // Act
153
+ controller.updateAnnotation("a1", "revised body");
154
+ await tick();
155
+
156
+ // Assert - a failed local write must never leak to the share
157
+ expect(pushShare).not.toHaveBeenCalled();
158
+ });
159
+ });
160
+
161
+ describe("startSharePoll", () => {
162
+ test("pulls now and again each interval until stopped", async () => {
163
+ // Arrange
164
+ pullShare.mockClear();
165
+ remote = sessionFixture({ annotations: [] });
166
+ const clock = new ManualClock();
167
+ const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }), clock);
168
+
169
+ // Act - immediate pull
170
+ const stop = controller.startSharePoll();
171
+ await tick();
172
+
173
+ // Assert
174
+ expect(pullShare).toHaveBeenCalledTimes(1);
175
+
176
+ // Act - one interval later, it pulls again
177
+ clock.advance(SHARE_POLL_MS);
178
+ await tick();
179
+
180
+ // Assert
181
+ expect(pullShare).toHaveBeenCalledTimes(2);
182
+
183
+ // Act - stop, then advance: no further pulls
184
+ stop();
185
+ clock.advance(SHARE_POLL_MS * 3);
186
+ await tick();
187
+
188
+ // Assert
189
+ expect(pullShare).toHaveBeenCalledTimes(2);
190
+ });
191
+
192
+ test("a stop during an in-flight pull leaves no zombie timer", async () => {
193
+ // Arrange - the first pull hangs until released
194
+ pullShare.mockClear();
195
+ remote = sessionFixture({ annotations: [] });
196
+ let release: () => void = () => {};
197
+ pullShare.mockImplementationOnce(() => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))));
198
+ const clock = new ManualClock();
199
+ const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }), clock);
200
+
201
+ // Act - start (pull is in flight), leave, then let the pull settle
202
+ const stop = controller.startSharePoll();
203
+ await tick();
204
+ expect(pullShare).toHaveBeenCalledTimes(1);
205
+ stop();
206
+ release();
207
+ await tick();
208
+ clock.advance(SHARE_POLL_MS * 3);
209
+ await tick();
210
+
211
+ // Assert - the settled pull's finally must not re-arm the timer
212
+ expect(pullShare).toHaveBeenCalledTimes(1);
213
+ });
214
+
215
+ test("a restart during an in-flight pull does not double the poll", async () => {
216
+ // Arrange - run A's first pull hangs; later pulls resolve immediately
217
+ pullShare.mockClear();
218
+ remote = sessionFixture({ annotations: [] });
219
+ let release: () => void = () => {};
220
+ pullShare.mockImplementationOnce(() => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))));
221
+ const clock = new ManualClock();
222
+ const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }), clock);
223
+
224
+ // Act - run A starts (pull hangs), then run B restarts while A is in flight
225
+ controller.startSharePoll();
226
+ await tick();
227
+ expect(pullShare).toHaveBeenCalledTimes(1);
228
+ const stopB = controller.startSharePoll();
229
+ await tick();
230
+ expect(pullShare).toHaveBeenCalledTimes(2);
231
+
232
+ // A's stale pull settles - it must not re-arm; then one interval fires only run B
233
+ release();
234
+ await tick();
235
+ clock.advance(SHARE_POLL_MS);
236
+ await tick();
237
+
238
+ // Assert - exactly one more pull (run B), not two
239
+ expect(pullShare).toHaveBeenCalledTimes(3);
240
+ stopB();
241
+ });
242
+ });
@@ -19,11 +19,12 @@ import {
19
19
  restoreBlock,
20
20
  restoreLine,
21
21
  returnPaneFor,
22
+ type Annotation,
22
23
  type ReviewSession,
23
24
  type VerdictKind,
24
25
  } from "@cueloop/schema";
25
26
  import { loadBundledExporters, type BundledExporter } from "./integrations";
26
- import { publishShare } from "./share";
27
+ import { collaboratorAnnotations, publishShare, pullShare, pushShare, shareIdFromLine } from "./share";
27
28
  import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
28
29
  import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
29
30
  import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
@@ -46,6 +47,9 @@ export type Completion =
46
47
  /** Seconds the completion overlay counts down before it hands back. */
47
48
  export const DEFAULT_AUTO_CLOSE = 5;
48
49
 
50
+ /** How often an open shared plan re-pulls collaborator notes (ADR 0005 stage 2). */
51
+ export const SHARE_POLL_MS = 4000;
52
+
49
53
  export interface ControllerSnapshot {
50
54
  session: ReviewSession | null;
51
55
  inbox: ReviewSession[] | null;
@@ -124,6 +128,10 @@ export interface ReviewController {
124
128
  submit(verdict: VerdictKind, summary: string): void;
125
129
  /** Publish the current session as a share; the ssh line lands on the clipboard. */
126
130
  share(): void;
131
+ /** Pull a shared plan's collaborator notes back and union them in (planner only). */
132
+ pullShared(): Promise<void>;
133
+ /** Poll the share for collaborator notes while it is open; returns a stop handle. */
134
+ startSharePoll(): () => void;
127
135
  /** Close the review and, inside herdr, bounce focus back to the agent. */
128
136
  finishReview(): void;
129
137
  dismissCompletion(): void;
@@ -160,6 +168,8 @@ class Controller implements ReviewController {
160
168
  private exporters: BundledExporter[] = [];
161
169
  private readonly clock: Clock;
162
170
  private countdown: TimerHandle | undefined;
171
+ private sharePoll: TimerHandle | undefined;
172
+ private shareRun: object | null = null;
163
173
  /** Projections keyed by session identity so renders reuse one computation. */
164
174
  private derivedFor: ReviewSession | null = null;
165
175
  private derived: { display: DisplayBlock[]; rows: DiffRow[]; files: WalkFile[] } = {
@@ -213,6 +223,7 @@ class Controller implements ReviewController {
213
223
  close(): void {
214
224
  this.closed = true;
215
225
  this.clearCountdown();
226
+ this.stopSharePoll();
216
227
  this.client?.close();
217
228
  }
218
229
 
@@ -366,17 +377,12 @@ class Controller implements ReviewController {
366
377
  const workBlockIndex = display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
367
378
  anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
368
379
  }
369
- const annotationId = newAnnotationId();
370
- this.apply(
371
- this.client!.sessionAnnotate(session.id, {
372
- id: annotationId,
373
- kind,
374
- anchor,
375
- body,
376
- }),
377
- );
380
+ const wire = { id: newAnnotationId(), kind, anchor, body };
381
+ const persisted = this.client!.sessionAnnotate(session.id, wire);
382
+ this.apply(persisted);
383
+ this.mirrorAnnotation(persisted, wire);
378
384
  this.setStatus(kind === "suggestion" ? "suggestion added - the agent applies it" : "comment added");
379
- return annotationId;
385
+ return wire.id;
380
386
  }
381
387
 
382
388
  updateAnnotation(id: string, body: string): void {
@@ -385,17 +391,20 @@ class Controller implements ReviewController {
385
391
  const existing = session.annotations.find((annotation) => annotation.id === id);
386
392
  if (!existing) return;
387
393
  // the daemon's annotate verb upserts by id: same id + anchor, new body
388
- this.apply(
389
- this.client!.sessionAnnotate(session.id, {
390
- id: existing.id,
391
- kind: existing.kind,
392
- anchor: existing.anchor,
393
- body,
394
- }),
395
- );
394
+ const wire = { id: existing.id, kind: existing.kind, anchor: existing.anchor, body };
395
+ const persisted = this.client!.sessionAnnotate(session.id, wire);
396
+ this.apply(persisted);
397
+ this.mirrorAnnotation(persisted, wire);
396
398
  this.setStatus("annotation updated");
397
399
  }
398
400
 
401
+ // push only after the local write lands, so a rejected write never leaks to the share
402
+ private mirrorAnnotation(persisted: Promise<ReviewSession>, annotation: Omit<Annotation, "createdAt">): void {
403
+ const shareId = this.snapshot.session?.shareId;
404
+ if (!shareId) return;
405
+ void persisted.then(() => pushShare(shareId, [annotation])).catch(() => {});
406
+ }
407
+
399
408
  removeAnnotation(id: string): void {
400
409
  const session = this.snapshot.session;
401
410
  if (!session) return;
@@ -485,10 +494,58 @@ class Controller implements ReviewController {
485
494
  if (!session) return;
486
495
  this.setStatus("sharing…");
487
496
  publishShare(session)
488
- .then(({ line, copied }) => this.setStatus(copied ? `✓ share link copied - ${line}` : line))
497
+ .then(async ({ line, copied }) => {
498
+ // Stamp the id back so a later pull knows which share to collect from.
499
+ const shareId = shareIdFromLine(line);
500
+ if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
501
+ this.setStatus(copied ? `✓ share link copied - ${line}` : line);
502
+ })
489
503
  .catch((error: unknown) => this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`));
490
504
  }
491
505
 
506
+ /**
507
+ * Pull collaborator notes for a shared plan and union them in, awaiting the
508
+ * merge so a poll never overlaps rounds. The daemon's merge emits
509
+ * session.updated, so the re-render happens through the normal event path.
510
+ * Best-effort: a failed refresh is silent and the next tick retries.
511
+ */
512
+ pullShared(): Promise<void> {
513
+ const session = this.snapshot.session;
514
+ if (!session?.shareId || !this.client) return Promise.resolve();
515
+ const client = this.client;
516
+ return pullShare(session.shareId)
517
+ .then((remote) => client.sessionMergeAnnotations(session.id, collaboratorAnnotations(remote)))
518
+ .then(() => {})
519
+ .catch(() => {});
520
+ }
521
+
522
+ /**
523
+ * Poll the share for collaborator notes while it is open: pull now, then again
524
+ * every few seconds, each round waiting for the last so slow pulls never stack.
525
+ * Returns a stop handle the caller runs on leave.
526
+ */
527
+ startSharePoll(): () => void {
528
+ this.stopSharePoll();
529
+ // per-run token so a stale run's in-flight pull never re-arms the poll
530
+ const run = {};
531
+ this.shareRun = run;
532
+ const tick = (): void => {
533
+ void this.pullShared().finally(() => {
534
+ if (this.shareRun === run && !this.closed) this.sharePoll = this.clock.setTimeout(tick, SHARE_POLL_MS);
535
+ });
536
+ };
537
+ tick();
538
+ return () => {
539
+ if (this.shareRun === run) this.stopSharePoll();
540
+ };
541
+ }
542
+
543
+ private stopSharePoll(): void {
544
+ this.shareRun = null;
545
+ if (this.sharePoll !== undefined) this.clock.clearTimeout(this.sharePoll);
546
+ this.sharePoll = undefined;
547
+ }
548
+
492
549
  // ── completion hand-back ────────────────────
493
550
  private startCounting(remaining: number): void {
494
551
  if (remaining <= 0) return this.finishReview();
package/src/share.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { DEFAULT_SHARE_HOST, DEFAULT_SHARE_PORT, SHARE_UPLOAD_USER, packSessionBlob } from "@cueloop/daemon/share-blob";
10
- import type { ReviewSession } from "@cueloop/schema";
10
+ import type { Annotation, ReviewSession } from "@cueloop/schema";
11
11
  import { copyToClipboard } from "./clipboard";
12
12
 
13
13
  export interface ShareTarget {
@@ -22,26 +22,59 @@ export interface ShareResult {
22
22
  copied: boolean;
23
23
  }
24
24
 
25
+ /** The `p_…` share id inside a `ssh p_…@host` line, or undefined. */
26
+ export function shareIdFromLine(line: string): string | undefined {
27
+ return line.match(/^ssh (\S+)@/)?.[1];
28
+ }
29
+
25
30
  /** Upload the session to the gateway and copy the resulting ssh line. */
26
31
  export async function publishShare(session: ReviewSession, target: ShareTarget = {}): Promise<ShareResult> {
27
- const line = await uploadOverSsh(packSessionBlob(session), target.host ?? DEFAULT_SHARE_HOST, target.port ?? DEFAULT_SHARE_PORT);
32
+ const { stdout, stderr, code } = await runShareSsh("cueloop-share", packSessionBlob(session), target);
33
+ if (code !== 0) throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
34
+ const line = stdout.trim();
35
+ if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
28
36
  const copied = await copyToClipboard(line);
29
37
  return { line, copied };
30
38
  }
31
39
 
32
- /** Stream the blob to `share@host:port` over the system `ssh` client. */
33
- async function uploadOverSsh(blob: Buffer, host: string, port: number): Promise<string> {
40
+ /**
41
+ * Pull a share's current session back from the gateway. The gateway lets only
42
+ * the fingerprint that uploaded it through, so collaborator notes reach the
43
+ * planner without exposing the master key.
44
+ */
45
+ export async function pullShare(shareId: string, target: ShareTarget = {}): Promise<ReviewSession> {
46
+ const { stdout, stderr, code } = await runShareSsh("cueloop-pull", Buffer.from(shareId), target);
47
+ if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
48
+ return JSON.parse(stdout) as ReviewSession;
49
+ }
50
+
51
+ /** A share's collaborator notes: the ones a viewer authored (author stamped). */
52
+ export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
53
+ return session.annotations.filter((annotation) => annotation.author);
54
+ }
55
+
56
+ /**
57
+ * Mirror the planner's own annotations up into the share, so collaborators see
58
+ * them on their next refresh. Owner-gated at the gateway; the notes stay
59
+ * unauthored (the planner's), unioned by id.
60
+ */
61
+ export async function pushShare(shareId: string, annotations: Array<Omit<Annotation, "createdAt">>, target: ShareTarget = {}): Promise<void> {
62
+ const { stderr, code } = await runShareSsh("cueloop-push", Buffer.from(JSON.stringify({ shareId, annotations })), target);
63
+ if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
64
+ }
65
+
66
+ /** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
67
+ async function runShareSsh(command: string, stdin: Buffer, target: ShareTarget): Promise<{ stdout: string; stderr: string; code: number }> {
68
+ const host = target.host ?? DEFAULT_SHARE_HOST;
69
+ const port = target.port ?? DEFAULT_SHARE_PORT;
34
70
  const proc = Bun.spawn(
35
- ["ssh", "-p", String(port), "-o", "StrictHostKeyChecking=accept-new", `${SHARE_UPLOAD_USER}@${host}`, "cueloop-share"],
36
- { stdin: blob, stdout: "pipe", stderr: "pipe" },
71
+ ["ssh", "-p", String(port), "-o", "StrictHostKeyChecking=accept-new", `${SHARE_UPLOAD_USER}@${host}`, command],
72
+ { stdin, stdout: "pipe", stderr: "pipe" },
37
73
  );
38
74
  const [stdout, stderr, code] = await Promise.all([
39
75
  new Response(proc.stdout).text(),
40
76
  new Response(proc.stderr).text(),
41
77
  proc.exited,
42
78
  ]);
43
- if (code !== 0) throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
44
- const line = stdout.trim();
45
- if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
46
- return line;
79
+ return { stdout, stderr, code };
47
80
  }