@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41

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 (138) hide show
  1. package/native/build.sh +56 -0
  2. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  3. package/native/src/shim.c +107 -0
  4. package/package.json +34 -24
  5. package/src/App.autoclose.test.tsx +137 -0
  6. package/src/App.diff.test.tsx +158 -22
  7. package/src/App.export.test.tsx +44 -37
  8. package/src/App.plan-review.test.tsx +632 -0
  9. package/src/App.readonly.test.tsx +60 -36
  10. package/src/App.rendering.test.tsx +132 -0
  11. package/src/App.review-panel.test.tsx +136 -0
  12. package/src/App.submit-confirm.test.tsx +220 -0
  13. package/src/App.test.tsx +128 -69
  14. package/src/App.toast.test.tsx +75 -0
  15. package/src/App.tsx +0 -0
  16. package/src/App.walk.test.tsx +288 -0
  17. package/src/attribution.test.ts +31 -0
  18. package/src/attribution.ts +38 -0
  19. package/src/clipboard.test.ts +23 -0
  20. package/src/clipboard.ts +30 -0
  21. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  22. package/src/components/AnnotationCard.stories.tsx +94 -0
  23. package/src/components/AnnotationCard.tsx +228 -0
  24. package/src/components/Breadcrumb.stories.tsx +19 -0
  25. package/src/components/Breadcrumb.tsx +44 -0
  26. package/src/components/CodeBlock.stories.tsx +42 -0
  27. package/src/components/CodeBlock.tsx +83 -0
  28. package/src/components/CompletionOverlay.stories.tsx +23 -0
  29. package/src/components/CompletionOverlay.tsx +59 -0
  30. package/src/components/ConfirmCard.stories.tsx +56 -0
  31. package/src/components/ConfirmCard.tsx +135 -0
  32. package/src/components/ConfirmDialog.stories.tsx +21 -0
  33. package/src/components/ConfirmDialog.tsx +64 -0
  34. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  35. package/src/components/DiffSheet.stories.tsx +78 -0
  36. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  37. package/src/components/DiffSheet.tsx +355 -0
  38. package/src/components/InboxList.stories.tsx +31 -0
  39. package/src/components/InboxList.tsx +62 -0
  40. package/src/components/KeybindsDialog.stories.tsx +31 -0
  41. package/src/components/KeybindsDialog.tsx +58 -0
  42. package/src/components/MarkerPopover.stories.tsx +58 -0
  43. package/src/components/MarkerPopover.tsx +102 -0
  44. package/src/components/MenuBar.stories.tsx +37 -0
  45. package/src/components/MenuBar.tsx +74 -0
  46. package/src/components/PlanSheet.stories.tsx +113 -0
  47. package/src/components/PlanSheet.tsx +389 -0
  48. package/src/components/PromptDialog.stories.tsx +36 -0
  49. package/src/components/PromptDialog.tsx +78 -0
  50. package/src/components/ReviewPanel.stories.tsx +86 -0
  51. package/src/components/ReviewPanel.tsx +101 -0
  52. package/src/components/ReviewRail.stories.tsx +220 -0
  53. package/src/components/ReviewRail.tsx +324 -0
  54. package/src/components/SettingsDialog.stories.tsx +111 -0
  55. package/src/components/SettingsDialog.tsx +141 -0
  56. package/src/components/SettingsRows.stories.tsx +18 -0
  57. package/src/components/SettingsRows.tsx +108 -0
  58. package/src/components/Toast.stories.tsx +12 -0
  59. package/src/components/Toast.tsx +44 -0
  60. package/src/components/WalkWizard.stories.tsx +100 -0
  61. package/src/components/WalkWizard.tsx +152 -0
  62. package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
  63. package/src/components/agent-launcher.stories.tsx +26 -0
  64. package/src/components/agent-launcher.test.tsx +21 -0
  65. package/src/components/agent-launcher.tsx +180 -0
  66. package/src/components/diff-sheet-layout.test.ts +127 -0
  67. package/src/components/diff-sheet-layout.ts +167 -0
  68. package/src/components/primitives/Button.stories.tsx +32 -0
  69. package/src/components/primitives/Button.tsx +48 -0
  70. package/src/components/primitives/Card.stories.tsx +25 -0
  71. package/src/components/primitives/Card.tsx +59 -0
  72. package/src/components/primitives/Dialog.stories.tsx +24 -0
  73. package/src/components/primitives/Dialog.tsx +64 -0
  74. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  75. package/src/components/primitives/StatusBar.tsx +22 -0
  76. package/src/components/primitives/Tabs.stories.tsx +29 -0
  77. package/src/components/primitives/Tabs.tsx +98 -0
  78. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  79. package/src/components/primitives/Toolbar.tsx +14 -0
  80. package/src/components/primitives/frame.test.ts +12 -0
  81. package/src/components/primitives/frame.ts +14 -0
  82. package/src/components/quick-actions-editor.stories.tsx +54 -0
  83. package/src/components/quick-actions-editor.tsx +136 -0
  84. package/src/components/stories-app.tsx +123 -0
  85. package/src/components/stories.test.tsx +62 -0
  86. package/src/components/story-fixtures.ts +98 -0
  87. package/src/components/story.ts +69 -0
  88. package/src/components/syntax-highlight.ts +92 -0
  89. package/src/components/terminal-pane.test.tsx +40 -0
  90. package/src/components/terminal-pane.ts +174 -0
  91. package/src/components/theme-context.stories.tsx +46 -0
  92. package/src/components/theme-context.tsx +36 -0
  93. package/src/components/truncate-text.ts +6 -0
  94. package/src/config.test.ts +430 -31
  95. package/src/config.ts +304 -26
  96. package/src/diff-hunk-curate.test.ts +315 -0
  97. package/src/diff-hunk-curate.ts +233 -0
  98. package/src/diff-intraline.test.ts +164 -0
  99. package/src/diff-intraline.ts +132 -0
  100. package/src/diff-syntax.test.ts +96 -0
  101. package/src/diff-syntax.ts +154 -0
  102. package/src/editor.test.ts +143 -0
  103. package/src/editor.ts +123 -9
  104. package/src/ghostty-terminal.test.ts +45 -0
  105. package/src/ghostty-terminal.ts +168 -0
  106. package/src/herdr.test.ts +41 -0
  107. package/src/herdr.ts +27 -0
  108. package/src/index.ts +10 -0
  109. package/src/integrations.test.ts +41 -0
  110. package/src/integrations.ts +30 -0
  111. package/src/intent-dispatch.test.ts +492 -0
  112. package/src/intent-dispatch.ts +428 -0
  113. package/src/key-bindings.test.ts +152 -0
  114. package/src/key-bindings.ts +424 -0
  115. package/src/keymap.test.ts +622 -0
  116. package/src/keymap.ts +339 -0
  117. package/src/review-panel.test.ts +113 -0
  118. package/src/review-panel.ts +83 -0
  119. package/src/run.ts +9 -5
  120. package/src/serve.test.ts +24 -9
  121. package/src/serve.ts +7 -7
  122. package/src/session-controller.curate.test.ts +253 -0
  123. package/src/session-controller.share.test.ts +309 -0
  124. package/src/session-controller.ts +900 -0
  125. package/src/share.ts +113 -0
  126. package/src/test-support.test.ts +55 -0
  127. package/src/test-support.ts +187 -0
  128. package/src/theme-presets.ts +168 -0
  129. package/src/theme.ts +36 -15
  130. package/src/version.ts +5 -0
  131. package/src/view-diff.test.ts +19 -8
  132. package/src/view-diff.ts +34 -23
  133. package/src/view-plan.test.ts +294 -0
  134. package/src/view-plan.ts +408 -0
  135. package/src/walk.test.ts +121 -0
  136. package/src/walk.ts +70 -0
  137. package/src/view.test.ts +0 -157
  138. package/src/view.ts +0 -0
package/src/App.test.tsx CHANGED
@@ -1,8 +1,4 @@
1
- /**
2
- * Virtual-terminal component tests (tier 2): the real App over a real
3
- * in-process daemon in a temp home. Char-frame assertions + mock keys -
4
- * the whole review loop drivable without a terminal.
5
- */
1
+ /** Virtual-terminal component tests (tier 2): the real App over a real in-process daemon in a temp home. Char-frame assertions + mock keys - the whole review loop drivable without a terminal. */
6
2
 
7
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -13,6 +9,13 @@ import { testRender } from "@opentui/react/test-utils";
13
9
  import { DaemonServer } from "@cueloop/daemon";
14
10
  import type { ReviewSession } from "@cueloop/schema";
15
11
  import { App } from "./App";
12
+ import {
13
+ isolateUserConfig,
14
+ press,
15
+ typeText as type,
16
+ waitForState,
17
+ waitForText,
18
+ } from "./test-support";
16
19
 
17
20
  const PLAN = `# Migration Plan
18
21
 
@@ -27,19 +30,26 @@ The daemon persists sessions to disk atomically.
27
30
  `;
28
31
 
29
32
  let home: string;
33
+ let restoreUserConfig: () => void;
30
34
  let server: DaemonServer;
31
35
  let session: ReviewSession;
32
36
 
33
37
  beforeEach(() => {
34
38
  home = mkdtempSync(join(tmpdir(), "cueloop-app-"));
39
+ restoreUserConfig = isolateUserConfig(home);
35
40
  server = new DaemonServer({ home, idleExitMs: 0 });
36
41
  server.start();
37
42
  session = server.core.sessionCreate({
38
43
  workspace: { repoRoot: "/repo", branch: "main" },
39
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
44
+ artifact: {
45
+ type: "plan",
46
+ content: PLAN,
47
+ meta: { title: "Migration Plan", planPath: "plan.md" },
48
+ },
40
49
  });
41
50
  });
42
51
  afterEach(() => {
52
+ restoreUserConfig();
43
53
  server.stop();
44
54
  rmSync(home, { recursive: true, force: true });
45
55
  });
@@ -49,78 +59,72 @@ async function renderApp(sessionId?: string) {
49
59
  width: 120,
50
60
  height: 32,
51
61
  });
52
- // let the async daemon connect + first fetch land
53
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
54
- await Bun.sleep(25);
55
- await setup.renderOnce();
56
- }
57
- await setup.renderOnce();
62
+ // the async daemon connect + first fetch land within the frame wait
63
+ await waitForText(setup, "cueloop");
58
64
  return setup;
59
65
  }
60
66
 
61
- type Setup = Awaited<ReturnType<typeof renderApp>>;
62
-
63
- /** Drive one key press: letters type as text; named keys use KeyCodes ids. */
64
- async function press(setup: Setup, k: string): Promise<void> {
65
- if (k === "enter") setup.mockInput.pressKey("RETURN");
66
- else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
67
- else if (k === "backspace") setup.mockInput.pressKey("BACKSPACE");
68
- else if (k === "left") setup.mockInput.pressKey("ARROW_LEFT");
69
- else if (k === "right") setup.mockInput.pressKey("ARROW_RIGHT");
70
- else await type(setup, k);
71
- await Bun.sleep(15);
72
- await setup.renderOnce();
73
- }
74
-
75
- async function type(setup: Setup, text: string): Promise<void> {
76
- await setup.mockInput.typeText(text);
77
- await Bun.sleep(15);
78
- await setup.renderOnce();
79
- }
80
-
81
67
  describe("plan rendering", () => {
82
68
  test("renders the plan with headings, list markers, and the rail", async () => {
69
+ // Arrange
83
70
  const setup = await renderApp();
71
+
72
+ // Assert
84
73
  const frame = setup.captureCharFrame();
85
74
  expect(frame).toContain("Migration Plan");
86
75
  expect(frame).toContain("Context");
87
76
  expect(frame).toContain("persists sessions to disk atomically");
88
77
  expect(frame).toContain("- move the store");
89
- expect(frame).toContain("REVIEW (0)");
90
- expect(frame).toContain("Submit review (0)");
78
+ expect(frame).toContain("Review");
79
+ expect(frame).toContain("Submit review");
91
80
  });
92
81
  });
93
82
 
94
83
  describe("keyboard grammar", () => {
95
84
  test("j/k moves the cursor glyph between blocks", async () => {
85
+ // Arrange
96
86
  const setup = await renderApp();
87
+
88
+ // Act
97
89
  await press(setup, "j");
98
90
  await press(setup, "j");
99
91
  await setup.renderOnce();
92
+
93
+ // Assert
100
94
  const lines = setup.captureCharFrame().split("\n");
101
- const cursorLine = lines.find((l) => l.includes("▎"))!;
95
+ const cursorLine = lines.find((line) => line.includes("▎"))!;
102
96
  expect(cursorLine).toContain("persists sessions");
103
97
  });
104
98
 
105
99
  test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
100
+ // Arrange
106
101
  const setup = await renderApp();
102
+
103
+ // Act
107
104
  await press(setup, "j");
108
105
  await press(setup, "j");
109
106
  await press(setup, "c");
110
107
  await setup.renderOnce();
111
- expect(setup.captureCharFrame()).toContain("COMMENT ON");
108
+
109
+ // Assert
110
+ await waitForText(setup, 'comment on "The daemon');
111
+
112
+ // Act
112
113
  await type(setup, "Define atomically.");
113
114
  await press(setup, "enter");
114
- await Bun.sleep(80);
115
- await setup.renderOnce();
115
+
116
+ // Assert
117
+ await waitForText(setup, "COMMENT");
116
118
  const stored = server.core.sessionGet(session.id);
117
119
  expect(stored.annotations.length).toBe(1);
118
120
  expect(stored.annotations[0]!.body).toBe("Define atomically.");
119
- expect(setup.captureCharFrame()).toContain("COMMENT");
120
121
  });
121
122
 
122
123
  test("span mode: v + l selects words, c anchors the exact span", async () => {
124
+ // Arrange
123
125
  const setup = await renderApp();
126
+
127
+ // Act
124
128
  await press(setup, "j");
125
129
  await press(setup, "j");
126
130
  await press(setup, "v");
@@ -128,24 +132,31 @@ describe("keyboard grammar", () => {
128
132
  await press(setup, "c");
129
133
  await type(setup, "Which daemon?");
130
134
  await press(setup, "enter");
131
- await Bun.sleep(80);
135
+
136
+ // Assert
137
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
132
138
  const stored = server.core.sessionGet(session.id);
133
139
  expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
134
140
  });
135
141
 
136
142
  test("x cuts a block into the working copy; x restores it", async () => {
143
+ // Arrange
137
144
  const setup = await renderApp();
138
145
  // move to "- move the store" (h1, h2, p, h2 = 4 steps in)
139
146
  for (let i = 0; i < 4; i++) await press(setup, "j");
147
+
148
+ // Act
140
149
  await press(setup, "x");
141
- await Bun.sleep(80);
142
- await setup.renderOnce();
150
+
151
+ // Assert - the rail shows the cut as a removal card titled CUT · me
152
+ await waitForText(setup, "CUT · me");
143
153
  expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
144
- expect(setup.captureCharFrame()).toContain("[cut]");
154
+
155
+ // Act
145
156
  await press(setup, "x");
146
- await Bun.sleep(80);
147
- await setup.renderOnce();
148
- expect(server.core.sessionGet(session.id).workingCopy).toBeUndefined();
157
+
158
+ // Assert
159
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
149
160
  });
150
161
 
151
162
  test("e runs $EDITOR on the working copy and tracks the diff", async () => {
@@ -154,12 +165,15 @@ describe("keyboard grammar", () => {
154
165
  Bun.spawnSync(["chmod", "+x", script]);
155
166
  process.env.CUELOOP_EDITOR = script;
156
167
  try {
168
+ // Arrange
157
169
  const setup = await renderApp();
170
+
171
+ // Act
158
172
  await press(setup, "e");
159
- await Bun.sleep(150);
160
- await setup.renderOnce();
173
+
174
+ // Assert
175
+ await waitForText(setup, "[edited]");
161
176
  expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
162
- expect(setup.captureCharFrame()).toContain("[edited]");
163
177
  } finally {
164
178
  delete process.env.CUELOOP_EDITOR;
165
179
  }
@@ -167,51 +181,96 @@ describe("keyboard grammar", () => {
167
181
  });
168
182
 
169
183
  describe("submit", () => {
170
- test("⏎ opens the verdict bar; verdict + summary resolve the session", async () => {
184
+ test("⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
185
+ // Arrange
171
186
  const setup = await renderApp();
172
187
  await press(setup, "j");
173
188
  await press(setup, "j");
174
189
  await press(setup, "c");
175
190
  await type(setup, "Needs a phase list.");
176
191
  await press(setup, "enter");
177
- await Bun.sleep(80);
192
+ await waitForText(setup, "Needs a phase list");
193
+
194
+ // Act
178
195
  await press(setup, "enter"); // open submit
179
- await setup.renderOnce();
180
- expect(setup.captureCharFrame()).toContain("[Request changes]");
196
+
197
+ // Assert
198
+ await waitForText(setup, "[Changes]");
199
+
200
+ // Act
181
201
  await type(setup, "Expand the steps.");
182
202
  await press(setup, "enter");
183
- await Bun.sleep(120);
184
- await setup.renderOnce();
203
+
204
+ // Assert
205
+ await waitForText(setup, "feedback sent");
185
206
  const stored = server.core.sessionGet(session.id);
186
207
  expect(stored.status).toBe("resolved");
187
208
  expect(stored.verdict!.kind).toBe("request_changes");
188
209
  expect(stored.verdict!.feedback).toContain("Needs a phase list.");
189
- expect(setup.captureCharFrame()).toContain("resolved: request changes");
210
+ // submit hands the reviewer back to the agent via the completion overlay
211
+ expect(setup.captureCharFrame()).toContain("feedback sent");
190
212
  });
191
213
 
192
214
  test("approve via ←/→ verdict cycling", async () => {
215
+ // Arrange
193
216
  const setup = await renderApp();
217
+
218
+ // Act
194
219
  await press(setup, "enter");
195
- await setup.renderOnce();
196
- expect(setup.captureCharFrame()).toContain("[Approve]"); // no pending items → approve default
220
+
221
+ // Assert
222
+ await waitForText(setup, "[Approve]"); // no pending items → approve default
223
+
224
+ // Act
197
225
  await press(setup, "enter");
198
- await Bun.sleep(120);
226
+
227
+ // Assert
228
+ await waitForState(setup, () => server.core.sessionGet(session.id).verdict !== undefined);
199
229
  expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
200
230
  });
201
231
  });
202
232
 
203
233
  describe("inbox", () => {
204
- test("inbox mode renders and opens a session", async () => {
234
+ test("inbox mode wears the review chrome (header + menu bar) and opens a session", async () => {
235
+ // Arrange
205
236
  const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
206
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("inbox"); i++) {
207
- await Bun.sleep(25);
208
- await setup.renderOnce();
209
- }
210
- expect(setup.captureCharFrame()).toContain("inbox (1 pending)");
211
- expect(setup.captureCharFrame()).toContain("Migration Plan");
237
+ await waitForText(setup, "cueloop");
238
+
239
+ // Assert - the same header/menu chrome as review, plus the session row
240
+ const frame = setup.captureCharFrame();
241
+ expect(frame).toContain("cueloop · resume");
242
+ expect(frame).toContain("Migration Plan");
243
+ expect(frame).toContain("menu"); // the shared MenuBar sits at the bottom
244
+ expect(frame).not.toContain("inbox ("); // the old inline header is gone
245
+
246
+ // Act
212
247
  await press(setup, "enter");
213
- await Bun.sleep(80);
214
- await setup.renderOnce();
215
- expect(setup.captureCharFrame()).toContain("Submit review (0)");
248
+
249
+ // Assert
250
+ await waitForText(setup, "Submit review");
251
+ });
252
+
253
+ test("the menu opens from the inbox and escape is not a trap", async () => {
254
+ // Arrange
255
+ const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
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");
260
+
261
+ // Act - open the drop-up from the shared menu bar
262
+ await setup.mockMouse.click(menuColumn + 1, menuRow);
263
+
264
+ // Assert - the drop-up options appear
265
+ await waitForText(setup, "Keybinds");
266
+ expect(setup.captureCharFrame()).toContain("Settings");
267
+
268
+ // Act - escape closes the menu (not a trap), and the inbox nav still works
269
+ await press(setup, "escape");
270
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Keybinds"));
271
+ await press(setup, "enter");
272
+
273
+ // Assert
274
+ await waitForText(setup, "Submit review");
216
275
  });
217
276
  });
@@ -0,0 +1,75 @@
1
+ /** The share toast is non-modal: while it is up, escape still cancels an open overlay. */
2
+
3
+ import { afterEach, beforeEach, describe, mock, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+
12
+ // the controller reaches the gateway through "./share"; stub it so `S` yields a toast
13
+ const publishShare = mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true }));
14
+ mock.module("./share", () => ({
15
+ publishShare,
16
+ pullShare: mock(async () => null),
17
+ pushShare: mock(async () => {}),
18
+ shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
19
+ collaboratorAnnotations: () => [],
20
+ }));
21
+
22
+ const { App } = await import("./App");
23
+ const { isolateUserConfig, press, waitForText, waitForTextGone } = await import("./test-support");
24
+
25
+ const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
26
+
27
+ let home: string;
28
+ let restoreUserConfig: () => void;
29
+ let server: DaemonServer;
30
+ let session: ReviewSession;
31
+
32
+ beforeEach(() => {
33
+ home = mkdtempSync(join(tmpdir(), "cueloop-toast-"));
34
+ restoreUserConfig = isolateUserConfig(home);
35
+ server = new DaemonServer({ home, idleExitMs: 0 });
36
+ server.start();
37
+ session = server.core.sessionCreate({
38
+ workspace: { repoRoot: "/repo", branch: "main" },
39
+ artifact: {
40
+ type: "plan",
41
+ content: PLAN,
42
+ meta: { title: "Migration Plan", planPath: "plan.md" },
43
+ },
44
+ });
45
+ });
46
+ afterEach(() => {
47
+ restoreUserConfig();
48
+ server.stop();
49
+ rmSync(home, { recursive: true, force: true });
50
+ });
51
+
52
+ describe("share toast", () => {
53
+ test("escape cancels an open composer even while the toast is up", async () => {
54
+ // Arrange
55
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
56
+ width: 120,
57
+ height: 32,
58
+ });
59
+ await waitForText(setup, "cueloop");
60
+
61
+ // Act: share raises the toast, then open a composer under it
62
+ await press(setup, "S");
63
+ await waitForText(setup, "share link copied");
64
+ await press(setup, "j");
65
+ await press(setup, "j");
66
+ await press(setup, "c");
67
+ await waitForText(setup, 'comment on "');
68
+
69
+ // Act: escape must reach the composer, not get eaten by the toast
70
+ await press(setup, "escape");
71
+
72
+ // Assert: the composer closed on the first escape
73
+ await waitForTextGone(setup, 'comment on "');
74
+ });
75
+ });
package/src/App.tsx CHANGED
Binary file