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

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 (85) hide show
  1. package/package.json +7 -6
  2. package/src/App.appearance.test.tsx +8 -6
  3. package/src/App.diff.test.tsx +3 -3
  4. package/src/App.export.test.tsx +9 -3
  5. package/src/App.plan-review.test.tsx +10 -8
  6. package/src/App.prototype-markdown.test.tsx +92 -0
  7. package/src/App.prototype.test.tsx +17 -28
  8. package/src/App.readonly.test.tsx +3 -3
  9. package/src/App.rendering.test.tsx +3 -4
  10. package/src/App.test.tsx +14 -18
  11. package/src/App.tsx +117 -42
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/App.workbench.test.tsx +85 -14
  14. package/src/app-screens.tsx +26 -10
  15. package/src/app-view-model.ts +17 -3
  16. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  17. package/src/components/AnnotatableFileView.test.tsx +102 -0
  18. package/src/components/AnnotatableFileView.tsx +126 -0
  19. package/src/components/AnnotationCards.tsx +6 -6
  20. package/src/components/AppShell.tsx +62 -15
  21. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  22. package/src/components/BareWorkbenchFileView.tsx +69 -0
  23. package/src/components/ChangesColumn.tsx +3 -2
  24. package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
  25. package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
  26. package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
  27. package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
  28. package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
  29. package/src/components/DiscussionMarkerRail.tsx +2 -2
  30. package/src/components/EditorGrid.tabs.test.tsx +17 -4
  31. package/src/components/EditorGrid.tsx +49 -19
  32. package/src/components/FileContentsView.test.tsx +3 -3
  33. package/src/components/FileContentsView.tsx +37 -17
  34. package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
  35. package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
  36. package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
  37. package/src/components/ProjectTreeView.tsx +3 -2
  38. package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
  39. package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
  40. package/src/components/ScrollArea.stories.tsx +20 -0
  41. package/src/components/ScrollArea.tsx +44 -0
  42. package/src/components/SettingsDialog.tsx +3 -2
  43. package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
  44. package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
  45. package/src/components/ThreadsSidebar.tsx +5 -4
  46. package/src/components/TreePane.tsx +3 -2
  47. package/src/components/WelcomePlayground.stories.tsx +12 -0
  48. package/src/components/WelcomePlayground.test.tsx +47 -0
  49. package/src/components/WelcomePlayground.tsx +140 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
  51. package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
  52. package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
  53. package/src/config.test.ts +19 -6
  54. package/src/config.ts +26 -6
  55. package/src/ghostty-terminal.test.ts +2 -1
  56. package/src/ghostty-terminal.ts +27 -4
  57. package/src/intent-dispatch.test.ts +2 -0
  58. package/src/intent-dispatch.ts +1 -1
  59. package/src/key-bindings.test.ts +1 -1
  60. package/src/key-bindings.ts +5 -1
  61. package/src/keymap.ts +1 -1
  62. package/src/prototype-browser.ts +8 -0
  63. package/src/prototype-pixels.tsx +35 -0
  64. package/src/ready-signal.test.ts +85 -0
  65. package/src/ready-signal.ts +52 -0
  66. package/src/session-controller.live-diff.test.ts +198 -0
  67. package/src/session-controller.ts +141 -26
  68. package/src/slash-palette.test.ts +54 -0
  69. package/src/slash-palette.ts +32 -18
  70. package/src/test-support.test.ts +12 -0
  71. package/src/test-support.ts +69 -10
  72. package/src/theme-presets.ts +20 -0
  73. package/src/theme.ts +18 -0
  74. package/src/thread-chords.ts +1 -1
  75. package/src/use-annotation-surface.tsx +12 -2
  76. package/src/use-settings-dialog.tsx +4 -4
  77. package/src/view-diff.test.ts +44 -1
  78. package/src/view-diff.ts +81 -0
  79. package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
  80. package/src/components/AnnotationCard.stories.tsx +0 -94
  81. package/src/components/AnnotationCard.tsx +0 -250
  82. package/src/components/MarkerPopover.stories.tsx +0 -58
  83. package/src/components/MarkerPopover.tsx +0 -109
  84. package/src/components/WelcomeSurface.stories.tsx +0 -12
  85. package/src/components/WelcomeSurface.tsx +0 -52
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.67",
3
+ "version": "0.1.0-alpha.69",
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": {
@@ -20,16 +20,17 @@
20
20
  "type": "module",
21
21
  "exports": {
22
22
  ".": "./src/index.ts",
23
- "./config": "./src/config.ts"
23
+ "./config": "./src/config.ts",
24
+ "./slash-palette": "./src/slash-palette.ts"
24
25
  },
25
26
  "publishConfig": {
26
27
  "access": "public"
27
28
  },
28
29
  "dependencies": {
29
- "@cueloop/daemon": "0.1.0-alpha.67",
30
- "@cueloop/extension-api": "0.1.0-alpha.67",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.67",
32
- "@cueloop/schema": "0.1.0-alpha.67",
30
+ "@cueloop/daemon": "0.1.0-alpha.69",
31
+ "@cueloop/extension-api": "0.1.0-alpha.69",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.69",
33
+ "@cueloop/schema": "0.1.0-alpha.69",
33
34
  "@opentui/core": "^0.5.8",
34
35
  "@opentui/keymap": "0.5.8",
35
36
  "@opentui/react": "^0.5.8",
@@ -5,11 +5,10 @@ import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
8
  import { DaemonServer } from "@cueloop/daemon";
10
9
  import type { ReviewSession } from "@cueloop/schema";
11
10
  import { App } from "./App";
12
- import { isolateUserConfig, settle, waitForText } from "./test-support";
11
+ import { isolateUserConfig, settle, waitForText, renderReadyApp } from "./test-support";
13
12
 
14
13
  const PLAN = "# Sync Plan\n\n## Steps\n\n- flush the queue\n";
15
14
 
@@ -37,10 +36,13 @@ afterEach(() => {
37
36
  describe("appearance", () => {
38
37
  test("renders the review on a light terminal", async () => {
39
38
  // Arrange + Act
40
- const setup = await testRender(<App home={home} sessionId={session.id} appearance="light" />, {
41
- width: 120,
42
- height: 32,
43
- });
39
+ const setup = await renderReadyApp(
40
+ <App home={home} sessionId={session.id} appearance="light" />,
41
+ {
42
+ width: 120,
43
+ height: 32,
44
+ },
45
+ );
44
46
 
45
47
  // Assert
46
48
  await waitForText(setup, "cueloop");
@@ -5,7 +5,6 @@ import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
8
  import { DaemonServer } from "@cueloop/daemon";
10
9
  import type { ReviewSession } from "@cueloop/schema";
11
10
  import { App } from "./App";
@@ -16,6 +15,7 @@ import {
16
15
  isolateUserConfig,
17
16
  press,
18
17
  pressKey,
18
+ renderReadyApp,
19
19
  typeText,
20
20
  waitForState,
21
21
  waitForText,
@@ -54,7 +54,7 @@ afterEach(() => {
54
54
  });
55
55
 
56
56
  async function renderApp(sessionId = session.id) {
57
- const setup = await testRender(<App home={home} sessionId={sessionId} />, {
57
+ const setup = await renderReadyApp(<App home={home} sessionId={sessionId} />, {
58
58
  width: 120,
59
59
  height: 30,
60
60
  });
@@ -199,7 +199,7 @@ describe("diff review", () => {
199
199
  ],
200
200
  },
201
201
  });
202
- const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
202
+ const setup = await renderReadyApp(<App home={home} sessionId={withFiles.id} />, {
203
203
  width: 120,
204
204
  height: 30,
205
205
  });
@@ -13,11 +13,17 @@ import {
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import React from "react";
16
- import { testRender } from "@opentui/react/test-utils";
17
16
  import { DaemonServer } from "@cueloop/daemon";
18
17
  import type { ReviewSession } from "@cueloop/schema";
19
18
  import { App } from "./App";
20
- import { isolateUserConfig, press, waitForState, waitForText, pressKey } from "./test-support";
19
+ import {
20
+ isolateUserConfig,
21
+ press,
22
+ waitForState,
23
+ waitForText,
24
+ pressKey,
25
+ renderReadyApp,
26
+ } from "./test-support";
21
27
 
22
28
  const PLAN = `# Migration Plan
23
29
 
@@ -61,7 +67,7 @@ afterEach(() => {
61
67
  describe("obsidian export on resolve", () => {
62
68
  test("submitting a review writes the plan into the vault and shows the path", async () => {
63
69
  // Arrange
64
- const setup = await testRender(<App home={home} sessionId={session.id} />, {
70
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
65
71
  width: 120,
66
72
  height: 32,
67
73
  });
@@ -5,17 +5,18 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
8
  import { DaemonServer } from "@cueloop/daemon";
10
9
  import type { ReviewSession } from "@cueloop/schema";
11
10
  import { App } from "./App";
12
- import { DEFAULT_QUICK_ACTIONS, quickActionBody } from "./config";
11
+ import { DEFAULT_QUICK_ACTIONS } from "./config";
12
+ import { slashItemsFrom } from "./slash-palette";
13
13
  import {
14
14
  clickText,
15
15
  dragText,
16
16
  isolateUserConfig,
17
17
  press,
18
18
  pressKey,
19
+ renderReadyApp,
19
20
  typeText as type,
20
21
  waitForState,
21
22
  waitForText,
@@ -60,7 +61,7 @@ afterEach(() => {
60
61
  });
61
62
 
62
63
  async function renderApp() {
63
- const setup = await testRender(<App home={home} sessionId={session.id} />, {
64
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
64
65
  width: 120,
65
66
  height: 32,
66
67
  });
@@ -110,7 +111,7 @@ describe("share button", () => {
110
111
 
111
112
  test("a read-only viewer (a plan shared over ssh) never sees the Share button", async () => {
112
113
  // Arrange / Act
113
- const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, {
114
+ const viewer = await renderReadyApp(<App home={home} sessionId={session.id} readOnly />, {
114
115
  width: 120,
115
116
  height: 32,
116
117
  });
@@ -149,7 +150,7 @@ describe("edit affordance", () => {
149
150
 
150
151
  test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
151
152
  // Arrange / Act
152
- const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, {
153
+ const viewer = await renderReadyApp(<App home={home} sessionId={session.id} readOnly />, {
153
154
  width: 120,
154
155
  height: 32,
155
156
  });
@@ -338,7 +339,7 @@ describe("edit-exit reconciliation", () => {
338
339
  });
339
340
 
340
341
  describe("the quick-action palette", () => {
341
- test("/ lists the quick actions and a pick seeds the comment with the preset body", async () => {
342
+ test("/ lists the quick actions and a pick inserts the reference, not the body", async () => {
342
343
  // Arrange
343
344
  const setup = await renderApp();
344
345
 
@@ -351,11 +352,12 @@ describe("the quick-action palette", () => {
351
352
  await press(setup, "enter");
352
353
  await pressKey(setup, "RETURN", { meta: true });
353
354
 
354
- // Assert - a comment annotation was created with the second default's body
355
+ // Assert - the comment holds the /name reference, not the expanded body
355
356
  await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
356
357
  const stored = server.core.sessionGet(session.id);
358
+ const secondName = slashItemsFrom(DEFAULT_QUICK_ACTIONS)[1]!.name;
357
359
 
358
360
  expect(stored.annotations[0]!.kind).toBe("comment");
359
- expect(stored.annotations[0]!.body).toBe(quickActionBody(DEFAULT_QUICK_ACTIONS[1]!));
361
+ expect(stored.annotations[0]!.body.trim()).toBe(`/${secondName}`);
360
362
  });
361
363
  });
@@ -0,0 +1,92 @@
1
+ /** The default prototype is a Markdown design doc: with no experimental flag it renders through the thread/markdown path (not the kitty pixel view), its headings and fenced sections show as text, and a quote-anchored comment lands on a line - the same as a plan. A real in-process daemon; char-frame assertions over the real App. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, 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 { DaemonServer } from "@cueloop/daemon";
9
+ import type { ReviewSession } from "@cueloop/schema";
10
+ import { App } from "./App";
11
+ import { isolateUserConfig, renderReadyApp, settle, waitForText } from "./test-support";
12
+
13
+ const DOC = `# PromoCard
14
+
15
+ A promotional card for a growth surface.
16
+
17
+ ## API
18
+
19
+ \`\`\`ts
20
+ interface PromoCardProps {
21
+ title: string
22
+ onDismiss?: () => void
23
+ }
24
+ \`\`\`
25
+
26
+ ## Composition
27
+
28
+ \`\`\`
29
+ PromoCard
30
+ └─ TextGroup reuse: Text
31
+ \`\`\`
32
+ `;
33
+
34
+ let home: string;
35
+ let server: DaemonServer;
36
+ let session: ReviewSession;
37
+ let restoreUserConfig: () => void;
38
+
39
+ beforeEach(() => {
40
+ home = mkdtempSync(join(tmpdir(), "cueloop-prototype-md-"));
41
+ restoreUserConfig = isolateUserConfig(home);
42
+ server = new DaemonServer({ home, idleExitMs: 0 });
43
+ server.start();
44
+ session = server.core.sessionCreate({
45
+ workspace: { repoRoot: "/repo", branch: "promo-card" },
46
+ artifact: { type: "prototype", content: DOC, meta: { title: "PromoCard", agent: "pi" } },
47
+ });
48
+ });
49
+ afterEach(() => {
50
+ restoreUserConfig();
51
+ server.stop();
52
+ rmSync(home, { recursive: true, force: true });
53
+ });
54
+
55
+ describe("markdown prototype review", () => {
56
+ test("renders as a markdown design doc, not the pixel view", async () => {
57
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
58
+ width: 120,
59
+ height: 40,
60
+ });
61
+
62
+ await waitForText(setup, "PromoCard");
63
+ await settle(setup);
64
+ const frame = setup.captureCharFrame();
65
+
66
+ // the markdown path renders the doc's headings and interface as text
67
+ expect(frame).toContain("API");
68
+ expect(frame).toContain("PromoCardProps");
69
+ expect(frame).toContain("Composition");
70
+ // and never falls into the pixel view's status line
71
+ expect(frame).not.toContain("rendering prototype");
72
+ expect(frame).not.toContain("graphics terminal");
73
+ });
74
+
75
+ test("a quote-anchored comment stores against the doc, like a plan", async () => {
76
+ server.core.sessionAnnotate(session.id, {
77
+ id: "own_1",
78
+ kind: "comment",
79
+ anchor: { quote: "onDismiss?: () => void", prefix: "", suffix: "" },
80
+ body: "make dismiss required for a modal surface?",
81
+ });
82
+
83
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
84
+ width: 120,
85
+ height: 40,
86
+ });
87
+
88
+ await waitForText(setup, "make dismiss required");
89
+ expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
90
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.selector).toBeUndefined();
91
+ });
92
+ });
@@ -1,11 +1,10 @@
1
- /** Prototype review end to end: a click on the rendered page selects a DOM element, the marker actions bar opens, a typed comment saves through the controller, and the annotation shows in the rail. The headless browser is replaced with a fake so no Chrome or kitty output is needed. Char-frame assertions over the real App and a real in-process daemon. */
1
+ /** Prototype review end to end, in the opt-in EXPERIMENTAL pixel mode ([experimental] prototype_pixels): a click on the rendered page selects a DOM element and opens the compose card directly - the same inline composer plan and diff use - a typed comment saves through the controller on cmd+enter, and the annotation anchors to the element's selector. The headless browser is replaced with a fake so no Chrome or kitty output is needed. Char-frame assertions over the real App and a real in-process daemon. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
- import { mkdtempSync, rmSync } from "node:fs";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
8
  import { DaemonServer } from "@cueloop/daemon";
10
9
  import type { ReviewSession } from "@cueloop/schema";
11
10
  import { App } from "./App";
@@ -17,6 +16,7 @@ import {
17
16
  import {
18
17
  isolateUserConfig,
19
18
  press,
19
+ renderReadyApp,
20
20
  settle,
21
21
  typeText,
22
22
  waitForState,
@@ -60,7 +60,9 @@ let restoreUserConfig: () => void;
60
60
 
61
61
  beforeEach(() => {
62
62
  home = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
63
- restoreUserConfig = isolateUserConfig(home);
63
+ // the pixel mockup is opt-in; these tests exercise that experimental path
64
+ writeFileSync(join(home, "config.toml"), "[experimental]\nprototype_pixels = true\n");
65
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
64
66
  server = new DaemonServer({ home, idleExitMs: 0 });
65
67
  server.start();
66
68
  session = server.core.sessionCreate({
@@ -84,7 +86,7 @@ afterEach(() => {
84
86
  });
85
87
 
86
88
  async function renderApp() {
87
- const setup = await testRender(<App home={home} sessionId={session.id} />, {
89
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
88
90
  width: 120,
89
91
  height: 32,
90
92
  });
@@ -94,16 +96,6 @@ async function renderApp() {
94
96
  return setup;
95
97
  }
96
98
 
97
- type Setup = Awaited<ReturnType<typeof renderApp>>;
98
-
99
- async function clickText(setup: Setup, needle: string): Promise<void> {
100
- const lines = setup.captureCharFrame().split("\n");
101
- const row = lines.findIndex((line) => line.includes(needle));
102
-
103
- await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
104
- await setup.renderOnce();
105
- }
106
-
107
99
  describe("prototype review", () => {
108
100
  test("click an element, comment on it, and it saves to the daemon", async () => {
109
101
  // Arrange - wait until the fake browser produced its first screenshot, so
@@ -112,21 +104,20 @@ describe("prototype review", () => {
112
104
 
113
105
  await waitForState(setup, () => rendered);
114
106
  await settle(setup);
115
- // selecting an element must not re-screenshot the page (the popover-click
116
- // lag regression): the highlight is the popover, not a fresh Chromium capture
107
+ // selecting an element must not re-screenshot the page (the compose-open
108
+ // lag regression): the click opens the composer, not a fresh Chromium capture
117
109
  const screenshotsAfterLoad = screenshotCount;
118
110
 
119
- // Act
111
+ // Act - a click opens the compose card directly on the element
120
112
  await setup.mockMouse.click(6, 6);
121
- await waitForText(setup, "comment");
113
+ await waitForText(setup, "●");
122
114
  expect(screenshotCount).toBe(screenshotsAfterLoad);
123
- await clickText(setup, "comment");
124
- await waitForText(setup, "write a note");
125
115
  // the keymap-suppression flag must propagate before typing so keys reach the
126
116
  // textarea, not the global keymap
127
117
  await settle(setup);
128
118
  await typeText(setup, "tighten the padding");
129
- await press(setup, "enter");
119
+ // cmd+enter sends, matching the plan and diff composer; plain enter is a newline
120
+ setup.mockInput.pressKey("RETURN", { meta: true });
130
121
 
131
122
  // Assert - the annotation is stored against the element's selector
132
123
  await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
@@ -144,16 +135,14 @@ describe("prototype review", () => {
144
135
  await waitForState(setup, () => rendered);
145
136
  await settle(setup);
146
137
  await setup.mockMouse.click(6, 6);
147
- await waitForText(setup, "comment");
148
- await clickText(setup, "comment");
149
- await waitForText(setup, "write a note");
138
+ await waitForText(setup, "●");
150
139
  await settle(setup);
151
140
 
152
141
  // Act
153
142
  await press(setup, "escape");
154
143
 
155
144
  // Assert - the composer closes and nothing was saved
156
- await waitForTextGone(setup, "write a note");
145
+ await waitForTextGone(setup, "●");
157
146
  expect(server.core.sessionGet(session.id).annotations).toHaveLength(0);
158
147
  });
159
148
 
@@ -167,13 +156,13 @@ describe("prototype review", () => {
167
156
  // open a selection, then scroll: the scroll drops it (it would drift once
168
157
  // the page moves under it)
169
158
  await setup.mockMouse.click(6, 6);
170
- await waitForText(setup, "comment");
159
+ await waitForText(setup, "●");
171
160
  // scrolling moves the page, so it DOES re-screenshot (unlike a click)
172
161
  const screenshotsBeforeScroll = screenshotCount;
173
162
 
174
163
  await setup.mockMouse.scroll(6, 6, "down");
175
164
  await waitForState(setup, () => scrollDeltas.length === 1);
176
- await waitForTextGone(setup, "comment");
165
+ await waitForTextGone(setup, "●");
177
166
  await waitForState(setup, () => screenshotCount > screenshotsBeforeScroll);
178
167
 
179
168
  // Act - scroll back up
@@ -5,7 +5,6 @@ import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
8
  import { DaemonServer } from "@cueloop/daemon";
10
9
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
11
10
  import { App } from "./App";
@@ -15,6 +14,7 @@ import {
15
14
  frameRow,
16
15
  isolateUserConfig,
17
16
  pressKey,
17
+ renderReadyApp,
18
18
  typeText,
19
19
  waitForText,
20
20
  } from "./test-support";
@@ -57,7 +57,7 @@ afterEach(() => {
57
57
  });
58
58
 
59
59
  async function renderObserver() {
60
- const setup = await testRender(<App home={home} sessionId={session.id} readOnly />, {
60
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} readOnly />, {
61
61
  width: 120,
62
62
  height: 32,
63
63
  });
@@ -144,7 +144,7 @@ describe("a resolved review is read-only for its owner too", () => {
144
144
  body: "settled",
145
145
  });
146
146
  server.core.sessionResolve(session.id, "approve", "");
147
- const setup = await testRender(<App home={home} sessionId={session.id} />, {
147
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
148
148
  width: 120,
149
149
  height: 32,
150
150
  });
@@ -5,11 +5,10 @@ import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
8
  import { DaemonServer } from "@cueloop/daemon";
10
9
  import type { ReviewSession } from "@cueloop/schema";
11
10
  import { App } from "./App";
12
- import { isolateUserConfig, waitForText } from "./test-support";
11
+ import { isolateUserConfig, waitForText, renderReadyApp } from "./test-support";
13
12
 
14
13
  const PLAN = `# Render Plan
15
14
 
@@ -51,7 +50,7 @@ afterEach(() => {
51
50
  });
52
51
 
53
52
  async function frame(): Promise<string> {
54
- const setup = await testRender(<App home={home} sessionId={session.id} />, {
53
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
55
54
  width: 120,
56
55
  height: 36,
57
56
  });
@@ -116,7 +115,7 @@ describe("block spacing", () => {
116
115
  workspace: { repoRoot: "/repo", branch: "main" },
117
116
  artifact: { type: "plan", content: listPlan, meta: {} },
118
117
  });
119
- const setup = await testRender(<App home={home} sessionId={s2.id} />, {
118
+ const setup = await renderReadyApp(<App home={home} sessionId={s2.id} />, {
120
119
  width: 120,
121
120
  height: 30,
122
121
  });
package/src/App.test.tsx CHANGED
@@ -5,7 +5,6 @@ import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
8
  import { DaemonServer } from "@cueloop/daemon";
10
9
  import type { ReviewSession } from "@cueloop/schema";
11
10
  import { App } from "./App";
@@ -17,6 +16,7 @@ import {
17
16
  locateText,
18
17
  press,
19
18
  pressKey,
19
+ renderReadyApp,
20
20
  typeText as type,
21
21
  waitForState,
22
22
  waitForText,
@@ -60,15 +60,10 @@ afterEach(() => {
60
60
  });
61
61
 
62
62
  async function renderApp(sessionId?: string) {
63
- const setup = await testRender(<App home={home} sessionId={sessionId ?? session.id} />, {
63
+ return renderReadyApp(<App home={home} sessionId={sessionId ?? session.id} />, {
64
64
  width: 120,
65
65
  height: 32,
66
66
  });
67
-
68
- // the async daemon connect + first fetch land within the frame wait
69
- await waitForText(setup, "cueloop");
70
-
71
- return setup;
72
67
  }
73
68
 
74
69
  describe("plan rendering", () => {
@@ -247,14 +242,15 @@ describe("submit", () => {
247
242
  describe("no-thread shell", () => {
248
243
  test("opening with nothing selected lands in the shell with a Welcome tab and the Threads sidebar", async () => {
249
244
  // Arrange
250
- const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
245
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
251
246
 
252
- await waitForText(setup, "cueloop");
247
+ // the Welcome playground measures its width before it paints, so wait for its copy, not the header
248
+ await waitForText(setup, "Getting started");
253
249
 
254
250
  // Assert - the same shell header, the disposable Welcome tab, and the pending thread
255
251
  const frame = setup.captureCharFrame();
256
252
 
257
- expect(frame).toContain("Welcome to cueloop");
253
+ expect(frame).toContain("Getting started");
258
254
  expect(frame).toContain("Migration Plan");
259
255
  expect(frame).not.toContain("· resume"); // the bespoke inbox header is retired
260
256
 
@@ -272,9 +268,9 @@ describe("no-thread shell", () => {
272
268
  workspace: { repoRoot: "/repo", branch: "main" },
273
269
  artifact: { type: "plan", content: "# Other Plan\n", meta: { title: "Other Plan" } },
274
270
  });
275
- const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
271
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
276
272
 
277
- await waitForText(setup, "Welcome to cueloop");
273
+ await waitForText(setup, "Getting started");
278
274
  await waitForText(setup, "Other Plan"); // the sidebar opens by default here
279
275
 
280
276
  // Act - open the thread under the cursor
@@ -291,7 +287,7 @@ describe("no-thread shell", () => {
291
287
 
292
288
  test("the row kebab menu pins a thread into a Pinned section", async () => {
293
289
  // Arrange
294
- const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
290
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
295
291
 
296
292
  await waitForText(setup, "Migration Plan");
297
293
 
@@ -311,23 +307,23 @@ describe("no-thread shell", () => {
311
307
 
312
308
  test("a bare launch shows the Welcome surface with the right region collapsed", async () => {
313
309
  // Arrange
314
- const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
310
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
315
311
 
316
- await waitForText(setup, "Welcome to cueloop");
312
+ await waitForText(setup, "Getting started");
317
313
 
318
314
  // Assert - Welcome fills the thread pane (not a Changes tab); the right region stays collapsed
319
315
  const frame = setup.captureCharFrame();
320
316
 
321
- expect(frame).toContain("Welcome to cueloop");
317
+ expect(frame).toContain("Getting started");
322
318
  expect(frame).not.toContain("Changes"); // the Changes editor is closed on a bare launch
323
319
  expect(frame).toContain("Migration Plan"); // the sidebar lists the pending thread
324
320
  });
325
321
 
326
322
  test("the menu opens from the shell gear and escape is not a trap", async () => {
327
323
  // Arrange
328
- const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
324
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
329
325
 
330
- await waitForText(setup, "Welcome to cueloop");
326
+ await waitForText(setup, "Getting started");
331
327
 
332
328
  // Act - open the settings dialog from the top-left gear (the Threads panel header, row 0)
333
329
  await setup.mockMouse.click(1, 0);