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

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 (109) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +8 -73
  4. package/src/App.export.test.tsx +2 -2
  5. package/src/App.plan-review.test.tsx +60 -412
  6. package/src/App.prototype.test.tsx +1 -8
  7. package/src/App.readonly.test.tsx +85 -62
  8. package/src/App.rendering.test.tsx +5 -6
  9. package/src/App.submit-confirm.test.tsx +32 -70
  10. package/src/App.test.tsx +136 -66
  11. package/src/App.toast.test.tsx +17 -8
  12. package/src/App.tsx +243 -284
  13. package/src/App.walk.test.tsx +1 -1
  14. package/src/app-screens.tsx +67 -52
  15. package/src/app-view-model.ts +6 -100
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +2 -2
  18. package/src/components/AppShell.stories.tsx +84 -0
  19. package/src/components/AppShell.tsx +127 -0
  20. package/src/components/Breadcrumb.stories.tsx +1 -1
  21. package/src/components/ChangesColumn.stories.tsx +39 -0
  22. package/src/components/ChangesColumn.tsx +147 -0
  23. package/src/components/CodeBlock.stories.tsx +1 -1
  24. package/src/components/CompletionOverlay.stories.tsx +1 -1
  25. package/src/components/ConfirmCard.stories.tsx +1 -1
  26. package/src/components/ConfirmCard.tsx +3 -4
  27. package/src/components/ConfirmDialog.stories.tsx +1 -1
  28. package/src/components/DiffSheet.stories.tsx +1 -1
  29. package/src/components/InboxList.stories.tsx +23 -8
  30. package/src/components/InboxList.tsx +45 -19
  31. package/src/components/KeybindsDialog.stories.tsx +1 -1
  32. package/src/components/KeybindsDialog.tsx +61 -17
  33. package/src/components/MarkerPopover.stories.tsx +1 -1
  34. package/src/components/MenuBar.stories.tsx +3 -12
  35. package/src/components/MenuBar.tsx +25 -56
  36. package/src/components/PromptDialog.stories.tsx +1 -1
  37. package/src/components/PrototypeSheet.stories.tsx +1 -1
  38. package/src/components/ReviewPanel.stories.tsx +15 -1
  39. package/src/components/ReviewRail.stories.tsx +15 -1
  40. package/src/components/ReviewRail.tsx +13 -6
  41. package/src/components/SettingsDialog.stories.tsx +7 -1
  42. package/src/components/SettingsDialog.tsx +112 -66
  43. package/src/components/SettingsRows.stories.tsx +1 -1
  44. package/src/components/ShellHeader.stories.tsx +50 -0
  45. package/src/components/ShellHeader.tsx +137 -0
  46. package/src/components/ThreadFooter.stories.tsx +30 -0
  47. package/src/components/ThreadFooter.tsx +49 -0
  48. package/src/components/ThreadView.selection.test.tsx +812 -0
  49. package/src/components/ThreadView.slash.test.tsx +113 -0
  50. package/src/components/ThreadView.stories.tsx +26 -0
  51. package/src/components/ThreadView.tsx +1708 -0
  52. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  53. package/src/components/ThreadsSidebar.tsx +57 -0
  54. package/src/components/Toast.stories.tsx +1 -1
  55. package/src/components/TreePane.stories.tsx +106 -0
  56. package/src/components/TreePane.tsx +156 -0
  57. package/src/components/WalkWizard.stories.tsx +1 -1
  58. package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
  59. package/src/components/agent-launcher.stories.tsx +1 -1
  60. package/src/components/file-tree.test.ts +45 -0
  61. package/src/components/file-tree.ts +57 -0
  62. package/src/components/pages/Workspace.stories.tsx +181 -0
  63. package/src/components/primitives/Button.stories.tsx +1 -1
  64. package/src/components/primitives/Card.stories.tsx +1 -1
  65. package/src/components/primitives/Dialog.stories.tsx +1 -1
  66. package/src/components/primitives/IconButton.stories.tsx +36 -0
  67. package/src/components/primitives/IconButton.tsx +43 -0
  68. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  69. package/src/components/primitives/Tabs.stories.tsx +1 -1
  70. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  71. package/src/components/primitives/Tree.stories.tsx +124 -0
  72. package/src/components/primitives/Tree.tsx +87 -0
  73. package/src/components/primitives/icons.ts +38 -0
  74. package/src/components/primitives/tree-model.test.ts +99 -0
  75. package/src/components/primitives/tree-model.ts +137 -0
  76. package/src/components/quick-actions-editor.stories.tsx +1 -1
  77. package/src/components/session-tree.test.ts +114 -0
  78. package/src/components/session-tree.ts +94 -0
  79. package/src/components/stories-app.tsx +142 -82
  80. package/src/components/story-tree.test.ts +100 -0
  81. package/src/components/story.ts +55 -0
  82. package/src/components/theme-context.stories.tsx +1 -1
  83. package/src/diff-hunk-curate.ts +4 -104
  84. package/src/index.ts +1 -0
  85. package/src/intent-dispatch.test.ts +136 -3
  86. package/src/intent-dispatch.ts +92 -24
  87. package/src/key-bindings.test.ts +1 -1
  88. package/src/keymap.test.ts +4 -4
  89. package/src/keymap.ts +14 -7
  90. package/src/session-controller.curate.test.ts +89 -2
  91. package/src/session-controller.share.test.ts +195 -55
  92. package/src/session-controller.tree.test.ts +228 -0
  93. package/src/session-controller.ts +331 -72
  94. package/src/share.ts +114 -16
  95. package/src/test-support.test.ts +193 -1
  96. package/src/test-support.ts +64 -0
  97. package/src/theme.ts +4 -4
  98. package/src/thread-chords.test.ts +154 -0
  99. package/src/thread-chords.ts +144 -0
  100. package/src/thread-selection.test.ts +210 -0
  101. package/src/thread-selection.ts +243 -0
  102. package/src/tree-view.test.ts +128 -0
  103. package/src/tree-view.ts +148 -0
  104. package/src/use-settings-dialog.tsx +9 -0
  105. package/src/view-plan.ts +29 -12
  106. package/src/App.review-panel.test.tsx +0 -143
  107. package/src/components/PlanSheet.stories.tsx +0 -113
  108. package/src/components/PlanSheet.tsx +0 -419
  109. package/src/components/plan-sheet-run-style.ts +0 -88
package/src/App.test.tsx CHANGED
@@ -10,8 +10,12 @@ import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
12
  import {
13
+ clickText,
14
+ dragText,
15
+ frameRow,
13
16
  isolateUserConfig,
14
17
  press,
18
+ pressKey,
15
19
  typeText as type,
16
20
  waitForState,
17
21
  waitForText,
@@ -67,7 +71,7 @@ async function renderApp(sessionId?: string) {
67
71
  }
68
72
 
69
73
  describe("plan rendering", () => {
70
- test("renders the plan with headings, list markers, and the rail", async () => {
74
+ test("renders the plan with headings, list markers, and the footer", async () => {
71
75
  // Arrange
72
76
  const setup = await renderApp();
73
77
 
@@ -77,96 +81,97 @@ describe("plan rendering", () => {
77
81
  expect(frame).toContain("Migration Plan");
78
82
  expect(frame).toContain("Context");
79
83
  expect(frame).toContain("persists sessions to disk atomically");
80
- expect(frame).toContain("- move the store");
81
- expect(frame).toContain("Review");
82
- expect(frame).toContain("Submit review");
84
+ expect(frame).toContain("· move the store");
85
+ expect(frame).toContain("send message");
83
86
  });
84
- });
85
87
 
86
- describe("keyboard grammar", () => {
87
- test("j/k moves the cursor glyph between blocks", async () => {
88
- // Arrange
88
+ test("a direct open still populates the Threads sidebar with other pending reviews", async () => {
89
+ // Arrange - a second pending review exists alongside the one opened directly
90
+ server.core.sessionCreate({
91
+ workspace: { repoRoot: "/repo", branch: "main" },
92
+ artifact: { type: "plan", content: "# Other Plan\n", meta: { title: "Other Plan" } },
93
+ });
89
94
  const setup = await renderApp();
90
95
 
91
- // Act
92
- await press(setup, "j");
93
- await press(setup, "j");
94
- await setup.renderOnce();
95
-
96
- // Assert
97
- const lines = setup.captureCharFrame().split("\n");
98
- const cursorLine = lines.find((line) => line.includes("▎"))!;
96
+ // Act - open the collapsed Threads sidebar (the mirrored panel toggle by the gear)
97
+ await setup.mockMouse.click(3, 1);
99
98
 
100
- expect(cursorLine).toContain("persists sessions");
99
+ // Assert - the sidebar lists the other pending review, so you can jump to it
100
+ await waitForText(setup, "Other Plan");
101
101
  });
102
+ });
102
103
 
103
- test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
104
+ describe("thread view grammar", () => {
105
+ test("↓ moves the caret between blocks and typing anchors to the block under it", async () => {
104
106
  // Arrange
105
107
  const setup = await renderApp();
106
108
 
107
- // Act
108
- await press(setup, "j");
109
- await press(setup, "j");
110
- await press(setup, "c");
111
- await setup.renderOnce();
109
+ // Act: two blocks down lands on the paragraph; a printable opens a draft there
110
+ await pressKey(setup, "ARROW_DOWN");
111
+ await pressKey(setup, "ARROW_DOWN");
112
+ await type(setup, "x");
112
113
 
113
- // Assert
114
- await waitForText(setup, 'comment on "The daemon');
114
+ // Assert: the draft card sits right under the paragraph's row
115
+ await waitForText(setup, "● x");
116
+ expect(frameRow(setup, "● x")).toBeGreaterThan(frameRow(setup, "persists sessions"));
117
+ expect(frameRow(setup, "● x")).toBeLessThan(frameRow(setup, "Steps"));
118
+ });
119
+
120
+ test("comment flow: click a word, type a body, cmd+⏎ saves to the daemon", async () => {
121
+ // Arrange
122
+ const setup = await renderApp();
115
123
 
116
124
  // Act
125
+ await clickText(setup, "daemon");
117
126
  await type(setup, "Define atomically.");
118
- await press(setup, "enter");
127
+ await pressKey(setup, "RETURN", { meta: true });
119
128
 
120
129
  // Assert
121
- await waitForText(setup, "COMMENT");
130
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
122
131
  const stored = server.core.sessionGet(session.id);
123
132
 
124
133
  expect(stored.annotations.length).toBe(1);
125
134
  expect(stored.annotations[0]!.body).toBe("Define atomically.");
135
+ expect(stored.annotations[0]!.anchor.quote).toBe("daemon");
126
136
  });
127
137
 
128
- test("span mode: v + l selects words, c anchors the exact span", async () => {
138
+ test("a drag marks the exact span the comment anchors to", async () => {
129
139
  // Arrange
130
140
  const setup = await renderApp();
131
141
 
132
- // Act
133
- await press(setup, "j");
134
- await press(setup, "j");
135
- await press(setup, "v");
136
- await press(setup, "l"); // "The daemon"
137
- await press(setup, "c");
142
+ // Act: from the start of "The" to the end of "daemon"
143
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
138
144
  await type(setup, "Which daemon?");
139
- await press(setup, "enter");
145
+ await pressKey(setup, "RETURN", { meta: true });
140
146
 
141
147
  // Assert
142
148
  await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
143
- const stored = server.core.sessionGet(session.id);
144
-
145
- expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
149
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
146
150
  });
147
151
 
148
- test("x cuts a block into the working copy; x restores it", async () => {
152
+ test("option+x cuts the caret's block into the working copy; option+x restores it", async () => {
149
153
  // Arrange
150
154
  const setup = await renderApp();
151
155
 
152
- // move to "- move the store" (h1, h2, p, h2 = 4 steps in)
153
- for (let i = 0; i < 4; i++) await press(setup, "j");
156
+ await clickText(setup, "move the store");
154
157
 
155
158
  // Act
156
- await press(setup, "x");
159
+ await pressKey(setup, "x", { meta: true });
157
160
 
158
- // Assert - the rail shows the cut as a removal card titled CUT · me
159
- await waitForText(setup, "CUT · me");
160
- expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
161
+ // Assert - the cut lands in the working copy
162
+ await waitForState(
163
+ setup,
164
+ () => !(server.core.sessionGet(session.id).workingCopy ?? "").includes("move the store"),
165
+ );
161
166
 
162
167
  // Act
163
- await press(setup, "x");
168
+ await pressKey(setup, "x", { meta: true });
164
169
 
165
170
  // Assert
166
171
  await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
167
172
  });
168
173
 
169
- test("e runs $EDITOR on the working copy and tracks the diff", async () => {
174
+ test("ctrl+e runs $EDITOR on the working copy and tracks the diff", async () => {
170
175
  const script = join(home, "fake-editor.sh");
171
176
 
172
177
  await Bun.write(script, `#!/bin/sh\nsed -i '' 's/atomically/very atomically/' "$1"\n`);
@@ -177,7 +182,7 @@ describe("keyboard grammar", () => {
177
182
  const setup = await renderApp();
178
183
 
179
184
  // Act
180
- await press(setup, "e");
185
+ await pressKey(setup, "e", { ctrl: true });
181
186
 
182
187
  // Assert
183
188
  await waitForText(setup, "[edited]");
@@ -189,19 +194,17 @@ describe("keyboard grammar", () => {
189
194
  });
190
195
 
191
196
  describe("submit", () => {
192
- test("⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
197
+ test("cmd+⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
193
198
  // Arrange
194
199
  const setup = await renderApp();
195
200
 
196
- await press(setup, "j");
197
- await press(setup, "j");
198
- await press(setup, "c");
201
+ await clickText(setup, "daemon");
199
202
  await type(setup, "Needs a phase list.");
200
- await press(setup, "enter");
203
+ await pressKey(setup, "RETURN", { meta: true });
201
204
  await waitForText(setup, "Needs a phase list");
202
205
 
203
- // Act
204
- await press(setup, "enter"); // open submit
206
+ // Act: with no composer open the same chord opens submit
207
+ await pressKey(setup, "RETURN", { meta: true });
205
208
 
206
209
  // Assert
207
210
  await waitForText(setup, "[Changes]");
@@ -226,7 +229,7 @@ describe("submit", () => {
226
229
  const setup = await renderApp();
227
230
 
228
231
  // Act
229
- await press(setup, "enter");
232
+ await pressKey(setup, "RETURN", { meta: true });
230
233
 
231
234
  // Assert
232
235
  await waitForText(setup, "[Approve]"); // no pending items → approve default
@@ -241,25 +244,24 @@ describe("submit", () => {
241
244
  });
242
245
 
243
246
  describe("inbox", () => {
244
- test("inbox mode wears the review chrome (header + menu bar) and opens a session", async () => {
247
+ test("inbox mode wears the review header and opens a session", async () => {
245
248
  // Arrange
246
249
  const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
247
250
 
248
251
  await waitForText(setup, "cueloop");
249
252
 
250
- // Assert - the same header/menu chrome as review, plus the session row
253
+ // Assert - the same header as review, plus the session row
251
254
  const frame = setup.captureCharFrame();
252
255
 
253
256
  expect(frame).toContain("cueloop · resume");
254
257
  expect(frame).toContain("Migration Plan");
255
- expect(frame).toContain("menu"); // the shared MenuBar sits at the bottom
256
258
  expect(frame).not.toContain("inbox ("); // the old inline header is gone
257
259
 
258
260
  // Act
259
261
  await press(setup, "enter");
260
262
 
261
263
  // Assert
262
- await waitForText(setup, "Submit review");
264
+ await waitForText(setup, "send message");
263
265
  });
264
266
 
265
267
  test("the menu opens from the inbox and escape is not a trap", async () => {
@@ -267,12 +269,9 @@ describe("inbox", () => {
267
269
  const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
268
270
 
269
271
  await waitForText(setup, "cueloop · resume");
270
- const lines = setup.captureCharFrame().split("\n");
271
- const menuRow = lines.findIndex((line) => line.includes("menu"));
272
- const menuColumn = lines[menuRow]!.indexOf("menu");
273
272
 
274
- // Act - open the drop-up from the shared menu bar
275
- await setup.mockMouse.click(menuColumn + 1, menuRow);
273
+ // Act - open the drop-down from the top-left settings gear (header content row)
274
+ await setup.mockMouse.click(1, 1);
276
275
 
277
276
  // Assert - the drop-up options appear
278
277
  await waitForText(setup, "Keybinds");
@@ -284,6 +283,77 @@ describe("inbox", () => {
284
283
  await press(setup, "enter");
285
284
 
286
285
  // Assert
287
- await waitForText(setup, "Submit review");
286
+ await waitForText(setup, "send message");
287
+ });
288
+ });
289
+
290
+ describe("the thread view and the menu", () => {
291
+ test("the keybinds dialog lists the thread view grammar, keeps keys away from the text, and closes on escape", async () => {
292
+ // Arrange
293
+ const setup = await renderApp();
294
+
295
+ await waitForText(setup, "The daemon persists");
296
+
297
+ // Act - open the menu from the top-left settings gear, then the keybinds dialog
298
+ await setup.mockMouse.click(1, 1);
299
+ await waitForText(setup, "Keybinds");
300
+ const dropUp = setup.captureCharFrame().split("\n");
301
+ const keybindsRow = dropUp.findIndex((line) => line.includes("Keybinds"));
302
+
303
+ await setup.mockMouse.click(dropUp[keybindsRow]!.indexOf("Keybinds") + 1, keybindsRow);
304
+ await waitForText(setup, "mark text, across blocks");
305
+
306
+ // Assert - the thread grammar, not the plan sheet's
307
+ const dialog = setup.captureCharFrame();
308
+
309
+ expect(dialog).toContain("⌘⌥m");
310
+ expect(dialog).toContain("place the caret");
311
+ expect(dialog).not.toContain("grow/shrink");
312
+
313
+ // Act - a printable behind the dialog must not open a comment; escape closes it
314
+ await press(setup, "x");
315
+ expect(setup.captureCharFrame()).not.toContain("● x");
316
+ await press(setup, "escape");
317
+
318
+ // Assert
319
+ await waitForState(setup, () => !setup.captureCharFrame().includes("mark text, across blocks"));
320
+ expect(setup.captureCharFrame()).not.toContain("● x");
321
+ });
322
+
323
+ test("clicking the Settings nav group is inert and never corrupts the active category", async () => {
324
+ // Arrange - open the settings dialog from the gear
325
+ const setup = await renderApp();
326
+
327
+ await setup.mockMouse.click(1, 1);
328
+ await waitForText(setup, "Keybinds");
329
+
330
+ // the nav folder is the last "Settings" on screen (the first is the dialog title)
331
+ const lines = setup.captureCharFrame().split("\n");
332
+ let folderRow = -1;
333
+ let folderColumn = -1;
334
+
335
+ lines.forEach((line, row) => {
336
+ const column = line.indexOf("Settings");
337
+
338
+ if (column !== -1) {
339
+ folderColumn = column;
340
+ folderRow = row;
341
+ }
342
+ });
343
+
344
+ // Act - click the synthetic Settings group folder, then press a nav key. The
345
+ // key handler dereferences the active category, so a corrupt id would throw
346
+ // (the global key handler swallows the throw into console.error).
347
+ const keyHandlerErrors: string[] = [];
348
+ const originalConsoleError = console.error;
349
+
350
+ console.error = (...args: unknown[]) => keyHandlerErrors.push(args.map(String).join(" "));
351
+ await setup.mockMouse.click(folderColumn + 1, folderRow);
352
+ await pressKey(setup, "ARROW_DOWN");
353
+ console.error = originalConsoleError;
354
+
355
+ // Assert - no key-handler crash, and the dialog is still up
356
+ expect(keyHandlerErrors.filter((line) => line.includes("keypress handler"))).toEqual([]);
357
+ expect(setup.captureCharFrame()).toContain("Keybinds");
288
358
  });
289
359
  });
@@ -10,7 +10,15 @@ import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
12
  import type { ShareTransport } from "./session-controller";
13
- import { isolateUserConfig, press, waitForText, waitForTextGone } from "./test-support";
13
+ import {
14
+ clickText,
15
+ isolateUserConfig,
16
+ press,
17
+ pressKey,
18
+ typeText,
19
+ waitForText,
20
+ waitForTextGone,
21
+ } from "./test-support";
14
22
 
15
23
  const publishShare = mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true }));
16
24
  const shareTransport: ShareTransport = {
@@ -19,8 +27,10 @@ const shareTransport: ShareTransport = {
19
27
  throw new Error("Unexpected share pull");
20
28
  }),
21
29
  push: mock(async () => {}),
30
+ watch: () => () => {},
22
31
  parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
23
32
  collaboratorAnnotations: () => [],
33
+ mergeFromShare: () => ({ annotations: [] }),
24
34
  };
25
35
 
26
36
  const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
@@ -63,18 +73,17 @@ describe("share toast", () => {
63
73
 
64
74
  await waitForText(setup, "cueloop");
65
75
 
66
- // Act: share raises the toast, then open a composer under it
67
- await press(setup, "S");
76
+ // Act: share raises the toast, then open a composer under it by typing
77
+ await pressKey(setup, "s", { ctrl: true });
68
78
  await waitForText(setup, "share link copied");
69
- await press(setup, "j");
70
- await press(setup, "j");
71
- await press(setup, "c");
72
- await waitForText(setup, 'comment on "');
79
+ await clickText(setup, "daemon");
80
+ await typeText(setup, "x");
81
+ await waitForText(setup, "● x");
73
82
 
74
83
  // Act: escape must reach the composer, not get eaten by the toast
75
84
  await press(setup, "escape");
76
85
 
77
86
  // Assert: the composer closed on the first escape
78
- await waitForTextGone(setup, 'comment on "');
87
+ await waitForTextGone(setup, "● x");
79
88
  });
80
89
  });