@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.33
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.diff.test.tsx +1 -1
- package/src/App.export.test.tsx +1 -1
- package/src/App.plan-review.test.tsx +6 -5
- package/src/App.readonly.test.tsx +5 -6
- package/src/App.rendering.test.tsx +2 -1
- package/src/App.review-panel.test.tsx +16 -12
- package/src/App.submit-confirm.test.tsx +2 -6
- package/src/App.test.tsx +4 -4
- package/src/App.toast.test.tsx +68 -0
- package/src/App.tsx +153 -49
- package/src/App.walk.test.tsx +1 -1
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AnnotationCard.tsx +18 -13
- package/src/components/ConfirmCard.tsx +1 -0
- package/src/components/ConfirmDialog.tsx +1 -1
- package/src/components/DiffSheet.stories.tsx +18 -0
- package/src/components/DiffSheet.tsx +38 -12
- package/src/components/InboxList.tsx +1 -1
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +42 -0
- package/src/components/MenuBar.stories.tsx +33 -0
- package/src/components/MenuBar.tsx +58 -0
- package/src/components/PlanSheet.stories.tsx +1 -1
- package/src/components/PlanSheet.tsx +21 -70
- package/src/components/PromptDialog.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +4 -16
- package/src/components/ReviewPanel.tsx +10 -29
- package/src/components/ReviewRail.stories.tsx +39 -0
- package/src/components/ReviewRail.tsx +62 -57
- package/src/components/SettingsDialog.tsx +4 -3
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/SettingsRows.tsx +1 -1
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +29 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +598 -566
- package/src/components/primitives/Button.stories.tsx +2 -2
- package/src/components/primitives/Dialog.tsx +4 -1
- package/src/components/primitives/Tabs.tsx +19 -38
- package/src/key-bindings.test.ts +4 -4
- package/src/key-bindings.ts +41 -2
- package/src/serve.test.ts +2 -2
- package/src/session-controller.share.test.ts +13 -0
- package/src/session-controller.ts +19 -1
- package/src/test-support.ts +87 -23
- package/src/version.ts +5 -0
- package/src/components/ComposeBar.stories.tsx +0 -18
- package/src/components/ComposeBar.tsx +0 -54
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.33",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
11
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
12
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
10
|
+
"@cueloop/daemon": "0.1.0-alpha.33",
|
|
11
|
+
"@cueloop/extension-api": "0.1.0-alpha.33",
|
|
12
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.33",
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.33",
|
|
14
14
|
"@opentui/core": "^0.5.2",
|
|
15
15
|
"@opentui/keymap": "0.5.2",
|
|
16
16
|
"@opentui/react": "^0.5.2",
|
package/src/App.diff.test.tsx
CHANGED
package/src/App.export.test.tsx
CHANGED
|
@@ -52,7 +52,7 @@ describe("obsidian export on resolve", () => {
|
|
|
52
52
|
await press(setup, "enter"); // open the submit overlay, keep the default verdict (approve)
|
|
53
53
|
|
|
54
54
|
// Assert
|
|
55
|
-
await waitForText(setup, "
|
|
55
|
+
await waitForText(setup, "[Approve]");
|
|
56
56
|
|
|
57
57
|
// Act
|
|
58
58
|
await press(setup, "enter"); // submit
|
|
@@ -221,7 +221,7 @@ describe("inline compose keeps the anchor painted", () => {
|
|
|
221
221
|
// Assert
|
|
222
222
|
// compose open: the anchor is painted selection-style. The box can render
|
|
223
223
|
// a frame before the anchor repaint settles, so wait on the color itself.
|
|
224
|
-
await waitForText(setup, "Save
|
|
224
|
+
await waitForText(setup, "Save");
|
|
225
225
|
expect(setup.captureCharFrame()).toContain("Cancel esc");
|
|
226
226
|
await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
|
|
227
227
|
expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
|
|
@@ -250,7 +250,7 @@ describe("inline compose keeps the anchor painted", () => {
|
|
|
250
250
|
// renderApp plus this many frame-waits grazes the 5s default on a loaded CI
|
|
251
251
|
// runner, and the whole-suite publish lane has timed even 15s out; give the
|
|
252
252
|
// heaviest frame-wait chain generous headroom so runner load cannot flake it.
|
|
253
|
-
},
|
|
253
|
+
}, 60_000);
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
describe("compose newline convention", () => {
|
|
@@ -432,7 +432,7 @@ describe("addressed annotations leave the open list", () => {
|
|
|
432
432
|
const frame = setup.captureCharFrame();
|
|
433
433
|
expect(frame).toContain("✓ 1 addressed by revision");
|
|
434
434
|
expect(frame).not.toContain("settled note");
|
|
435
|
-
expect(frame).toContain("
|
|
435
|
+
expect(frame).toContain("still open note"); // the open card survives; the addressed one does not
|
|
436
436
|
expect(backgroundsOf(setup, "The daemon")).not.toContain(T.markCommentBackground); // no highlight paint
|
|
437
437
|
});
|
|
438
438
|
});
|
|
@@ -444,7 +444,8 @@ describe("sheet header", () => {
|
|
|
444
444
|
|
|
445
445
|
// Assert
|
|
446
446
|
const frame = setup.captureCharFrame();
|
|
447
|
-
expect(frame).toContain("submitted by agent/worker-3
|
|
447
|
+
expect(frame).toContain("submitted by agent/worker-3");
|
|
448
|
+
expect(frame).toContain("rev 1");
|
|
448
449
|
expect(frame).toContain("Edit");
|
|
449
450
|
expect(frame).toContain("Share");
|
|
450
451
|
});
|
|
@@ -453,7 +454,7 @@ describe("sheet header", () => {
|
|
|
453
454
|
// Arrange
|
|
454
455
|
const setup = await renderApp();
|
|
455
456
|
const lines = setup.captureCharFrame().split("\n");
|
|
456
|
-
const tabRow = lines.findIndex((line) => line.includes("Review (
|
|
457
|
+
const tabRow = lines.findIndex((line) => line.includes("Review") && line.includes("Agent"));
|
|
457
458
|
const tabColumn = lines[tabRow]!.indexOf("Agent");
|
|
458
459
|
|
|
459
460
|
// Act
|
|
@@ -60,15 +60,14 @@ function snapshot() {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
describe("observer rendering", () => {
|
|
63
|
-
test("shows the observer badge and
|
|
63
|
+
test("shows the observer badge and the bottom menu", async () => {
|
|
64
64
|
// Arrange
|
|
65
65
|
const setup = await renderObserver();
|
|
66
66
|
|
|
67
67
|
// Assert
|
|
68
68
|
const frame = setup.captureCharFrame();
|
|
69
69
|
expect(frame).toContain("· observer");
|
|
70
|
-
expect(frame).toContain("
|
|
71
|
-
expect(frame).not.toContain("⏎ submit");
|
|
70
|
+
expect(frame).toContain("menu");
|
|
72
71
|
});
|
|
73
72
|
});
|
|
74
73
|
|
|
@@ -88,8 +87,8 @@ describe("observer verbs are blocked", () => {
|
|
|
88
87
|
await press(setup, key);
|
|
89
88
|
const frame = await waitForText(setup, "observer - read-only");
|
|
90
89
|
// no overlay opened: compose/submit bars never appear
|
|
91
|
-
expect(frame).not.toContain(
|
|
92
|
-
expect(frame).not.toContain(
|
|
90
|
+
expect(frame).not.toContain('comment on "');
|
|
91
|
+
expect(frame).not.toContain('suggest replacement for "');
|
|
93
92
|
expect(frame).not.toContain("verdict ←/→");
|
|
94
93
|
expect(snapshot()).toEqual(before);
|
|
95
94
|
});
|
|
@@ -109,7 +108,7 @@ describe("observer verbs are blocked", () => {
|
|
|
109
108
|
// Assert
|
|
110
109
|
const frame = setup.captureCharFrame();
|
|
111
110
|
expect(frame).toContain("observer - read-only");
|
|
112
|
-
expect(frame).not.toContain(
|
|
111
|
+
expect(frame).not.toContain('comment on "');
|
|
113
112
|
expect(snapshot().annotations).toBe(0);
|
|
114
113
|
});
|
|
115
114
|
});
|
|
@@ -80,7 +80,8 @@ describe("code blocks", () => {
|
|
|
80
80
|
describe("block spacing", () => {
|
|
81
81
|
// the review panel's full-height divider paints a `│` on every row, so read
|
|
82
82
|
// the plan column (everything left of the divider) before asserting spacing
|
|
83
|
-
|
|
83
|
+
// the plan body is framed: content sits between the left border and the next │
|
|
84
|
+
const planColumn = (line: string): string => line.split("│")[1]?.trimEnd() ?? "";
|
|
84
85
|
|
|
85
86
|
test("a code block never glues to the list item above it", async () => {
|
|
86
87
|
// Arrange
|
|
@@ -10,7 +10,7 @@ import { DaemonServer } from "@cueloop/daemon";
|
|
|
10
10
|
import type { ReviewSession } from "@cueloop/schema";
|
|
11
11
|
import { App } from "./App";
|
|
12
12
|
import { loadConfig } from "./config";
|
|
13
|
-
import { isolateUserConfig, press, waitForText, waitForTextGone } from "./test-support";
|
|
13
|
+
import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
|
|
14
14
|
|
|
15
15
|
const PLAN = "# Plan\n\nShip the thing.\n";
|
|
16
16
|
|
|
@@ -47,26 +47,30 @@ describe("review panel", () => {
|
|
|
47
47
|
test("b cycles expanded -> compact -> hidden -> expanded", async () => {
|
|
48
48
|
// Arrange
|
|
49
49
|
const setup = await renderApp();
|
|
50
|
+
// the plan frame widens as the rail shrinks: expanded < compact < hidden.
|
|
51
|
+
// its width is the column of its top-right corner (the first ╮ on the row).
|
|
52
|
+
const planWidth = () => setup.captureCharFrame().split("\n").find((line) => line.includes("╮"))?.indexOf("╮") ?? 0;
|
|
50
53
|
|
|
51
|
-
// Assert
|
|
52
|
-
expect(setup.captureCharFrame()).toContain("Submit review");
|
|
54
|
+
// Assert: expanded shows the submit button and leaves room for the rail
|
|
55
|
+
expect(setup.captureCharFrame()).toContain("Submit review");
|
|
56
|
+
const expandedWidth = planWidth();
|
|
53
57
|
|
|
54
58
|
// Act
|
|
55
59
|
await press(setup, "b");
|
|
56
60
|
|
|
57
|
-
// Assert
|
|
58
|
-
await
|
|
59
|
-
|
|
60
|
-
expect(
|
|
61
|
-
expect(frame).not.toContain("Submit review");
|
|
61
|
+
// Assert: compact drops the submit button and widens the plan (rail is a strip)
|
|
62
|
+
await waitForTextGone(setup, "Submit review");
|
|
63
|
+
const compactWidth = planWidth();
|
|
64
|
+
expect(compactWidth).toBeGreaterThan(expandedWidth);
|
|
62
65
|
|
|
63
66
|
// Act
|
|
64
67
|
await press(setup, "b");
|
|
68
|
+
await settle(setup);
|
|
65
69
|
|
|
66
|
-
// Assert
|
|
67
|
-
|
|
68
|
-
expect(
|
|
69
|
-
expect(
|
|
70
|
+
// Assert: hidden spans the plan full width, no rail column
|
|
71
|
+
const hiddenWidth = planWidth();
|
|
72
|
+
expect(hiddenWidth).toBeGreaterThan(compactWidth);
|
|
73
|
+
expect(hiddenWidth).toBeGreaterThan(115);
|
|
70
74
|
|
|
71
75
|
// Act
|
|
72
76
|
await press(setup, "b");
|
|
@@ -74,7 +74,7 @@ describe("rail submit confirm", () => {
|
|
|
74
74
|
const setup = await renderApp();
|
|
75
75
|
|
|
76
76
|
// Assert
|
|
77
|
-
expect(setup.captureCharFrame()).toContain("Submit review
|
|
77
|
+
expect(setup.captureCharFrame()).toContain("Submit review");
|
|
78
78
|
|
|
79
79
|
// Act
|
|
80
80
|
await press(setup, "enter");
|
|
@@ -88,10 +88,6 @@ describe("rail submit confirm", () => {
|
|
|
88
88
|
expect(frame).toContain("[Approve]"); // nothing pending: approve default
|
|
89
89
|
expect(frame).toContain(" Submit ");
|
|
90
90
|
expect(frame).toContain(" Cancel ");
|
|
91
|
-
// key hints live in the status line, not on the buttons
|
|
92
|
-
expect(frame).toContain("verdict ←/→ · ⏎ submit · esc cancel");
|
|
93
|
-
// the bottom bar stayed a one-line hint: no detached verdict bar
|
|
94
|
-
expect(frame).not.toContain("Submit review (0) on ⏎");
|
|
95
91
|
});
|
|
96
92
|
|
|
97
93
|
test("left/right cycles the verdict selector in the card", async () => {
|
|
@@ -136,7 +132,7 @@ describe("rail submit confirm", () => {
|
|
|
136
132
|
// a bare ESC settles after the parser's escape-sequence window
|
|
137
133
|
const frame = await waitForTextGone(setup, "[Approve]");
|
|
138
134
|
expect(frame).not.toContain("0 annotations");
|
|
139
|
-
expect(frame).toContain("Submit review
|
|
135
|
+
expect(frame).toContain("Submit review");
|
|
140
136
|
});
|
|
141
137
|
|
|
142
138
|
test("enter in the card resolves the session through the controller", async () => {
|
package/src/App.test.tsx
CHANGED
|
@@ -65,8 +65,8 @@ describe("plan rendering", () => {
|
|
|
65
65
|
expect(frame).toContain("Context");
|
|
66
66
|
expect(frame).toContain("persists sessions to disk atomically");
|
|
67
67
|
expect(frame).toContain("- move the store");
|
|
68
|
-
expect(frame).toContain("Review
|
|
69
|
-
expect(frame).toContain("Submit review
|
|
68
|
+
expect(frame).toContain("Review");
|
|
69
|
+
expect(frame).toContain("Submit review");
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
72
|
|
|
@@ -179,7 +179,7 @@ describe("submit", () => {
|
|
|
179
179
|
await press(setup, "c");
|
|
180
180
|
await type(setup, "Needs a phase list.");
|
|
181
181
|
await press(setup, "enter");
|
|
182
|
-
await waitForText(setup, "
|
|
182
|
+
await waitForText(setup, "Needs a phase list");
|
|
183
183
|
|
|
184
184
|
// Act
|
|
185
185
|
await press(setup, "enter"); // open submit
|
|
@@ -234,6 +234,6 @@ describe("inbox", () => {
|
|
|
234
234
|
await press(setup, "enter");
|
|
235
235
|
|
|
236
236
|
// Assert
|
|
237
|
-
await waitForText(setup, "Submit review
|
|
237
|
+
await waitForText(setup, "Submit review");
|
|
238
238
|
});
|
|
239
239
|
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** The share toast is non-modal: while it is up, escape still cancels an open overlay. */
|
|
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 { ReviewSession } from "@cueloop/schema";
|
|
11
|
+
|
|
12
|
+
// the controller reaches the gateway through "./share"; stub it so `S` yields a toast
|
|
13
|
+
const publishShare = mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true }));
|
|
14
|
+
mock.module("./share", () => ({
|
|
15
|
+
publishShare,
|
|
16
|
+
pullShare: mock(async () => null),
|
|
17
|
+
pushShare: mock(async () => {}),
|
|
18
|
+
shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
|
|
19
|
+
collaboratorAnnotations: () => [],
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const { App } = await import("./App");
|
|
23
|
+
const { isolateUserConfig, press, waitForText, waitForTextGone } = await import("./test-support");
|
|
24
|
+
|
|
25
|
+
const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
|
|
26
|
+
|
|
27
|
+
let home: string;
|
|
28
|
+
let restoreUserConfig: () => void;
|
|
29
|
+
let server: DaemonServer;
|
|
30
|
+
let session: ReviewSession;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-toast-"));
|
|
34
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
35
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
36
|
+
server.start();
|
|
37
|
+
session = server.core.sessionCreate({
|
|
38
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
39
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
restoreUserConfig();
|
|
44
|
+
server.stop();
|
|
45
|
+
rmSync(home, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("share toast", () => {
|
|
49
|
+
test("escape cancels an open composer even while the toast is up", async () => {
|
|
50
|
+
// Arrange
|
|
51
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 32 });
|
|
52
|
+
await waitForText(setup, "cueloop");
|
|
53
|
+
|
|
54
|
+
// Act: share raises the toast, then open a composer under it
|
|
55
|
+
await press(setup, "S");
|
|
56
|
+
await waitForText(setup, "share link copied");
|
|
57
|
+
await press(setup, "j");
|
|
58
|
+
await press(setup, "j");
|
|
59
|
+
await press(setup, "c");
|
|
60
|
+
await waitForText(setup, 'comment on "');
|
|
61
|
+
|
|
62
|
+
// Act: escape must reach the composer, not get eaten by the toast
|
|
63
|
+
await press(setup, "escape");
|
|
64
|
+
|
|
65
|
+
// Assert: the composer closed on the first escape
|
|
66
|
+
await waitForTextGone(setup, 'comment on "');
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/App.tsx
CHANGED
|
@@ -16,21 +16,27 @@ import { type VerdictKind } from "@cueloop/schema";
|
|
|
16
16
|
import { displayText, marksByDisplay, type Mark } from "./view-plan";
|
|
17
17
|
import { noteForFile, viewedCount } from "./walk";
|
|
18
18
|
import { DARK, dimmedTheme } from "./theme";
|
|
19
|
-
import { DEFAULT_KEYS, loadConfig, persistAuthorName } from "./config";
|
|
19
|
+
import { DEFAULT_KEYS, loadConfig, persistAuthorName, persistAutoClose, type AutoClose } from "./config";
|
|
20
20
|
import { returnPaneFor } from "@cueloop/schema";
|
|
21
21
|
import { createReviewController } from "./session-controller";
|
|
22
22
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
23
23
|
import { createIntentDispatch, reviewerAnnotations, type Mode } from "./intent-dispatch";
|
|
24
24
|
import { reduceKey, type KeyState } from "./keymap";
|
|
25
|
-
import { KeyBindings
|
|
25
|
+
import { KeyBindings } from "./key-bindings";
|
|
26
26
|
import { ThemeProvider } from "./components/theme-context";
|
|
27
|
-
import {
|
|
27
|
+
import { Button } from "./components/primitives/Button";
|
|
28
|
+
import { Toolbar } from "./components/primitives/Toolbar";
|
|
29
|
+
import { MenuBar } from "./components/MenuBar";
|
|
30
|
+
import { KeybindsDialog } from "./components/KeybindsDialog";
|
|
31
|
+
import { SettingsDialog, type SettingsCategory } from "./components/SettingsDialog";
|
|
32
|
+
import { CLIENT_VERSION } from "./version";
|
|
28
33
|
import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
|
|
29
34
|
import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
|
|
30
35
|
import { DiffSheet } from "./components/DiffSheet";
|
|
31
36
|
import { annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
|
|
32
37
|
import { ReviewPanel } from "./components/ReviewPanel";
|
|
33
38
|
import {
|
|
39
|
+
REVIEW_COMPACT_WIDTH,
|
|
34
40
|
REVIEW_DEFAULT_WIDTH,
|
|
35
41
|
resolveReviewWidth,
|
|
36
42
|
toggleReviewPanelMode,
|
|
@@ -41,14 +47,11 @@ import { CompletionOverlay } from "./components/CompletionOverlay";
|
|
|
41
47
|
import { InboxList } from "./components/InboxList";
|
|
42
48
|
import { ConfirmDialog } from "./components/ConfirmDialog";
|
|
43
49
|
import { PromptDialog } from "./components/PromptDialog";
|
|
44
|
-
import { ComposeBar } from "./components/ComposeBar";
|
|
45
50
|
import { WalkWizard } from "./components/WalkWizard";
|
|
51
|
+
import { Toast } from "./components/Toast";
|
|
46
52
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
* review layout (plan column, divider, rail) gets the rows that remain.
|
|
50
|
-
*/
|
|
51
|
-
const CHROME_ROWS = 2;
|
|
53
|
+
/** A toast clears itself after this idle; esc dismisses it sooner. */
|
|
54
|
+
const TOAST_DISMISS_MS = 4000;
|
|
52
55
|
|
|
53
56
|
export interface AppProps {
|
|
54
57
|
home?: string;
|
|
@@ -94,7 +97,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
94
97
|
controller.connect();
|
|
95
98
|
return () => controller.close();
|
|
96
99
|
}, [controller]);
|
|
97
|
-
const { session, inbox, status, error, completion, editOrphanCount, walk } = useSyncExternalStore(
|
|
100
|
+
const { session, inbox, status, toast, error, completion, editOrphanCount, walk } = useSyncExternalStore(
|
|
98
101
|
controller.subscribe,
|
|
99
102
|
controller.getSnapshot,
|
|
100
103
|
controller.getSnapshot,
|
|
@@ -106,12 +109,21 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
106
109
|
return controller.startSharePoll();
|
|
107
110
|
}, [isOwner, session?.id, session?.shareId, controller]);
|
|
108
111
|
const renderer = useRenderer();
|
|
109
|
-
const { width: terminalWidth
|
|
112
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
110
113
|
|
|
111
114
|
// ── view state ──────────────────────────────
|
|
112
115
|
const [cursor, setCursor] = useState(0);
|
|
113
116
|
const [inboxCursor, setInboxCursor] = useState(0);
|
|
114
117
|
const [mode, setMode] = useState<Mode>({ type: "normal" });
|
|
118
|
+
// the bottom-left menu drop-up and the centered dialog it opens
|
|
119
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
120
|
+
const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
|
|
121
|
+
const [autoClose, setAutoClose] = useState<AutoClose>("off");
|
|
122
|
+
const [settingsNav, setSettingsNav] = useState<{ categoryId: string; rowIndex: number; zone: "nav" | "body" }>({
|
|
123
|
+
categoryId: "general",
|
|
124
|
+
rowIndex: 0,
|
|
125
|
+
zone: "body",
|
|
126
|
+
});
|
|
115
127
|
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
|
|
116
128
|
const [railTab, setRailTab] = useState<"review" | "agent">("review");
|
|
117
129
|
// review panel layout: mode + expanded width are client view state, loaded
|
|
@@ -143,6 +155,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
143
155
|
setReviewWidth(config.ui.reviewWidth);
|
|
144
156
|
reviewWidthRef.current = config.ui.reviewWidth;
|
|
145
157
|
setAuthorNames(config.authors);
|
|
158
|
+
setAutoClose(config.ui.autoClose);
|
|
146
159
|
controller.applyConfig(config);
|
|
147
160
|
}, [session?.workspace.repoRoot, controller, keyBindings]);
|
|
148
161
|
useEffect(
|
|
@@ -152,6 +165,12 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
152
165
|
[],
|
|
153
166
|
);
|
|
154
167
|
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!toast) return;
|
|
170
|
+
const timer = setTimeout(() => controller.dismissToast(), TOAST_DISMISS_MS);
|
|
171
|
+
return () => clearTimeout(timer);
|
|
172
|
+
}, [toast, controller]);
|
|
173
|
+
|
|
155
174
|
// First open of a share: ask the collaborator for a display name once, unless
|
|
156
175
|
// a past visit already recorded one. esc skips and their notes read anonymous.
|
|
157
176
|
const promptedSelfRef = useRef(false);
|
|
@@ -300,7 +319,57 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
300
319
|
? "walk"
|
|
301
320
|
: "none";
|
|
302
321
|
|
|
322
|
+
// ── settings dialog: config-backed model, navigation, persistence ──
|
|
323
|
+
const settingsCategories: SettingsCategory[] = [
|
|
324
|
+
{
|
|
325
|
+
id: "general",
|
|
326
|
+
name: "General",
|
|
327
|
+
description: "submission behaviour",
|
|
328
|
+
rows: [{ key: "autoClose", label: "Auto-close on submit", kind: "cycle", options: ["off", "3s", "10s"] }],
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: "display",
|
|
332
|
+
name: "Display",
|
|
333
|
+
description: "the review panel",
|
|
334
|
+
rows: [{ key: "reviewPanel", label: "Review panel", kind: "cycle", options: ["expanded", "compact", "hidden"] }],
|
|
335
|
+
},
|
|
336
|
+
];
|
|
337
|
+
const settingsValues = { autoClose: autoClose === "off" ? "off" : `${autoClose}s`, reviewPanel: reviewMode };
|
|
338
|
+
const cycleSetting = (rowKey: string): void => {
|
|
339
|
+
if (rowKey === "autoClose") {
|
|
340
|
+
const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
|
|
341
|
+
setAutoClose(next);
|
|
342
|
+
persistAutoClose(next);
|
|
343
|
+
} else if (rowKey === "reviewPanel") {
|
|
344
|
+
const order: ReviewPanelMode[] = ["expanded", "compact", "hidden"];
|
|
345
|
+
const next = order[(order.indexOf(reviewMode) + 1) % order.length]!;
|
|
346
|
+
setReviewMode(next);
|
|
347
|
+
controller.saveReviewPanel({ mode: next });
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const handleSettingsKey = (name: string): void => {
|
|
351
|
+
if (name === "escape") return void setMenuDialog(null);
|
|
352
|
+
const categoryIndex = settingsCategories.findIndex((category) => category.id === settingsNav.categoryId);
|
|
353
|
+
const category = settingsCategories[categoryIndex]!;
|
|
354
|
+
if (settingsNav.zone === "nav") {
|
|
355
|
+
if (name === "j" || name === "down") setSettingsNav({ categoryId: settingsCategories[Math.min(settingsCategories.length - 1, categoryIndex + 1)]!.id, rowIndex: 0, zone: "nav" });
|
|
356
|
+
else if (name === "k" || name === "up") setSettingsNav({ categoryId: settingsCategories[Math.max(0, categoryIndex - 1)]!.id, rowIndex: 0, zone: "nav" });
|
|
357
|
+
else if (name === "l" || name === "tab" || name === "return") setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: 0 }));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (name === "j" || name === "down") setSettingsNav((state) => ({ ...state, rowIndex: Math.min(category.rows.length - 1, state.rowIndex + 1) }));
|
|
361
|
+
else if (name === "k" || name === "up") setSettingsNav((state) => ({ ...state, rowIndex: Math.max(0, state.rowIndex - 1) }));
|
|
362
|
+
else if (name === "h" || name === "tab") setSettingsNav((state) => ({ ...state, zone: "nav" }));
|
|
363
|
+
else if (name === "return" || name === "space") cycleSetting(category.rows[settingsNav.rowIndex]!.key);
|
|
364
|
+
};
|
|
365
|
+
|
|
303
366
|
useKeyboard((key) => {
|
|
367
|
+
if (menuDialog === "settings") return void handleSettingsKey(key.name);
|
|
368
|
+
if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
|
|
369
|
+
if (menuOpen) return void (key.name === "escape" && setMenuOpen(false));
|
|
370
|
+
// the toast is non-modal: escape only dismisses it when nothing else owns
|
|
371
|
+
// escape, so an open overlay (compose, submit, prompt, walk) still cancels
|
|
372
|
+
if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span") return controller.dismissToast();
|
|
304
373
|
const state: KeyState = {
|
|
305
374
|
keys: keysRef.current,
|
|
306
375
|
readOnly: observer,
|
|
@@ -398,6 +467,24 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
398
467
|
}
|
|
399
468
|
: null;
|
|
400
469
|
|
|
470
|
+
const diffComposeState =
|
|
471
|
+
mode.type === "compose" && isDiff
|
|
472
|
+
? {
|
|
473
|
+
kind: mode.kind,
|
|
474
|
+
rowIndex: mode.displayIndex,
|
|
475
|
+
quote: rows[mode.displayIndex]?.text.replace(/\n$/, "") ?? "",
|
|
476
|
+
draft: {
|
|
477
|
+
text: mode.text,
|
|
478
|
+
onInput: (text: string) => {
|
|
479
|
+
liveInput.current = text;
|
|
480
|
+
setMode({ ...mode, text });
|
|
481
|
+
},
|
|
482
|
+
onSave: () => dispatch({ type: "saveCompose" }),
|
|
483
|
+
onCancel: () => dispatch({ type: "closeOverlay" }),
|
|
484
|
+
},
|
|
485
|
+
}
|
|
486
|
+
: null;
|
|
487
|
+
|
|
401
488
|
const activeSpan =
|
|
402
489
|
mode.type === "span"
|
|
403
490
|
? { displayIndex: mode.span.displayIndex, start: mode.span.start, end: mode.span.end }
|
|
@@ -484,32 +571,24 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
484
571
|
}
|
|
485
572
|
: null;
|
|
486
573
|
|
|
574
|
+
// width the review panel occupies (divider + rail), so the header can reserve
|
|
575
|
+
// it and keep Edit/Share pinned to the plan sheet's right edge, not the rail's
|
|
576
|
+
const railFootprint =
|
|
577
|
+
reviewMode === "hidden"
|
|
578
|
+
? 0
|
|
579
|
+
: 1 + (reviewMode === "compact" ? REVIEW_COMPACT_WIDTH : resolveReviewWidth(reviewWidth, terminalWidth));
|
|
580
|
+
|
|
581
|
+
// status badges sit right after the product word so they survive a header
|
|
582
|
+
// that is too narrow for the whole trail (the rail can eat the width)
|
|
487
583
|
const headerItems: BreadcrumbItem[] = [
|
|
488
584
|
{ label: "cueloop", tone: "accent" },
|
|
489
|
-
{ label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
|
|
490
585
|
...(resolved ? [{ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
|
|
491
586
|
...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
|
|
492
587
|
...(role === "collaborator" ? [{ label: "shared · your notes save as you go", tone: "dim" as const }] : []),
|
|
493
|
-
|
|
588
|
+
{ label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
|
|
589
|
+
{ label: `submitted by ${activeSession.artifact.meta.agent ?? "unknown"}`, tone: "dim" },
|
|
494
590
|
];
|
|
495
591
|
|
|
496
|
-
keyBindings.setContext({ overlay, spanMode: mode.type === "span" });
|
|
497
|
-
const hintMode: HintMode = observer
|
|
498
|
-
? "read-only"
|
|
499
|
-
: mode.type === "submit"
|
|
500
|
-
? "submit"
|
|
501
|
-
: mode.type === "span"
|
|
502
|
-
? "span"
|
|
503
|
-
: mode.type === "compose" || mode.type === "railEdit"
|
|
504
|
-
? "compose"
|
|
505
|
-
: walking
|
|
506
|
-
? "walk"
|
|
507
|
-
: focusedAnnotationId !== undefined
|
|
508
|
-
? "card"
|
|
509
|
-
: role === "collaborator"
|
|
510
|
-
? "collaborator"
|
|
511
|
-
: "normal";
|
|
512
|
-
|
|
513
592
|
return (
|
|
514
593
|
<ThemeProvider theme={theme}>
|
|
515
594
|
<box
|
|
@@ -526,7 +605,19 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
526
605
|
controller.saveReviewPanel({ width: reviewWidthRef.current });
|
|
527
606
|
}}
|
|
528
607
|
>
|
|
529
|
-
<
|
|
608
|
+
<box style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}>
|
|
609
|
+
<box style={{ flexGrow: 1, flexDirection: "row", paddingRight: 1 }}>
|
|
610
|
+
<Breadcrumb items={headerItems} />
|
|
611
|
+
<box style={{ flexGrow: 1 }} />
|
|
612
|
+
{isOwner && !isDiff && !resolved ? (
|
|
613
|
+
<Toolbar>
|
|
614
|
+
<Button onPress={onEditRequest} theme={theme}>{" Edit "}</Button>
|
|
615
|
+
<Button onPress={onShareRequest} theme={theme}>{" Share "}</Button>
|
|
616
|
+
</Toolbar>
|
|
617
|
+
) : null}
|
|
618
|
+
</box>
|
|
619
|
+
<box style={{ width: railFootprint }} />
|
|
620
|
+
</box>
|
|
530
621
|
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
531
622
|
{isDiff ? (
|
|
532
623
|
// the sheet dims to reading-quiet colors while the wizard has focus
|
|
@@ -535,6 +626,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
535
626
|
cursor={cursor}
|
|
536
627
|
annotations={activeSession.annotations}
|
|
537
628
|
focusedAnnotationId={focusedAnnotationId}
|
|
629
|
+
compose={diffComposeState}
|
|
538
630
|
theme={walking ? dimmedTheme(theme) : undefined}
|
|
539
631
|
/>
|
|
540
632
|
) : (
|
|
@@ -548,16 +640,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
548
640
|
compose={composeState}
|
|
549
641
|
editOrphanCount={editOrphanCount}
|
|
550
642
|
onLineActivate={onLineActivate}
|
|
551
|
-
onEditRequest={onEditRequest}
|
|
552
|
-
onShareRequest={onShareRequest}
|
|
553
|
-
canEdit={isOwner}
|
|
554
643
|
/>
|
|
555
644
|
)}
|
|
556
645
|
<ReviewPanel
|
|
557
646
|
mode={reviewMode}
|
|
558
647
|
width={resolveReviewWidth(reviewWidth, terminalWidth)}
|
|
559
|
-
height={terminalHeight - CHROME_ROWS}
|
|
560
|
-
dragging={dividerDragging}
|
|
561
648
|
onDividerGrab={onDividerGrab}
|
|
562
649
|
onToggle={onToggleReviewPanel}
|
|
563
650
|
railRef={railRef}
|
|
@@ -577,19 +664,21 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
577
664
|
}}
|
|
578
665
|
/>
|
|
579
666
|
</box>
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
667
|
+
<MenuBar
|
|
668
|
+
open={menuOpen}
|
|
669
|
+
version={CLIENT_VERSION}
|
|
670
|
+
status={status}
|
|
671
|
+
onToggle={() => setMenuOpen((isOpen) => !isOpen)}
|
|
672
|
+
onSettings={() => {
|
|
673
|
+
setMenuOpen(false);
|
|
674
|
+
setMenuDialog("settings");
|
|
675
|
+
}}
|
|
676
|
+
onKeybinds={() => {
|
|
677
|
+
setMenuOpen(false);
|
|
678
|
+
setMenuDialog("keybinds");
|
|
679
|
+
}}
|
|
680
|
+
theme={theme}
|
|
681
|
+
/>
|
|
593
682
|
{walking && walk !== null ? (
|
|
594
683
|
<WalkWizard
|
|
595
684
|
files={walkFileList}
|
|
@@ -630,6 +719,21 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
630
719
|
theme={theme}
|
|
631
720
|
/>
|
|
632
721
|
) : null}
|
|
722
|
+
{toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
|
|
723
|
+
{menuDialog === "keybinds" ? <KeybindsDialog sections={keyBindings.cheatsheet()} theme={theme} /> : null}
|
|
724
|
+
{menuDialog === "settings" ? (
|
|
725
|
+
<SettingsDialog
|
|
726
|
+
isOpen
|
|
727
|
+
categories={settingsCategories}
|
|
728
|
+
values={settingsValues}
|
|
729
|
+
activeCategoryId={settingsNav.categoryId}
|
|
730
|
+
activeRowIndex={settingsNav.rowIndex}
|
|
731
|
+
activeZone={settingsNav.zone}
|
|
732
|
+
onCategorySelect={(id) => setSettingsNav({ categoryId: id, rowIndex: 0, zone: "body" })}
|
|
733
|
+
onRowActivate={(row) => cycleSetting(row.key)}
|
|
734
|
+
theme={theme}
|
|
735
|
+
/>
|
|
736
|
+
) : null}
|
|
633
737
|
</box>
|
|
634
738
|
</ThemeProvider>
|
|
635
739
|
);
|
package/src/App.walk.test.tsx
CHANGED
|
@@ -190,7 +190,7 @@ describe("the guided walk", () => {
|
|
|
190
190
|
// two full App boots with daemon round-trips need more than the default
|
|
191
191
|
// budget, and the whole-suite publish lane has timed even 15s out; give the
|
|
192
192
|
// double-boot chain generous headroom so runner load cannot flake it.
|
|
193
|
-
},
|
|
193
|
+
}, 60_000);
|
|
194
194
|
|
|
195
195
|
test("the agent-note block renders only for files carrying a note", async () => {
|
|
196
196
|
// Arrange
|