@cueloop/client 0.1.0-alpha.65 → 0.1.0-alpha.66

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 (78) hide show
  1. package/package.json +5 -5
  2. package/src/App.diff.test.tsx +8 -73
  3. package/src/App.plan-review.test.tsx +4 -185
  4. package/src/App.prototype.test.tsx +1 -8
  5. package/src/App.readonly.test.tsx +6 -28
  6. package/src/App.submit-confirm.test.tsx +19 -72
  7. package/src/App.test.tsx +70 -24
  8. package/src/App.tsx +177 -254
  9. package/src/App.walk.test.tsx +1 -1
  10. package/src/app-screens.tsx +40 -51
  11. package/src/app-view-model.ts +2 -4
  12. package/src/components/AnnotationCard.stories.tsx +1 -1
  13. package/src/components/AppShell.stories.tsx +84 -0
  14. package/src/components/AppShell.tsx +127 -0
  15. package/src/components/Breadcrumb.stories.tsx +1 -1
  16. package/src/components/ChangesColumn.stories.tsx +39 -0
  17. package/src/components/ChangesColumn.tsx +147 -0
  18. package/src/components/CodeBlock.stories.tsx +1 -1
  19. package/src/components/CompletionOverlay.stories.tsx +1 -1
  20. package/src/components/ConfirmCard.stories.tsx +1 -1
  21. package/src/components/ConfirmCard.tsx +3 -4
  22. package/src/components/ConfirmDialog.stories.tsx +1 -1
  23. package/src/components/DiffSheet.stories.tsx +1 -1
  24. package/src/components/InboxList.stories.tsx +23 -8
  25. package/src/components/InboxList.tsx +45 -19
  26. package/src/components/KeybindsDialog.stories.tsx +1 -1
  27. package/src/components/MarkerPopover.stories.tsx +1 -1
  28. package/src/components/MenuBar.stories.tsx +3 -12
  29. package/src/components/MenuBar.tsx +25 -56
  30. package/src/components/PromptDialog.stories.tsx +1 -1
  31. package/src/components/PrototypeSheet.stories.tsx +1 -1
  32. package/src/components/ReviewPanel.stories.tsx +1 -1
  33. package/src/components/ReviewRail.stories.tsx +1 -1
  34. package/src/components/SettingsDialog.stories.tsx +7 -1
  35. package/src/components/SettingsDialog.tsx +112 -66
  36. package/src/components/SettingsRows.stories.tsx +1 -1
  37. package/src/components/ShellHeader.stories.tsx +50 -0
  38. package/src/components/ShellHeader.tsx +137 -0
  39. package/src/components/ThreadFooter.stories.tsx +30 -0
  40. package/src/components/ThreadFooter.tsx +49 -0
  41. package/src/components/ThreadView.slash.test.tsx +113 -0
  42. package/src/components/ThreadView.stories.tsx +1 -1
  43. package/src/components/ThreadView.tsx +99 -11
  44. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  45. package/src/components/ThreadsSidebar.tsx +57 -0
  46. package/src/components/Toast.stories.tsx +1 -1
  47. package/src/components/TreePane.stories.tsx +1 -1
  48. package/src/components/WalkWizard.stories.tsx +1 -1
  49. package/src/components/__snapshots__/stories.test.tsx.snap +348 -138
  50. package/src/components/agent-launcher.stories.tsx +1 -1
  51. package/src/components/file-tree.test.ts +45 -0
  52. package/src/components/file-tree.ts +57 -0
  53. package/src/components/pages/Workspace.stories.tsx +181 -0
  54. package/src/components/primitives/Button.stories.tsx +1 -1
  55. package/src/components/primitives/Card.stories.tsx +1 -1
  56. package/src/components/primitives/Dialog.stories.tsx +1 -1
  57. package/src/components/primitives/IconButton.stories.tsx +36 -0
  58. package/src/components/primitives/IconButton.tsx +43 -0
  59. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  60. package/src/components/primitives/Tabs.stories.tsx +1 -1
  61. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  62. package/src/components/primitives/Tree.stories.tsx +124 -0
  63. package/src/components/primitives/Tree.tsx +87 -0
  64. package/src/components/primitives/icons.ts +38 -0
  65. package/src/components/primitives/tree-model.test.ts +99 -0
  66. package/src/components/primitives/tree-model.ts +137 -0
  67. package/src/components/quick-actions-editor.stories.tsx +1 -1
  68. package/src/components/session-tree.test.ts +114 -0
  69. package/src/components/session-tree.ts +94 -0
  70. package/src/components/stories-app.tsx +142 -82
  71. package/src/components/story-tree.test.ts +100 -0
  72. package/src/components/story.ts +55 -0
  73. package/src/components/theme-context.stories.tsx +1 -1
  74. package/src/session-controller.ts +9 -4
  75. package/src/theme.ts +4 -4
  76. package/src/use-settings-dialog.tsx +9 -0
  77. package/src/App.review-panel.test.tsx +0 -140
  78. package/src/App.tree.test.tsx +0 -224
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.65",
3
+ "version": "0.1.0-alpha.66",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -26,10 +26,10 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@cueloop/daemon": "0.1.0-alpha.65",
30
- "@cueloop/extension-api": "0.1.0-alpha.65",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.65",
32
- "@cueloop/schema": "0.1.0-alpha.65",
29
+ "@cueloop/daemon": "0.1.0-alpha.66",
30
+ "@cueloop/extension-api": "0.1.0-alpha.66",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.66",
32
+ "@cueloop/schema": "0.1.0-alpha.66",
33
33
  "@opentui/core": "^0.5.8",
34
34
  "@opentui/keymap": "0.5.8",
35
35
  "@opentui/react": "^0.5.8",
@@ -152,50 +152,9 @@ describe("diff review", () => {
152
152
  expect(stored.workingCopy).toBe("");
153
153
  });
154
154
 
155
- test("a rejected change lists in the rail and undo (u) restores it", async () => {
156
- // Arrange - a diff session that carries full file contents
157
- const withFiles = server.core.sessionCreate({
158
- workspace: { repoRoot: "/repo", branch: "main" },
159
- artifact: {
160
- type: "diff",
161
- content: PATCH,
162
- meta: { title: "working tree" },
163
- files: [
164
- {
165
- path: "src/store.ts",
166
- oldContents: "export class Store {\n private items = [];\n}\n",
167
- newContents: "export class Store {\n private items = new Map();\n}\n",
168
- status: "modified",
169
- },
170
- ],
171
- },
172
- });
173
- const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
174
- width: 120,
175
- height: 30,
176
- });
177
-
178
- await waitForText(setup, "cueloop");
179
-
180
- // Act - reject the change under the added line
181
- // rows: file(0), hunk(1), ctx(2), del(3), add(4)
182
- for (let i = 0; i < 4; i++) await press(setup, "j");
183
- await press(setup, "x");
184
-
185
- // Assert - the rail shows a removal card titled REJECT · me for the change
186
- await waitForText(setup, "REJECT · me");
187
- expect(server.core.sessionGet(withFiles.id).workingCopy).toBe("");
188
-
189
- // Act - undo restores it (no selection, so the last item)
190
- await press(setup, "u");
191
-
192
- // Assert - the change returns to the curated diff (working copy cleared)
193
- await waitForText(setup, "removal restored");
194
- expect(server.core.sessionGet(withFiles.id).workingCopy).toBeUndefined();
195
- });
196
-
197
- test("a selected removal card shows an undo button that restores it on click", async () => {
198
- // Arrange - a diff session that carries full file contents
155
+ test("a diff opens with the Changes column listing every changed file", async () => {
156
+ // Arrange - two changed files; config.ts is not in the patch text, so it can
157
+ // only appear here by way of the Changes column reading artifact.files
199
158
  const withFiles = server.core.sessionCreate({
200
159
  workspace: { repoRoot: "/repo", branch: "main" },
201
160
  artifact: {
@@ -203,12 +162,8 @@ describe("diff review", () => {
203
162
  content: PATCH,
204
163
  meta: { title: "working tree" },
205
164
  files: [
206
- {
207
- path: "src/store.ts",
208
- oldContents: "export class Store {\n private items = [];\n}\n",
209
- newContents: "export class Store {\n private items = new Map();\n}\n",
210
- status: "modified",
211
- },
165
+ { path: "src/store.ts", oldContents: "a", newContents: "b", status: "modified" },
166
+ { path: "src/config.ts", oldContents: "", newContents: "c", status: "added" },
212
167
  ],
213
168
  },
214
169
  });
@@ -219,28 +174,8 @@ describe("diff review", () => {
219
174
 
220
175
  await waitForText(setup, "cueloop");
221
176
 
222
- // Act - reject the change; the removal card lands unselected (no undo button)
223
- for (let i = 0; i < 4; i++) await press(setup, "j");
224
- await press(setup, "x");
225
- await waitForText(setup, "REJECT · me");
226
- expect(setup.captureCharFrame()).not.toContain("undo");
227
-
228
- // Act - click the card to select it; the undo button appears
229
- const clickText = async (needle: string): Promise<void> => {
230
- const lines = setup.captureCharFrame().split("\n");
231
- const row = lines.findIndex((line) => line.includes(needle));
232
-
233
- await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
234
- };
235
-
236
- await clickText("REJECT · me");
237
- await waitForText(setup, "undo");
238
-
239
- // Act - click the undo button
240
- await clickText("undo");
241
-
242
- // Assert - the change returns to the curated diff (working copy cleared)
243
- await waitForText(setup, "removal restored");
244
- expect(server.core.sessionGet(withFiles.id).workingCopy).toBeUndefined();
177
+ // Assert - the context default opens the column for a diff, so both files show
178
+ await waitForText(setup, "config.ts");
179
+ expect(setup.captureCharFrame()).toContain("store.ts");
245
180
  });
246
181
  });
@@ -10,7 +10,6 @@ import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
12
  import { DEFAULT_QUICK_ACTIONS, quickActionBody } from "./config";
13
- import { DARK } from "./theme";
14
13
  import {
15
14
  clickText,
16
15
  dragText,
@@ -166,31 +165,6 @@ describe("edit affordance", () => {
166
165
  });
167
166
  });
168
167
 
169
- describe("attribution", () => {
170
- test("a collaborator's note shows their name in the card border", async () => {
171
- // Arrange - a pulled collaborator note carries an author fingerprint
172
- const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
173
- const blocks = parseBlocks(PLAN);
174
- const contextBlockIndex = blocks.findIndex((block) =>
175
- block.text.startsWith("The daemon persists"),
176
- );
177
-
178
- server.core.sessionAnnotate(session.id, {
179
- id: "collab-1",
180
- kind: "comment",
181
- anchor: makeAnchor(blocks, contextBlockIndex, 0, 10),
182
- body: "who owns retries?",
183
- author: "SHA256:1a2b3c4d5e6f",
184
- });
185
-
186
- // Act
187
- const setup = await renderApp();
188
-
189
- // Assert - the short author handle rides the card border
190
- await waitForText(setup, "1a2b3c4d");
191
- });
192
- });
193
-
194
168
  describe("marks feed the comment anchor", () => {
195
169
  test("a drag marks a character-precise span and typing anchors the comment to it", async () => {
196
170
  // Arrange
@@ -299,140 +273,6 @@ describe("compose newline convention", () => {
299
273
  });
300
274
  });
301
275
 
302
- describe("the document selects, the rail edits", () => {
303
- test("option+e edits the focused card in place; enter saves through the controller", async () => {
304
- // Arrange
305
- const setup = await renderApp();
306
-
307
- await clickText(setup, "daemon");
308
- await type(setup, "Needs a citation.");
309
- await pressKey(setup, "RETURN", { meta: true });
310
- await waitForText(setup, "COMMENT · me");
311
-
312
- // Act: focus the card, then turn its body into an input in place
313
- await pressKey(setup, "n", { meta: true });
314
- await pressKey(setup, "e", { meta: true });
315
- await type(setup, " And a link.");
316
- await press(setup, "enter");
317
-
318
- // Assert
319
- await waitForState(
320
- setup,
321
- () =>
322
- server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
323
- );
324
- });
325
-
326
- test("activating a collaborator's card opens rename, not a body edit", async () => {
327
- // Arrange - a pulled collaborator note carries an author fingerprint
328
- const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
329
- const blocks = parseBlocks(PLAN);
330
- const contextBlockIndex = blocks.findIndex((block) =>
331
- block.text.startsWith("The daemon persists"),
332
- );
333
-
334
- server.core.sessionAnnotate(session.id, {
335
- id: "collab-1",
336
- kind: "comment",
337
- anchor: makeAnchor(blocks, contextBlockIndex, 0, 10),
338
- body: "who owns retries?",
339
- author: "SHA256:1a2b3c4d5e6f",
340
- });
341
- const setup = await renderApp();
342
-
343
- await waitForText(setup, "1a2b3c4d");
344
-
345
- // Act - focus the collaborator card, then activate it
346
- await pressKey(setup, "n", { meta: true });
347
- await pressKey(setup, "e", { meta: true });
348
-
349
- // Assert - the rename prompt opens; the body never becomes an editor
350
- await waitForText(setup, "Rename author");
351
- });
352
-
353
- test("option+backspace deletes the focused card and un-paints the document mark", async () => {
354
- // Arrange
355
- const setup = await renderApp();
356
-
357
- await dragText(setup, "daemon", "daemon persists", "daemon".length);
358
- await type(setup, "Delete me.");
359
- await pressKey(setup, "RETURN", { meta: true });
360
- await waitForText(setup, "COMMENT · me");
361
- expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
362
- await waitForState(setup, () => backgroundsOf(setup, "daemon").includes(THREAD_MARK));
363
-
364
- // Act
365
- await pressKey(setup, "n", { meta: true });
366
- await pressKey(setup, "BACKSPACE", { meta: true });
367
-
368
- // Assert
369
- await waitForTextGone(setup, "COMMENT · me");
370
- expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
371
- expect(backgroundsOf(setup, "daemon")).not.toContain(THREAD_MARK);
372
- });
373
- });
374
-
375
- describe("plan cut removals", () => {
376
- test("option+x cuts the caret's block into a rail removal card and option+u restores it", async () => {
377
- // Arrange
378
- const setup = await renderApp();
379
-
380
- await clickText(setup, "daemon");
381
-
382
- // Act - no card is focused, so the cut takes the block under the caret
383
- await pressKey(setup, "x", { meta: true });
384
-
385
- // Assert - the rail shows a removal card titled CUT · me for the cut block
386
- await waitForText(setup, "CUT · me");
387
- await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy !== undefined);
388
-
389
- // Act - undo restores it (no selection, so the last removal)
390
- await pressKey(setup, "u", { meta: true });
391
-
392
- // Assert - the block returns and the working copy is pristine again
393
- await waitForText(setup, "removal restored");
394
- await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
395
- });
396
- });
397
-
398
- describe("addressed annotations leave the open list", () => {
399
- test("a revision that addresses one card hides it, un-paints it, and shows the count", async () => {
400
- // Arrange: two annotations through the daemon, then a revision addressing one
401
- const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
402
- const blocks = parseBlocks(PLAN);
403
- const contextBlockIndex = blocks.findIndex((block) =>
404
- block.text.startsWith("The daemon persists"),
405
- );
406
-
407
- server.core.sessionAnnotate(session.id, {
408
- id: "a_settled",
409
- kind: "comment",
410
- anchor: makeAnchor(blocks, contextBlockIndex, 0, 10), // "The daemon"
411
- body: "settled note",
412
- });
413
- server.core.sessionAnnotate(session.id, {
414
- id: "a_open",
415
- kind: "comment",
416
- anchor: makeAnchor(blocks, contextBlockIndex, 11, 19), // "persists"
417
- body: "still open note",
418
- });
419
- server.core.sessionSubmitRevision(session.id, PLAN, ["a_settled"]);
420
-
421
- // Act
422
- const setup = await renderApp();
423
-
424
- await waitForText(setup, "still open note");
425
-
426
- // Assert: the open card renders, the addressed one is gone behind the count
427
- const frame = setup.captureCharFrame();
428
-
429
- expect(frame).toContain("✓ 1 addressed by revision");
430
- expect(frame).not.toContain("settled note");
431
- expect(frame).toContain("still open note"); // the open card survives; the addressed one does not
432
- expect(backgroundsOf(setup, "The daemon")).not.toContain(DARK.markCommentBackground); // no highlight paint
433
- });
434
- });
435
-
436
276
  describe("sheet header", () => {
437
277
  test("shows the submitting agent, the revision, and the Edit and Share word-buttons", async () => {
438
278
  // Arrange
@@ -446,25 +286,6 @@ describe("sheet header", () => {
446
286
  expect(frame).toContain("Edit");
447
287
  expect(frame).toContain("Share");
448
288
  });
449
-
450
- test("the Agent tab shows the text-only harness launcher buttons", async () => {
451
- // Arrange
452
- const setup = await renderApp();
453
- const lines = setup.captureCharFrame().split("\n");
454
- const tabRow = lines.findIndex((line) => line.includes("Review") && line.includes("Agent"));
455
- const tabColumn = lines[tabRow]!.indexOf("Agent");
456
-
457
- // Act
458
- await setup.mockMouse.click(tabColumn + 1, tabRow);
459
- await setup.renderOnce();
460
-
461
- // Assert - the three launcher buttons, in order
462
- await waitForText(setup, "Claude Code");
463
- const frame = setup.captureCharFrame();
464
-
465
- expect(frame).toContain("Pi");
466
- expect(frame).toContain("OpenAI Codex");
467
- });
468
289
  });
469
290
 
470
291
  describe("quick-actions settings editor", () => {
@@ -472,8 +293,8 @@ describe("quick-actions settings editor", () => {
472
293
  // Arrange
473
294
  const setup = await renderApp();
474
295
 
475
- // Act - open Settings, enter Actions, expand the first action, type into its input
476
- await clickText(setup, "menu");
296
+ // Act - open Settings from the top-left gear, enter Actions, expand the first action, type
297
+ await setup.mockMouse.click(1, 1);
477
298
  await clickText(setup, "Settings");
478
299
  await clickText(setup, "Actions");
479
300
  await clickText(setup, "Zoom out, research in depth");
@@ -502,15 +323,13 @@ describe("edit-exit reconciliation", () => {
502
323
  await clickText(setup, "daemon");
503
324
  await type(setup, "Anchor me to the doomed passage.");
504
325
  await pressKey(setup, "RETURN", { meta: true });
505
- await waitForText(setup, "COMMENT · me");
326
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
506
327
 
507
328
  // Act
508
329
  await pressKey(setup, "e", { ctrl: true });
509
330
 
510
- // Assert
331
+ // Assert - the thread view banner reports the orphaned annotation
511
332
  await waitForText(setup, "1 annotation no longer match - the passage was removed.");
512
- // the rail card flips to ORPHANED once the working-copy refresh lands
513
- await waitForText(setup, "· ORPHANED");
514
333
  // the annotation is NOT deleted: the feedback serializer handles orphans
515
334
  expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
516
335
  } finally {
@@ -105,7 +105,7 @@ async function clickText(setup: Setup, needle: string): Promise<void> {
105
105
  }
106
106
 
107
107
  describe("prototype review", () => {
108
- test("click an element, comment on it, and it lands in the rail", async () => {
108
+ test("click an element, comment on it, and it saves to the daemon", async () => {
109
109
  // Arrange - wait until the fake browser produced its first screenshot, so
110
110
  // the sheet is interactive before the click
111
111
  const setup = await renderApp();
@@ -135,13 +135,6 @@ describe("prototype review", () => {
135
135
  expect(stored.kind).toBe("comment");
136
136
  expect(stored.body).toBe("tighten the padding");
137
137
  expect(stored.anchor.selector).toBe("main > div.card");
138
-
139
- // and it renders in the rail
140
- await settle(setup);
141
- const frame = setup.captureCharFrame();
142
-
143
- expect(frame).toContain("tighten the padding");
144
- expect(frame).toContain("COMMENT");
145
138
  });
146
139
 
147
140
  test("escape cancels the compose, matching the plan composer", async () => {
@@ -9,7 +9,6 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
- import { DARK } from "./theme";
13
12
  import {
14
13
  clickText,
15
14
  dragText,
@@ -17,7 +16,6 @@ import {
17
16
  isolateUserConfig,
18
17
  pressKey,
19
18
  typeText,
20
- waitForState,
21
19
  waitForText,
22
20
  } from "./test-support";
23
21
 
@@ -83,15 +81,12 @@ function snapshot() {
83
81
  }
84
82
 
85
83
  describe("observer rendering", () => {
86
- test("shows the observer badge and the bottom menu", async () => {
84
+ test("shows the observer badge in the header", async () => {
87
85
  // Arrange
88
86
  const setup = await renderObserver();
89
87
 
90
88
  // Assert
91
- const frame = setup.captureCharFrame();
92
-
93
- expect(frame).toContain("· observer");
94
- expect(frame).toContain("menu");
89
+ expect(setup.captureCharFrame()).toContain("· observer");
95
90
  });
96
91
  });
97
92
 
@@ -199,29 +194,12 @@ describe("observer navigation still works", () => {
199
194
  });
200
195
  const setup = await renderObserver();
201
196
 
202
- // Act
197
+ // Act - the observer navigates to the annotation without mutating anything
203
198
  await pressKey(setup, "n", { meta: true });
204
199
  await setup.renderOnce();
205
200
 
206
- // Assert - focus shows as the card's blue border (transparent fill); a
207
- // comment card is the only blue-bordered box in the frame
208
- await waitForText(setup, "COMMENT · me");
209
- await waitForState(setup, () => hasBorderColor(setup, DARK.blue));
201
+ // Assert - the observer reads the controller's comment inline in the thread
202
+ await waitForText(setup, "From the controller.");
203
+ expect(snapshot().annotations).toBe(1);
210
204
  });
211
205
  });
212
-
213
- /** Whether any border character (box-drawing) is painted in the given foreground hex. */
214
- function hasBorderColor(setup: Awaited<ReturnType<typeof renderObserver>>, hex: string): boolean {
215
- for (const line of setup.captureSpans().lines) {
216
- for (const span of line.spans) {
217
- if (!/[╭╮╰╯│─┌┐└┘]/.test(span.text)) continue;
218
- const [red, green, blue] = span.fg.toInts();
219
- const rendered =
220
- "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
221
-
222
- if (rendered === hex) return true;
223
- }
224
- }
225
-
226
- return false;
227
- }
@@ -1,4 +1,4 @@
1
- /** The rail submit confirm (tier 2): pressing submit expands the rail's Submit button into a bordered confirm card - counts, verdict selector, summary input, word-buttons. Char-frame assertions over the real App and an in-process daemon, like App.test.tsx. */
1
+ /** The send-message confirm: cmd+enter opens a centered overlay with a verdict selector, summary input, and word-buttons. Char-frame assertions over the real App and an in-process daemon, like App.test.tsx. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -83,29 +83,27 @@ function seedAnnotations(count: number): void {
83
83
  }
84
84
  }
85
85
 
86
- describe("rail submit confirm", () => {
87
- test("submit expands the Submit button into the confirm card", async () => {
86
+ describe("send message confirm", () => {
87
+ test("cmd+enter opens the send-message confirm overlay", async () => {
88
88
  // Arrange
89
89
  const setup = await renderApp();
90
90
 
91
- // Assert
92
- expect(setup.captureCharFrame()).toContain("Submit review");
91
+ // Assert - the footer carries the send control before the overlay opens
92
+ expect(setup.captureCharFrame()).toContain("send message");
93
93
 
94
94
  // Act
95
95
  await pressKey(setup, "RETURN", { meta: true });
96
96
 
97
- // Assert
98
- await waitForText(setup, "submit review");
97
+ // Assert - the overlay: verdict selector and word-buttons
98
+ await waitForText(setup, "[Approve]"); // nothing pending: approve default
99
99
  const frame = setup.captureCharFrame();
100
100
 
101
- // the card: title, verdict selector, word-buttons
102
- expect(frame).toContain("submit review");
103
- expect(frame).toContain("[Approve]"); // nothing pending: approve default
104
- expect(frame).toContain(" Submit ");
101
+ expect(frame).toContain("[Approve]");
102
+ expect(frame).toContain(" send message ");
105
103
  expect(frame).toContain(" Cancel ");
106
104
  });
107
105
 
108
- test("left/right cycles the verdict selector in the card", async () => {
106
+ test("left/right cycles the verdict selector in the overlay", async () => {
109
107
  // Arrange
110
108
  const setup = await renderApp();
111
109
 
@@ -133,7 +131,7 @@ describe("rail submit confirm", () => {
133
131
  await waitForText(setup, "[Changes]");
134
132
  });
135
133
 
136
- test("esc cancels the card and restores the plain Submit button", async () => {
134
+ test("esc closes the overlay", async () => {
137
135
  // Arrange
138
136
  const setup = await renderApp();
139
137
 
@@ -145,15 +143,13 @@ describe("rail submit confirm", () => {
145
143
  // Act
146
144
  await press(setup, "escape");
147
145
 
148
- // Assert
149
- // a bare ESC settles after the parser's escape-sequence window
146
+ // Assert - a bare ESC settles after the parser's escape-sequence window
150
147
  const frame = await waitForTextGone(setup, "[Approve]");
151
148
 
152
- expect(frame).not.toContain("submit review");
153
- expect(frame).toContain("Submit review");
149
+ expect(frame).not.toContain(" Cancel ");
154
150
  });
155
151
 
156
- test("enter in the card resolves the session through the controller", async () => {
152
+ test("enter in the overlay resolves the session through the controller", async () => {
157
153
  // Arrange
158
154
  seedAnnotations(1);
159
155
  const setup = await renderApp();
@@ -163,16 +159,12 @@ describe("rail submit confirm", () => {
163
159
 
164
160
  // Assert
165
161
  await waitForText(setup, "[Changes]"); // pending items: request changes default
166
- const frame = setup.captureCharFrame();
167
-
168
- expect(frame).toContain("submit review");
169
162
 
170
163
  // Act
171
164
  await setup.mockInput.typeText("Tighten the steps.");
172
165
  await press(setup, "enter");
173
166
 
174
- // Assert
175
- // the completion flow after submit is unchanged
167
+ // Assert - the completion flow after submit is unchanged
176
168
  await waitForText(setup, "feedback sent");
177
169
  const stored = server.core.sessionGet(session.id);
178
170
 
@@ -180,65 +172,20 @@ describe("rail submit confirm", () => {
180
172
  expect(stored.verdict!.kind).toBe("request_changes");
181
173
  });
182
174
 
183
- /** The rail's columns of the frame, so document text never leaks into a rail assertion. */
184
- function railText(frame: string): string {
185
- const rows = frame.split("\n");
186
- const railColumn = rows.find((row) => row.includes("Review Tree Agent"))!.indexOf("│");
187
-
188
- return rows.map((row) => row.slice(railColumn)).join("\n");
189
- }
190
-
191
- test("the annotation stack stays scrollable while the card is open", async () => {
192
- // Arrange
193
- seedAnnotations(12);
194
- const setup = await renderApp();
195
-
196
- // Act
197
- // walk card focus to the last one: the rail scrolls it into view
198
- for (let index = 0; index < 12; index++) await pressKey(setup, "n", { meta: true });
199
-
200
- // Assert
201
- await waitForText(setup, "note 12");
202
- expect(railText(setup.captureCharFrame())).not.toContain("note 01");
203
-
204
- // Act
205
- await pressKey(setup, "RETURN", { meta: true });
206
-
207
- // Assert
208
- await waitForText(setup, "[Changes]");
209
- // the card is pinned outside the scrollbox; the stack stays scrolled off the top
210
- expect(railText(setup.captureCharFrame())).not.toContain("note 01");
211
-
212
- // Act
213
- // the stack still scrolls with the card open: wheel over the rail
214
- // brings the last card back into view while the confirm card stays put
215
- for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
216
- await settle(setup);
217
-
218
- // Assert
219
- await waitForText(setup, "note 12");
220
- const scrolledWithCard = railText(setup.captureCharFrame());
221
-
222
- expect(scrolledWithCard).toContain("submit review");
223
- expect(scrolledWithCard).not.toContain("note 01");
224
- });
225
-
226
- test("read-only observers never see the confirm card", async () => {
175
+ test("read-only observers cannot open the confirm overlay", async () => {
227
176
  // Arrange
228
177
  seedAnnotations(1);
229
178
  const setup = await renderApp({ readOnly: true });
230
179
 
231
180
  // Act
232
181
  await pressKey(setup, "RETURN", { meta: true });
182
+ await settle(setup);
233
183
 
234
- // Assert
235
- await waitForText(setup, "observer - read-only");
184
+ // Assert - no overlay, and the session stays pending
236
185
  const frame = setup.captureCharFrame();
237
186
 
238
- expect(frame).toContain("observer - read-only");
239
- expect(frame).not.toContain("1 annotations");
240
187
  expect(frame).not.toContain("[Changes]");
241
- expect(setup.captureCharFrame()).not.toContain("verdict ←/→");
188
+ expect(frame).not.toContain(" Cancel ");
242
189
  expect(server.core.sessionGet(session.id).status).toBe("pending");
243
190
  });
244
191
  });