@cueloop/client 0.1.0-alpha.78 → 0.1.0-alpha.79

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 (92) hide show
  1. package/package.json +14 -13
  2. package/src/App.diff.test.tsx +9 -4
  3. package/src/App.focus.test.ts +1 -59
  4. package/src/App.identity.test.tsx +3 -3
  5. package/src/App.plan-render.test.tsx +209 -0
  6. package/src/App.plan-review.test.tsx +75 -26
  7. package/src/App.readonly.test.tsx +6 -5
  8. package/src/App.share-dialog.test.tsx +172 -0
  9. package/src/App.submit-confirm.test.tsx +6 -4
  10. package/src/App.test.tsx +31 -28
  11. package/src/App.toast.test.tsx +11 -4
  12. package/src/App.tsx +259 -274
  13. package/src/App.walk.test.tsx +9 -8
  14. package/src/App.workbench.test.tsx +86 -0
  15. package/src/annotation-palette.ts +1 -0
  16. package/src/app-screens.tsx +12 -6
  17. package/src/app-view-model.ts +0 -1
  18. package/src/components/ConfirmDialog.stories.tsx +3 -3
  19. package/src/components/DiffContentView.tsx +14 -7
  20. package/src/components/DiscussionMarkerRail.tsx +9 -4
  21. package/src/components/GridTabContent.tsx +8 -2
  22. package/src/components/HandleChipsInput.stories.tsx +24 -0
  23. package/src/components/HandleChipsInput.test.tsx +35 -0
  24. package/src/components/HandleChipsInput.tsx +145 -0
  25. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  26. package/src/components/MarkdownGridBlock.tsx +72 -0
  27. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  28. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  29. package/src/components/MarkdownThreadEditor.tsx +162 -0
  30. package/src/components/NavModeHint.stories.tsx +40 -0
  31. package/src/components/NavModeHint.tsx +31 -0
  32. package/src/components/SettingsDialog.tsx +1 -0
  33. package/src/components/ShareDialog.stories.tsx +121 -0
  34. package/src/components/ShareDialog.tsx +552 -0
  35. package/src/components/ThreadFooter.tsx +4 -1
  36. package/src/components/ThreadTree.tsx +1 -1
  37. package/src/components/ThreadView.slash.test.tsx +0 -35
  38. package/src/components/ThreadView.tsx +142 -68
  39. package/src/components/Toast.stories.tsx +7 -0
  40. package/src/components/Toast.tsx +15 -3
  41. package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
  42. package/src/components/primitives/Button.tsx +6 -2
  43. package/src/components/primitives/Card.tsx +2 -2
  44. package/src/components/primitives/Dialog.tsx +2 -2
  45. package/src/components/primitives/Switch.stories.tsx +17 -0
  46. package/src/components/primitives/Switch.tsx +32 -0
  47. package/src/components/primitives/Tree.tsx +5 -1
  48. package/src/components/primitives/frame.ts +5 -0
  49. package/src/components/share-dialog-store.ts +58 -0
  50. package/src/components/stories.test.tsx +4 -3
  51. package/src/config.test.ts +18 -0
  52. package/src/config.ts +8 -1
  53. package/src/diff-fold.ts +1 -1
  54. package/src/intent-dispatch.test.ts +12 -3
  55. package/src/intent-dispatch.ts +10 -25
  56. package/src/key-bindings.ts +0 -6
  57. package/src/keymap.ts +0 -1
  58. package/src/markdown-editor-syntax-style.ts +50 -0
  59. package/src/markdown-grid.test.ts +101 -0
  60. package/src/markdown-grid.ts +203 -0
  61. package/src/markdown-highlight.test.ts +61 -0
  62. package/src/markdown-highlight.ts +122 -0
  63. package/src/prototype-browser.test.ts +9 -1
  64. package/src/prototype-browser.ts +22 -0
  65. package/src/run.ts +8 -6
  66. package/src/share.ts +15 -1
  67. package/src/test-support.ts +14 -0
  68. package/src/thread-chords.test.ts +55 -178
  69. package/src/thread-chords.ts +78 -169
  70. package/src/thread-controller.curate.test.ts +1 -0
  71. package/src/thread-controller.live-diff.test.ts +3 -0
  72. package/src/thread-controller.share.test.ts +60 -5
  73. package/src/thread-controller.switch-cache.test.ts +30 -0
  74. package/src/thread-controller.tree.test.ts +6 -1
  75. package/src/thread-controller.ts +229 -54
  76. package/src/use-annotation-surface.tsx +45 -36
  77. package/src/use-settings-dialog.tsx +14 -3
  78. package/src/use-thread-body-editing.tsx +58 -0
  79. package/src/view-plan.test.ts +56 -6
  80. package/src/view-plan.ts +154 -5
  81. package/src/App.share-choice.test.tsx +0 -105
  82. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  83. package/src/components/ManageAccessDialog.test.tsx +0 -94
  84. package/src/components/ManageAccessDialog.tsx +0 -122
  85. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  86. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  87. package/src/components/ShareChoiceDialog.tsx +0 -80
  88. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  89. package/src/components/ThreadsSidebar.tsx +0 -71
  90. package/src/components/agent-launcher.stories.tsx +0 -26
  91. package/src/components/agent-launcher.test.tsx +0 -50
  92. package/src/components/agent-launcher.tsx +0 -149
@@ -0,0 +1,172 @@
1
+ /** The share shortcut opens the two-column share dialog reachable from any view. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, 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 { Thread } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import type { ShareTransport } from "./thread-controller";
13
+ import {
14
+ isolateUserConfig,
15
+ press,
16
+ pressKey,
17
+ settle,
18
+ waitForText,
19
+ waitForTextGone,
20
+ } from "./test-support";
21
+
22
+ const shareTransport: ShareTransport = {
23
+ publish: mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true })),
24
+ pull: mock(async () => {
25
+ throw new Error("Unexpected share pull");
26
+ }),
27
+ push: mock(async () => {}),
28
+ watch: () => () => {},
29
+ revoke: async () => {},
30
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
31
+ formatShareLine: (id: string) => "ssh " + id + "@cueloop.dev",
32
+ collaboratorAnnotations: () => [],
33
+ mergeFromShare: () => ({ annotations: [] }),
34
+ };
35
+
36
+ const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
37
+
38
+ let home: string;
39
+ let restoreUserConfig: () => void;
40
+ let server: DaemonServer;
41
+ let session: Thread;
42
+
43
+ beforeEach(() => {
44
+ home = mkdtempSync(join(tmpdir(), "cueloop-share-choice-"));
45
+ restoreUserConfig = isolateUserConfig(home);
46
+ server = new DaemonServer({ home, idleExitMs: 0 });
47
+ server.start();
48
+ session = server.core.sessionCreate({
49
+ workspace: { repoRoot: "/repo", branch: "main" },
50
+ artifact: {
51
+ type: "plan",
52
+ content: PLAN,
53
+ meta: { title: "Migration Plan", planPath: "plan.md" },
54
+ },
55
+ });
56
+ });
57
+ afterEach(() => {
58
+ restoreUserConfig();
59
+ server.stop();
60
+ rmSync(home, { recursive: true, force: true });
61
+ });
62
+
63
+ describe("share dialog", () => {
64
+ test("the shortcut opens the two-column share dialog", async () => {
65
+ // Arrange
66
+ const setup = await testRender(
67
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
68
+ { width: 120, height: 32 },
69
+ );
70
+ await waitForText(setup, "cueloop");
71
+
72
+ // Act
73
+ await pressKey(setup, "s", { ctrl: true });
74
+
75
+ // Assert - the nav categories and the empty-state new-link affordance
76
+ await waitForText(setup, "share externally");
77
+ await waitForText(setup, "export");
78
+ await waitForText(setup, "+ new link");
79
+ });
80
+
81
+ test("new link opens the wizard, and require auth reveals the allowlist", async () => {
82
+ // Arrange
83
+ const setup = await testRender(
84
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
85
+ { width: 120, height: 32 },
86
+ );
87
+ await waitForText(setup, "cueloop");
88
+
89
+ // Act: open the dialog, step into the body, activate "+ new link"
90
+ await pressKey(setup, "s", { ctrl: true });
91
+ await waitForText(setup, "+ new link");
92
+ await press(setup, "enter");
93
+ await press(setup, "enter");
94
+
95
+ // Assert - the wizard's fields
96
+ await waitForText(setup, "link name");
97
+ await waitForText(setup, "require auth");
98
+
99
+ // Act: focus the auth row and switch it on (enter toggles the focused row)
100
+ await press(setup, "down");
101
+ await press(setup, "enter");
102
+
103
+ // Assert - the handles input appears with its grayed placeholder
104
+ await waitForText(setup, "handle");
105
+
106
+ // Act: backspace on the empty allowlist collapses require auth back off
107
+ await press(setup, "backspace");
108
+
109
+ // Assert - the handles section is gone
110
+ await waitForTextGone(setup, "handle");
111
+ });
112
+
113
+ test("a private link with no handles cannot be created", async () => {
114
+ // Arrange
115
+ const setup = await testRender(
116
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
117
+ { width: 120, height: 32 },
118
+ );
119
+ await waitForText(setup, "cueloop");
120
+
121
+ // Act: open the wizard, turn auth on but add no handles, then try to create
122
+ await pressKey(setup, "s", { ctrl: true });
123
+ await waitForText(setup, "+ new link");
124
+ await press(setup, "enter"); // into the body
125
+ await press(setup, "enter"); // open the wizard
126
+ await waitForText(setup, "require auth");
127
+ await press(setup, "down"); // focus the auth row
128
+ await press(setup, "enter"); // toggle auth on (focus moves to the empty allowlist)
129
+ await press(setup, "down"); // move to the create action
130
+ await press(setup, "enter"); // create is blocked while the allowlist is empty
131
+ await settle(setup);
132
+
133
+ // Assert - the wizard is still open (nothing was created and the dialog did not fall back to the list)
134
+ expect(setup.captureCharFrame()).toContain("require auth");
135
+ });
136
+
137
+ test("right enters the links body and left returns to the nav", async () => {
138
+ // Arrange
139
+ const setup = await testRender(
140
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
141
+ { width: 120, height: 32 },
142
+ );
143
+ await waitForText(setup, "cueloop");
144
+
145
+ // Act + Assert: right arrow crosses into the body (its hint appears)
146
+ await pressKey(setup, "s", { ctrl: true });
147
+ await waitForText(setup, "+ new link");
148
+ await press(setup, "right");
149
+ await waitForText(setup, "h back");
150
+
151
+ // Act + Assert: left arrow returns to the nav (its hint returns)
152
+ await press(setup, "left");
153
+ await waitForText(setup, "l/enter open");
154
+ });
155
+
156
+ test("escape closes the dialog", async () => {
157
+ // Arrange
158
+ const setup = await testRender(
159
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
160
+ { width: 120, height: 32 },
161
+ );
162
+ await waitForText(setup, "cueloop");
163
+
164
+ // Act
165
+ await pressKey(setup, "s", { ctrl: true });
166
+ await waitForText(setup, "share externally");
167
+ await press(setup, "escape");
168
+
169
+ // Assert
170
+ await waitForTextGone(setup, "share externally");
171
+ });
172
+ });
@@ -157,8 +157,10 @@ describe("send message confirm", () => {
157
157
  // Act
158
158
  await pressKey(setup, "RETURN", { meta: true });
159
159
 
160
- // Assert
161
- await waitForText(setup, "[Changes]"); // pending items: request changes default
160
+ // Assert - opens on the default verdict, then cycle to request changes
161
+ await waitForText(setup, "[Approve]");
162
+ await press(setup, "right");
163
+ await waitForText(setup, "[Changes]");
162
164
 
163
165
  // Act
164
166
  await setup.mockInput.typeText("Tighten the steps.");
@@ -177,7 +179,7 @@ describe("send message confirm", () => {
177
179
  seedAnnotations(1);
178
180
  const setup = await renderApp();
179
181
  await pressKey(setup, "RETURN", { meta: true });
180
- await waitForText(setup, "[Changes]");
182
+ await waitForText(setup, "[Approve]");
181
183
 
182
184
  // Act
183
185
  await setup.mockInput.typeText("/restate");
@@ -213,7 +215,7 @@ describe("send message confirm", () => {
213
215
  // Assert - no overlay, and the session stays pending
214
216
  const frame = setup.captureCharFrame();
215
217
 
216
- expect(frame).not.toContain("[Changes]");
218
+ expect(frame).not.toContain("[Approve]");
217
219
  expect(frame).not.toContain(" cancel ");
218
220
  expect(server.core.sessionGet(session.id).status).toBe("pending");
219
221
  });
package/src/App.test.tsx CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  isolateUserConfig,
16
16
  locateText,
17
17
  press,
18
+ navCommand,
18
19
  pressKey,
19
20
  renderReadyApp,
20
21
  typeText as type,
@@ -152,7 +153,7 @@ describe("thread view grammar", () => {
152
153
  await clickText(setup, "move the store");
153
154
 
154
155
  // Act
155
- await pressKey(setup, "x", { meta: true });
156
+ await navCommand(setup, "x");
156
157
 
157
158
  // Assert - the cut lands in the working copy
158
159
  await waitForState(
@@ -161,31 +162,31 @@ describe("thread view grammar", () => {
161
162
  );
162
163
 
163
164
  // Act
164
- await pressKey(setup, "x", { meta: true });
165
+ await navCommand(setup, "x");
165
166
 
166
167
  // Assert
167
168
  await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
168
169
  });
169
170
 
170
- test("ctrl+e runs $EDITOR on the working copy and tracks the diff", async () => {
171
- const script = join(home, "fake-editor.sh");
172
-
173
- await Bun.write(script, `#!/bin/sh\nsed -i '' 's/atomically/very atomically/' "$1"\n`);
174
- Bun.spawnSync(["chmod", "+x", script]);
175
- process.env.CUELOOP_EDITOR = script;
176
- try {
177
- // Arrange
178
- const setup = await renderApp();
179
-
180
- // Act
181
- await pressKey(setup, "e", { ctrl: true });
182
-
183
- // Assert
184
- await waitForText(setup, "[edited]");
185
- expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
186
- } finally {
187
- delete process.env.CUELOOP_EDITOR;
188
- }
171
+ test("ctrl+e opens the inline editor; the header toggles to normal and leaving tracks the edit", async () => {
172
+ // Arrange
173
+ const setup = await renderApp();
174
+
175
+ // Act - open the inline markdown editor over the thread body
176
+ await pressKey(setup, "e", { ctrl: true });
177
+
178
+ // Assert - the editor owns the pane (its save hint shows) and the header offers the way back
179
+ await waitForText(setup, "save & close");
180
+ expect(setup.captureCharFrame()).toContain("normal");
181
+
182
+ // Act - type into the body, then leave through the header toggle
183
+ await type(setup, "MORE");
184
+ await clickText(setup, "normal");
185
+
186
+ // Assert - the edit is tracked into the working copy, and the read-only view is back
187
+ await waitForState(setup, () =>
188
+ (server.core.sessionGet(session.id).workingCopy ?? "").includes("MORE"),
189
+ );
189
190
  });
190
191
  });
191
192
 
@@ -202,10 +203,12 @@ describe("submit", () => {
202
203
  // Act: with no composer open the same chord opens submit
203
204
  await pressKey(setup, "RETURN", { meta: true });
204
205
 
205
- // Assert
206
- await waitForText(setup, "[Changes]");
206
+ // Assert - the card opens on the default verdict, approve
207
+ await waitForText(setup, "[Approve]");
207
208
 
208
- // Act
209
+ // Act - cycle to request changes, then send with a summary
210
+ await press(setup, "right");
211
+ await waitForText(setup, "[Changes]");
209
212
  await type(setup, "Expand the steps.");
210
213
  await pressKey(setup, "RETURN", { meta: true });
211
214
 
@@ -333,7 +336,7 @@ describe("no-thread shell", () => {
333
336
 
334
337
  // Assert - the settings dialog with its Keybinds leaf appears
335
338
  await waitForText(setup, "Keybinds");
336
- expect(setup.captureCharFrame()).toContain("Settings");
339
+ expect(setup.captureCharFrame()).toContain("settings");
337
340
 
338
341
  // Act - escape closes the menu (not a trap), and the thread nav still works
339
342
  await press(setup, "escape");
@@ -364,7 +367,7 @@ describe("the thread view and the menu", () => {
364
367
  // Assert - the thread grammar, not the plan sheet's
365
368
  const dialog = setup.captureCharFrame();
366
369
 
367
- expect(dialog).toContain("⌘⌥m");
370
+ expect(dialog).toContain("nav mode");
368
371
  expect(dialog).toContain("place the caret");
369
372
  expect(dialog).not.toContain("grow/shrink");
370
373
 
@@ -385,13 +388,13 @@ describe("the thread view and the menu", () => {
385
388
  await setup.mockMouse.click(1, 0);
386
389
  await waitForText(setup, "Keybinds");
387
390
 
388
- // the nav folder is the last "Settings" on screen (the first is the dialog title)
391
+ // the nav folder is the last "settings" on screen (the first is the dialog title)
389
392
  const lines = setup.captureCharFrame().split("\n");
390
393
  let folderRow = -1;
391
394
  let folderColumn = -1;
392
395
 
393
396
  lines.forEach((line, row) => {
394
- const column = line.indexOf("Settings");
397
+ const column = line.indexOf("settings");
395
398
 
396
399
  if (column !== -1) {
397
400
  folderColumn = column;
@@ -28,7 +28,9 @@ const shareTransport: ShareTransport = {
28
28
  }),
29
29
  push: mock(async () => {}),
30
30
  watch: () => () => {},
31
+ revoke: async () => {},
31
32
  parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
33
+ formatShareLine: (id: string) => "ssh " + id + "@cueloop.dev",
32
34
  collaboratorAnnotations: () => [],
33
35
  mergeFromShare: () => ({ annotations: [] }),
34
36
  };
@@ -73,11 +75,16 @@ describe("share toast", () => {
73
75
 
74
76
  await waitForText(setup, "cueloop");
75
77
 
76
- // Act: share opens the choice, enter picks public and raises the toast, then a composer under it
78
+ // Act: share opens the dialog, the new-link wizard publishes and raises the toast, then a composer under it
77
79
  await pressKey(setup, "s", { ctrl: true });
78
- await waitForText(setup, "public link");
79
- await press(setup, "enter");
80
- await waitForText(setup, "share link copied");
80
+ await waitForText(setup, "+ new link");
81
+ await press(setup, "enter"); // step into the links body
82
+ await press(setup, "enter"); // activate "+ new link" - the wizard opens
83
+ await waitForText(setup, "link name");
84
+ await press(setup, "enter"); // the name field submits and creates the link
85
+ await waitForText(setup, "link copied");
86
+ await press(setup, "escape"); // close the dialog; the non-modal toast stays up
87
+ await waitForTextGone(setup, "share externally");
81
88
  await clickText(setup, "daemon");
82
89
  await typeText(setup, "x");
83
90
  await waitForText(setup, "● x");