@cueloop/client 0.1.0-alpha.75 → 0.1.0-alpha.77
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 +5 -5
- package/src/App.focus.test.ts +147 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.regression.test.tsx +94 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.test.tsx +11 -8
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +648 -221
- package/src/App.visual.test.tsx +57 -0
- package/src/App.workbench.test.tsx +41 -22
- package/src/app-screens.tsx +181 -123
- package/src/app-view-model.ts +1 -0
- package/src/components/AnnotatableFileView.tsx +9 -1
- package/src/components/AnnotationCards.tsx +22 -4
- package/src/components/AppShell.brand.test.tsx +1 -1
- package/src/components/AppShell.tsx +14 -1
- package/src/components/ChangesColumn.tsx +40 -12
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/ConfirmDialog.tsx +8 -1
- package/src/components/DiffContentView.tsx +12 -2
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tsx +84 -26
- package/src/components/GridTabContent.tsx +12 -1
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +120 -0
- package/src/components/PanelColumn.tsx +3 -0
- package/src/components/ProjectTreeView.test.tsx +39 -12
- package/src/components/ProjectTreeView.tsx +38 -10
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +16 -3
- package/src/components/RootOverlay.stories.tsx +1 -0
- package/src/components/RootOverlay.tsx +19 -6
- package/src/components/SettingsDialog.stories.tsx +3 -3
- package/src/components/SettingsDialog.tsx +3 -4
- package/src/components/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +82 -0
- package/src/components/ThreadFooter.test.tsx +12 -7
- package/src/components/ThreadFooter.tsx +18 -4
- package/src/components/ThreadTree.tsx +79 -36
- package/src/components/ThreadView.slash.test.tsx +36 -0
- package/src/components/ThreadView.tsx +12 -2
- package/src/components/WelcomePlayground.tsx +2 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +5 -0
- package/src/components/primitives/icons.ts +1 -1
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +35 -1
- package/src/components/quick-actions-editor.tsx +23 -22
- package/src/components/session-tree.test.ts +3 -3
- package/src/components/session-tree.ts +1 -1
- package/src/config-identity.test.ts +78 -0
- package/src/config.ts +45 -1
- package/src/diff-intraline.ts +26 -5
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/intent-dispatch.test.ts +17 -2
- package/src/intent-dispatch.ts +7 -1
- package/src/keymap.ts +1 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/run.ts +22 -2
- package/src/share.ts +10 -2
- package/src/thread-chords.test.ts +33 -1
- package/src/thread-chords.ts +114 -29
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +1 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +18 -3
- package/src/use-annotation-surface.tsx +32 -2
- package/src/use-frame-measure.ts +4 -1
- package/src/use-settings-dialog.tsx +33 -7
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.77",
|
|
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": {
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.77",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.77",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.77",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.77",
|
|
34
34
|
"@opentui/core": "^0.5.8",
|
|
35
35
|
"@opentui/keymap": "0.5.8",
|
|
36
36
|
"@opentui/react": "^0.5.8",
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
appLeaderHandled,
|
|
4
|
+
nextFocusPane,
|
|
5
|
+
reconciledFocus,
|
|
6
|
+
threadsNavHandled,
|
|
7
|
+
visiblePanes,
|
|
8
|
+
} from "./App";
|
|
9
|
+
|
|
10
|
+
describe("threads keyboard nav", () => {
|
|
11
|
+
const base = () => {
|
|
12
|
+
let cursor = 1;
|
|
13
|
+
let opened = 0;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
setInboxCursor: (update: (cursor: number) => number) => (cursor = update(cursor)),
|
|
17
|
+
openSession: () => (opened += 1),
|
|
18
|
+
read: () => ({
|
|
19
|
+
get cursor() {
|
|
20
|
+
return cursor;
|
|
21
|
+
},
|
|
22
|
+
get opened() {
|
|
23
|
+
return opened;
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test("moves the cursor and opens only when the Threads pane is quiet and focused", () => {
|
|
30
|
+
const spy = base();
|
|
31
|
+
const call = (name: string) =>
|
|
32
|
+
threadsNavHandled({
|
|
33
|
+
focusedPane: "threads",
|
|
34
|
+
quiet: true,
|
|
35
|
+
key: { name },
|
|
36
|
+
count: 3,
|
|
37
|
+
setInboxCursor: spy.setInboxCursor,
|
|
38
|
+
openSession: spy.openSession,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(call("down")).toBe(true);
|
|
42
|
+
expect(spy.read().cursor).toBe(2);
|
|
43
|
+
expect(call("down")).toBe(true); // clamps at the last row
|
|
44
|
+
expect(spy.read().cursor).toBe(2);
|
|
45
|
+
expect(call("k")).toBe(true);
|
|
46
|
+
expect(spy.read().cursor).toBe(1);
|
|
47
|
+
expect(call("return")).toBe(true);
|
|
48
|
+
expect(spy.read().opened).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("defers when another pane is focused, keys are owned elsewhere, or the inbox is empty", () => {
|
|
52
|
+
const spy = base();
|
|
53
|
+
const params = {
|
|
54
|
+
quiet: true,
|
|
55
|
+
key: { name: "down" },
|
|
56
|
+
count: 3,
|
|
57
|
+
setInboxCursor: spy.setInboxCursor,
|
|
58
|
+
openSession: spy.openSession,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
expect(threadsNavHandled({ ...params, focusedPane: "changes" })).toBe(false);
|
|
62
|
+
expect(threadsNavHandled({ ...params, focusedPane: "threads", quiet: false })).toBe(false);
|
|
63
|
+
expect(threadsNavHandled({ ...params, focusedPane: "threads", count: 0 })).toBe(false);
|
|
64
|
+
expect(spy.read().cursor).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("pane focus cycle", () => {
|
|
69
|
+
test("visiblePanes lists only the open panes, in reading order", () => {
|
|
70
|
+
expect(visiblePanes(true, true, true, true)).toEqual([
|
|
71
|
+
"threads",
|
|
72
|
+
"thread",
|
|
73
|
+
"changes",
|
|
74
|
+
"project",
|
|
75
|
+
]);
|
|
76
|
+
expect(visiblePanes(true, false, true, true)).toEqual(["threads", "changes", "project"]);
|
|
77
|
+
expect(visiblePanes(false, false, true, false)).toEqual(["changes"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("nextFocusPane wraps forward and back over the visible panes", () => {
|
|
81
|
+
const panes = visiblePanes(true, false, true, true);
|
|
82
|
+
|
|
83
|
+
expect(nextFocusPane("threads", panes, false)).toBe("changes");
|
|
84
|
+
expect(nextFocusPane("project", panes, false)).toBe("threads");
|
|
85
|
+
expect(nextFocusPane("threads", panes, true)).toBe("project");
|
|
86
|
+
expect(nextFocusPane("changes", [], false)).toBe("changes");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("reconciledFocus falls back only when the focused pane is not navigable", () => {
|
|
90
|
+
expect(reconciledFocus(["threads", "changes"], "threads")).toBeNull();
|
|
91
|
+
expect(reconciledFocus(["threads", "changes"], "thread")).toBe("threads");
|
|
92
|
+
expect(reconciledFocus(["changes", "project"], "thread")).toBe("changes");
|
|
93
|
+
expect(reconciledFocus([], "thread")).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("appLeaderHandled captures the leader for the sidebar panes, then the next key", () => {
|
|
97
|
+
const leaderCombos = ["ctrl+g"];
|
|
98
|
+
const pending = { current: false };
|
|
99
|
+
const ran: string[] = [];
|
|
100
|
+
const call = (
|
|
101
|
+
focusedPane: "threads" | "project" | "thread" | "changes",
|
|
102
|
+
key: { name: string; ctrl?: boolean },
|
|
103
|
+
) =>
|
|
104
|
+
appLeaderHandled({
|
|
105
|
+
focusedPane,
|
|
106
|
+
key,
|
|
107
|
+
leaderCombos,
|
|
108
|
+
pending,
|
|
109
|
+
runLeaderCommand: (pressed) => ran.push(pressed.name),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(call("threads", { name: "g", ctrl: true })).toBe(true);
|
|
113
|
+
expect(pending.current).toBe(true);
|
|
114
|
+
expect(call("threads", { name: "tab" })).toBe(true);
|
|
115
|
+
expect(pending.current).toBe(false);
|
|
116
|
+
expect(ran).toEqual(["tab"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("appLeaderHandled defers on the content panes and swallows escape without a command", () => {
|
|
120
|
+
const leaderCombos = ["ctrl+g"];
|
|
121
|
+
let ran = false;
|
|
122
|
+
const runLeaderCommand = () => (ran = true);
|
|
123
|
+
|
|
124
|
+
expect(
|
|
125
|
+
appLeaderHandled({
|
|
126
|
+
focusedPane: "thread",
|
|
127
|
+
key: { name: "ctrl+g" },
|
|
128
|
+
leaderCombos,
|
|
129
|
+
pending: { current: false },
|
|
130
|
+
runLeaderCommand,
|
|
131
|
+
}),
|
|
132
|
+
).toBe(false);
|
|
133
|
+
|
|
134
|
+
const pending = { current: true };
|
|
135
|
+
expect(
|
|
136
|
+
appLeaderHandled({
|
|
137
|
+
focusedPane: "project",
|
|
138
|
+
key: { name: "escape" },
|
|
139
|
+
leaderCombos,
|
|
140
|
+
pending,
|
|
141
|
+
runLeaderCommand,
|
|
142
|
+
}),
|
|
143
|
+
).toBe(true);
|
|
144
|
+
expect(pending.current).toBe(false);
|
|
145
|
+
expect(ran).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Reviewer identity surfaces: the Account settings tab and the author-name tooltip on a comment dot. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, 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 { App } from "./App";
|
|
10
|
+
import { isolateUserConfig, locateText, renderReadyApp, waitForText } from "./test-support";
|
|
11
|
+
|
|
12
|
+
const PLAN = "# Migration Plan\n\nThe daemon persists sessions to disk atomically.\n";
|
|
13
|
+
|
|
14
|
+
let home: string;
|
|
15
|
+
let restoreUserConfig: () => void;
|
|
16
|
+
let server: DaemonServer;
|
|
17
|
+
let sessionId: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-identity-"));
|
|
21
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
22
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
23
|
+
server.start();
|
|
24
|
+
const session = server.core.sessionCreate({
|
|
25
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
26
|
+
artifact: {
|
|
27
|
+
type: "plan",
|
|
28
|
+
content: PLAN,
|
|
29
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
sessionId = session.id;
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
restoreUserConfig();
|
|
36
|
+
server.stop();
|
|
37
|
+
rmSync(home, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function renderApp() {
|
|
41
|
+
return renderReadyApp(<App home={home} sessionId={sessionId} />, { width: 120, height: 32 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("the Account settings tab shows the display name row and the GitHub sync action", async () => {
|
|
45
|
+
const setup = await renderApp();
|
|
46
|
+
|
|
47
|
+
await setup.mockMouse.click(1, 0);
|
|
48
|
+
await waitForText(setup, "Settings");
|
|
49
|
+
const menu = setup.captureCharFrame().split("\n");
|
|
50
|
+
const settingsRow = menu.findIndex((line) => line.includes("Settings"));
|
|
51
|
+
|
|
52
|
+
await setup.mockMouse.click(menu[settingsRow]!.indexOf("Settings") + 1, settingsRow);
|
|
53
|
+
await waitForText(setup, "Account");
|
|
54
|
+
const dialog = setup.captureCharFrame().split("\n");
|
|
55
|
+
const accountRow = dialog.findIndex((line) => line.includes("Account"));
|
|
56
|
+
|
|
57
|
+
await setup.mockMouse.click(dialog[accountRow]!.indexOf("Account") + 1, accountRow);
|
|
58
|
+
await waitForText(setup, "Display name");
|
|
59
|
+
expect(setup.captureCharFrame()).toContain("Sync from GitHub");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("hovering an own comment's dot surfaces the author tooltip", async () => {
|
|
63
|
+
server.core.sessionAnnotate(sessionId, {
|
|
64
|
+
id: "a_own",
|
|
65
|
+
kind: "comment",
|
|
66
|
+
anchor: { quote: "atomically", prefix: "sessions to disk ", suffix: "." },
|
|
67
|
+
body: "My note.",
|
|
68
|
+
});
|
|
69
|
+
const setup = await renderApp();
|
|
70
|
+
|
|
71
|
+
await waitForText(setup, "● My note.");
|
|
72
|
+
// the tooltip is not painted until the pointer is over the dot
|
|
73
|
+
expect(setup.captureCharFrame()).not.toContain("you");
|
|
74
|
+
const dot = locateText(setup, "● My note.");
|
|
75
|
+
|
|
76
|
+
await setup.mockMouse.moveTo(dot.column, dot.row);
|
|
77
|
+
await waitForText(setup, "you");
|
|
78
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Regressions from the focus/perf round: ctrl+q must quit from any keyboard state, and clicking a
|
|
2
|
+
* sidebar thread must give it the selected accent at once (the cursor follows the click). */
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
5
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import { App } from "./App";
|
|
11
|
+
import {
|
|
12
|
+
clickText,
|
|
13
|
+
isolateUserConfig,
|
|
14
|
+
pressKey,
|
|
15
|
+
renderReadyApp,
|
|
16
|
+
waitForText,
|
|
17
|
+
} from "./test-support";
|
|
18
|
+
|
|
19
|
+
const ACCENT_DARK: [number, number, number] = [203, 166, 247];
|
|
20
|
+
|
|
21
|
+
let home: string;
|
|
22
|
+
let restoreUserConfig: () => void;
|
|
23
|
+
let server: DaemonServer;
|
|
24
|
+
|
|
25
|
+
function plan(title: string): void {
|
|
26
|
+
server.core.sessionCreate({
|
|
27
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
28
|
+
artifact: { type: "plan", content: `# ${title}\n`, meta: { title, planPath: "plan.md" } },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-regression-"));
|
|
34
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
35
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
36
|
+
server.start();
|
|
37
|
+
plan("First review");
|
|
38
|
+
plan("Second review");
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
restoreUserConfig();
|
|
42
|
+
server.stop();
|
|
43
|
+
rmSync(home, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The text of the sidebar title span currently painted in the accent colour, or undefined. */
|
|
47
|
+
function accentTitle(setup: Awaited<ReturnType<typeof renderReadyApp>>): string | undefined {
|
|
48
|
+
for (const line of setup.captureSpans().lines) {
|
|
49
|
+
for (const span of line.spans) {
|
|
50
|
+
const rgb = span.fg?.toInts();
|
|
51
|
+
if (
|
|
52
|
+
rgb &&
|
|
53
|
+
rgb[0] === ACCENT_DARK[0] &&
|
|
54
|
+
rgb[1] === ACCENT_DARK[1] &&
|
|
55
|
+
rgb[2] === ACCENT_DARK[2]
|
|
56
|
+
) {
|
|
57
|
+
const text = span.text.trim();
|
|
58
|
+
if (text.includes("review")) return text;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("ctrl+q quits even when a menu holds the keyboard", async () => {
|
|
67
|
+
let exitCode: number | undefined;
|
|
68
|
+
const setup = await renderReadyApp(<App home={home} onExit={(code) => (exitCode = code)} />, {
|
|
69
|
+
width: 120,
|
|
70
|
+
height: 32,
|
|
71
|
+
});
|
|
72
|
+
await waitForText(setup, "review");
|
|
73
|
+
|
|
74
|
+
// open the settings dialog from the top-left gear - it takes the keyboard
|
|
75
|
+
await setup.mockMouse.click(1, 0);
|
|
76
|
+
await waitForText(setup, "Keybinds");
|
|
77
|
+
|
|
78
|
+
await pressKey(setup, "q", { ctrl: true });
|
|
79
|
+
|
|
80
|
+
expect(exitCode).toBe(0);
|
|
81
|
+
setup.renderer.destroy();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("clicking a sidebar thread gives it the selected accent at once", async () => {
|
|
85
|
+
const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
|
|
86
|
+
await waitForText(setup, "Second review");
|
|
87
|
+
|
|
88
|
+
// click whichever thread is not already the selected (accent) one
|
|
89
|
+
const target = accentTitle(setup)?.includes("Second") ? "First review" : "Second review";
|
|
90
|
+
await clickText(setup, target);
|
|
91
|
+
|
|
92
|
+
expect(accentTitle(setup)).toContain(target.split(" ")[0]!);
|
|
93
|
+
setup.renderer.destroy();
|
|
94
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** The share shortcut opens an app-level public/private choice reachable from any view. */
|
|
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 { Thread } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
import type { ShareTransport } from "./thread-controller";
|
|
13
|
+
import { isolateUserConfig, press, pressKey, waitForText, waitForTextGone } from "./test-support";
|
|
14
|
+
|
|
15
|
+
const shareTransport: ShareTransport = {
|
|
16
|
+
publish: mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true })),
|
|
17
|
+
pull: mock(async () => {
|
|
18
|
+
throw new Error("Unexpected share pull");
|
|
19
|
+
}),
|
|
20
|
+
push: mock(async () => {}),
|
|
21
|
+
watch: () => () => {},
|
|
22
|
+
parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
|
|
23
|
+
collaboratorAnnotations: () => [],
|
|
24
|
+
mergeFromShare: () => ({ annotations: [] }),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
|
|
28
|
+
|
|
29
|
+
let home: string;
|
|
30
|
+
let restoreUserConfig: () => void;
|
|
31
|
+
let server: DaemonServer;
|
|
32
|
+
let session: Thread;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-share-choice-"));
|
|
36
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
37
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
38
|
+
server.start();
|
|
39
|
+
session = server.core.sessionCreate({
|
|
40
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
41
|
+
artifact: {
|
|
42
|
+
type: "plan",
|
|
43
|
+
content: PLAN,
|
|
44
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
restoreUserConfig();
|
|
50
|
+
server.stop();
|
|
51
|
+
rmSync(home, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("share choice", () => {
|
|
55
|
+
test("the shortcut opens the public/private choice", async () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
const setup = await testRender(
|
|
58
|
+
<App home={home} sessionId={session.id} shareTransport={shareTransport} />,
|
|
59
|
+
{ width: 120, height: 32 },
|
|
60
|
+
);
|
|
61
|
+
await waitForText(setup, "cueloop");
|
|
62
|
+
|
|
63
|
+
// Act
|
|
64
|
+
await pressKey(setup, "s", { ctrl: true });
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
await waitForText(setup, "public link");
|
|
68
|
+
await waitForText(setup, "private link");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the private choice opens the manage-access surface", async () => {
|
|
72
|
+
// Arrange
|
|
73
|
+
const setup = await testRender(
|
|
74
|
+
<App home={home} sessionId={session.id} shareTransport={shareTransport} />,
|
|
75
|
+
{ width: 120, height: 32 },
|
|
76
|
+
);
|
|
77
|
+
await waitForText(setup, "cueloop");
|
|
78
|
+
|
|
79
|
+
// Act: open the choice, move to private, select it
|
|
80
|
+
await pressKey(setup, "s", { ctrl: true });
|
|
81
|
+
await waitForText(setup, "private link");
|
|
82
|
+
await press(setup, "down");
|
|
83
|
+
await press(setup, "enter");
|
|
84
|
+
|
|
85
|
+
// Assert
|
|
86
|
+
await waitForText(setup, "Manage access");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("escape cancels the choice without sharing", async () => {
|
|
90
|
+
// Arrange
|
|
91
|
+
const setup = await testRender(
|
|
92
|
+
<App home={home} sessionId={session.id} shareTransport={shareTransport} />,
|
|
93
|
+
{ width: 120, height: 32 },
|
|
94
|
+
);
|
|
95
|
+
await waitForText(setup, "cueloop");
|
|
96
|
+
|
|
97
|
+
// Act
|
|
98
|
+
await pressKey(setup, "s", { ctrl: true });
|
|
99
|
+
await waitForText(setup, "public link");
|
|
100
|
+
await press(setup, "escape");
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
103
|
+
await waitForTextGone(setup, "public link");
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/App.test.tsx
CHANGED
|
@@ -285,24 +285,27 @@ describe("no-thread shell", () => {
|
|
|
285
285
|
expect(frame).toContain("Other Plan");
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
-
test("the row kebab menu
|
|
288
|
+
test("the row kebab menu stars a thread into a Starred section", async () => {
|
|
289
289
|
// Arrange
|
|
290
290
|
const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
|
|
291
291
|
|
|
292
292
|
await waitForText(setup, "Migration Plan");
|
|
293
293
|
|
|
294
|
-
// Act - hover the row to reveal its kebab, open the menu, and pick
|
|
294
|
+
// Act - hover the row to reveal its kebab, open the menu, and pick Star
|
|
295
295
|
const row = locateText(setup, "Migration Plan");
|
|
296
296
|
|
|
297
297
|
await setup.mockMouse.moveTo(row.column, row.row);
|
|
298
298
|
await waitForText(setup, "⋮");
|
|
299
299
|
await clickText(setup, "⋮");
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
await
|
|
305
|
-
|
|
300
|
+
// "star" alone collides with "Getting started" in the welcome pane, so target the menu via its
|
|
301
|
+
// unique "rename" row - the star action sits directly above it
|
|
302
|
+
await waitForText(setup, "rename");
|
|
303
|
+
const rename = locateText(setup, "rename");
|
|
304
|
+
await setup.mockMouse.click(rename.column, rename.row - 1);
|
|
305
|
+
|
|
306
|
+
// Assert - a Starred section now holds the thread
|
|
307
|
+
await waitForText(setup, "Starred");
|
|
308
|
+
expect(frameRow(setup, "Migration Plan")).toBeGreaterThan(frameRow(setup, "Starred"));
|
|
306
309
|
});
|
|
307
310
|
|
|
308
311
|
test("a bare launch shows the Welcome surface with the right region collapsed", async () => {
|
package/src/App.toast.test.tsx
CHANGED
|
@@ -73,8 +73,10 @@ describe("share toast", () => {
|
|
|
73
73
|
|
|
74
74
|
await waitForText(setup, "cueloop");
|
|
75
75
|
|
|
76
|
-
// Act: share raises the toast, then
|
|
76
|
+
// Act: share opens the choice, enter picks public and raises the toast, then a composer under it
|
|
77
77
|
await pressKey(setup, "s", { ctrl: true });
|
|
78
|
+
await waitForText(setup, "public link");
|
|
79
|
+
await press(setup, "enter");
|
|
78
80
|
await waitForText(setup, "share link copied");
|
|
79
81
|
await clickText(setup, "daemon");
|
|
80
82
|
await typeText(setup, "x");
|