@cueloop/client 0.1.0-alpha.22 → 0.1.0-alpha.25

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.22",
3
+ "version": "0.1.0-alpha.25",
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.22",
11
- "@cueloop/extension-api": "0.1.0-alpha.22",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.22",
13
- "@cueloop/schema": "0.1.0-alpha.22",
10
+ "@cueloop/daemon": "0.1.0-alpha.25",
11
+ "@cueloop/extension-api": "0.1.0-alpha.25",
12
+ "@cueloop/integration-obsidian": "0.1.0-alpha.25",
13
+ "@cueloop/schema": "0.1.0-alpha.25",
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,57 @@ 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");
111
+ });
112
+ });
113
+
114
+ describe("edit affordance", () => {
115
+ test("the owner sees the Edit button", async () => {
116
+ // Arrange / Act
117
+ const owner = await renderApp();
118
+
119
+ // Assert
120
+ expect(owner.captureCharFrame()).toContain("Edit");
121
+ });
122
+
123
+ test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
124
+ // Arrange / Act
125
+ const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, { width: 120, height: 32 });
126
+ await waitForText(viewer, "cueloop");
127
+
128
+ // Assert
129
+ expect(viewer.captureCharFrame()).not.toContain("Edit");
85
130
  });
86
131
  });
87
132
 
@@ -97,7 +142,7 @@ describe("selection feeds the annotation quote", () => {
97
142
  await press(setup, "c");
98
143
 
99
144
  // Assert
100
- expect(setup.captureCharFrame()).toContain('comment on "The daemon"');
145
+ await waitForText(setup, 'comment on "The daemon"');
101
146
 
102
147
  // Act
103
148
  await type(setup, "Which daemon?");
@@ -124,7 +169,7 @@ describe("selection feeds the annotation quote", () => {
124
169
  await press(setup, "c");
125
170
 
126
171
  // Assert
127
- expect(setup.captureCharFrame()).toContain('comment on "persists sessions"');
172
+ await waitForText(setup, 'comment on "persists sessions"');
128
173
 
129
174
  // Act
130
175
  await type(setup, "sessions plural?");
@@ -151,7 +196,7 @@ describe("inline compose keeps the anchor painted", () => {
151
196
  // Assert
152
197
  // compose open: the anchor is painted selection-style. The box can render
153
198
  // a frame before the anchor repaint settles, so wait on the color itself.
154
- expect(setup.captureCharFrame()).toContain("Save ⏎");
199
+ await waitForText(setup, "Save ⏎");
155
200
  expect(setup.captureCharFrame()).toContain("Cancel esc");
156
201
  await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
157
202
  expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
@@ -178,8 +223,9 @@ describe("inline compose keeps the anchor painted", () => {
178
223
  expect(stored.annotations.length).toBe(1);
179
224
  expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBg);
180
225
  // renderApp plus this many frame-waits grazes the 5s default on a loaded CI
181
- // runner; the sibling walk resume test carries the same budget.
182
- }, 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);
183
229
  });
184
230
 
185
231
  describe("compose newline convention", () => {
@@ -344,7 +390,7 @@ describe("addressed annotations leave the open list", () => {
344
390
  });
345
391
 
346
392
  describe("sheet header", () => {
347
- 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 () => {
348
394
  // Arrange
349
395
  const setup = await renderApp();
350
396
 
@@ -352,6 +398,7 @@ describe("sheet header", () => {
352
398
  const frame = setup.captureCharFrame();
353
399
  expect(frame).toContain("submitted by agent/worker-3 · revision 1");
354
400
  expect(frame).toContain("Edit");
401
+ expect(frame).toContain("Share");
355
402
  });
356
403
 
357
404
  test("the Agent tab shows the submitter, status, and revision", async () => {
@@ -366,6 +413,7 @@ describe("sheet header", () => {
366
413
  await setup.renderOnce();
367
414
 
368
415
  // Assert
416
+ await waitForText(setup, "status: pending");
369
417
  const frame = setup.captureCharFrame();
370
418
  expect(frame).toContain("agent/worker-3");
371
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
@@ -74,9 +74,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
74
74
  // the plan or a verdict. `observer` is what the controller and every write
75
75
  // gate key off; the two capability flags carve out the collaborator's middle.
76
76
  const observer = readOnly || role === "observer";
77
- const canEditPlan = !observer && role !== "collaborator";
78
- const canSubmitVerdict = !observer && role !== "collaborator";
79
- const canShare = !observer && role === "owner";
77
+ // Editing the plan, submitting a verdict, and sharing are all the owner's
78
+ // alone - a collaborator annotates, an observer only reads. One predicate
79
+ // feeds all three; split it the day a collaborator earns one of them.
80
+ const isOwner = !observer && role === "owner";
80
81
  const controller = useMemo(
81
82
  () => createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
82
83
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -91,6 +92,12 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
91
92
  controller.getSnapshot,
92
93
  controller.getSnapshot,
93
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]);
94
101
  const renderer = useRenderer();
95
102
  const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
96
103
 
@@ -263,9 +270,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
263
270
  const state: KeyState = {
264
271
  keys: keysRef.current,
265
272
  readOnly: observer,
266
- canEditPlan,
267
- canSubmitVerdict,
268
- canShare,
273
+ canEditPlan: isOwner,
274
+ canSubmitVerdict: isOwner,
275
+ canShare: isOwner,
269
276
  overlay,
270
277
  view: !session ? "inbox" : isDiff ? "diff" : "plan",
271
278
  spanMode: mode.type === "span",
@@ -358,22 +365,23 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
358
365
  };
359
366
 
360
367
  const onEditRequest = (): void => {
361
- if (observer) return controller.setStatus("observer - read-only");
362
- if (!canEditPlan) return controller.setStatus("shared plan - edit it in your own copy");
368
+ // A share viewer/observer has no Edit affordance (the button is hidden), so
369
+ // this is owner-only; stay silent rather than nag if it is ever reached.
370
+ if (!isOwner) return;
363
371
  if (resolved) return controller.setStatus("review submitted - read-only");
364
372
  runEditorHandOff();
365
373
  };
366
374
 
367
- // 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
368
376
  const onShareRequest = (): void => {
369
- if (!canShare) return controller.setStatus("only the plan owner can share");
377
+ if (!isOwner) return controller.setStatus("only the plan owner can share");
370
378
  controller.share();
371
379
  };
372
380
 
373
381
  // clicking the rail Submit button: same read-only answer as the submit key
374
382
  const onSubmitRequest = (): void => {
375
383
  if (observer) return controller.setStatus("observer - read-only");
376
- if (!canSubmitVerdict) return controller.setStatus("shared view - your notes save as you go");
384
+ if (!isOwner) return controller.setStatus("shared view - your notes save as you go");
377
385
  if (resolved) return;
378
386
  dispatch({ type: "openSubmit" });
379
387
  };
@@ -448,7 +456,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
448
456
  ? "walk"
449
457
  : focusedAnnotationId !== undefined
450
458
  ? "card"
451
- : "normal";
459
+ : role === "collaborator"
460
+ ? "collaborator"
461
+ : "normal";
452
462
 
453
463
  return (
454
464
  <ThemeProvider theme={theme}>
@@ -489,6 +499,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
489
499
  editOrphanCount={editOrphanCount}
490
500
  onLineActivate={onLineActivate}
491
501
  onEditRequest={onEditRequest}
502
+ onShareRequest={onShareRequest}
503
+ canEdit={isOwner}
492
504
  />
493
505
  )}
494
506
  <ReviewPanel
@@ -511,7 +523,6 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
511
523
  onSelectCard: selectCardFromRail,
512
524
  onActivateCard: openCardEdit,
513
525
  onSubmitRequest,
514
- onShareRequest: canShare ? onShareRequest : undefined,
515
526
  }}
516
527
  />
517
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: () => {} };
9
+ const callbacks = { onLineActivate: () => {}, onEditRequest: () => {}, onShareRequest: () => {}, canEdit: true };
10
10
 
11
11
  export const AnnotatedPlan: Story = {
12
12
  render: () => (
@@ -64,6 +64,9 @@ export interface PlanSheetProps {
64
64
  editOrphanCount: number;
65
65
  onLineActivate: (displayIndex: number) => void;
66
66
  onEditRequest: () => void;
67
+ onShareRequest: () => void;
68
+ /** Owner-only: show the Edit and Share affordances. A share viewer never sees either. */
69
+ canEdit: boolean;
67
70
  theme?: Theme;
68
71
  }
69
72
 
@@ -97,6 +100,8 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
97
100
  editOrphanCount,
98
101
  onLineActivate,
99
102
  onEditRequest,
103
+ onShareRequest,
104
+ canEdit,
100
105
  theme,
101
106
  }: PlanSheetProps,
102
107
  handleRef,
@@ -215,7 +220,13 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
215
220
 
216
221
  return (
217
222
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
218
- <SheetHeader session={session} onEditRequest={onEditRequest} theme={theme} />
223
+ <SheetHeader
224
+ session={session}
225
+ onEditRequest={onEditRequest}
226
+ onShareRequest={onShareRequest}
227
+ canEdit={canEdit}
228
+ theme={theme}
229
+ />
219
230
  {editOrphanCount > 0 ? (
220
231
  <box style={{ height: 1, backgroundColor: tokens.markCommentBg, paddingLeft: 2 }}>
221
232
  <text fg={tokens.red}>
@@ -230,14 +241,18 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
230
241
  );
231
242
  });
232
243
 
233
- /** 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. */
234
245
  function SheetHeader({
235
246
  session,
236
247
  onEditRequest,
248
+ onShareRequest,
249
+ canEdit,
237
250
  theme,
238
251
  }: {
239
252
  session: ReviewSession;
240
253
  onEditRequest: () => void;
254
+ onShareRequest: () => void;
255
+ canEdit: boolean;
241
256
  theme?: Theme;
242
257
  }): React.ReactNode {
243
258
  const tokens = useComponentTheme(theme);
@@ -255,11 +270,16 @@ function SheetHeader({
255
270
  ) : null}
256
271
  </text>
257
272
  <box style={{ flexGrow: 1 }} />
258
- <Toolbar>
259
- <Button onPress={onEditRequest} theme={theme}>
260
- {" Edit "}
261
- </Button>
262
- </Toolbar>
273
+ {canEdit && session.status !== "resolved" ? (
274
+ <Toolbar>
275
+ <Button onPress={onEditRequest} theme={theme}>
276
+ {" Edit "}
277
+ </Button>
278
+ <Button onPress={onShareRequest} theme={theme}>
279
+ {" Share "}
280
+ </Button>
281
+ </Toolbar>
282
+ ) : null}
263
283
  </box>
264
284
  );
265
285
  }
@@ -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";
@@ -118,6 +118,17 @@ describe("getActiveKeys-generated status hints", () => {
118
118
  expect(bindings().statusHint("read-only")).toBe("observer - read-only · j/k move · n/p annotations · q quit");
119
119
  });
120
120
 
121
+ test("collaborator hint annotates but shows no plan edit or submit", () => {
122
+ // Arrange / Act
123
+ const hint = bindings().statusHint("collaborator");
124
+
125
+ // Assert
126
+ expect(hint).toBe("j/k move · v span · drag selects · c comment · s suggest · n/p annotations · q quit");
127
+ expect(hint).not.toContain("cut");
128
+ expect(hint).not.toContain("edit");
129
+ expect(hint).not.toContain("submit");
130
+ });
131
+
121
132
  test("a rebound key shows its real binding in the hint", () => {
122
133
  // Arrange
123
134
  const resolver = new KeyBindings({ ...DEFAULT_KEYS, comment: ["m"] });
@@ -24,7 +24,7 @@ export interface KeyLayerContext {
24
24
  }
25
25
 
26
26
  /** Hint templates keyed by the same view states the status line shows. */
27
- export type HintMode = "normal" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
27
+ export type HintMode = "normal" | "collaborator" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
28
28
 
29
29
  type HintEntry =
30
30
  | { text: string }
@@ -43,6 +43,16 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
43
43
  { commands: ["submit"], label: "submit" },
44
44
  { commands: ["quit"], label: "quit" },
45
45
  ],
46
+ // A share viewer: annotate and navigate, no plan edit / verdict submit.
47
+ collaborator: [
48
+ { commands: ["down", "up"], label: "move" },
49
+ { commands: ["span"], label: "span" },
50
+ { text: "drag selects" },
51
+ { commands: ["comment"], label: "comment" },
52
+ { commands: ["suggest"], label: "suggest" },
53
+ { commands: ["next_annotation", "prev_annotation"], label: "annotations" },
54
+ { commands: ["quit"], label: "quit" },
55
+ ],
46
56
  card: [
47
57
  { text: "card" },
48
58
  { commands: ["edit"], label: "edit" },
@@ -144,13 +144,13 @@ describe("collaborator (share) capabilities", () => {
144
144
  expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "editCard" }]);
145
145
  });
146
146
 
147
- test("cannot edit the plan: cut/edit with no card is refused", () => {
147
+ test("cannot edit the plan: cut/edit with no card is silent (no nag, no button)", () => {
148
148
  // Arrange
149
149
  const collab = state({ canEditPlan: false, hasFocusedAnnotation: false });
150
150
 
151
151
  // Act / Assert
152
- expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "status", message: "shared plan - edit it in your own copy" }]);
153
- expect(reduceKey(collab, key("c"), "cut")).toEqual([{ type: "status", message: "shared plan - edit it in your own copy" }]);
152
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([]);
153
+ expect(reduceKey(collab, key("c"), "cut")).toEqual([]);
154
154
  });
155
155
 
156
156
  test("cannot submit a verdict: there is no agent on a share", () => {
package/src/keymap.ts CHANGED
@@ -217,8 +217,9 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
217
217
  // the document selects, the rail edits: with a card selected, Cut deletes
218
218
  // the annotation and edit rewrites the card body in place
219
219
  if (state.hasFocusedAnnotation) return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
220
- // editing the plan itself is the owner's verb - a collaborator only annotates
221
- if (state.canEditPlan === false) return status("shared plan - edit it in your own copy");
220
+ // editing the plan itself is the owner's verb; a share viewer only annotates,
221
+ // and has no edit affordance, so the key is silent rather than a nag
222
+ if (state.canEditPlan === false) return [];
222
223
  return [{ type: action }];
223
224
  }
224
225
  return annotationCluster(state, action) ?? [];
@@ -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
  }