@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 +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 +56 -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 +24 -13
- package/src/App.walk.test.tsx +4 -2
- package/src/components/PlanSheet.stories.tsx +1 -1
- package/src/components/PlanSheet.tsx +27 -7
- 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/key-bindings.test.ts +11 -0
- package/src/key-bindings.ts +11 -1
- package/src/keymap.test.ts +3 -3
- package/src/keymap.ts +3 -2
- 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.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.
|
|
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.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",
|
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,57 @@ 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");
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
|
|
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-
|
|
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");
|
|
@@ -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
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
|
375
|
+
// clicking the header Share button: publish the plan, copy the ssh line
|
|
368
376
|
const onShareRequest = (): void => {
|
|
369
|
-
if (!
|
|
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 (!
|
|
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
|
-
: "
|
|
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>
|
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: () => {} };
|
|
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
|
|
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-
|
|
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
|
-
|
|
259
|
-
<
|
|
260
|
-
{
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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";
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -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"] });
|
package/src/key-bindings.ts
CHANGED
|
@@ -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" },
|
package/src/keymap.test.ts
CHANGED
|
@@ -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
|
|
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([
|
|
153
|
-
expect(reduceKey(collab, key("c"), "cut")).toEqual([
|
|
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
|
|
221
|
-
|
|
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
|
|
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
|
}
|