@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +17 -11
- package/src/App.autoclose.test.tsx +133 -0
- package/src/App.diff.test.tsx +27 -20
- package/src/App.export.test.tsx +19 -28
- package/src/App.plan-review.test.tsx +507 -0
- package/src/App.readonly.test.tsx +26 -27
- package/src/App.rendering.test.tsx +119 -0
- package/src/App.review-panel.test.tsx +123 -0
- package/src/App.submit-confirm.test.tsx +216 -0
- package/src/App.test.tsx +83 -64
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +265 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +32 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +208 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +49 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +117 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +55 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +329 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +62 -0
- package/src/components/ReviewPanel.stories.tsx +87 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +107 -0
- package/src/components/ReviewRail.tsx +180 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +54 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +95 -0
- package/src/components/stories.test.tsx +60 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +196 -30
- package/src/config.ts +128 -16
- package/src/editor.test.ts +134 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +21 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +28 -0
- package/src/intent-dispatch.test.ts +308 -0
- package/src/intent-dispatch.ts +295 -0
- package/src/key-bindings.test.ts +142 -0
- package/src/key-bindings.ts +363 -0
- package/src/keymap.test.ts +477 -0
- package/src/keymap.ts +272 -0
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +13 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.share.test.ts +248 -0
- package/src/session-controller.ts +607 -0
- package/src/share.ts +80 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +26 -3
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +260 -0
- package/src/view-plan.ts +365 -0
- package/src/walk.test.ts +116 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/** The plan review surface v2: native selection feeds the annotation quote, the inline compose box keeps its anchor painted, the rail edits what the document selects, and edit-exit reconciliation orphans annotations whose passage was removed. Char-frame + styled-span assertions over the real App and a real in-process daemon. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
import { DARK as T } from "./theme";
|
|
13
|
+
import { press, settle, typeText as type, waitForState, waitForText, waitForTextGone } from "./test-support";
|
|
14
|
+
|
|
15
|
+
const PLAN = `# Migration Plan
|
|
16
|
+
|
|
17
|
+
## Context
|
|
18
|
+
|
|
19
|
+
The daemon persists sessions to disk atomically.
|
|
20
|
+
|
|
21
|
+
## Steps
|
|
22
|
+
|
|
23
|
+
- move the store
|
|
24
|
+
- add recovery
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
let home: string;
|
|
28
|
+
let server: DaemonServer;
|
|
29
|
+
let session: ReviewSession;
|
|
30
|
+
let priorConfig: string | undefined;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-plan-review-"));
|
|
34
|
+
// Isolate from the dev's real config so the rail state is the default here,
|
|
35
|
+
// not whatever review_state they last persisted (CI runs clean already).
|
|
36
|
+
priorConfig = process.env.CUELOOP_CONFIG;
|
|
37
|
+
process.env.CUELOOP_CONFIG = join(home, "no-config.toml");
|
|
38
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
39
|
+
server.start();
|
|
40
|
+
session = server.core.sessionCreate({
|
|
41
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
42
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" } },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
if (priorConfig === undefined) delete process.env.CUELOOP_CONFIG;
|
|
47
|
+
else process.env.CUELOOP_CONFIG = priorConfig;
|
|
48
|
+
server.stop();
|
|
49
|
+
rmSync(home, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
async function renderApp() {
|
|
53
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
54
|
+
width: 120,
|
|
55
|
+
height: 32,
|
|
56
|
+
});
|
|
57
|
+
await waitForText(setup, "cueloop");
|
|
58
|
+
return setup;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Background colors (hex) of every styled span containing the needle - the
|
|
65
|
+
* document highlight and the rail's quote excerpt can both match.
|
|
66
|
+
*/
|
|
67
|
+
function backgroundsOf(setup: Setup, needle: string): string[] {
|
|
68
|
+
const backgrounds: string[] = [];
|
|
69
|
+
for (const line of setup.captureSpans().lines) {
|
|
70
|
+
for (const span of line.spans) {
|
|
71
|
+
if (!span.text.includes(needle)) continue;
|
|
72
|
+
const [red, green, blue] = span.bg.toInts();
|
|
73
|
+
backgrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return backgrounds;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Move the cursor to "The daemon persists sessions to disk atomically." */
|
|
80
|
+
async function toContextParagraph(setup: Setup): Promise<void> {
|
|
81
|
+
await press(setup, "j");
|
|
82
|
+
await press(setup, "j");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("share button", () => {
|
|
86
|
+
test("the owner's plan header shows the Share button next to Edit", async () => {
|
|
87
|
+
// Arrange / Act
|
|
88
|
+
const setup = await renderApp();
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
// both word-buttons ride the same header row, so Share sits next to Edit
|
|
92
|
+
const headerLine = setup.captureCharFrame().split("\n").find((line) => line.includes("submitted by"));
|
|
93
|
+
expect(headerLine).toContain("Edit");
|
|
94
|
+
expect(headerLine).toContain("Share");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a read-only viewer (a plan shared over ssh) never sees the Share button", async () => {
|
|
98
|
+
// Arrange / Act
|
|
99
|
+
const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, { width: 120, height: 32 });
|
|
100
|
+
await waitForText(viewer, "cueloop");
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
103
|
+
expect(viewer.captureCharFrame()).not.toContain("Share");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a resolved plan hides Edit and Share (no re-sharing a finished review)", async () => {
|
|
107
|
+
// Arrange - resolve the session before opening it
|
|
108
|
+
server.core.sessionResolve(session.id, "approve", "");
|
|
109
|
+
|
|
110
|
+
// Act
|
|
111
|
+
const setup = await renderApp();
|
|
112
|
+
await waitForText(setup, "submitted by");
|
|
113
|
+
|
|
114
|
+
// Assert - the owner toolbar is gone once the review is resolved
|
|
115
|
+
const headerLine = setup.captureCharFrame().split("\n").find((line) => line.includes("submitted by"));
|
|
116
|
+
expect(headerLine).not.toContain("Edit");
|
|
117
|
+
expect(headerLine).not.toContain("Share");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("edit affordance", () => {
|
|
122
|
+
test("the owner sees the Edit button", async () => {
|
|
123
|
+
// Arrange / Act
|
|
124
|
+
const owner = await renderApp();
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
127
|
+
expect(owner.captureCharFrame()).toContain("Edit");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
|
|
131
|
+
// Arrange / Act
|
|
132
|
+
const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, { width: 120, height: 32 });
|
|
133
|
+
await waitForText(viewer, "cueloop");
|
|
134
|
+
|
|
135
|
+
// Assert
|
|
136
|
+
expect(viewer.captureCharFrame()).not.toContain("Edit");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("attribution", () => {
|
|
141
|
+
test("a collaborator's note shows their name in the card border", async () => {
|
|
142
|
+
// Arrange - a pulled collaborator note carries an author fingerprint
|
|
143
|
+
const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
|
|
144
|
+
const blocks = parseBlocks(PLAN);
|
|
145
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
|
|
146
|
+
server.core.sessionAnnotate(session.id, {
|
|
147
|
+
id: "collab-1",
|
|
148
|
+
kind: "comment",
|
|
149
|
+
anchor: makeAnchor(blocks, contextBlockIndex, 0, 10),
|
|
150
|
+
body: "who owns retries?",
|
|
151
|
+
author: "SHA256:1a2b3c4d5e6f",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Act
|
|
155
|
+
const setup = await renderApp();
|
|
156
|
+
|
|
157
|
+
// Assert - the short author handle rides the card border
|
|
158
|
+
await waitForText(setup, "1a2b3c4d");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("selection feeds the annotation quote", () => {
|
|
163
|
+
test("v anchors, l extends, c composes with the char-precise quote", async () => {
|
|
164
|
+
// Arrange
|
|
165
|
+
const setup = await renderApp();
|
|
166
|
+
await toContextParagraph(setup);
|
|
167
|
+
|
|
168
|
+
// Act
|
|
169
|
+
await press(setup, "v");
|
|
170
|
+
await press(setup, "l"); // "The daemon"
|
|
171
|
+
await press(setup, "c");
|
|
172
|
+
|
|
173
|
+
// Assert
|
|
174
|
+
await waitForText(setup, 'comment on "The daemon"');
|
|
175
|
+
|
|
176
|
+
// Act
|
|
177
|
+
await type(setup, "Which daemon?");
|
|
178
|
+
await press(setup, "enter");
|
|
179
|
+
|
|
180
|
+
// Assert
|
|
181
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
182
|
+
const stored = server.core.sessionGet(session.id);
|
|
183
|
+
expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a mouse drag over the text becomes the compose quote", async () => {
|
|
187
|
+
// Arrange
|
|
188
|
+
const setup = await renderApp();
|
|
189
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
190
|
+
const row = lines.findIndex((line) => line.includes("persists sessions"));
|
|
191
|
+
const startColumn = lines[row]!.indexOf("persists");
|
|
192
|
+
expect(row).toBeGreaterThan(-1);
|
|
193
|
+
|
|
194
|
+
// Act
|
|
195
|
+
// the drag's end cell is exclusive: release one past the last character
|
|
196
|
+
await setup.mockMouse.drag(startColumn, row, startColumn + "persists sessions".length, row);
|
|
197
|
+
await settle(setup);
|
|
198
|
+
await press(setup, "c");
|
|
199
|
+
|
|
200
|
+
// Assert
|
|
201
|
+
await waitForText(setup, 'comment on "persists sessions"');
|
|
202
|
+
|
|
203
|
+
// Act
|
|
204
|
+
await type(setup, "sessions plural?");
|
|
205
|
+
await press(setup, "enter");
|
|
206
|
+
|
|
207
|
+
// Assert
|
|
208
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
209
|
+
const stored = server.core.sessionGet(session.id);
|
|
210
|
+
expect(stored.annotations[0]!.anchor.quote).toBe("persists sessions");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("inline compose keeps the anchor painted", () => {
|
|
215
|
+
test("the selection stays painted while composing; cancel un-paints; save converts to the comment highlight", async () => {
|
|
216
|
+
// Arrange
|
|
217
|
+
const setup = await renderApp();
|
|
218
|
+
await toContextParagraph(setup);
|
|
219
|
+
|
|
220
|
+
// Act
|
|
221
|
+
await press(setup, "v");
|
|
222
|
+
await press(setup, "l");
|
|
223
|
+
await press(setup, "c");
|
|
224
|
+
|
|
225
|
+
// Assert
|
|
226
|
+
// compose open: the anchor is painted selection-style. The box can render
|
|
227
|
+
// a frame before the anchor repaint settles, so wait on the color itself.
|
|
228
|
+
await waitForText(setup, "Save ⏎");
|
|
229
|
+
expect(setup.captureCharFrame()).toContain("Cancel esc");
|
|
230
|
+
await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
|
|
231
|
+
expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
|
|
232
|
+
|
|
233
|
+
// Act
|
|
234
|
+
// cancel un-paints (a bare ESC settles after the parser's escape window)
|
|
235
|
+
await press(setup, "escape");
|
|
236
|
+
|
|
237
|
+
// Assert
|
|
238
|
+
await waitForTextGone(setup, 'comment on "');
|
|
239
|
+
expect(backgroundsOf(setup, "The daemon")).not.toContain(T.accent);
|
|
240
|
+
|
|
241
|
+
// Act
|
|
242
|
+
// save converts the paint to the kind-colored annotation highlight
|
|
243
|
+
await press(setup, "c");
|
|
244
|
+
await type(setup, "Which daemon?");
|
|
245
|
+
await press(setup, "enter");
|
|
246
|
+
|
|
247
|
+
// Assert
|
|
248
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
249
|
+
await settle(setup);
|
|
250
|
+
// the whole cursor block is the anchor here; check the highlight landed
|
|
251
|
+
const stored = server.core.sessionGet(session.id);
|
|
252
|
+
expect(stored.annotations.length).toBe(1);
|
|
253
|
+
expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBg);
|
|
254
|
+
// renderApp plus this many frame-waits grazes the 5s default on a loaded CI
|
|
255
|
+
// runner, and the whole-suite publish lane has timed even 15s out; give the
|
|
256
|
+
// heaviest frame-wait chain generous headroom so runner load cannot flake it.
|
|
257
|
+
}, 30_000);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("compose newline convention", () => {
|
|
261
|
+
// Option/Alt and Shift reach the App as `meta`/`shift` on the return key.
|
|
262
|
+
// modifyOtherKeys encoding preserves those modifiers so the grammar and the
|
|
263
|
+
// focused textarea can tell a bare submit from a newline.
|
|
264
|
+
async function renderModifierAwareApp() {
|
|
265
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
266
|
+
width: 120,
|
|
267
|
+
height: 32,
|
|
268
|
+
otherModifiersMode: true,
|
|
269
|
+
});
|
|
270
|
+
await waitForText(setup, "cueloop");
|
|
271
|
+
return setup;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function pressReturnWith(setup: Setup, modifiers: { shift?: boolean; meta?: boolean }): Promise<void> {
|
|
275
|
+
setup.mockInput.pressKey("RETURN", modifiers);
|
|
276
|
+
await settle(setup);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
test("option+return inserts a newline; a bare return submits the multiline body", async () => {
|
|
280
|
+
// Arrange
|
|
281
|
+
const setup = await renderModifierAwareApp();
|
|
282
|
+
await toContextParagraph(setup);
|
|
283
|
+
await press(setup, "v");
|
|
284
|
+
await press(setup, "l");
|
|
285
|
+
await press(setup, "c");
|
|
286
|
+
await waitForText(setup, 'comment on "The daemon"');
|
|
287
|
+
|
|
288
|
+
// Act
|
|
289
|
+
await type(setup, "first line");
|
|
290
|
+
await pressReturnWith(setup, { meta: true }); // option/alt+return -> newline
|
|
291
|
+
await type(setup, "second line");
|
|
292
|
+
|
|
293
|
+
// Assert
|
|
294
|
+
// still composing: the newline did not submit
|
|
295
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
296
|
+
|
|
297
|
+
// Act
|
|
298
|
+
await press(setup, "enter"); // bare return -> save
|
|
299
|
+
|
|
300
|
+
// Assert
|
|
301
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
302
|
+
expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("first line\nsecond line");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("shift+return still inserts a newline (existing muscle memory)", async () => {
|
|
306
|
+
// Arrange
|
|
307
|
+
const setup = await renderModifierAwareApp();
|
|
308
|
+
await toContextParagraph(setup);
|
|
309
|
+
await press(setup, "c");
|
|
310
|
+
|
|
311
|
+
// Act
|
|
312
|
+
await type(setup, "alpha");
|
|
313
|
+
await pressReturnWith(setup, { shift: true }); // shift+return -> newline
|
|
314
|
+
await type(setup, "beta");
|
|
315
|
+
|
|
316
|
+
// Assert
|
|
317
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
318
|
+
|
|
319
|
+
// Act
|
|
320
|
+
await press(setup, "enter");
|
|
321
|
+
|
|
322
|
+
// Assert
|
|
323
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
324
|
+
expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("alpha\nbeta");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("escape cancels the composer without saving", async () => {
|
|
328
|
+
// Arrange
|
|
329
|
+
const setup = await renderModifierAwareApp();
|
|
330
|
+
await toContextParagraph(setup);
|
|
331
|
+
await press(setup, "c");
|
|
332
|
+
await waitForText(setup, 'comment on "');
|
|
333
|
+
await type(setup, "never saved");
|
|
334
|
+
|
|
335
|
+
// Act
|
|
336
|
+
await press(setup, "escape");
|
|
337
|
+
|
|
338
|
+
// Assert
|
|
339
|
+
await waitForTextGone(setup, 'comment on "');
|
|
340
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe("the document selects, the rail edits", () => {
|
|
345
|
+
test("e edits the selected card in place; enter saves through the controller", async () => {
|
|
346
|
+
// Arrange
|
|
347
|
+
const setup = await renderApp();
|
|
348
|
+
await toContextParagraph(setup);
|
|
349
|
+
await press(setup, "c");
|
|
350
|
+
await type(setup, "Needs a citation.");
|
|
351
|
+
await press(setup, "enter");
|
|
352
|
+
await waitForText(setup, "COMMENT · pending");
|
|
353
|
+
|
|
354
|
+
// Act
|
|
355
|
+
// the saved card is selected; e turns its body into an input in place
|
|
356
|
+
await press(setup, "e");
|
|
357
|
+
await type(setup, " And a link.");
|
|
358
|
+
await press(setup, "enter");
|
|
359
|
+
|
|
360
|
+
// Assert
|
|
361
|
+
await waitForState(
|
|
362
|
+
setup,
|
|
363
|
+
() => server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("activating a collaborator's card opens rename, not a body edit", async () => {
|
|
368
|
+
// Arrange - a pulled collaborator note carries an author fingerprint
|
|
369
|
+
const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
|
|
370
|
+
const blocks = parseBlocks(PLAN);
|
|
371
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
|
|
372
|
+
server.core.sessionAnnotate(session.id, {
|
|
373
|
+
id: "collab-1",
|
|
374
|
+
kind: "comment",
|
|
375
|
+
anchor: makeAnchor(blocks, contextBlockIndex, 0, 10),
|
|
376
|
+
body: "who owns retries?",
|
|
377
|
+
author: "SHA256:1a2b3c4d5e6f",
|
|
378
|
+
});
|
|
379
|
+
const setup = await renderApp();
|
|
380
|
+
await waitForText(setup, "1a2b3c4d");
|
|
381
|
+
|
|
382
|
+
// Act - focus the collaborator card, then activate it
|
|
383
|
+
await press(setup, "n");
|
|
384
|
+
await press(setup, "e");
|
|
385
|
+
|
|
386
|
+
// Assert - the rename prompt opens; the body never becomes an editor
|
|
387
|
+
await waitForText(setup, "Rename author");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("x deletes the selected card and un-paints the document highlight", async () => {
|
|
391
|
+
// Arrange
|
|
392
|
+
const setup = await renderApp();
|
|
393
|
+
await toContextParagraph(setup);
|
|
394
|
+
await press(setup, "c");
|
|
395
|
+
await type(setup, "Delete me.");
|
|
396
|
+
await press(setup, "enter");
|
|
397
|
+
await waitForText(setup, "COMMENT · pending");
|
|
398
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
399
|
+
expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
|
|
400
|
+
|
|
401
|
+
// Act
|
|
402
|
+
await press(setup, "x");
|
|
403
|
+
|
|
404
|
+
// Assert
|
|
405
|
+
await waitForTextGone(setup, "COMMENT · pending");
|
|
406
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
407
|
+
expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("addressed annotations leave the open list", () => {
|
|
412
|
+
test("a revision that addresses one card hides it, un-paints it, and shows the count", async () => {
|
|
413
|
+
// Arrange: two annotations through the daemon, then a revision addressing one
|
|
414
|
+
const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
|
|
415
|
+
const blocks = parseBlocks(PLAN);
|
|
416
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
|
|
417
|
+
server.core.sessionAnnotate(session.id, {
|
|
418
|
+
id: "a_settled",
|
|
419
|
+
kind: "comment",
|
|
420
|
+
anchor: makeAnchor(blocks, contextBlockIndex, 0, 10), // "The daemon"
|
|
421
|
+
body: "settled note",
|
|
422
|
+
});
|
|
423
|
+
server.core.sessionAnnotate(session.id, {
|
|
424
|
+
id: "a_open",
|
|
425
|
+
kind: "comment",
|
|
426
|
+
anchor: makeAnchor(blocks, contextBlockIndex, 11, 19), // "persists"
|
|
427
|
+
body: "still open note",
|
|
428
|
+
});
|
|
429
|
+
server.core.sessionSubmitRevision(session.id, PLAN, ["a_settled"]);
|
|
430
|
+
|
|
431
|
+
// Act
|
|
432
|
+
const setup = await renderApp();
|
|
433
|
+
await waitForText(setup, "still open note");
|
|
434
|
+
|
|
435
|
+
// Assert: the open card renders, the addressed one is gone behind the count
|
|
436
|
+
const frame = setup.captureCharFrame();
|
|
437
|
+
expect(frame).toContain("✓ 1 addressed by revision");
|
|
438
|
+
expect(frame).not.toContain("settled note");
|
|
439
|
+
expect(frame).toContain("Review (1)"); // the addressed card no longer counts as pending
|
|
440
|
+
expect(backgroundsOf(setup, "The daemon")).not.toContain(T.markCommentBg); // no highlight paint
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe("sheet header", () => {
|
|
445
|
+
test("shows the submitting agent, the revision, and the Edit and Share word-buttons", async () => {
|
|
446
|
+
// Arrange
|
|
447
|
+
const setup = await renderApp();
|
|
448
|
+
|
|
449
|
+
// Assert
|
|
450
|
+
const frame = setup.captureCharFrame();
|
|
451
|
+
expect(frame).toContain("submitted by agent/worker-3 · revision 1");
|
|
452
|
+
expect(frame).toContain("Edit");
|
|
453
|
+
expect(frame).toContain("Share");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("the Agent tab shows the submitter, status, and revision", async () => {
|
|
457
|
+
// Arrange
|
|
458
|
+
const setup = await renderApp();
|
|
459
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
460
|
+
const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
|
|
461
|
+
const tabColumn = lines[tabRow]!.indexOf("Agent");
|
|
462
|
+
|
|
463
|
+
// Act
|
|
464
|
+
await setup.mockMouse.click(tabColumn + 1, tabRow);
|
|
465
|
+
await setup.renderOnce();
|
|
466
|
+
|
|
467
|
+
// Assert
|
|
468
|
+
await waitForText(setup, "status: pending");
|
|
469
|
+
const frame = setup.captureCharFrame();
|
|
470
|
+
expect(frame).toContain("agent/worker-3");
|
|
471
|
+
expect(frame).toContain("status: pending");
|
|
472
|
+
expect(frame).toContain("revision 1");
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("edit-exit reconciliation", () => {
|
|
477
|
+
test("an edit that removes an anchored passage orphans the annotation and shows the banner", async () => {
|
|
478
|
+
const script = join(home, "fake-editor.sh");
|
|
479
|
+
await Bun.write(script, `#!/bin/sh\nsed -i '' '/^The daemon persists/d' "$1"\n`);
|
|
480
|
+
Bun.spawnSync(["chmod", "+x", script]);
|
|
481
|
+
process.env.CUELOOP_EDITOR = script;
|
|
482
|
+
try {
|
|
483
|
+
// Arrange
|
|
484
|
+
const setup = await renderApp();
|
|
485
|
+
await toContextParagraph(setup);
|
|
486
|
+
await press(setup, "c");
|
|
487
|
+
await type(setup, "Anchor me to the doomed passage.");
|
|
488
|
+
await press(setup, "enter");
|
|
489
|
+
await waitForText(setup, "· pending");
|
|
490
|
+
|
|
491
|
+
// Act
|
|
492
|
+
// deselect the card so e reaches the editor hand-off, then edit
|
|
493
|
+
await press(setup, "escape");
|
|
494
|
+
await waitForTextGone(setup, "▸ COMMENT");
|
|
495
|
+
await press(setup, "e");
|
|
496
|
+
|
|
497
|
+
// Assert
|
|
498
|
+
await waitForText(setup, "1 annotation no longer match - the passage was removed.");
|
|
499
|
+
// the rail card flips to ORPHANED once the working-copy refresh lands
|
|
500
|
+
await waitForText(setup, "· ORPHANED");
|
|
501
|
+
// the annotation is NOT deleted: the feedback serializer handles orphans
|
|
502
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
503
|
+
} finally {
|
|
504
|
+
delete process.env.CUELOOP_EDITOR;
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
});
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Observer mode (tier 2): the App with readOnly renders and navigates like the
|
|
3
|
-
* controller's, but every mutating verb answers "observer - read-only" and
|
|
4
|
-
* leaves daemon state untouched.
|
|
5
|
-
*/
|
|
1
|
+
/** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating verb answers "observer - read-only" and leaves daemon state untouched. */
|
|
6
2
|
|
|
7
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
@@ -13,6 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
13
9
|
import { DaemonServer } from "@cueloop/daemon";
|
|
14
10
|
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
15
11
|
import { App } from "./App";
|
|
12
|
+
import { press, waitForText } from "./test-support";
|
|
16
13
|
|
|
17
14
|
const PLAN = `# Migration Plan
|
|
18
15
|
|
|
@@ -49,32 +46,22 @@ async function renderObserver() {
|
|
|
49
46
|
width: 120,
|
|
50
47
|
height: 32,
|
|
51
48
|
});
|
|
52
|
-
|
|
53
|
-
await Bun.sleep(25);
|
|
54
|
-
await setup.renderOnce();
|
|
55
|
-
}
|
|
56
|
-
await setup.renderOnce();
|
|
49
|
+
await waitForText(setup, "cueloop");
|
|
57
50
|
return setup;
|
|
58
51
|
}
|
|
59
52
|
|
|
60
|
-
type Setup = Awaited<ReturnType<typeof renderObserver>>;
|
|
61
|
-
|
|
62
|
-
async function press(setup: Setup, k: string): Promise<void> {
|
|
63
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
64
|
-
else await setup.mockInput.typeText(k);
|
|
65
|
-
await Bun.sleep(15);
|
|
66
|
-
await setup.renderOnce();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
53
|
/** Session state that any mutating verb would change. */
|
|
70
54
|
function snapshot() {
|
|
71
|
-
const
|
|
72
|
-
return { annotations:
|
|
55
|
+
const stored = server.core.sessionGet(session.id);
|
|
56
|
+
return { annotations: stored.annotations.length, workingCopy: stored.workingCopy, status: stored.status };
|
|
73
57
|
}
|
|
74
58
|
|
|
75
59
|
describe("observer rendering", () => {
|
|
76
60
|
test("shows the observer badge and read-only hint bar", async () => {
|
|
61
|
+
// Arrange
|
|
77
62
|
const setup = await renderObserver();
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
78
65
|
const frame = setup.captureCharFrame();
|
|
79
66
|
expect(frame).toContain("· observer");
|
|
80
67
|
expect(frame).toContain("observer - read-only · j/k move");
|
|
@@ -96,10 +83,7 @@ describe("observer verbs are blocked", () => {
|
|
|
96
83
|
await press(setup, "j");
|
|
97
84
|
await press(setup, "j");
|
|
98
85
|
await press(setup, key);
|
|
99
|
-
await
|
|
100
|
-
await setup.renderOnce();
|
|
101
|
-
const frame = setup.captureCharFrame();
|
|
102
|
-
expect(frame).toContain("observer - read-only");
|
|
86
|
+
const frame = await waitForText(setup, "observer - read-only");
|
|
103
87
|
// no overlay opened: compose/submit bars never appear
|
|
104
88
|
expect(frame).not.toContain("COMMENT ON");
|
|
105
89
|
expect(frame).not.toContain("SUGGEST REPLACEMENT");
|
|
@@ -109,12 +93,17 @@ describe("observer verbs are blocked", () => {
|
|
|
109
93
|
}
|
|
110
94
|
|
|
111
95
|
test("span mode c/s is blocked too", async () => {
|
|
96
|
+
// Arrange
|
|
112
97
|
const setup = await renderObserver();
|
|
98
|
+
|
|
99
|
+
// Act
|
|
113
100
|
await press(setup, "j");
|
|
114
101
|
await press(setup, "j");
|
|
115
102
|
await press(setup, "v");
|
|
116
103
|
await press(setup, "c");
|
|
117
104
|
await setup.renderOnce();
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
118
107
|
const frame = setup.captureCharFrame();
|
|
119
108
|
expect(frame).toContain("observer - read-only");
|
|
120
109
|
expect(frame).not.toContain("COMMENT ON");
|
|
@@ -124,16 +113,22 @@ describe("observer verbs are blocked", () => {
|
|
|
124
113
|
|
|
125
114
|
describe("observer navigation still works", () => {
|
|
126
115
|
test("j/k moves the cursor between blocks", async () => {
|
|
116
|
+
// Arrange
|
|
127
117
|
const setup = await renderObserver();
|
|
118
|
+
|
|
119
|
+
// Act
|
|
128
120
|
await press(setup, "j");
|
|
129
121
|
await press(setup, "j");
|
|
130
122
|
await setup.renderOnce();
|
|
123
|
+
|
|
124
|
+
// Assert
|
|
131
125
|
const lines = setup.captureCharFrame().split("\n");
|
|
132
|
-
const cursorLine = lines.find((
|
|
126
|
+
const cursorLine = lines.find((line) => line.includes("▎"))!;
|
|
133
127
|
expect(cursorLine).toContain("persists sessions");
|
|
134
128
|
});
|
|
135
129
|
|
|
136
130
|
test("n focuses annotations made by the controller", async () => {
|
|
131
|
+
// Arrange
|
|
137
132
|
server.core.sessionAnnotate(session.id, {
|
|
138
133
|
id: "a_obs1",
|
|
139
134
|
kind: "comment",
|
|
@@ -141,8 +136,12 @@ describe("observer navigation still works", () => {
|
|
|
141
136
|
body: "From the controller.",
|
|
142
137
|
});
|
|
143
138
|
const setup = await renderObserver();
|
|
139
|
+
|
|
140
|
+
// Act
|
|
144
141
|
await press(setup, "n");
|
|
145
142
|
await setup.renderOnce();
|
|
146
|
-
|
|
143
|
+
|
|
144
|
+
// Assert
|
|
145
|
+
await waitForText(setup, "▸ COMMENT");
|
|
147
146
|
});
|
|
148
147
|
});
|