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

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 (160) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +79 -112
  4. package/src/App.plan-review.test.tsx +23 -205
  5. package/src/App.prototype.test.tsx +1 -8
  6. package/src/App.readonly.test.tsx +9 -27
  7. package/src/App.submit-confirm.test.tsx +19 -72
  8. package/src/App.test.tsx +136 -31
  9. package/src/App.tsx +491 -322
  10. package/src/App.walk.test.tsx +15 -16
  11. package/src/App.workbench.test.tsx +390 -0
  12. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  13. package/src/annotation-palette.ts +61 -0
  14. package/src/app-screens.tsx +197 -77
  15. package/src/app-view-model.ts +9 -69
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +1 -1
  18. package/src/components/AnnotationCards.stories.tsx +87 -0
  19. package/src/components/AnnotationCards.tsx +303 -0
  20. package/src/components/AppHeader.stories.tsx +50 -0
  21. package/src/components/AppHeader.tsx +83 -0
  22. package/src/components/AppShell.stories.tsx +89 -0
  23. package/src/components/AppShell.tsx +239 -0
  24. package/src/components/AppShell.zoom.test.tsx +94 -0
  25. package/src/components/Breadcrumb.stories.tsx +1 -1
  26. package/src/components/ChangesColumn.stories.tsx +39 -0
  27. package/src/components/ChangesColumn.tsx +170 -0
  28. package/src/components/CodeBlock.stories.tsx +1 -1
  29. package/src/components/CompletionOverlay.stories.tsx +1 -1
  30. package/src/components/ConfirmCard.stories.tsx +1 -1
  31. package/src/components/ConfirmCard.tsx +3 -4
  32. package/src/components/ConfirmDialog.stories.tsx +1 -1
  33. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  34. package/src/components/DiffSheet.split.test.tsx +140 -0
  35. package/src/components/DiffSheet.stories.tsx +56 -54
  36. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  37. package/src/components/DiffSheet.tsx +607 -276
  38. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  39. package/src/components/DiscussionMarkerRail.tsx +181 -0
  40. package/src/components/EditorGrid.stories.tsx +59 -0
  41. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  42. package/src/components/EditorGrid.tsx +338 -0
  43. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  44. package/src/components/FileContentsView.stories.tsx +16 -0
  45. package/src/components/FileContentsView.test.tsx +29 -0
  46. package/src/components/FileContentsView.tsx +78 -0
  47. package/src/components/InboxList.stories.tsx +23 -8
  48. package/src/components/InboxList.tsx +188 -33
  49. package/src/components/KeybindsDialog.stories.tsx +1 -1
  50. package/src/components/MarkerPopover.stories.tsx +1 -1
  51. package/src/components/MenuBar.stories.tsx +3 -12
  52. package/src/components/MenuBar.tsx +25 -56
  53. package/src/components/PaneGrid.stories.tsx +84 -0
  54. package/src/components/PaneGrid.tsx +128 -0
  55. package/src/components/PanelColumn.stories.tsx +25 -0
  56. package/src/components/PanelColumn.tsx +95 -0
  57. package/src/components/ProjectTreeView.stories.tsx +25 -0
  58. package/src/components/ProjectTreeView.test.tsx +32 -0
  59. package/src/components/ProjectTreeView.tsx +82 -0
  60. package/src/components/PromptDialog.stories.tsx +1 -1
  61. package/src/components/PromptDialog.tsx +8 -1
  62. package/src/components/PrototypeSheet.stories.tsx +1 -1
  63. package/src/components/RootOverlay.stories.tsx +44 -0
  64. package/src/components/RootOverlay.tsx +35 -0
  65. package/src/components/SettingsDialog.stories.tsx +7 -1
  66. package/src/components/SettingsDialog.tsx +114 -67
  67. package/src/components/SettingsRows.stories.tsx +1 -1
  68. package/src/components/ShellHeader.stories.tsx +50 -0
  69. package/src/components/ShellHeader.tsx +137 -0
  70. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  71. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  72. package/src/components/SurfaceScrollbar.tsx +93 -0
  73. package/src/components/ThreadFooter.stories.tsx +30 -0
  74. package/src/components/ThreadFooter.test.tsx +31 -0
  75. package/src/components/ThreadFooter.tsx +54 -0
  76. package/src/components/ThreadView.slash.test.tsx +113 -0
  77. package/src/components/ThreadView.stories.tsx +1 -1
  78. package/src/components/ThreadView.tsx +62 -1352
  79. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  80. package/src/components/ThreadsSidebar.tsx +70 -0
  81. package/src/components/Toast.stories.tsx +1 -1
  82. package/src/components/Tooltip.stories.tsx +26 -0
  83. package/src/components/Tooltip.tsx +72 -0
  84. package/src/components/TreePane.stories.tsx +1 -1
  85. package/src/components/WalkWizard.stories.tsx +1 -1
  86. package/src/components/WelcomeSurface.stories.tsx +12 -0
  87. package/src/components/WelcomeSurface.tsx +52 -0
  88. package/src/components/__snapshots__/stories.test.tsx.snap +732 -572
  89. package/src/components/agent-launcher.stories.tsx +1 -1
  90. package/src/components/diff-sheet-layout.test.ts +125 -55
  91. package/src/components/diff-sheet-layout.ts +3 -103
  92. package/src/components/editor-grid.test.ts +86 -0
  93. package/src/components/editor-grid.ts +138 -0
  94. package/src/components/fade-title.test.ts +49 -0
  95. package/src/components/fade-title.ts +61 -0
  96. package/src/components/file-tree.test.ts +60 -0
  97. package/src/components/file-tree.ts +112 -0
  98. package/src/components/pages/Workspace.stories.tsx +181 -0
  99. package/src/components/primitives/Button.stories.tsx +1 -1
  100. package/src/components/primitives/Card.stories.tsx +1 -1
  101. package/src/components/primitives/Dialog.stories.tsx +1 -1
  102. package/src/components/primitives/IconButton.stories.tsx +36 -0
  103. package/src/components/primitives/IconButton.tsx +62 -0
  104. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  105. package/src/components/primitives/Tabs.stories.tsx +1 -1
  106. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  107. package/src/components/primitives/Tree.stories.tsx +124 -0
  108. package/src/components/primitives/Tree.tsx +96 -0
  109. package/src/components/primitives/icons.ts +61 -0
  110. package/src/components/primitives/tree-model.test.ts +99 -0
  111. package/src/components/primitives/tree-model.ts +137 -0
  112. package/src/components/quick-actions-editor.stories.tsx +1 -1
  113. package/src/components/session-tree.test.ts +134 -0
  114. package/src/components/session-tree.ts +108 -0
  115. package/src/components/stories-app.tsx +142 -82
  116. package/src/components/story-tree.test.ts +100 -0
  117. package/src/components/story.ts +55 -0
  118. package/src/components/theme-context.stories.tsx +1 -1
  119. package/src/config.test.ts +20 -49
  120. package/src/config.ts +26 -28
  121. package/src/diff-fold.test.ts +78 -0
  122. package/src/diff-fold.ts +126 -0
  123. package/src/diff-syntax.test.ts +16 -0
  124. package/src/diff-syntax.ts +6 -3
  125. package/src/discussions.ts +79 -0
  126. package/src/intent-dispatch.test.ts +31 -11
  127. package/src/intent-dispatch.ts +93 -48
  128. package/src/key-bindings.test.ts +1 -1
  129. package/src/keymap.test.ts +3 -25
  130. package/src/keymap.ts +17 -18
  131. package/src/mark-runs.ts +120 -0
  132. package/src/pty.ts +10 -2
  133. package/src/run.ts +17 -7
  134. package/src/serve.test.ts +4 -8
  135. package/src/session-controller.curate.test.ts +75 -0
  136. package/src/session-controller.share.test.ts +1 -0
  137. package/src/session-controller.tree.test.ts +26 -0
  138. package/src/session-controller.ts +160 -31
  139. package/src/slash-palette.ts +83 -0
  140. package/src/split-diff.test.ts +110 -0
  141. package/src/split-diff.ts +149 -0
  142. package/src/test-support.test.ts +3 -3
  143. package/src/test-support.ts +39 -8
  144. package/src/theme.ts +4 -4
  145. package/src/thread-chords.test.ts +25 -9
  146. package/src/thread-chords.ts +33 -8
  147. package/src/use-annotation-surface.tsx +837 -0
  148. package/src/use-changes-workbench.ts +151 -0
  149. package/src/use-frame-measure.ts +38 -0
  150. package/src/use-settings-dialog.tsx +23 -24
  151. package/src/use-terminal-virtualizer.ts +233 -0
  152. package/src/view-diff.ts +114 -0
  153. package/src/App.review-panel.test.tsx +0 -140
  154. package/src/App.tree.test.tsx +0 -224
  155. package/src/components/ReviewPanel.stories.tsx +0 -100
  156. package/src/components/ReviewPanel.tsx +0 -103
  157. package/src/components/ReviewRail.stories.tsx +0 -232
  158. package/src/components/ReviewRail.tsx +0 -327
  159. package/src/review-panel.test.ts +0 -113
  160. package/src/review-panel.ts +0 -83
@@ -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,16 @@ function snapshot() {
83
81
  }
84
82
 
85
83
  describe("observer rendering", () => {
86
- test("shows the observer badge and the bottom menu", async () => {
84
+ test("the minimal header mirrors the thread name, with no observer badge", async () => {
87
85
  // Arrange
88
86
  const setup = await renderObserver();
89
87
 
90
- // Assert
88
+ // Assert - the header shows the thread title only; read-only is enforced by
89
+ // the blocked primitives below, not a header badge
91
90
  const frame = setup.captureCharFrame();
92
91
 
93
- expect(frame).toContain("· observer");
94
- expect(frame).toContain("menu");
92
+ expect(frame).toContain("Migration Plan");
93
+ expect(frame).not.toContain("· observer");
95
94
  });
96
95
  });
97
96
 
@@ -199,29 +198,12 @@ describe("observer navigation still works", () => {
199
198
  });
200
199
  const setup = await renderObserver();
201
200
 
202
- // Act
201
+ // Act - the observer navigates to the annotation without mutating anything
203
202
  await pressKey(setup, "n", { meta: true });
204
203
  await setup.renderOnce();
205
204
 
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));
205
+ // Assert - the observer reads the controller's comment inline in the thread
206
+ await waitForText(setup, "From the controller.");
207
+ expect(snapshot().annotations).toBe(1);
210
208
  });
211
209
  });
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
  });
package/src/App.test.tsx CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  dragText,
15
15
  frameRow,
16
16
  isolateUserConfig,
17
+ locateText,
17
18
  press,
18
19
  pressKey,
19
20
  typeText as type,
@@ -71,7 +72,7 @@ async function renderApp(sessionId?: string) {
71
72
  }
72
73
 
73
74
  describe("plan rendering", () => {
74
- test("renders the plan with headings, list markers, and the rail", async () => {
75
+ test("renders the plan with headings, list markers, and the footer", async () => {
75
76
  // Arrange
76
77
  const setup = await renderApp();
77
78
 
@@ -82,8 +83,22 @@ describe("plan rendering", () => {
82
83
  expect(frame).toContain("Context");
83
84
  expect(frame).toContain("persists sessions to disk atomically");
84
85
  expect(frame).toContain("· move the store");
85
- expect(frame).toContain("Review");
86
- expect(frame).toContain("Submit review");
86
+ expect(frame).toContain("send message");
87
+ });
88
+
89
+ test("a direct open still populates the Threads sidebar with other pending reviews", async () => {
90
+ // Arrange - a second pending review exists alongside the one opened directly
91
+ server.core.sessionCreate({
92
+ workspace: { repoRoot: "/repo", branch: "main" },
93
+ artifact: { type: "plan", content: "# Other Plan\n", meta: { title: "Other Plan" } },
94
+ });
95
+ const setup = await renderApp();
96
+
97
+ // Act - open the collapsed Threads sidebar (the mirrored panel toggle by the gear)
98
+ await setup.mockMouse.click(4, 0);
99
+
100
+ // Assert - the sidebar lists the other pending review, so you can jump to it
101
+ await waitForText(setup, "Other Plan");
87
102
  });
88
103
  });
89
104
 
@@ -113,7 +128,7 @@ describe("thread view grammar", () => {
113
128
  await pressKey(setup, "RETURN", { meta: true });
114
129
 
115
130
  // Assert
116
- await waitForText(setup, "COMMENT");
131
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
117
132
  const stored = server.core.sessionGet(session.id);
118
133
 
119
134
  expect(stored.annotations.length).toBe(1);
@@ -144,9 +159,11 @@ describe("thread view grammar", () => {
144
159
  // Act
145
160
  await pressKey(setup, "x", { meta: true });
146
161
 
147
- // Assert - the rail shows the cut as a removal card titled CUT · me
148
- await waitForText(setup, "CUT · me");
149
- expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
162
+ // Assert - the cut lands in the working copy
163
+ await waitForState(
164
+ setup,
165
+ () => !(server.core.sessionGet(session.id).workingCopy ?? "").includes("move the store"),
166
+ );
150
167
 
151
168
  // Act
152
169
  await pressKey(setup, "x", { meta: true });
@@ -227,51 +244,105 @@ describe("submit", () => {
227
244
  });
228
245
  });
229
246
 
230
- describe("inbox", () => {
231
- test("inbox mode wears the review chrome (header + menu bar) and opens a session", async () => {
247
+ describe("no-thread shell", () => {
248
+ test("opening with nothing selected lands in the shell with a Welcome tab and the Threads sidebar", async () => {
232
249
  // Arrange
233
250
  const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
234
251
 
235
252
  await waitForText(setup, "cueloop");
236
253
 
237
- // Assert - the same header/menu chrome as review, plus the session row
254
+ // Assert - the same shell header, the disposable Welcome tab, and the pending thread
238
255
  const frame = setup.captureCharFrame();
239
256
 
240
- expect(frame).toContain("cueloop · resume");
257
+ expect(frame).toContain("Welcome to cueloop");
241
258
  expect(frame).toContain("Migration Plan");
242
- expect(frame).toContain("menu"); // the shared MenuBar sits at the bottom
243
- expect(frame).not.toContain("inbox ("); // the old inline header is gone
259
+ expect(frame).not.toContain("· resume"); // the bespoke inbox header is retired
244
260
 
245
261
  // Act
246
262
  await press(setup, "enter");
247
263
 
248
264
  // Assert
249
- await waitForText(setup, "Submit review");
265
+ await waitForText(setup, "send message");
266
+ });
267
+
268
+ test("picking a thread from the no-thread shell keeps the Threads sidebar open", async () => {
269
+ // Arrange - a second pending thread so the sidebar has a row that never shows
270
+ // in the opened thread's own view
271
+ server.core.sessionCreate({
272
+ workspace: { repoRoot: "/repo", branch: "main" },
273
+ artifact: { type: "plan", content: "# Other Plan\n", meta: { title: "Other Plan" } },
274
+ });
275
+ const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
276
+
277
+ await waitForText(setup, "Welcome to cueloop");
278
+ await waitForText(setup, "Other Plan"); // the sidebar opens by default here
279
+
280
+ // Act - open the thread under the cursor
281
+ await press(setup, "enter");
282
+ await waitForText(setup, "send message");
283
+
284
+ // Assert - the sidebar stayed open across the swap: both titles are on screen,
285
+ // and the non-opened one can only come from the still-open sidebar
286
+ const frame = setup.captureCharFrame();
287
+
288
+ expect(frame).toContain("Migration Plan");
289
+ expect(frame).toContain("Other Plan");
250
290
  });
251
291
 
252
- test("the menu opens from the inbox and escape is not a trap", async () => {
292
+ test("the row kebab menu pins a thread into a Pinned section", async () => {
253
293
  // Arrange
254
294
  const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
255
295
 
256
- await waitForText(setup, "cueloop · resume");
257
- const lines = setup.captureCharFrame().split("\n");
258
- const menuRow = lines.findIndex((line) => line.includes("menu"));
259
- const menuColumn = lines[menuRow]!.indexOf("menu");
296
+ await waitForText(setup, "Migration Plan");
260
297
 
261
- // Act - open the drop-up from the shared menu bar
262
- await setup.mockMouse.click(menuColumn + 1, menuRow);
298
+ // Act - hover the row to reveal its kebab, open the menu, and pick Pin
299
+ const row = locateText(setup, "Migration Plan");
263
300
 
264
- // Assert - the drop-up options appear
301
+ await setup.mockMouse.moveTo(row.column, row.row);
302
+ await waitForText(setup, "⋮");
303
+ await clickText(setup, "⋮");
304
+ await waitForText(setup, "Pin");
305
+ await clickText(setup, "Pin");
306
+
307
+ // Assert - a Pinned section now holds the thread
308
+ await waitForText(setup, "Pinned");
309
+ expect(frameRow(setup, "Migration Plan")).toBeGreaterThan(frameRow(setup, "Pinned"));
310
+ });
311
+
312
+ test("a bare launch shows the Welcome surface with the right region collapsed", async () => {
313
+ // Arrange
314
+ const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
315
+
316
+ await waitForText(setup, "Welcome to cueloop");
317
+
318
+ // Assert - Welcome fills the thread pane (not a Changes tab); the right region stays collapsed
319
+ const frame = setup.captureCharFrame();
320
+
321
+ expect(frame).toContain("Welcome to cueloop");
322
+ expect(frame).not.toContain("Changes"); // the Changes editor is closed on a bare launch
323
+ expect(frame).toContain("Migration Plan"); // the sidebar lists the pending thread
324
+ });
325
+
326
+ test("the menu opens from the shell gear and escape is not a trap", async () => {
327
+ // Arrange
328
+ const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
329
+
330
+ await waitForText(setup, "Welcome to cueloop");
331
+
332
+ // Act - open the settings dialog from the top-left gear (the Threads panel header, row 0)
333
+ await setup.mockMouse.click(1, 0);
334
+
335
+ // Assert - the settings dialog with its Keybinds leaf appears
265
336
  await waitForText(setup, "Keybinds");
266
337
  expect(setup.captureCharFrame()).toContain("Settings");
267
338
 
268
- // Act - escape closes the menu (not a trap), and the inbox nav still works
339
+ // Act - escape closes the menu (not a trap), and the thread nav still works
269
340
  await press(setup, "escape");
270
341
  await waitForState(setup, () => !setup.captureCharFrame().includes("Keybinds"));
271
342
  await press(setup, "enter");
272
343
 
273
344
  // Assert
274
- await waitForText(setup, "Submit review");
345
+ await waitForText(setup, "send message");
275
346
  });
276
347
  });
277
348
 
@@ -281,12 +352,9 @@ describe("the thread view and the menu", () => {
281
352
  const setup = await renderApp();
282
353
 
283
354
  await waitForText(setup, "The daemon persists");
284
- const lines = setup.captureCharFrame().split("\n");
285
- const menuRow = lines.findIndex((line) => line.includes("menu"));
286
- const menuColumn = lines[menuRow]!.indexOf("menu");
287
355
 
288
- // Act - open the menu, then the keybinds dialog
289
- await setup.mockMouse.click(menuColumn + 1, menuRow);
356
+ // Act - open the menu from the top-left settings gear, then the keybinds dialog
357
+ await setup.mockMouse.click(1, 0);
290
358
  await waitForText(setup, "Keybinds");
291
359
  const dropUp = setup.captureCharFrame().split("\n");
292
360
  const keybindsRow = dropUp.findIndex((line) => line.includes("Keybinds"));
@@ -298,7 +366,7 @@ describe("the thread view and the menu", () => {
298
366
  const dialog = setup.captureCharFrame();
299
367
 
300
368
  expect(dialog).toContain("⌘⌥m");
301
- expect(dialog).toContain("dismiss an empty draft");
369
+ expect(dialog).toContain("place the caret");
302
370
  expect(dialog).not.toContain("grow/shrink");
303
371
 
304
372
  // Act - a printable behind the dialog must not open a comment; escape closes it
@@ -307,7 +375,44 @@ describe("the thread view and the menu", () => {
307
375
  await press(setup, "escape");
308
376
 
309
377
  // Assert
310
- await waitForState(setup, () => !setup.captureCharFrame().includes("dismiss an empty draft"));
378
+ await waitForState(setup, () => !setup.captureCharFrame().includes("mark text, across blocks"));
311
379
  expect(setup.captureCharFrame()).not.toContain("● x");
312
380
  });
381
+
382
+ test("clicking the Settings nav group is inert and never corrupts the active category", async () => {
383
+ // Arrange - open the settings dialog from the gear
384
+ const setup = await renderApp();
385
+
386
+ await setup.mockMouse.click(1, 0);
387
+ await waitForText(setup, "Keybinds");
388
+
389
+ // the nav folder is the last "Settings" on screen (the first is the dialog title)
390
+ const lines = setup.captureCharFrame().split("\n");
391
+ let folderRow = -1;
392
+ let folderColumn = -1;
393
+
394
+ lines.forEach((line, row) => {
395
+ const column = line.indexOf("Settings");
396
+
397
+ if (column !== -1) {
398
+ folderColumn = column;
399
+ folderRow = row;
400
+ }
401
+ });
402
+
403
+ // Act - click the synthetic Settings group folder, then press a nav key. The
404
+ // key handler dereferences the active category, so a corrupt id would throw
405
+ // (the global key handler swallows the throw into console.error).
406
+ const keyHandlerErrors: string[] = [];
407
+ const originalConsoleError = console.error;
408
+
409
+ console.error = (...args: unknown[]) => keyHandlerErrors.push(args.map(String).join(" "));
410
+ await setup.mockMouse.click(folderColumn + 1, folderRow);
411
+ await pressKey(setup, "ARROW_DOWN");
412
+ console.error = originalConsoleError;
413
+
414
+ // Assert - no key-handler crash, and the dialog is still up
415
+ expect(keyHandlerErrors.filter((line) => line.includes("keypress handler"))).toEqual([]);
416
+ expect(setup.captureCharFrame()).toContain("Keybinds");
417
+ });
313
418
  });