@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 +5 -5
- package/src/App.diff.test.tsx +2 -2
- package/src/App.export.test.tsx +1 -1
- package/src/App.plan-review.test.tsx +37 -8
- package/src/App.readonly.test.tsx +1 -1
- package/src/App.review-panel.test.tsx +7 -4
- package/src/App.submit-confirm.test.tsx +14 -14
- package/src/App.test.tsx +3 -3
- package/src/App.tsx +8 -2
- package/src/App.walk.test.tsx +4 -2
- package/src/components/PlanSheet.stories.tsx +1 -1
- package/src/components/PlanSheet.tsx +17 -4
- package/src/components/ReviewRail.tsx +0 -8
- package/src/components/__snapshots__/stories.test.tsx.snap +4 -4
- package/src/index.ts +1 -1
- package/src/session-controller.share.test.ts +242 -0
- package/src/session-controller.ts +77 -20
- package/src/share.ts +43 -10
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
11
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
12
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
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",
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -69,7 +69,7 @@ describe("diff review", () => {
|
|
|
69
69
|
await press(setup, "c");
|
|
70
70
|
|
|
71
71
|
// Assert
|
|
72
|
-
|
|
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
|
-
|
|
104
|
+
await waitForText(setup, "plan-only verb");
|
|
105
105
|
});
|
|
106
106
|
});
|
package/src/App.export.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
201
|
-
|
|
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-
|
|
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");
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
await waitForText(setup, "[Approve]");
|
|
100
101
|
|
|
101
102
|
// Act
|
|
102
103
|
await press(setup, "right");
|
|
103
104
|
|
|
104
105
|
// Assert
|
|
105
|
-
|
|
106
|
+
await waitForText(setup, "[Changes]");
|
|
106
107
|
|
|
107
108
|
// Act
|
|
108
109
|
await press(setup, "right");
|
|
109
110
|
|
|
110
111
|
// Assert
|
|
111
|
-
|
|
112
|
+
await waitForText(setup, "[Comment]");
|
|
112
113
|
|
|
113
114
|
// Act
|
|
114
115
|
await press(setup, "left");
|
|
115
116
|
|
|
116
117
|
// Assert
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
expect(
|
|
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
|
-
|
|
182
|
-
// the card is pinned outside the scrollbox; the stack
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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>
|
package/src/App.walk.test.tsx
CHANGED
|
@@ -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
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
370
|
-
this.
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 }) =>
|
|
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
|
|
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
|
-
/**
|
|
33
|
-
|
|
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}`,
|
|
36
|
-
{ stdin
|
|
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
|
-
|
|
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
|
}
|