@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.65
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.
- package/package.json +7 -6
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +76 -247
- package/src/App.readonly.test.tsx +80 -35
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.review-panel.test.tsx +29 -32
- package/src/App.submit-confirm.test.tsx +27 -12
- package/src/App.test.tsx +73 -49
- package/src/App.toast.test.tsx +34 -22
- package/src/App.tree.test.tsx +224 -0
- package/src/App.tsx +134 -87
- package/src/app-screens.tsx +27 -1
- package/src/app-view-model.ts +7 -109
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/DiffSheet.scroll.test.tsx +5 -2
- package/src/components/DiffSheet.tsx +1 -6
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/PrototypeSheet.tsx +5 -9
- package/src/components/ReviewPanel.stories.tsx +14 -0
- package/src/components/ReviewRail.stories.tsx +14 -0
- package/src/components/ReviewRail.tsx +15 -6
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1620 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
- package/src/components/primitives/Tabs.tsx +1 -1
- package/src/components/story-fixtures.ts +6 -2
- package/src/components/story.ts +16 -4
- package/src/components/syntax-highlight.ts +10 -2
- package/src/components/terminal-pane.test.tsx +1 -1
- package/src/components/terminal-pane.ts +7 -1
- package/src/config.ts +112 -99
- package/src/diff-hunk-curate.ts +4 -104
- package/src/editor.ts +5 -1
- package/src/ghostty-terminal.test.ts +1 -1
- package/src/herdr.ts +10 -2
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +235 -55
- package/src/intent-dispatch.ts +104 -34
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +16 -7
- package/src/keymap.test.ts +9 -11
- package/src/keymap.ts +14 -7
- package/src/kitty-image.test.ts +3 -3
- package/src/prototype-browser.ts +7 -6
- package/src/serve.ts +1 -1
- package/src/session-controller.curate.test.ts +106 -4
- package/src/session-controller.share.test.ts +220 -70
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +370 -94
- package/src/share.ts +117 -17
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +69 -1
- package/src/theme-presets.ts +9 -2
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/view-diff.ts +1 -4
- package/src/view-plan.ts +35 -19
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -422
- package/src/components/plan-sheet-run-style.ts +0 -90
package/src/App.toast.test.tsx
CHANGED
|
@@ -8,20 +8,30 @@ import React from "react";
|
|
|
8
8
|
import { testRender } from "@opentui/react/test-utils";
|
|
9
9
|
import { DaemonServer } from "@cueloop/daemon";
|
|
10
10
|
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
import type { ShareTransport } from "./session-controller";
|
|
13
|
+
import {
|
|
14
|
+
clickText,
|
|
15
|
+
isolateUserConfig,
|
|
16
|
+
press,
|
|
17
|
+
pressKey,
|
|
18
|
+
typeText,
|
|
19
|
+
waitForText,
|
|
20
|
+
waitForTextGone,
|
|
21
|
+
} from "./test-support";
|
|
11
22
|
|
|
12
|
-
// the controller reaches the gateway through "./share"; stub it so `S` yields a toast
|
|
13
23
|
const publishShare = mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true }));
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
const shareTransport: ShareTransport = {
|
|
25
|
+
publish: publishShare,
|
|
26
|
+
pull: mock(async () => {
|
|
27
|
+
throw new Error("Unexpected share pull");
|
|
28
|
+
}),
|
|
29
|
+
push: mock(async () => {}),
|
|
30
|
+
watch: () => () => {},
|
|
31
|
+
parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
|
|
20
32
|
collaboratorAnnotations: () => [],
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
const { App } = await import("./App");
|
|
24
|
-
const { isolateUserConfig, press, waitForText, waitForTextGone } = await import("./test-support");
|
|
33
|
+
mergeFromShare: () => ({ annotations: [] }),
|
|
34
|
+
};
|
|
25
35
|
|
|
26
36
|
const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
|
|
27
37
|
|
|
@@ -53,25 +63,27 @@ afterEach(() => {
|
|
|
53
63
|
describe("share toast", () => {
|
|
54
64
|
test("escape cancels an open composer even while the toast is up", async () => {
|
|
55
65
|
// Arrange
|
|
56
|
-
const setup = await testRender(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
const setup = await testRender(
|
|
67
|
+
<App home={home} sessionId={session.id} shareTransport={shareTransport} />,
|
|
68
|
+
{
|
|
69
|
+
width: 120,
|
|
70
|
+
height: 32,
|
|
71
|
+
},
|
|
72
|
+
);
|
|
60
73
|
|
|
61
74
|
await waitForText(setup, "cueloop");
|
|
62
75
|
|
|
63
|
-
// Act: share raises the toast, then open a composer under it
|
|
64
|
-
await
|
|
76
|
+
// Act: share raises the toast, then open a composer under it by typing
|
|
77
|
+
await pressKey(setup, "s", { ctrl: true });
|
|
65
78
|
await waitForText(setup, "share link copied");
|
|
66
|
-
await
|
|
67
|
-
await
|
|
68
|
-
await
|
|
69
|
-
await waitForText(setup, 'comment on "');
|
|
79
|
+
await clickText(setup, "daemon");
|
|
80
|
+
await typeText(setup, "x");
|
|
81
|
+
await waitForText(setup, "● x");
|
|
70
82
|
|
|
71
83
|
// Act: escape must reach the composer, not get eaten by the toast
|
|
72
84
|
await press(setup, "escape");
|
|
73
85
|
|
|
74
86
|
// Assert: the composer closed on the first escape
|
|
75
|
-
await waitForTextGone(setup,
|
|
87
|
+
await waitForTextGone(setup, "● x");
|
|
76
88
|
});
|
|
77
89
|
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rail's Tree tab over the real App and an in-process daemon: the option
|
|
3
|
+
* chords and the mouse reach the same primitives, the pane paints the path,
|
|
4
|
+
* the tips, and the checkpoints, and the thread view shows a moved path in the
|
|
5
|
+
* frame after the key.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
14
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
15
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
16
|
+
import { App } from "./App";
|
|
17
|
+
import {
|
|
18
|
+
clickText,
|
|
19
|
+
isolateUserConfig,
|
|
20
|
+
press,
|
|
21
|
+
pressKey,
|
|
22
|
+
typeText as type,
|
|
23
|
+
waitForState,
|
|
24
|
+
waitForText,
|
|
25
|
+
waitForTextGone,
|
|
26
|
+
} from "./test-support";
|
|
27
|
+
|
|
28
|
+
const PLAN = `# Migration Plan
|
|
29
|
+
|
|
30
|
+
## Context
|
|
31
|
+
|
|
32
|
+
The daemon persists sessions to disk atomically.
|
|
33
|
+
|
|
34
|
+
## Steps
|
|
35
|
+
|
|
36
|
+
- move the store
|
|
37
|
+
- add recovery
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
let home: string;
|
|
41
|
+
let server: DaemonServer;
|
|
42
|
+
let session: ReviewSession;
|
|
43
|
+
let restoreUserConfig: () => void;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-tree-"));
|
|
47
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
48
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
49
|
+
server.start();
|
|
50
|
+
session = server.core.sessionCreate({
|
|
51
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
52
|
+
artifact: {
|
|
53
|
+
type: "plan",
|
|
54
|
+
content: PLAN,
|
|
55
|
+
meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
restoreUserConfig();
|
|
61
|
+
server.stop();
|
|
62
|
+
rmSync(home, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function renderApp(role: "owner" | "collaborator" = "owner") {
|
|
66
|
+
const setup = await testRender(<App home={home} sessionId={session.id} role={role} />, {
|
|
67
|
+
width: 120,
|
|
68
|
+
height: 32,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await waitForText(setup, "cueloop");
|
|
72
|
+
|
|
73
|
+
return setup;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
77
|
+
|
|
78
|
+
/** Leave a comment on the word, sent with the terminal-safe chord. */
|
|
79
|
+
async function comment(setup: Setup, word: string, body: string): Promise<void> {
|
|
80
|
+
await clickText(setup, word);
|
|
81
|
+
await type(setup, body);
|
|
82
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
83
|
+
await waitForText(setup, body);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Frames painted until the needle leaves the screen, starting right after a key with no settle. */
|
|
87
|
+
async function framesUntilGone(setup: Setup, needle: string, limit = 4): Promise<number> {
|
|
88
|
+
for (let frame = 1; frame <= limit; frame++) {
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
90
|
+
await setup.renderOnce();
|
|
91
|
+
if (!setup.captureCharFrame().includes(needle)) return frame;
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`"${needle}" never left the frame`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("the Tree tab", () => {
|
|
97
|
+
test("option+t shows the tree: the revision, the comments, the current tip, a checkpoint", async () => {
|
|
98
|
+
// Arrange
|
|
99
|
+
const setup = await renderApp();
|
|
100
|
+
|
|
101
|
+
await comment(setup, "daemon", "Which daemon?");
|
|
102
|
+
|
|
103
|
+
// Act
|
|
104
|
+
await pressKey(setup, "t", { meta: true });
|
|
105
|
+
await waitForText(setup, "● comment");
|
|
106
|
+
await pressKey(setup, "l", { meta: true });
|
|
107
|
+
await waitForText(setup, "Checkpoint");
|
|
108
|
+
await type(setup, "first pass");
|
|
109
|
+
await press(setup, "enter");
|
|
110
|
+
|
|
111
|
+
// Assert: the tip row carries the branch and the new label
|
|
112
|
+
await waitForText(setup, "⚑ first pass");
|
|
113
|
+
expect(setup.captureCharFrame()).toContain("◉ revision 1");
|
|
114
|
+
expect(setup.captureCharFrame()).toContain("← main");
|
|
115
|
+
await waitForState(
|
|
116
|
+
setup,
|
|
117
|
+
() => Object.values(server.core.sessionGet(session.id).history!.labels)[0] === "first pass",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("option+b branches; option+p / option+g move back and the path repaints in one frame", async () => {
|
|
122
|
+
// Arrange: a comment on main, a branch, a second comment on it
|
|
123
|
+
const setup = await renderApp();
|
|
124
|
+
|
|
125
|
+
await comment(setup, "daemon", "Which daemon?");
|
|
126
|
+
await pressKey(setup, "t", { meta: true });
|
|
127
|
+
await waitForText(setup, "● comment");
|
|
128
|
+
await pressKey(setup, "b", { meta: true });
|
|
129
|
+
await waitForText(setup, "Branch");
|
|
130
|
+
await type(setup, "alt");
|
|
131
|
+
await press(setup, "enter");
|
|
132
|
+
await waitForText(setup, "← main, alt");
|
|
133
|
+
await comment(setup, "recovery", "Only on alt.");
|
|
134
|
+
await waitForText(setup, "● comment ← alt");
|
|
135
|
+
|
|
136
|
+
// Act: stand on the revision (two rows up from alt's tip) and move back to it
|
|
137
|
+
await pressKey(setup, "p", { meta: true });
|
|
138
|
+
await pressKey(setup, "p", { meta: true });
|
|
139
|
+
await pressKey(setup, "g", { meta: true });
|
|
140
|
+
await waitForText(setup, "Move back");
|
|
141
|
+
setup.mockInput.pressKey("RETURN");
|
|
142
|
+
|
|
143
|
+
// Assert: both comments leave the document in the frame after the key; the record follows
|
|
144
|
+
expect(await framesUntilGone(setup, "Only on alt.")).toBeLessThanOrEqual(1);
|
|
145
|
+
expect(setup.captureCharFrame()).not.toContain("Which daemon?");
|
|
146
|
+
await waitForState(setup, () => {
|
|
147
|
+
const record = server.core.sessionGet(session.id);
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
record.history!.branch === "alt" &&
|
|
151
|
+
record.annotations.length === 0 &&
|
|
152
|
+
record.shelvedAnnotations?.length === 2
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Act: main's tip is the first comment - going there is a switch, no prompt
|
|
157
|
+
await pressKey(setup, "n", { meta: true });
|
|
158
|
+
await pressKey(setup, "g", { meta: true });
|
|
159
|
+
|
|
160
|
+
// Assert
|
|
161
|
+
await waitForText(setup, "on branch main");
|
|
162
|
+
await waitForText(setup, "Which daemon?");
|
|
163
|
+
expect(setup.captureCharFrame()).not.toContain("Only on alt.");
|
|
164
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).history!.branch === "main");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("the mouse reaches the same primitives: the tab, a row, and the buttons", async () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
const setup = await renderApp();
|
|
170
|
+
|
|
171
|
+
await comment(setup, "daemon", "Which daemon?");
|
|
172
|
+
|
|
173
|
+
// Act: open the tab, select the revision row, open the label prompt from its button
|
|
174
|
+
await clickText(setup, "Tree");
|
|
175
|
+
await waitForText(setup, "◉ revision 1");
|
|
176
|
+
await clickText(setup, "◉ revision 1");
|
|
177
|
+
await clickText(setup, "Label");
|
|
178
|
+
|
|
179
|
+
// Assert
|
|
180
|
+
await waitForText(setup, "Checkpoint");
|
|
181
|
+
await press(setup, "escape");
|
|
182
|
+
await waitForTextGone(setup, "Checkpoint");
|
|
183
|
+
|
|
184
|
+
// Act: a second click on the selected row goes there - a move back asks for a summary
|
|
185
|
+
await clickText(setup, "◉ revision 1");
|
|
186
|
+
await waitForText(setup, "Move back");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a collaborator reads the tree and cannot move it", async () => {
|
|
190
|
+
// Arrange
|
|
191
|
+
const setup = await renderApp("collaborator");
|
|
192
|
+
|
|
193
|
+
// Act
|
|
194
|
+
await pressKey(setup, "t", { meta: true });
|
|
195
|
+
await waitForText(setup, "● revision 1");
|
|
196
|
+
await pressKey(setup, "b", { meta: true });
|
|
197
|
+
|
|
198
|
+
// Assert: no prompt, the read-only answer, no buttons
|
|
199
|
+
await waitForText(setup, "observer - read-only");
|
|
200
|
+
expect(setup.captureCharFrame()).not.toContain("Fork+share");
|
|
201
|
+
|
|
202
|
+
// Act: two clicks on the revision row select it and nothing more
|
|
203
|
+
await clickText(setup, "● revision 1");
|
|
204
|
+
await clickText(setup, "● revision 1");
|
|
205
|
+
|
|
206
|
+
// Assert: no prompt opened, the record stands
|
|
207
|
+
expect(setup.captureCharFrame()).not.toContain("Move back");
|
|
208
|
+
expect(server.core.sessionGet(session.id).history!.entries).toHaveLength(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a resolved review keeps the fork buttons and drops the moving ones", async () => {
|
|
212
|
+
// Arrange
|
|
213
|
+
server.core.sessionResolve(session.id, "approve", "ship it");
|
|
214
|
+
const setup = await renderApp();
|
|
215
|
+
|
|
216
|
+
// Act
|
|
217
|
+
await pressKey(setup, "t", { meta: true });
|
|
218
|
+
await waitForText(setup, "Fork+share");
|
|
219
|
+
|
|
220
|
+
// Assert
|
|
221
|
+
expect(setup.captureCharFrame()).not.toContain("Branch");
|
|
222
|
+
expect(setup.captureCharFrame()).not.toContain("Label");
|
|
223
|
+
});
|
|
224
|
+
});
|