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

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 (117) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +71 -39
  4. package/src/App.plan-review.test.tsx +20 -21
  5. package/src/App.readonly.test.tsx +7 -3
  6. package/src/App.test.tsx +73 -14
  7. package/src/App.tsx +375 -129
  8. package/src/App.walk.test.tsx +14 -15
  9. package/src/App.workbench.test.tsx +390 -0
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/annotation-palette.ts +61 -0
  12. package/src/app-screens.tsx +176 -45
  13. package/src/app-view-model.ts +7 -65
  14. package/src/components/AnnotationCard.tsx +1 -1
  15. package/src/components/AnnotationCards.stories.tsx +87 -0
  16. package/src/components/AnnotationCards.tsx +303 -0
  17. package/src/components/AppHeader.stories.tsx +50 -0
  18. package/src/components/AppHeader.tsx +83 -0
  19. package/src/components/AppShell.stories.tsx +47 -42
  20. package/src/components/AppShell.tsx +212 -100
  21. package/src/components/AppShell.zoom.test.tsx +94 -0
  22. package/src/components/ChangesColumn.tsx +49 -26
  23. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  24. package/src/components/DiffSheet.split.test.tsx +140 -0
  25. package/src/components/DiffSheet.stories.tsx +55 -53
  26. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  27. package/src/components/DiffSheet.tsx +607 -276
  28. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  29. package/src/components/DiscussionMarkerRail.tsx +181 -0
  30. package/src/components/EditorGrid.stories.tsx +59 -0
  31. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  32. package/src/components/EditorGrid.tsx +338 -0
  33. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  34. package/src/components/FileContentsView.stories.tsx +16 -0
  35. package/src/components/FileContentsView.test.tsx +29 -0
  36. package/src/components/FileContentsView.tsx +78 -0
  37. package/src/components/InboxList.tsx +159 -30
  38. package/src/components/PaneGrid.stories.tsx +84 -0
  39. package/src/components/PaneGrid.tsx +128 -0
  40. package/src/components/PanelColumn.stories.tsx +25 -0
  41. package/src/components/PanelColumn.tsx +95 -0
  42. package/src/components/ProjectTreeView.stories.tsx +25 -0
  43. package/src/components/ProjectTreeView.test.tsx +32 -0
  44. package/src/components/ProjectTreeView.tsx +82 -0
  45. package/src/components/PromptDialog.tsx +8 -1
  46. package/src/components/RootOverlay.stories.tsx +44 -0
  47. package/src/components/RootOverlay.tsx +35 -0
  48. package/src/components/SettingsDialog.tsx +2 -1
  49. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  50. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  51. package/src/components/SurfaceScrollbar.tsx +93 -0
  52. package/src/components/ThreadFooter.stories.tsx +2 -2
  53. package/src/components/ThreadFooter.test.tsx +31 -0
  54. package/src/components/ThreadFooter.tsx +12 -7
  55. package/src/components/ThreadView.tsx +62 -1440
  56. package/src/components/ThreadsSidebar.tsx +14 -1
  57. package/src/components/Tooltip.stories.tsx +26 -0
  58. package/src/components/Tooltip.tsx +72 -0
  59. package/src/components/WelcomeSurface.stories.tsx +12 -0
  60. package/src/components/WelcomeSurface.tsx +52 -0
  61. package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
  62. package/src/components/diff-sheet-layout.test.ts +125 -55
  63. package/src/components/diff-sheet-layout.ts +3 -103
  64. package/src/components/editor-grid.test.ts +86 -0
  65. package/src/components/editor-grid.ts +138 -0
  66. package/src/components/fade-title.test.ts +49 -0
  67. package/src/components/fade-title.ts +61 -0
  68. package/src/components/file-tree.test.ts +15 -0
  69. package/src/components/file-tree.ts +59 -4
  70. package/src/components/pages/Workspace.stories.tsx +2 -2
  71. package/src/components/primitives/IconButton.tsx +21 -2
  72. package/src/components/primitives/Tree.tsx +13 -4
  73. package/src/components/primitives/icons.ts +27 -4
  74. package/src/components/session-tree.test.ts +20 -0
  75. package/src/components/session-tree.ts +16 -2
  76. package/src/components/stories-app.tsx +2 -2
  77. package/src/config.test.ts +20 -49
  78. package/src/config.ts +26 -28
  79. package/src/diff-fold.test.ts +78 -0
  80. package/src/diff-fold.ts +126 -0
  81. package/src/diff-syntax.test.ts +16 -0
  82. package/src/diff-syntax.ts +6 -3
  83. package/src/discussions.ts +79 -0
  84. package/src/ghostty-terminal.test.ts +2 -1
  85. package/src/ghostty-terminal.ts +27 -4
  86. package/src/intent-dispatch.test.ts +31 -11
  87. package/src/intent-dispatch.ts +93 -48
  88. package/src/key-bindings.test.ts +1 -1
  89. package/src/keymap.test.ts +3 -25
  90. package/src/keymap.ts +17 -18
  91. package/src/mark-runs.ts +120 -0
  92. package/src/pty.ts +10 -2
  93. package/src/run.ts +17 -7
  94. package/src/serve.test.ts +4 -8
  95. package/src/session-controller.curate.test.ts +75 -0
  96. package/src/session-controller.share.test.ts +1 -0
  97. package/src/session-controller.tree.test.ts +26 -0
  98. package/src/session-controller.ts +153 -29
  99. package/src/slash-palette.ts +83 -0
  100. package/src/split-diff.test.ts +110 -0
  101. package/src/split-diff.ts +149 -0
  102. package/src/test-support.test.ts +15 -3
  103. package/src/test-support.ts +66 -15
  104. package/src/thread-chords.test.ts +25 -9
  105. package/src/thread-chords.ts +33 -8
  106. package/src/use-annotation-surface.tsx +837 -0
  107. package/src/use-changes-workbench.ts +151 -0
  108. package/src/use-frame-measure.ts +38 -0
  109. package/src/use-settings-dialog.tsx +14 -24
  110. package/src/use-terminal-virtualizer.ts +233 -0
  111. package/src/view-diff.ts +114 -0
  112. package/src/components/ReviewPanel.stories.tsx +0 -100
  113. package/src/components/ReviewPanel.tsx +0 -103
  114. package/src/components/ReviewRail.stories.tsx +0 -232
  115. package/src/components/ReviewRail.tsx +0 -327
  116. package/src/review-panel.test.ts +0 -113
  117. package/src/review-panel.ts +0 -83
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.66",
3
+ "version": "0.1.0-alpha.68",
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,15 +26,16 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
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",
29
+ "@cueloop/daemon": "0.1.0-alpha.68",
30
+ "@cueloop/extension-api": "0.1.0-alpha.68",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.68",
32
+ "@cueloop/schema": "0.1.0-alpha.68",
33
33
  "@opentui/core": "^0.5.8",
34
34
  "@opentui/keymap": "0.5.8",
35
35
  "@opentui/react": "^0.5.8",
36
36
  "@opentui/ssh": "0.5.8",
37
37
  "@pierre/diffs": "^1.3.6",
38
+ "@tanstack/virtual-core": "^3.17.9",
38
39
  "diff": "^9",
39
40
  "puppeteer-core": "^25.8.0",
40
41
  "react": "^19.2.0",
@@ -118,8 +118,8 @@ describe("completion overlay", () => {
118
118
  // window before it is delivered - the frame wait absorbs it
119
119
  await press(setup, "escape");
120
120
 
121
- // Assert
122
- const frame = await waitForText(setup, "resolved: approve");
121
+ // Assert - the countdown is cancelled and we are back in the (resolved) thread view
122
+ const frame = await waitForText(setup, "Ship the thing");
123
123
 
124
124
  expect(frame).not.toContain("closing in");
125
125
  });
@@ -1,4 +1,4 @@
1
- /** Diff-review flow in the virtual terminal (slice 3). */
1
+ /** Diff-review flow in the virtual terminal: the diff sheet annotates like the thread view. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -9,7 +9,17 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
- import { isolateUserConfig, press, waitForText } from "./test-support";
12
+ import { NERD } from "./components/primitives/icons";
13
+ import {
14
+ clickText,
15
+ dragText,
16
+ isolateUserConfig,
17
+ press,
18
+ pressKey,
19
+ typeText,
20
+ waitForState,
21
+ waitForText,
22
+ } from "./test-support";
13
23
 
14
24
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
15
25
  index 111..222 100644
@@ -43,13 +53,13 @@ afterEach(() => {
43
53
  rmSync(home, { recursive: true, force: true });
44
54
  });
45
55
 
46
- async function renderApp() {
47
- const setup = await testRender(<App home={home} sessionId={session.id} />, {
56
+ async function renderApp(sessionId = session.id) {
57
+ const setup = await testRender(<App home={home} sessionId={sessionId} />, {
48
58
  width: 120,
49
59
  height: 30,
50
60
  });
51
61
 
52
- await waitForText(setup, "cueloop");
62
+ await waitForText(setup, "new Map()");
53
63
 
54
64
  return setup;
55
65
  }
@@ -62,46 +72,74 @@ describe("diff review", () => {
62
72
  // Assert
63
73
  const frame = setup.captureCharFrame();
64
74
 
65
- expect(frame).toContain("■ src/store.ts");
75
+ expect(frame).toContain("src/store.ts");
66
76
  expect(frame).toContain("@@ -1,4 +1,4 @@");
67
- expect(frame).toContain("- private items = [];");
68
- expect(frame).toContain("+ private items = new Map();");
77
+ expect(frame).toMatch(/- {2,}private items = \[\];/);
78
+ expect(frame).toMatch(/\+ {2,}private items = new Map\(\);/);
69
79
  });
70
80
 
71
- test("line comment: quote-anchored, lands in feedback with the code line", async () => {
81
+ test("a drag marks the code, typing comments on it, and the comment lands in feedback", async () => {
72
82
  // Arrange
73
83
  const setup = await renderApp();
74
84
 
75
- // Act
76
- // rows: file(0), hunk(1), ctx(2), del(3), add(4)
77
- for (let i = 0; i < 4; i++) await press(setup, "j");
78
- await press(setup, "c");
85
+ // Act - mark "new Map()" on the added line and just start typing, as in the thread view
86
+ await dragText(setup, "new Map()", "new Map()", "new Map()".length);
87
+ await typeText(setup, "Map needs an eviction story.");
88
+ await waitForText(setup, "● Map needs an eviction story.");
89
+ await pressKey(setup, "RETURN", { meta: true });
79
90
 
80
- // Assert
81
- await waitForText(setup, 'comment on "');
91
+ // Assert - the discussion card hangs under the line; the anchor is the marked words
92
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
93
+ await waitForText(setup, "● Map needs an eviction story.");
94
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("new Map()");
82
95
 
83
- // Act
84
- await setup.mockInput.typeText("Map needs an eviction story.");
96
+ // Act - submit with the session chord (cmd+enter, no composer open), confirm request_changes
97
+ await pressKey(setup, "RETURN", { meta: true });
85
98
  await press(setup, "enter");
86
99
 
87
100
  // Assert
88
- // inline annotation card rendered under the line
89
- await waitForText(setup, "◆ Map needs an eviction story.");
90
- const stored = server.core.sessionGet(session.id);
101
+ await waitForText(setup, "feedback sent");
102
+ const resolved = server.core.sessionGet(session.id);
91
103
 
92
- expect(stored.annotations.length).toBe(1);
93
- expect(stored.annotations[0]!.anchor.quote).toContain("new Map()");
104
+ expect(resolved.verdict!.feedback).toContain("new Map()");
105
+ expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
106
+ });
107
+
108
+ test("a comment reopens with its mark painted on the code", async () => {
109
+ // Arrange - a stored comment anchored to the added line's words
110
+ server.core.sessionAnnotate(session.id, {
111
+ id: "a_existing",
112
+ kind: "comment",
113
+ anchor: { quote: "new Map()", prefix: " private items = ", suffix: ";" },
114
+ body: "Existing note.",
115
+ });
94
116
 
95
117
  // Act
96
- await press(setup, "enter"); // submit
97
- await press(setup, "enter"); // confirm request_changes
118
+ const setup = await renderApp();
98
119
 
99
120
  // Assert
100
- await waitForText(setup, "feedback sent");
101
- const resolved = server.core.sessionGet(session.id);
121
+ await waitForText(setup, "● Existing note.");
122
+ });
102
123
 
103
- expect(resolved.verdict!.feedback).toContain("new Map()");
104
- expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
124
+ test("option+c folds the caret's file to its band; the chevron restores its body", async () => {
125
+ // Arrange - the caret opens on the file's first code line
126
+ const setup = await renderApp();
127
+
128
+ // Act
129
+ await pressKey(setup, "c", { meta: true });
130
+
131
+ // Assert - the body is gone but the band (with its counts) remains
132
+ await waitForState(setup, () => !setup.captureCharFrame().includes("new Map()"));
133
+ const collapsed = setup.captureCharFrame();
134
+
135
+ expect(collapsed).toContain("src/store.ts");
136
+ expect(collapsed).toContain("+1");
137
+
138
+ // Act - the band's chevron unfolds it again
139
+ await clickText(setup, NERD.chevronRight);
140
+
141
+ // Assert
142
+ await waitForText(setup, "new Map()");
105
143
  });
106
144
 
107
145
  test("curation needs full file contents; a legacy diff answers", async () => {
@@ -109,7 +147,7 @@ describe("diff review", () => {
109
147
  const setup = await renderApp();
110
148
 
111
149
  // Act
112
- await press(setup, "x");
150
+ await pressKey(setup, "x", { meta: true });
113
151
 
114
152
  // Assert
115
153
  await waitForText(setup, "hunk curation needs full file contents");
@@ -133,17 +171,11 @@ describe("diff review", () => {
133
171
  ],
134
172
  },
135
173
  });
136
- const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
137
- width: 120,
138
- height: 30,
139
- });
140
-
141
- await waitForText(setup, "cueloop");
174
+ const setup = await renderApp(withFiles.id);
142
175
 
143
- // Act - move to the added line and reject its change
144
- // rows: file(0), hunk(1), ctx(2), del(3), add(4)
145
- for (let i = 0; i < 4; i++) await press(setup, "j");
146
- await press(setup, "x");
176
+ // Act - place the caret on the added line and reject its change
177
+ await clickText(setup, "new Map()");
178
+ await pressKey(setup, "x", { meta: true });
147
179
 
148
180
  // Assert - the single change is gone, so the curated working copy is empty
149
181
  await waitForText(setup, "change rejected");
@@ -99,14 +99,13 @@ describe("share button", () => {
99
99
  const setup = await renderApp();
100
100
 
101
101
  // Assert
102
- // both word-buttons ride the same header row, so Share sits next to Edit
102
+ // both word-buttons ride the header's thread segment, so share sits next to edit
103
103
  const headerLine = setup
104
104
  .captureCharFrame()
105
105
  .split("\n")
106
- .find((line) => line.includes("submitted by"));
106
+ .find((line) => line.includes("edit"));
107
107
 
108
- expect(headerLine).toContain("Edit");
109
- expect(headerLine).toContain("Share");
108
+ expect(headerLine).toContain("share");
110
109
  });
111
110
 
112
111
  test("a read-only viewer (a plan shared over ssh) never sees the Share button", async () => {
@@ -119,7 +118,7 @@ describe("share button", () => {
119
118
  await waitForText(viewer, "cueloop");
120
119
 
121
120
  // Assert
122
- expect(viewer.captureCharFrame()).not.toContain("Share");
121
+ expect(viewer.captureCharFrame()).not.toContain(" share ");
123
122
  });
124
123
 
125
124
  test("a resolved plan hides Edit and Share (no re-sharing a finished review)", async () => {
@@ -129,16 +128,13 @@ describe("share button", () => {
129
128
  // Act
130
129
  const setup = await renderApp();
131
130
 
132
- await waitForText(setup, "submitted by");
131
+ await waitForText(setup, "Migration Plan");
133
132
 
134
133
  // Assert - the owner toolbar is gone once the review is resolved
135
- const headerLine = setup
136
- .captureCharFrame()
137
- .split("\n")
138
- .find((line) => line.includes("submitted by"));
134
+ const frame = setup.captureCharFrame();
139
135
 
140
- expect(headerLine).not.toContain("Edit");
141
- expect(headerLine).not.toContain("Share");
136
+ expect(frame).not.toContain(" edit ");
137
+ expect(frame).not.toContain(" share ");
142
138
  });
143
139
  });
144
140
 
@@ -148,7 +144,7 @@ describe("edit affordance", () => {
148
144
  const owner = await renderApp();
149
145
 
150
146
  // Assert
151
- expect(owner.captureCharFrame()).toContain("Edit");
147
+ expect(owner.captureCharFrame()).toContain(" edit ");
152
148
  });
153
149
 
154
150
  test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
@@ -161,7 +157,7 @@ describe("edit affordance", () => {
161
157
  await waitForText(viewer, "cueloop");
162
158
 
163
159
  // Assert
164
- expect(viewer.captureCharFrame()).not.toContain("Edit");
160
+ expect(viewer.captureCharFrame()).not.toContain(" edit ");
165
161
  });
166
162
  });
167
163
 
@@ -274,17 +270,19 @@ describe("compose newline convention", () => {
274
270
  });
275
271
 
276
272
  describe("sheet header", () => {
277
- test("shows the submitting agent, the revision, and the Edit and Share word-buttons", async () => {
273
+ test("mirrors the thread name and carries the Edit and Share word-buttons", async () => {
278
274
  // Arrange
279
275
  const setup = await renderApp();
280
276
 
281
- // Assert
277
+ // Assert - the minimal header shows the thread title (mirror) plus the owner
278
+ // actions; the revision and submitter metadata are no longer in the header
282
279
  const frame = setup.captureCharFrame();
283
280
 
284
- expect(frame).toContain("submitted by agent/worker-3");
285
- expect(frame).toContain("rev 1");
286
- expect(frame).toContain("Edit");
287
- expect(frame).toContain("Share");
281
+ expect(frame).toContain("Migration Plan");
282
+ expect(frame).not.toContain("submitted by");
283
+ expect(frame).not.toContain("rev 1");
284
+ expect(frame).toContain(" edit ");
285
+ expect(frame).toContain(" share ");
288
286
  });
289
287
  });
290
288
 
@@ -294,7 +292,8 @@ describe("quick-actions settings editor", () => {
294
292
  const setup = await renderApp();
295
293
 
296
294
  // Act - open Settings from the top-left gear, enter Actions, expand the first action, type
297
- await setup.mockMouse.click(1, 1);
295
+ await setup.mockMouse.click(1, 0);
296
+ await waitForText(setup, "Keybinds");
298
297
  await clickText(setup, "Settings");
299
298
  await clickText(setup, "Actions");
300
299
  await clickText(setup, "Zoom out, research in depth");
@@ -81,12 +81,16 @@ function snapshot() {
81
81
  }
82
82
 
83
83
  describe("observer rendering", () => {
84
- test("shows the observer badge in the header", async () => {
84
+ test("the minimal header mirrors the thread name, with no observer badge", async () => {
85
85
  // Arrange
86
86
  const setup = await renderObserver();
87
87
 
88
- // Assert
89
- expect(setup.captureCharFrame()).toContain("· observer");
88
+ // Assert - the header shows the thread title only; read-only is enforced by
89
+ // the blocked primitives below, not a header badge
90
+ const frame = setup.captureCharFrame();
91
+
92
+ expect(frame).toContain("Migration Plan");
93
+ expect(frame).not.toContain("· observer");
90
94
  });
91
95
  });
92
96
 
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,
@@ -94,7 +95,7 @@ describe("plan rendering", () => {
94
95
  const setup = await renderApp();
95
96
 
96
97
  // Act - open the collapsed Threads sidebar (the mirrored panel toggle by the gear)
97
- await setup.mockMouse.click(3, 1);
98
+ await setup.mockMouse.click(4, 0);
98
99
 
99
100
  // Assert - the sidebar lists the other pending review, so you can jump to it
100
101
  await waitForText(setup, "Other Plan");
@@ -243,19 +244,19 @@ describe("submit", () => {
243
244
  });
244
245
  });
245
246
 
246
- describe("inbox", () => {
247
- test("inbox mode wears the review header 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 () => {
248
249
  // Arrange
249
250
  const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
250
251
 
251
252
  await waitForText(setup, "cueloop");
252
253
 
253
- // Assert - the same header as review, plus the session row
254
+ // Assert - the same shell header, the disposable Welcome tab, and the pending thread
254
255
  const frame = setup.captureCharFrame();
255
256
 
256
- expect(frame).toContain("cueloop · resume");
257
+ expect(frame).toContain("Welcome to cueloop");
257
258
  expect(frame).toContain("Migration Plan");
258
- expect(frame).not.toContain("inbox ("); // the old inline header is gone
259
+ expect(frame).not.toContain("· resume"); // the bespoke inbox header is retired
259
260
 
260
261
  // Act
261
262
  await press(setup, "enter");
@@ -264,20 +265,78 @@ describe("inbox", () => {
264
265
  await waitForText(setup, "send message");
265
266
  });
266
267
 
267
- test("the menu opens from the inbox and escape is not a trap", async () => {
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");
290
+ });
291
+
292
+ test("the row kebab menu pins a thread into a Pinned section", async () => {
293
+ // Arrange
294
+ const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
295
+
296
+ await waitForText(setup, "Migration Plan");
297
+
298
+ // Act - hover the row to reveal its kebab, open the menu, and pick Pin
299
+ const row = locateText(setup, "Migration Plan");
300
+
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 () => {
268
327
  // Arrange
269
328
  const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
270
329
 
271
- await waitForText(setup, "cueloop · resume");
330
+ await waitForText(setup, "Welcome to cueloop");
272
331
 
273
- // Act - open the drop-down from the top-left settings gear (header content row)
274
- await setup.mockMouse.click(1, 1);
332
+ // Act - open the settings dialog from the top-left gear (the Threads panel header, row 0)
333
+ await setup.mockMouse.click(1, 0);
275
334
 
276
- // Assert - the drop-up options appear
335
+ // Assert - the settings dialog with its Keybinds leaf appears
277
336
  await waitForText(setup, "Keybinds");
278
337
  expect(setup.captureCharFrame()).toContain("Settings");
279
338
 
280
- // 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
281
340
  await press(setup, "escape");
282
341
  await waitForState(setup, () => !setup.captureCharFrame().includes("Keybinds"));
283
342
  await press(setup, "enter");
@@ -295,7 +354,7 @@ describe("the thread view and the menu", () => {
295
354
  await waitForText(setup, "The daemon persists");
296
355
 
297
356
  // Act - open the menu from the top-left settings gear, then the keybinds dialog
298
- await setup.mockMouse.click(1, 1);
357
+ await setup.mockMouse.click(1, 0);
299
358
  await waitForText(setup, "Keybinds");
300
359
  const dropUp = setup.captureCharFrame().split("\n");
301
360
  const keybindsRow = dropUp.findIndex((line) => line.includes("Keybinds"));
@@ -324,7 +383,7 @@ describe("the thread view and the menu", () => {
324
383
  // Arrange - open the settings dialog from the gear
325
384
  const setup = await renderApp();
326
385
 
327
- await setup.mockMouse.click(1, 1);
386
+ await setup.mockMouse.click(1, 0);
328
387
  await waitForText(setup, "Keybinds");
329
388
 
330
389
  // the nav folder is the last "Settings" on screen (the first is the dialog title)