@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.
Files changed (49) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.export.test.tsx +2 -2
  4. package/src/App.plan-review.test.tsx +76 -247
  5. package/src/App.readonly.test.tsx +80 -35
  6. package/src/App.rendering.test.tsx +5 -6
  7. package/src/App.review-panel.test.tsx +29 -32
  8. package/src/App.submit-confirm.test.tsx +27 -12
  9. package/src/App.test.tsx +73 -49
  10. package/src/App.toast.test.tsx +17 -8
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +120 -84
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +4 -96
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/KeybindsDialog.tsx +61 -17
  17. package/src/components/ReviewPanel.stories.tsx +14 -0
  18. package/src/components/ReviewRail.stories.tsx +14 -0
  19. package/src/components/ReviewRail.tsx +13 -6
  20. package/src/components/ThreadView.selection.test.tsx +812 -0
  21. package/src/components/ThreadView.stories.tsx +26 -0
  22. package/src/components/ThreadView.tsx +1620 -0
  23. package/src/components/TreePane.stories.tsx +106 -0
  24. package/src/components/TreePane.tsx +156 -0
  25. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  26. package/src/diff-hunk-curate.ts +4 -104
  27. package/src/index.ts +1 -0
  28. package/src/intent-dispatch.test.ts +136 -3
  29. package/src/intent-dispatch.ts +92 -24
  30. package/src/key-bindings.test.ts +1 -1
  31. package/src/keymap.test.ts +4 -4
  32. package/src/keymap.ts +14 -7
  33. package/src/session-controller.curate.test.ts +89 -2
  34. package/src/session-controller.share.test.ts +195 -55
  35. package/src/session-controller.tree.test.ts +228 -0
  36. package/src/session-controller.ts +322 -68
  37. package/src/share.ts +114 -16
  38. package/src/test-support.test.ts +193 -1
  39. package/src/test-support.ts +64 -0
  40. package/src/thread-chords.test.ts +154 -0
  41. package/src/thread-chords.ts +144 -0
  42. package/src/thread-selection.test.ts +210 -0
  43. package/src/thread-selection.ts +243 -0
  44. package/src/tree-view.test.ts +128 -0
  45. package/src/tree-view.ts +148 -0
  46. package/src/view-plan.ts +29 -12
  47. package/src/components/PlanSheet.stories.tsx +0 -113
  48. package/src/components/PlanSheet.tsx +0 -419
  49. package/src/components/plan-sheet-run-style.ts +0 -88
@@ -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 { isolateUserConfig, press, waitForText, waitForTextGone } from "./test-support";
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 press(setup, "S");
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 press(setup, "j");
70
- await press(setup, "j");
71
- await press(setup, "c");
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, 'comment on "');
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
- * verbs in session-controller.ts, the keyboard grammar in keymap.ts with
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 { displayText, marksByDisplay, spanFromRange, type Mark } from "./view-plan";
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 { activeSpanState, createIntentDispatch, type Mode } from "./intent-dispatch";
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 { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
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 verb is ignored and
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.startSharePoll();
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<"review" | "agent">("review");
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.cheatsheet()}
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
- <PlanSheet
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
- cursor={cursor}
754
- activeSpan={activeSpan}
755
- compose={composeState}
756
- popover={popoverState}
757
- editOrphanCount={editOrphanCount}
758
- onLineActivate={onLineActivate}
759
- onSelectionRelease={onSelectionRelease}
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>