@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.
Files changed (105) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +133 -0
  3. package/src/App.diff.test.tsx +27 -20
  4. package/src/App.export.test.tsx +19 -28
  5. package/src/App.plan-review.test.tsx +507 -0
  6. package/src/App.readonly.test.tsx +26 -27
  7. package/src/App.rendering.test.tsx +119 -0
  8. package/src/App.review-panel.test.tsx +123 -0
  9. package/src/App.submit-confirm.test.tsx +216 -0
  10. package/src/App.test.tsx +83 -64
  11. package/src/App.tsx +0 -0
  12. package/src/App.walk.test.tsx +265 -0
  13. package/src/attribution.test.ts +31 -0
  14. package/src/attribution.ts +32 -0
  15. package/src/clipboard.test.ts +23 -0
  16. package/src/clipboard.ts +26 -0
  17. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  18. package/src/components/AnnotationCard.stories.tsx +75 -0
  19. package/src/components/AnnotationCard.tsx +208 -0
  20. package/src/components/Breadcrumb.stories.tsx +19 -0
  21. package/src/components/Breadcrumb.tsx +38 -0
  22. package/src/components/CodeBlock.stories.tsx +25 -0
  23. package/src/components/CodeBlock.tsx +68 -0
  24. package/src/components/CompletionOverlay.stories.tsx +23 -0
  25. package/src/components/CompletionOverlay.tsx +49 -0
  26. package/src/components/ComposeBar.stories.tsx +18 -0
  27. package/src/components/ComposeBar.tsx +54 -0
  28. package/src/components/ConfirmCard.stories.tsx +37 -0
  29. package/src/components/ConfirmCard.tsx +117 -0
  30. package/src/components/ConfirmDialog.stories.tsx +21 -0
  31. package/src/components/ConfirmDialog.tsx +56 -0
  32. package/src/components/DiffSheet.stories.tsx +44 -0
  33. package/src/components/DiffSheet.tsx +243 -0
  34. package/src/components/InboxList.stories.tsx +18 -0
  35. package/src/components/InboxList.tsx +55 -0
  36. package/src/components/PlanSheet.stories.tsx +81 -0
  37. package/src/components/PlanSheet.tsx +329 -0
  38. package/src/components/PromptDialog.stories.tsx +36 -0
  39. package/src/components/PromptDialog.tsx +62 -0
  40. package/src/components/ReviewPanel.stories.tsx +87 -0
  41. package/src/components/ReviewPanel.tsx +116 -0
  42. package/src/components/ReviewRail.stories.tsx +107 -0
  43. package/src/components/ReviewRail.tsx +180 -0
  44. package/src/components/SettingsDialog.stories.tsx +63 -0
  45. package/src/components/SettingsDialog.tsx +132 -0
  46. package/src/components/SettingsRows.stories.tsx +18 -0
  47. package/src/components/SettingsRows.tsx +92 -0
  48. package/src/components/WalkWizard.stories.tsx +89 -0
  49. package/src/components/WalkWizard.tsx +146 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
  51. package/src/components/primitives/Button.stories.tsx +32 -0
  52. package/src/components/primitives/Button.tsx +44 -0
  53. package/src/components/primitives/Card.stories.tsx +25 -0
  54. package/src/components/primitives/Card.tsx +59 -0
  55. package/src/components/primitives/Dialog.stories.tsx +24 -0
  56. package/src/components/primitives/Dialog.tsx +54 -0
  57. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  58. package/src/components/primitives/StatusBar.tsx +22 -0
  59. package/src/components/primitives/Tabs.stories.tsx +29 -0
  60. package/src/components/primitives/Tabs.tsx +97 -0
  61. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  62. package/src/components/primitives/Toolbar.tsx +14 -0
  63. package/src/components/primitives/frame.test.ts +12 -0
  64. package/src/components/primitives/frame.ts +14 -0
  65. package/src/components/stories-app.tsx +95 -0
  66. package/src/components/stories.test.tsx +60 -0
  67. package/src/components/story-fixtures.ts +90 -0
  68. package/src/components/story.ts +66 -0
  69. package/src/components/syntax-highlight.ts +57 -0
  70. package/src/components/theme-context.stories.tsx +41 -0
  71. package/src/components/theme-context.tsx +30 -0
  72. package/src/components/truncate-text.ts +6 -0
  73. package/src/config.test.ts +196 -30
  74. package/src/config.ts +128 -16
  75. package/src/editor.test.ts +134 -0
  76. package/src/editor.ts +101 -13
  77. package/src/herdr.test.ts +41 -0
  78. package/src/herdr.ts +21 -0
  79. package/src/index.ts +2 -0
  80. package/src/integrations.test.ts +41 -0
  81. package/src/integrations.ts +28 -0
  82. package/src/intent-dispatch.test.ts +308 -0
  83. package/src/intent-dispatch.ts +295 -0
  84. package/src/key-bindings.test.ts +142 -0
  85. package/src/key-bindings.ts +363 -0
  86. package/src/keymap.test.ts +477 -0
  87. package/src/keymap.ts +272 -0
  88. package/src/review-panel.test.ts +111 -0
  89. package/src/review-panel.ts +76 -0
  90. package/src/run.ts +9 -5
  91. package/src/serve.test.ts +13 -6
  92. package/src/serve.ts +7 -7
  93. package/src/session-controller.share.test.ts +248 -0
  94. package/src/session-controller.ts +607 -0
  95. package/src/share.ts +80 -0
  96. package/src/test-support.ts +87 -0
  97. package/src/theme.ts +26 -3
  98. package/src/view-diff.test.ts +19 -8
  99. package/src/view-diff.ts +22 -22
  100. package/src/view-plan.test.ts +260 -0
  101. package/src/view-plan.ts +365 -0
  102. package/src/walk.test.ts +116 -0
  103. package/src/walk.ts +65 -0
  104. package/src/view.test.ts +0 -157
  105. 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
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
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 s = server.core.sessionGet(session.id);
72
- return { annotations: s.annotations.length, workingCopy: s.workingCopy, status: s.status };
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 Bun.sleep(60);
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((l) => l.includes("▎"))!;
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
- expect(setup.captureCharFrame()).toContain("▸ COMMENT");
143
+
144
+ // Assert
145
+ await waitForText(setup, "▸ COMMENT");
147
146
  });
148
147
  });