@cueloop/client 0.1.0-alpha.78 → 0.1.0-alpha.79

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 (92) hide show
  1. package/package.json +14 -13
  2. package/src/App.diff.test.tsx +9 -4
  3. package/src/App.focus.test.ts +1 -59
  4. package/src/App.identity.test.tsx +3 -3
  5. package/src/App.plan-render.test.tsx +209 -0
  6. package/src/App.plan-review.test.tsx +75 -26
  7. package/src/App.readonly.test.tsx +6 -5
  8. package/src/App.share-dialog.test.tsx +172 -0
  9. package/src/App.submit-confirm.test.tsx +6 -4
  10. package/src/App.test.tsx +31 -28
  11. package/src/App.toast.test.tsx +11 -4
  12. package/src/App.tsx +259 -274
  13. package/src/App.walk.test.tsx +9 -8
  14. package/src/App.workbench.test.tsx +86 -0
  15. package/src/annotation-palette.ts +1 -0
  16. package/src/app-screens.tsx +12 -6
  17. package/src/app-view-model.ts +0 -1
  18. package/src/components/ConfirmDialog.stories.tsx +3 -3
  19. package/src/components/DiffContentView.tsx +14 -7
  20. package/src/components/DiscussionMarkerRail.tsx +9 -4
  21. package/src/components/GridTabContent.tsx +8 -2
  22. package/src/components/HandleChipsInput.stories.tsx +24 -0
  23. package/src/components/HandleChipsInput.test.tsx +35 -0
  24. package/src/components/HandleChipsInput.tsx +145 -0
  25. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  26. package/src/components/MarkdownGridBlock.tsx +72 -0
  27. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  28. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  29. package/src/components/MarkdownThreadEditor.tsx +162 -0
  30. package/src/components/NavModeHint.stories.tsx +40 -0
  31. package/src/components/NavModeHint.tsx +31 -0
  32. package/src/components/SettingsDialog.tsx +1 -0
  33. package/src/components/ShareDialog.stories.tsx +121 -0
  34. package/src/components/ShareDialog.tsx +552 -0
  35. package/src/components/ThreadFooter.tsx +4 -1
  36. package/src/components/ThreadTree.tsx +1 -1
  37. package/src/components/ThreadView.slash.test.tsx +0 -35
  38. package/src/components/ThreadView.tsx +142 -68
  39. package/src/components/Toast.stories.tsx +7 -0
  40. package/src/components/Toast.tsx +15 -3
  41. package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
  42. package/src/components/primitives/Button.tsx +6 -2
  43. package/src/components/primitives/Card.tsx +2 -2
  44. package/src/components/primitives/Dialog.tsx +2 -2
  45. package/src/components/primitives/Switch.stories.tsx +17 -0
  46. package/src/components/primitives/Switch.tsx +32 -0
  47. package/src/components/primitives/Tree.tsx +5 -1
  48. package/src/components/primitives/frame.ts +5 -0
  49. package/src/components/share-dialog-store.ts +58 -0
  50. package/src/components/stories.test.tsx +4 -3
  51. package/src/config.test.ts +18 -0
  52. package/src/config.ts +8 -1
  53. package/src/diff-fold.ts +1 -1
  54. package/src/intent-dispatch.test.ts +12 -3
  55. package/src/intent-dispatch.ts +10 -25
  56. package/src/key-bindings.ts +0 -6
  57. package/src/keymap.ts +0 -1
  58. package/src/markdown-editor-syntax-style.ts +50 -0
  59. package/src/markdown-grid.test.ts +101 -0
  60. package/src/markdown-grid.ts +203 -0
  61. package/src/markdown-highlight.test.ts +61 -0
  62. package/src/markdown-highlight.ts +122 -0
  63. package/src/prototype-browser.test.ts +9 -1
  64. package/src/prototype-browser.ts +22 -0
  65. package/src/run.ts +8 -6
  66. package/src/share.ts +15 -1
  67. package/src/test-support.ts +14 -0
  68. package/src/thread-chords.test.ts +55 -178
  69. package/src/thread-chords.ts +78 -169
  70. package/src/thread-controller.curate.test.ts +1 -0
  71. package/src/thread-controller.live-diff.test.ts +3 -0
  72. package/src/thread-controller.share.test.ts +60 -5
  73. package/src/thread-controller.switch-cache.test.ts +30 -0
  74. package/src/thread-controller.tree.test.ts +6 -1
  75. package/src/thread-controller.ts +229 -54
  76. package/src/use-annotation-surface.tsx +45 -36
  77. package/src/use-settings-dialog.tsx +14 -3
  78. package/src/use-thread-body-editing.tsx +58 -0
  79. package/src/view-plan.test.ts +56 -6
  80. package/src/view-plan.ts +154 -5
  81. package/src/App.share-choice.test.tsx +0 -105
  82. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  83. package/src/components/ManageAccessDialog.test.tsx +0 -94
  84. package/src/components/ManageAccessDialog.tsx +0 -122
  85. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  86. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  87. package/src/components/ShareChoiceDialog.tsx +0 -80
  88. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  89. package/src/components/ThreadsSidebar.tsx +0 -71
  90. package/src/components/agent-launcher.stories.tsx +0 -26
  91. package/src/components/agent-launcher.test.tsx +0 -50
  92. package/src/components/agent-launcher.tsx +0 -149
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.78",
3
+ "version": "0.1.0-alpha.79",
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": {
@@ -27,24 +27,25 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.78",
31
- "@cueloop/extension-api": "0.1.0-alpha.78",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.78",
33
- "@cueloop/schema": "0.1.0-alpha.78",
34
- "@opentui/core": "^0.5.8",
35
- "@opentui/keymap": "0.5.8",
36
- "@opentui/react": "^0.5.8",
37
- "@opentui/ssh": "0.5.8",
38
- "@pierre/diffs": "^1.3.6",
30
+ "@cueloop/daemon": "0.1.0-alpha.79",
31
+ "@cueloop/extension-api": "0.1.0-alpha.79",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.79",
33
+ "@cueloop/schema": "0.1.0-alpha.79",
34
+ "@opentui/core": "^0.5.11",
35
+ "@opentui/keymap": "0.5.11",
36
+ "@opentui/react": "^0.5.11",
37
+ "@opentui/ssh": "0.5.11",
38
+ "@pierre/diffs": "^1.4.1",
39
39
  "@tanstack/virtual-core": "^3.17.9",
40
40
  "diff": "^9",
41
- "puppeteer-core": "^25.8.0",
41
+ "puppeteer-core": "^25.9.0",
42
42
  "react": "^19.2.0",
43
- "valibot": "^1.4.2"
43
+ "valibot": "^1.4.2",
44
+ "zustand": "^5.0.15"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/react": "^19.2.0",
47
- "@types/ssh2": "^1.15.5",
48
+ "@types/ssh2": "^1.15.6",
48
49
  "ssh2": "^1.17.0"
49
50
  },
50
51
  "engines": {
@@ -13,6 +13,8 @@ import {
13
13
  clickText,
14
14
  dragText,
15
15
  isolateUserConfig,
16
+ navCommand,
17
+ press,
16
18
  pressKey,
17
19
  renderReadyApp,
18
20
  typeText,
@@ -92,8 +94,11 @@ describe("diff review", () => {
92
94
  await waitForText(setup, "● Map needs an eviction story.");
93
95
  expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("new Map()");
94
96
 
95
- // Act - submit with the session chord (cmd+enter, no composer open), confirm request_changes
97
+ // Act - submit with the session chord (cmd+enter, no composer open), cycle to request changes
96
98
  await pressKey(setup, "RETURN", { meta: true });
99
+ await waitForText(setup, "[Approve]");
100
+ await press(setup, "right");
101
+ await waitForText(setup, "[Changes]");
97
102
  await pressKey(setup, "RETURN", { meta: true });
98
103
 
99
104
  // Assert
@@ -138,7 +143,7 @@ describe("diff review", () => {
138
143
  const setup = await renderApp();
139
144
 
140
145
  // Act
141
- await pressKey(setup, "c", { meta: true });
146
+ await navCommand(setup, "c");
142
147
 
143
148
  // Assert - the body is gone but the band (with its counts) remains
144
149
  await waitForState(setup, () => !setup.captureCharFrame().includes("new Map()"));
@@ -159,7 +164,7 @@ describe("diff review", () => {
159
164
  const setup = await renderApp();
160
165
 
161
166
  // Act
162
- await pressKey(setup, "x", { meta: true });
167
+ await navCommand(setup, "x");
163
168
 
164
169
  // Assert
165
170
  await waitForText(setup, "hunk curation needs full file contents");
@@ -187,7 +192,7 @@ describe("diff review", () => {
187
192
 
188
193
  // Act - place the caret on the added line and reject its change
189
194
  await clickText(setup, "new Map()");
190
- await pressKey(setup, "x", { meta: true });
195
+ await navCommand(setup, "x");
191
196
 
192
197
  // Assert - the single change is gone, so the curated working copy is empty
193
198
  await waitForText(setup, "change rejected");
@@ -1,11 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import {
3
- appLeaderHandled,
4
- nextFocusPane,
5
- reconciledFocus,
6
- threadsNavHandled,
7
- visiblePanes,
8
- } from "./App";
2
+ import { nextFocusPane, reconciledFocus, threadsNavHandled, visiblePanes } from "./App";
9
3
 
10
4
  describe("threads keyboard nav", () => {
11
5
  const base = () => {
@@ -92,56 +86,4 @@ describe("pane focus cycle", () => {
92
86
  expect(reconciledFocus(["changes", "project"], "thread")).toBe("changes");
93
87
  expect(reconciledFocus([], "thread")).toBeNull();
94
88
  });
95
-
96
- test("appLeaderHandled captures the leader for the sidebar panes, then the next key", () => {
97
- const leaderCombos = ["ctrl+g"];
98
- const pending = { current: false };
99
- const ran: string[] = [];
100
- const call = (
101
- focusedPane: "threads" | "project" | "thread" | "changes",
102
- key: { name: string; ctrl?: boolean },
103
- ) =>
104
- appLeaderHandled({
105
- focusedPane,
106
- key,
107
- leaderCombos,
108
- pending,
109
- runLeaderCommand: (pressed) => ran.push(pressed.name),
110
- });
111
-
112
- expect(call("threads", { name: "g", ctrl: true })).toBe(true);
113
- expect(pending.current).toBe(true);
114
- expect(call("threads", { name: "tab" })).toBe(true);
115
- expect(pending.current).toBe(false);
116
- expect(ran).toEqual(["tab"]);
117
- });
118
-
119
- test("appLeaderHandled defers on the content panes and swallows escape without a command", () => {
120
- const leaderCombos = ["ctrl+g"];
121
- let ran = false;
122
- const runLeaderCommand = () => (ran = true);
123
-
124
- expect(
125
- appLeaderHandled({
126
- focusedPane: "thread",
127
- key: { name: "ctrl+g" },
128
- leaderCombos,
129
- pending: { current: false },
130
- runLeaderCommand,
131
- }),
132
- ).toBe(false);
133
-
134
- const pending = { current: true };
135
- expect(
136
- appLeaderHandled({
137
- focusedPane: "project",
138
- key: { name: "escape" },
139
- leaderCombos,
140
- pending,
141
- runLeaderCommand,
142
- }),
143
- ).toBe(true);
144
- expect(pending.current).toBe(false);
145
- expect(ran).toBe(false);
146
- });
147
89
  });
@@ -45,11 +45,11 @@ test("the Account settings tab shows the display name row and the GitHub sync ac
45
45
  const setup = await renderApp();
46
46
 
47
47
  await setup.mockMouse.click(1, 0);
48
- await waitForText(setup, "Settings");
48
+ await waitForText(setup, "settings");
49
49
  const menu = setup.captureCharFrame().split("\n");
50
- const settingsRow = menu.findIndex((line) => line.includes("Settings"));
50
+ const settingsRow = menu.findIndex((line) => line.includes("settings"));
51
51
 
52
- await setup.mockMouse.click(menu[settingsRow]!.indexOf("Settings") + 1, settingsRow);
52
+ await setup.mockMouse.click(menu[settingsRow]!.indexOf("settings") + 1, settingsRow);
53
53
  await waitForText(setup, "Account");
54
54
  const dialog = setup.captureCharFrame().split("\n");
55
55
  const accountRow = dialog.findIndex((line) => line.includes("Account"));
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The read-only plan render: h1/h2 sit over
3
+ * a rule, links show their label in the link color with the URL concealed,
4
+ * inline code grays with its backticks gone, GFM tables render as an aligned grid
5
+ * and leading YAML frontmatter as a bordered key/value table. Because rendered
6
+ * text now differs from the source, a comment dragged over a concealed-markup
7
+ * passage must still anchor to the source and repaint on the visible label.
8
+ * Char-frame + styled-span assertions over the real App and an in-process daemon.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import React from "react";
16
+ import { DaemonServer } from "@cueloop/daemon";
17
+ import type { Thread } from "@cueloop/schema";
18
+ import { App } from "./App";
19
+ import { DARK } from "./theme";
20
+ import {
21
+ dragText,
22
+ isolateUserConfig,
23
+ pressKey,
24
+ renderReadyApp,
25
+ settle,
26
+ typeText as type,
27
+ waitForState,
28
+ waitForText,
29
+ } from "./test-support";
30
+
31
+ const PLAN = `---
32
+ title: Migration Plan
33
+ owner: platform team
34
+ ---
35
+
36
+ # Migration Plan
37
+
38
+ ## Rollout
39
+
40
+ Ship it behind a \`flag\` and read [OpenTUI](https://opentui) first.
41
+
42
+ | Name | Size |
43
+ | :--- | ---: |
44
+ | daemon | 12 |
45
+ | client | 7 |
46
+ `;
47
+
48
+ let home: string;
49
+ let server: DaemonServer;
50
+ let session: Thread;
51
+ let restoreUserConfig: () => void;
52
+
53
+ beforeEach(() => {
54
+ home = mkdtempSync(join(tmpdir(), "cueloop-plan-render-"));
55
+ restoreUserConfig = isolateUserConfig(home);
56
+ server = new DaemonServer({ home, idleExitMs: 0 });
57
+ server.start();
58
+ session = server.core.sessionCreate({
59
+ workspace: { repoRoot: "/repo", branch: "main" },
60
+ artifact: {
61
+ type: "plan",
62
+ content: PLAN,
63
+ meta: { title: "Migration Plan", planPath: "plan.md" },
64
+ },
65
+ });
66
+ });
67
+ afterEach(() => {
68
+ restoreUserConfig();
69
+ server.stop();
70
+ rmSync(home, { recursive: true, force: true });
71
+ });
72
+
73
+ type Setup = Awaited<ReturnType<typeof renderPlan>>;
74
+
75
+ async function renderPlan() {
76
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
77
+ width: 90,
78
+ height: 40,
79
+ });
80
+
81
+ await waitForText(setup, "cueloop");
82
+ await settle(setup);
83
+
84
+ return setup;
85
+ }
86
+
87
+ /** Hex foregrounds of every styled span containing the needle. */
88
+ function foregroundsOf(setup: Setup, needle: string): string[] {
89
+ const foregrounds: string[] = [];
90
+
91
+ for (const line of setup.captureSpans().lines) {
92
+ for (const span of line.spans) {
93
+ if (span.fg === undefined || !span.text.includes(needle)) continue;
94
+ const [red, green, blue] = span.fg.toInts();
95
+
96
+ foregrounds.push(
97
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
98
+ );
99
+ }
100
+ }
101
+
102
+ return foregrounds;
103
+ }
104
+
105
+ /** Hex backgrounds of every styled span containing the needle. */
106
+ function backgroundsOf(setup: Setup, needle: string): string[] {
107
+ const backgrounds: string[] = [];
108
+
109
+ for (const line of setup.captureSpans().lines) {
110
+ for (const span of line.spans) {
111
+ if (!span.text.includes(needle)) continue;
112
+ const [red, green, blue] = span.bg.toInts();
113
+
114
+ backgrounds.push(
115
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
116
+ );
117
+ }
118
+ }
119
+
120
+ return backgrounds;
121
+ }
122
+
123
+ const THREAD_MARK = "#463852";
124
+
125
+ describe("read-only markdown render", () => {
126
+ test("h1 and h2 sit over a rule", async () => {
127
+ const setup = await renderPlan();
128
+ const rows = setup.captureCharFrame().split("\n");
129
+ // "Migration Plan" also rides the header bar; a heading is any body row directly over a rule
130
+ const ruleBelow = (heading: string): boolean =>
131
+ rows.some((row, index) => row.includes(heading) && (rows[index + 1] ?? "").includes("──"));
132
+
133
+ expect(ruleBelow("Migration Plan")).toBe(true);
134
+ expect(ruleBelow("Rollout")).toBe(true);
135
+ });
136
+
137
+ test("a link shows its label in the link color with the URL concealed", async () => {
138
+ const setup = await renderPlan();
139
+ const frame = setup.captureCharFrame();
140
+
141
+ expect(frame).toContain("OpenTUI");
142
+ expect(frame).not.toContain("https://opentui");
143
+ expect(frame).not.toContain("[OpenTUI]");
144
+ expect(foregroundsOf(setup, "OpenTUI")).toContain(DARK.blue);
145
+ });
146
+
147
+ test("inline code grays with its backticks concealed", async () => {
148
+ const setup = await renderPlan();
149
+ const frame = setup.captureCharFrame();
150
+
151
+ expect(frame).toContain("flag");
152
+ expect(frame).not.toContain("`flag`");
153
+ expect(foregroundsOf(setup, "flag")).toContain(DARK.textDim);
154
+ });
155
+
156
+ test("a table renders as an aligned grid without pipes", async () => {
157
+ const setup = await renderPlan();
158
+ const rows = setup.captureCharFrame().split("\n");
159
+ const header = rows.find((row) => row.includes("Name") && row.includes("Size"))!;
160
+
161
+ expect(header).not.toContain("|");
162
+ // the numeric column is right-aligned, so 12 and 7 line up on their last digit
163
+ const daemon = rows.find((row) => row.includes("daemon"))!;
164
+ const client = rows.find((row) => row.includes("client"))!;
165
+
166
+ expect(daemon.indexOf("12") + 1).toBe(client.indexOf("7"));
167
+ });
168
+
169
+ test("frontmatter renders as a bordered key/value grid", async () => {
170
+ const setup = await renderPlan();
171
+ const frame = setup.captureCharFrame();
172
+
173
+ expect(frame).toContain("┌");
174
+ expect(frame).toContain("└");
175
+ expect(frame).toContain("title");
176
+ expect(frame).toContain("platform team");
177
+ });
178
+ });
179
+
180
+ describe("annotating a grid block", () => {
181
+ test("a comment dragged over a table cell anchors on the grid and marks it", async () => {
182
+ const setup = await renderPlan();
183
+
184
+ // the table renders as a grid, yet it still takes a comment like any other block
185
+ await dragText(setup, "daemon", "daemon", "daemon".length);
186
+ await type(setup, "which daemon?");
187
+ await pressKey(setup, "RETURN", { meta: true });
188
+
189
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
190
+ // the grid carries the mark backdrop, so an annotation resolving into it stays visible
191
+ await waitForState(setup, () => backgroundsOf(setup, "daemon").includes(THREAD_MARK));
192
+ }, 60_000);
193
+ });
194
+
195
+ describe("anchoring over concealed markup", () => {
196
+ test("a comment dragged over a rendered link label anchors to the source and repaints", async () => {
197
+ const setup = await renderPlan();
198
+
199
+ // drag the visible label "OpenTUI" (its brackets and URL are concealed)
200
+ await dragText(setup, "OpenTUI", "OpenTUI", "OpenTUI".length);
201
+ await type(setup, "which build?");
202
+ await pressKey(setup, "RETURN", { meta: true });
203
+
204
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
205
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("OpenTUI");
206
+ // the saved comment keeps the visible label marked
207
+ await waitForState(setup, () => backgroundsOf(setup, "OpenTUI").includes(THREAD_MARK));
208
+ }, 60_000);
209
+ });
@@ -14,6 +14,7 @@ import {
14
14
  clickText,
15
15
  dragText,
16
16
  isolateUserConfig,
17
+ navCommand,
17
18
  press,
18
19
  pressKey,
19
20
  renderReadyApp,
@@ -210,11 +211,15 @@ describe("the mark stays painted while composing", () => {
210
211
  await waitForText(setup, "● x");
211
212
  expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
212
213
 
213
- // Act: escape discards the draft; the mark stays for re-typing, a second escape drops it
214
+ // Act: escape discards the draft and the mark stays for re-typing; the next escape
215
+ // enters nav mode with the mark still held; only an escape in nav drops it
214
216
  await press(setup, "escape");
215
217
  await waitForTextGone(setup, "● x");
216
218
  expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
217
219
  await press(setup, "escape");
220
+ await waitForText(setup, "type to leave");
221
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
222
+ await press(setup, "escape");
218
223
 
219
224
  // Assert
220
225
  await waitForState(setup, () => !backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
@@ -244,6 +249,22 @@ describe("the mark stays painted while composing", () => {
244
249
  await waitForTextGone(setup, "● x");
245
250
  expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
246
251
  }, 60_000);
252
+
253
+ test("esc from type mode keeps the mark, so c in nav comments on the marked text", async () => {
254
+ // Arrange
255
+ const setup = await renderApp();
256
+
257
+ // Act: mark, enter nav with esc, open the composer with c, save
258
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
259
+ await navCommand(setup, "c");
260
+ await type(setup, "Which daemon?");
261
+ await waitForText(setup, "● Which daemon?");
262
+ await pressKey(setup, "RETURN", { meta: true });
263
+
264
+ // Assert: the comment anchors to the mark, not to the word under the caret
265
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
266
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
267
+ }, 60_000);
247
268
  });
248
269
 
249
270
  describe("compose newline convention", () => {
@@ -311,7 +332,7 @@ describe("quick-actions settings editor", () => {
311
332
  // Act - open Settings from the top-left gear, enter Actions, expand the first action, type
312
333
  await setup.mockMouse.click(1, 0);
313
334
  await waitForText(setup, "Keybinds");
314
- await clickText(setup, "Settings");
335
+ await clickText(setup, "settings");
315
336
  await clickText(setup, "Actions");
316
337
  await clickText(setup, "Zoom out, research in depth");
317
338
  await type(setup, "CUSTOM");
@@ -327,30 +348,58 @@ describe("quick-actions settings editor", () => {
327
348
 
328
349
  describe("edit-exit reconciliation", () => {
329
350
  test("an edit that removes an anchored passage orphans the annotation and shows the banner", async () => {
330
- const script = join(home, "fake-editor.sh");
331
-
332
- await Bun.write(script, `#!/bin/sh\nsed -i '' '/^The daemon persists/d' "$1"\n`);
333
- Bun.spawnSync(["chmod", "+x", script]);
334
- process.env.CUELOOP_EDITOR = script;
335
- try {
336
- // Arrange
337
- const setup = await renderApp();
338
-
339
- await clickText(setup, "daemon");
340
- await type(setup, "Anchor me to the doomed passage.");
341
- await pressKey(setup, "RETURN", { meta: true });
342
- await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
343
-
344
- // Act
345
- await pressKey(setup, "e", { ctrl: true });
346
-
347
- // Assert - the thread view banner reports the orphaned annotation
348
- await waitForText(setup, "1 annotation no longer match - the passage was removed.");
349
- // the annotation is NOT deleted: the feedback serializer handles orphans
350
- expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
351
- } finally {
352
- delete process.env.CUELOOP_EDITOR;
353
- }
351
+ // Arrange - anchor a comment to a passage
352
+ const setup = await renderApp();
353
+
354
+ await clickText(setup, "daemon");
355
+ await type(setup, "Anchor me to the doomed passage.");
356
+ await pressKey(setup, "RETURN", { meta: true });
357
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
358
+
359
+ // Act - open the inline editor, replace the whole body (dropping the passage), and leave
360
+ await pressKey(setup, "e", { ctrl: true });
361
+ await waitForText(setup, "save & close");
362
+ await pressKey(setup, "a", { meta: true });
363
+ await type(setup, "A fresh body with no anchored passage.");
364
+ await clickText(setup, "normal");
365
+
366
+ // Assert - the thread view banner reports the orphaned annotation
367
+ await waitForText(setup, "1 annotation no longer match - the passage was removed.");
368
+ // the annotation is NOT deleted: the feedback serializer handles orphans
369
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
370
+ });
371
+ });
372
+
373
+ describe("thread switch preserves an open edit", () => {
374
+ test("clicking another thread while editing saves the body into the leaving thread", async () => {
375
+ // Arrange - a second thread sits in the sidebar to switch to
376
+ server.core.sessionCreate({
377
+ workspace: { repoRoot: "/repo", branch: "main" },
378
+ artifact: {
379
+ type: "plan",
380
+ content: "# Second Thread\n\nAnother body.\n",
381
+ meta: { title: "Second Thread", planPath: "plan.md", agent: "agent/worker-3" },
382
+ },
383
+ });
384
+ const setup = await renderApp();
385
+
386
+ // open the Threads sidebar so the other thread is on screen to click
387
+ await setup.mockMouse.click(4, 0);
388
+ await waitForText(setup, "Second Thread");
389
+
390
+ // Act - open the inline editor, rewrite the body, then click away before an explicit save
391
+ await pressKey(setup, "e", { ctrl: true });
392
+ await waitForText(setup, "save & close");
393
+ await pressKey(setup, "a", { meta: true });
394
+ await type(setup, "Body kept across a thread switch.");
395
+ await clickText(setup, "Second Thread");
396
+
397
+ // Assert - the leaving thread's working copy holds the edit rather than dropping it
398
+ await waitForState(setup, () =>
399
+ (server.core.sessionGet(session.id).workingCopy ?? "").includes(
400
+ "kept across a thread switch",
401
+ ),
402
+ );
354
403
  });
355
404
  });
356
405
 
@@ -13,6 +13,7 @@ import {
13
13
  dragText,
14
14
  frameRow,
15
15
  isolateUserConfig,
16
+ navCommand,
16
17
  pressKey,
17
18
  renderReadyApp,
18
19
  typeText,
@@ -97,7 +98,7 @@ describe("observer rendering", () => {
97
98
  describe("observer primitives are blocked", () => {
98
99
  const attempts: Array<[string, (setup: Setup) => Promise<void>]> = [
99
100
  ["comment (typing)", (setup) => typeText(setup, "c")],
100
- ["cut (option+x)", (setup) => pressKey(setup, "x", { meta: true })],
101
+ ["cut (option+x)", (setup) => navCommand(setup, "x")],
101
102
  ["edit (ctrl+e)", (setup) => pressKey(setup, "e", { ctrl: true })],
102
103
  ["submit (cmd+enter)", (setup) => pressKey(setup, "RETURN", { meta: true })],
103
104
  ];
@@ -159,10 +160,10 @@ describe("a resolved review is read-only for its owner too", () => {
159
160
  expect(setup.captureCharFrame()).not.toContain("● c");
160
161
 
161
162
  // Act + Assert: the focused card cannot be deleted or edited
162
- await pressKey(setup, "n", { meta: true });
163
+ await navCommand(setup, "n");
163
164
  await pressKey(setup, "BACKSPACE", { meta: true });
164
- await pressKey(setup, "e", { meta: true });
165
- await pressKey(setup, "x", { meta: true });
165
+ await pressKey(setup, "e", { ctrl: true });
166
+ await navCommand(setup, "x");
166
167
  expect(snapshot()).toEqual(before);
167
168
  expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
168
169
  });
@@ -199,7 +200,7 @@ describe("observer navigation still works", () => {
199
200
  const setup = await renderObserver();
200
201
 
201
202
  // Act - the observer navigates to the annotation without mutating anything
202
- await pressKey(setup, "n", { meta: true });
203
+ await navCommand(setup, "n");
203
204
  await setup.renderOnce();
204
205
 
205
206
  // Assert - the observer reads the controller's comment inline in the thread