@cueloop/client 0.1.0-alpha.64 → 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 +5 -5
- 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 +17 -8
- package/src/App.tree.test.tsx +224 -0
- package/src/App.tsx +120 -84
- package/src/app-screens.tsx +27 -1
- package/src/app-view-model.ts +4 -96
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/ReviewPanel.stories.tsx +14 -0
- package/src/components/ReviewRail.stories.tsx +14 -0
- package/src/components/ReviewRail.tsx +13 -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/diff-hunk-curate.ts +4 -104
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +136 -3
- package/src/intent-dispatch.ts +92 -24
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +4 -4
- package/src/keymap.ts +14 -7
- package/src/session-controller.curate.test.ts +89 -2
- package/src/session-controller.share.test.ts +195 -55
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +322 -68
- package/src/share.ts +114 -16
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +64 -0
- 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-plan.ts +29 -12
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -419
- package/src/components/plan-sheet-run-style.ts +0 -88
package/src/App.toast.test.tsx
CHANGED
|
@@ -10,7 +10,15 @@ import { DaemonServer } from "@cueloop/daemon";
|
|
|
10
10
|
import type { ReviewSession } from "@cueloop/schema";
|
|
11
11
|
import { App } from "./App";
|
|
12
12
|
import type { ShareTransport } from "./session-controller";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
clickText,
|
|
15
|
+
isolateUserConfig,
|
|
16
|
+
press,
|
|
17
|
+
pressKey,
|
|
18
|
+
typeText,
|
|
19
|
+
waitForText,
|
|
20
|
+
waitForTextGone,
|
|
21
|
+
} from "./test-support";
|
|
14
22
|
|
|
15
23
|
const publishShare = mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true }));
|
|
16
24
|
const shareTransport: ShareTransport = {
|
|
@@ -19,8 +27,10 @@ const shareTransport: ShareTransport = {
|
|
|
19
27
|
throw new Error("Unexpected share pull");
|
|
20
28
|
}),
|
|
21
29
|
push: mock(async () => {}),
|
|
30
|
+
watch: () => () => {},
|
|
22
31
|
parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
|
|
23
32
|
collaboratorAnnotations: () => [],
|
|
33
|
+
mergeFromShare: () => ({ annotations: [] }),
|
|
24
34
|
};
|
|
25
35
|
|
|
26
36
|
const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
|
|
@@ -63,18 +73,17 @@ describe("share toast", () => {
|
|
|
63
73
|
|
|
64
74
|
await waitForText(setup, "cueloop");
|
|
65
75
|
|
|
66
|
-
// Act: share raises the toast, then open a composer under it
|
|
67
|
-
await
|
|
76
|
+
// Act: share raises the toast, then open a composer under it by typing
|
|
77
|
+
await pressKey(setup, "s", { ctrl: true });
|
|
68
78
|
await waitForText(setup, "share link copied");
|
|
69
|
-
await
|
|
70
|
-
await
|
|
71
|
-
await
|
|
72
|
-
await waitForText(setup, 'comment on "');
|
|
79
|
+
await clickText(setup, "daemon");
|
|
80
|
+
await typeText(setup, "x");
|
|
81
|
+
await waitForText(setup, "● x");
|
|
73
82
|
|
|
74
83
|
// Act: escape must reach the composer, not get eaten by the toast
|
|
75
84
|
await press(setup, "escape");
|
|
76
85
|
|
|
77
86
|
// Assert: the composer closed on the first escape
|
|
78
|
-
await waitForTextGone(setup,
|
|
87
|
+
await waitForTextGone(setup, "● x");
|
|
79
88
|
});
|
|
80
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
|
+
});
|
package/src/App.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The cueloop TUI: state wiring, keymap dispatch, and layout composition -
|
|
3
3
|
* nothing else. Rendering lives in components/, daemon IO and the mutation
|
|
4
|
-
*
|
|
4
|
+
* primitives in session-controller.ts, the keyboard grammar in keymap.ts with
|
|
5
5
|
* binding resolution and status hints from key-bindings.ts, and theming in
|
|
6
6
|
* the ThemeProvider. Selection is the entry primitive (mouse drag or keyboard
|
|
7
7
|
* span on one native renderer selection); annotation text lives in the rail
|
|
@@ -19,7 +19,7 @@ import React, {
|
|
|
19
19
|
} from "react";
|
|
20
20
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
21
21
|
import type { Clock, MouseEvent } from "@opentui/core";
|
|
22
|
-
import {
|
|
22
|
+
import { marksByDisplay, type Mark } from "./view-plan";
|
|
23
23
|
import { dimmedTheme } from "./theme";
|
|
24
24
|
import {
|
|
25
25
|
DEFAULT_KEYS,
|
|
@@ -40,18 +40,24 @@ import type { Theme } from "./theme";
|
|
|
40
40
|
import { createReviewController, type ShareTransport } from "./session-controller";
|
|
41
41
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
42
42
|
import { launchHarnessInSplit } from "@cueloop/daemon/herdr-split";
|
|
43
|
-
import {
|
|
44
|
-
import { reduceKey } from "./keymap";
|
|
45
|
-
import { KeyBindings } from "./key-bindings";
|
|
43
|
+
import { createIntentDispatch, type Mode } from "./intent-dispatch";
|
|
44
|
+
import { reduceKey, type KeyState } from "./keymap";
|
|
45
|
+
import { KeyBindings, type CheatsheetSection } from "./key-bindings";
|
|
46
46
|
import { ThemeProvider } from "./components/theme-context";
|
|
47
47
|
import { Button } from "./components/primitives/Button";
|
|
48
48
|
import { Toolbar } from "./components/primitives/Toolbar";
|
|
49
49
|
import { Breadcrumb } from "./components/Breadcrumb";
|
|
50
|
-
import {
|
|
50
|
+
import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
|
|
51
|
+
import {
|
|
52
|
+
RAIL_CHORD_ENTRIES,
|
|
53
|
+
resolveThreadChord,
|
|
54
|
+
THREAD_CHORD_ENTRIES,
|
|
55
|
+
TREE_CHORD_ENTRIES,
|
|
56
|
+
} from "./thread-chords";
|
|
51
57
|
import { DiffSheet } from "./components/DiffSheet";
|
|
52
58
|
import { PrototypeSheet } from "./components/PrototypeSheet";
|
|
53
59
|
import type { PrototypeElement } from "./prototype-browser";
|
|
54
|
-
import { type ReviewRailHandle } from "./components/ReviewRail";
|
|
60
|
+
import { type RailTab, type ReviewRailHandle } from "./components/ReviewRail";
|
|
55
61
|
import type { AgentTerminalHandle } from "./components/agent-launcher";
|
|
56
62
|
import { ReviewPanel } from "./components/ReviewPanel";
|
|
57
63
|
import {
|
|
@@ -62,12 +68,9 @@ import {
|
|
|
62
68
|
type ReviewPanelMode,
|
|
63
69
|
} from "./review-panel";
|
|
64
70
|
import {
|
|
65
|
-
buildActiveSpan,
|
|
66
71
|
buildCardEditState,
|
|
67
|
-
buildComposeState,
|
|
68
72
|
buildDiffComposeState,
|
|
69
73
|
buildHeaderItems,
|
|
70
|
-
buildPopoverState,
|
|
71
74
|
buildRenderFlags,
|
|
72
75
|
buildSubmitConfirmState,
|
|
73
76
|
computePendingCount,
|
|
@@ -96,7 +99,7 @@ export interface AppProps {
|
|
|
96
99
|
home?: string;
|
|
97
100
|
sessionId?: string;
|
|
98
101
|
/**
|
|
99
|
-
* Observer mode (SSH-served connections): every mutating
|
|
102
|
+
* Observer mode (SSH-served connections): every mutating primitive is ignored and
|
|
100
103
|
* answers "observer - read-only" in the status line; navigation still works.
|
|
101
104
|
*/
|
|
102
105
|
readOnly?: boolean;
|
|
@@ -125,6 +128,37 @@ export interface AppProps {
|
|
|
125
128
|
appearance?: Appearance;
|
|
126
129
|
}
|
|
127
130
|
|
|
131
|
+
/** True while the drop-up or one of its dialogs is open and owns the keyboard. */
|
|
132
|
+
function menuChromeOpen(menuOpen: boolean, menuDialog: "keybinds" | "settings" | null): boolean {
|
|
133
|
+
return menuOpen || menuDialog !== null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True while a menu, an overlay, or the agent terminal owns the keyboard instead of the thread view. */
|
|
137
|
+
function keyboardOwnedElsewhere(
|
|
138
|
+
menuOwnsKeyboard: boolean,
|
|
139
|
+
overlay: KeyState["overlay"],
|
|
140
|
+
agentTerminal: AgentTerminalHandle | null,
|
|
141
|
+
): boolean {
|
|
142
|
+
return menuOwnsKeyboard || overlay !== "none" || agentTerminal !== null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
|
|
146
|
+
function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
|
|
147
|
+
const base = keyBindings.cheatsheet();
|
|
148
|
+
|
|
149
|
+
if (!threadViewActive) {
|
|
150
|
+
return base;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return [
|
|
154
|
+
...THREAD_VIEW_CHEATSHEET,
|
|
155
|
+
{ title: "Session", entries: [...THREAD_CHORD_ENTRIES] },
|
|
156
|
+
{ title: "Rail", entries: [...RAIL_CHORD_ENTRIES] },
|
|
157
|
+
{ title: "Tree", entries: [...TREE_CHORD_ENTRIES] },
|
|
158
|
+
...base.filter((section) => section.title === "Agent terminal"),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
128
162
|
export function App({
|
|
129
163
|
home,
|
|
130
164
|
sessionId,
|
|
@@ -173,7 +207,7 @@ export function App({
|
|
|
173
207
|
useEffect(() => {
|
|
174
208
|
if (!isOwner || !session?.shareId) return;
|
|
175
209
|
|
|
176
|
-
return controller.
|
|
210
|
+
return controller.startShareSync();
|
|
177
211
|
}, [isOwner, session?.id, session?.shareId, controller]);
|
|
178
212
|
const renderer = useRenderer();
|
|
179
213
|
const { width: terminalWidth } = useTerminalDimensions();
|
|
@@ -188,7 +222,9 @@ export function App({
|
|
|
188
222
|
const [autoClose, setAutoClose] = useState<AutoClose>("off");
|
|
189
223
|
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
|
|
190
224
|
const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
|
|
191
|
-
const [railTab, setRailTab] = useState<
|
|
225
|
+
const [railTab, setRailTab] = useState<RailTab>("review");
|
|
226
|
+
// the tree row the reviewer stands on in the rail's Tree tab
|
|
227
|
+
const [selectedEntryId, setSelectedEntryId] = useState<string | undefined>(undefined);
|
|
192
228
|
// A running in-tab agent terminal (embedded harness); while set, keys route to it.
|
|
193
229
|
const [agentTerminal, setAgentTerminal] = useState<AgentTerminalHandle | null>(null);
|
|
194
230
|
// review panel layout: mode + expanded width are client view state, loaded
|
|
@@ -204,7 +240,6 @@ export function App({
|
|
|
204
240
|
// live mirror of overlay input text: refs commit synchronously, so the
|
|
205
241
|
// RETURN handler never reads a stale value mid-typing
|
|
206
242
|
const liveInput = useRef("");
|
|
207
|
-
const planSheetRef = useRef<PlanSheetHandle | null>(null);
|
|
208
243
|
const railRef = useRef<ReviewRailHandle | null>(null);
|
|
209
244
|
// keymap from layered config; the loaded theme swaps the provider value
|
|
210
245
|
const keysRef = useRef(DEFAULT_KEYS);
|
|
@@ -305,6 +340,9 @@ export function App({
|
|
|
305
340
|
return ids;
|
|
306
341
|
}, [marks]);
|
|
307
342
|
const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
|
|
343
|
+
// plans and replies open in the thread view; diffs and prototypes keep their sheets
|
|
344
|
+
const threadViewActive = session !== null && !isDiff && !isPrototype;
|
|
345
|
+
const [threadComposing, setThreadComposing] = useState(false);
|
|
308
346
|
const [prototypeComposing, setPrototypeComposing] = useState(false);
|
|
309
347
|
// sort position per annotation so the rail interleaves annotation and removal
|
|
310
348
|
// cards in one line-ordered stack: a diff row carries its blockIndex; a plan
|
|
@@ -335,18 +373,6 @@ export function App({
|
|
|
335
373
|
const walking = isWalking(isDiff, walk);
|
|
336
374
|
const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
|
|
337
375
|
|
|
338
|
-
// driving needs committed layout, so it runs after render; any transition
|
|
339
|
-
// out of span mode clears the renderer selection (compose paints its own
|
|
340
|
-
// mark, and a mouse drag never changes the mode, so it survives)
|
|
341
|
-
useEffect(() => {
|
|
342
|
-
// one marker at a time: clear any prior selection, then paint the current
|
|
343
|
-
// span (span and its quick-actions sub-mode both keep it painted)
|
|
344
|
-
const span = activeSpanState(mode);
|
|
345
|
-
|
|
346
|
-
planSheetRef.current?.clearSelection();
|
|
347
|
-
if (span) planSheetRef.current?.driveSpanSelection(span);
|
|
348
|
-
}, [mode]);
|
|
349
|
-
|
|
350
376
|
// ── selection symmetry: one selected id, both sides ──
|
|
351
377
|
const pulse = (id: string): void => {
|
|
352
378
|
setPulsedAnnotationId(id);
|
|
@@ -359,7 +385,6 @@ export function App({
|
|
|
359
385
|
for (const [displayIndex, blockMarks] of marks) {
|
|
360
386
|
if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
|
|
361
387
|
setCursor(displayIndex);
|
|
362
|
-
planSheetRef.current?.revealBlock(displayIndex);
|
|
363
388
|
|
|
364
389
|
return;
|
|
365
390
|
}
|
|
@@ -385,7 +410,6 @@ export function App({
|
|
|
385
410
|
|
|
386
411
|
if (!item) return;
|
|
387
412
|
setCursor(item.revealIndex);
|
|
388
|
-
if (item.source === "plan") planSheetRef.current?.revealBlock(item.revealIndex);
|
|
389
413
|
};
|
|
390
414
|
|
|
391
415
|
// the selected removal card's undo button: same restore path as the u key,
|
|
@@ -444,6 +468,8 @@ export function App({
|
|
|
444
468
|
terminalWidth,
|
|
445
469
|
focusedAnnotationId,
|
|
446
470
|
selectedCurationId,
|
|
471
|
+
railTab,
|
|
472
|
+
selectedEntryId,
|
|
447
473
|
authorNames,
|
|
448
474
|
quickActions,
|
|
449
475
|
renameAuthor: (id: string, name: string) => {
|
|
@@ -452,13 +478,13 @@ export function App({
|
|
|
452
478
|
},
|
|
453
479
|
liveInput,
|
|
454
480
|
reviewWidthRef,
|
|
455
|
-
planSheetRef,
|
|
456
481
|
setCursor,
|
|
457
482
|
setInboxCursor,
|
|
458
483
|
setMode,
|
|
459
484
|
setReviewMode,
|
|
460
485
|
setReviewWidth,
|
|
461
486
|
setRailTab,
|
|
487
|
+
setSelectedEntryId,
|
|
462
488
|
setFocusedAnnotationId,
|
|
463
489
|
setSelectedCurationId,
|
|
464
490
|
setPulsedAnnotationId,
|
|
@@ -469,7 +495,28 @@ export function App({
|
|
|
469
495
|
|
|
470
496
|
const overlay = resolveOverlay(mode, completion.phase, walking);
|
|
471
497
|
|
|
498
|
+
const menuOwnsKeyboard = menuChromeOpen(menuOpen, menuDialog);
|
|
499
|
+
// an overlay (submit, walk, prompt, confirm) or the menu takes the keyboard
|
|
500
|
+
// from the thread view; the view suspends its own grammar meanwhile
|
|
501
|
+
const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay, agentTerminal);
|
|
502
|
+
|
|
472
503
|
useKeyboard((key) => {
|
|
504
|
+
// The thread view owns the document grammar while active (its own
|
|
505
|
+
// useKeyboard handles marks, comments, and ctrl+q); the session chords
|
|
506
|
+
// (submit, share, edit, walk, the rail) resolve here, and the keymap only
|
|
507
|
+
// sees keys while an overlay or the menu owns them.
|
|
508
|
+
if (threadViewActive && !threadViewSuspended) {
|
|
509
|
+
const chord = resolveThreadChord(key, {
|
|
510
|
+
composing: threadComposing,
|
|
511
|
+
isOwner,
|
|
512
|
+
resolved,
|
|
513
|
+
treeActive: railTab === "tree",
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
if (chord) dispatch(chord);
|
|
517
|
+
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
473
520
|
// The prototype compose textarea owns the keyboard while open: let it receive
|
|
474
521
|
// the typed note instead of the global keymap acting on each letter.
|
|
475
522
|
if (prototypeComposing) return;
|
|
@@ -526,7 +573,7 @@ export function App({
|
|
|
526
573
|
theme={theme}
|
|
527
574
|
setMenuOpen={setMenuOpen}
|
|
528
575
|
setMenuDialog={setMenuDialog}
|
|
529
|
-
keybindsSections={keyBindings
|
|
576
|
+
keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
|
|
530
577
|
settingsCategories={settingsCategories}
|
|
531
578
|
settingsValues={settingsValues}
|
|
532
579
|
settingsNav={settingsNav}
|
|
@@ -565,7 +612,6 @@ export function App({
|
|
|
565
612
|
/>
|
|
566
613
|
);
|
|
567
614
|
|
|
568
|
-
const composeState = buildComposeState({ mode, isDiff, display, liveInput, setMode, dispatch });
|
|
569
615
|
const diffComposeState = buildDiffComposeState({
|
|
570
616
|
mode,
|
|
571
617
|
isDiff,
|
|
@@ -574,17 +620,6 @@ export function App({
|
|
|
574
620
|
setMode,
|
|
575
621
|
dispatch,
|
|
576
622
|
});
|
|
577
|
-
const activeSpan = buildActiveSpan(mode, isDiff);
|
|
578
|
-
const popoverState = buildPopoverState({
|
|
579
|
-
mode,
|
|
580
|
-
isDiff,
|
|
581
|
-
quickActions,
|
|
582
|
-
isOwner,
|
|
583
|
-
observer,
|
|
584
|
-
resolved,
|
|
585
|
-
controller,
|
|
586
|
-
dispatch,
|
|
587
|
-
});
|
|
588
623
|
const submitConfirmState = buildSubmitConfirmState({
|
|
589
624
|
mode,
|
|
590
625
|
isDiff,
|
|
@@ -611,39 +646,6 @@ export function App({
|
|
|
611
646
|
resolvedIds,
|
|
612
647
|
});
|
|
613
648
|
|
|
614
|
-
// a mouse drag leaves a native selection: turn it into a word span so the
|
|
615
|
-
// marker popover opens at the dragged range, mirroring the `v` grammar.
|
|
616
|
-
// Returns whether a native selection existed (the release ended a drag).
|
|
617
|
-
const activateSpanFromSelection = (preferredIndex?: number): boolean => {
|
|
618
|
-
if (!renderer?.hasSelection) return false;
|
|
619
|
-
// the release block re-anchors the span, so each drag replaces the last
|
|
620
|
-
const selection = planSheetRef.current?.readSelection(preferredIndex);
|
|
621
|
-
const block = selection ? display[selection.displayIndex] : undefined;
|
|
622
|
-
const span =
|
|
623
|
-
selection && block
|
|
624
|
-
? spanFromRange(selection.displayIndex, displayText(block), selection.start, selection.end)
|
|
625
|
-
: null;
|
|
626
|
-
|
|
627
|
-
if (span) setMode({ type: "span", span });
|
|
628
|
-
|
|
629
|
-
return true;
|
|
630
|
-
};
|
|
631
|
-
|
|
632
|
-
const onLineActivate = (displayIndex: number): void => {
|
|
633
|
-
if (activateSpanFromSelection(displayIndex)) return;
|
|
634
|
-
setCursor(displayIndex);
|
|
635
|
-
const annotationId = marks.get(displayIndex)?.[0]?.annotationId;
|
|
636
|
-
|
|
637
|
-
if (annotationId) selectCardFromDocument(annotationId);
|
|
638
|
-
};
|
|
639
|
-
|
|
640
|
-
// a drag released outside any block's text (the gutter, past a line end, a
|
|
641
|
-
// gap between blocks) never reaches a block handler; the sheet-level release
|
|
642
|
-
// still turns the finished drag into a span
|
|
643
|
-
const onSelectionRelease = (): void => {
|
|
644
|
-
activateSpanFromSelection();
|
|
645
|
-
};
|
|
646
|
-
|
|
647
649
|
const onEditRequest = (): void => {
|
|
648
650
|
// A share viewer/observer has no Edit affordance (the button is hidden), so
|
|
649
651
|
// this is owner-only; stay silent rather than nag if it is ever reached.
|
|
@@ -745,18 +747,37 @@ export function App({
|
|
|
745
747
|
theme={walking ? dimmedTheme(theme) : undefined}
|
|
746
748
|
/>
|
|
747
749
|
) : (
|
|
748
|
-
<
|
|
749
|
-
ref={planSheetRef}
|
|
750
|
+
<ThreadView
|
|
750
751
|
session={activeSession}
|
|
752
|
+
suspended={threadViewSuspended}
|
|
753
|
+
editOrphanCount={editOrphanCount}
|
|
754
|
+
onComposingChange={setThreadComposing}
|
|
755
|
+
resolved={resolved}
|
|
756
|
+
onObserverBlocked={(reason) =>
|
|
757
|
+
controller.setStatus(
|
|
758
|
+
reason === "observer" ? "observer - read-only" : "review submitted - read-only",
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
onCursorChange={setCursor}
|
|
762
|
+
focusedAnnotationId={focusedAnnotationId}
|
|
763
|
+
onFocusAnnotation={setFocusedAnnotationId}
|
|
751
764
|
display={display}
|
|
752
765
|
marks={marks}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
766
|
+
quickActions={quickActions}
|
|
767
|
+
observer={observer}
|
|
768
|
+
onAnnotate={(span, body) =>
|
|
769
|
+
void controller.annotate(
|
|
770
|
+
"comment",
|
|
771
|
+
span.start.blockIndex,
|
|
772
|
+
span.start.char,
|
|
773
|
+
span.end.char,
|
|
774
|
+
body,
|
|
775
|
+
span.end.blockIndex,
|
|
776
|
+
)
|
|
777
|
+
}
|
|
778
|
+
onReply={(rootAnnotationId, body) => void controller.reply(rootAnnotationId, body)}
|
|
779
|
+
onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
|
|
780
|
+
onExit={() => onExit?.(0)}
|
|
760
781
|
/>
|
|
761
782
|
)}
|
|
762
783
|
<ReviewPanel
|
|
@@ -790,6 +811,21 @@ export function App({
|
|
|
790
811
|
seedText,
|
|
791
812
|
}),
|
|
792
813
|
onAgentTerminal: setAgentTerminal,
|
|
814
|
+
tree: {
|
|
815
|
+
rows: controller.treeRows(),
|
|
816
|
+
selectedEntryId,
|
|
817
|
+
canMove: isOwner && !resolved,
|
|
818
|
+
canFork: isOwner,
|
|
819
|
+
onSelect: setSelectedEntryId,
|
|
820
|
+
onGo: (entryId) => {
|
|
821
|
+
setSelectedEntryId(entryId);
|
|
822
|
+
dispatch({ type: "treeGo" });
|
|
823
|
+
},
|
|
824
|
+
onBranch: () => dispatch({ type: "treeBranch" }),
|
|
825
|
+
onLabel: () => dispatch({ type: "treeLabel" }),
|
|
826
|
+
onFork: () => dispatch({ type: "treeFork" }),
|
|
827
|
+
onForkAndShare: () => dispatch({ type: "treeForkShare" }),
|
|
828
|
+
},
|
|
793
829
|
}}
|
|
794
830
|
/>
|
|
795
831
|
</box>
|