@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66

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 (109) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +8 -73
  4. package/src/App.export.test.tsx +2 -2
  5. package/src/App.plan-review.test.tsx +60 -412
  6. package/src/App.prototype.test.tsx +1 -8
  7. package/src/App.readonly.test.tsx +85 -62
  8. package/src/App.rendering.test.tsx +5 -6
  9. package/src/App.submit-confirm.test.tsx +32 -70
  10. package/src/App.test.tsx +136 -66
  11. package/src/App.toast.test.tsx +17 -8
  12. package/src/App.tsx +243 -284
  13. package/src/App.walk.test.tsx +1 -1
  14. package/src/app-screens.tsx +67 -52
  15. package/src/app-view-model.ts +6 -100
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +2 -2
  18. package/src/components/AppShell.stories.tsx +84 -0
  19. package/src/components/AppShell.tsx +127 -0
  20. package/src/components/Breadcrumb.stories.tsx +1 -1
  21. package/src/components/ChangesColumn.stories.tsx +39 -0
  22. package/src/components/ChangesColumn.tsx +147 -0
  23. package/src/components/CodeBlock.stories.tsx +1 -1
  24. package/src/components/CompletionOverlay.stories.tsx +1 -1
  25. package/src/components/ConfirmCard.stories.tsx +1 -1
  26. package/src/components/ConfirmCard.tsx +3 -4
  27. package/src/components/ConfirmDialog.stories.tsx +1 -1
  28. package/src/components/DiffSheet.stories.tsx +1 -1
  29. package/src/components/InboxList.stories.tsx +23 -8
  30. package/src/components/InboxList.tsx +45 -19
  31. package/src/components/KeybindsDialog.stories.tsx +1 -1
  32. package/src/components/KeybindsDialog.tsx +61 -17
  33. package/src/components/MarkerPopover.stories.tsx +1 -1
  34. package/src/components/MenuBar.stories.tsx +3 -12
  35. package/src/components/MenuBar.tsx +25 -56
  36. package/src/components/PromptDialog.stories.tsx +1 -1
  37. package/src/components/PrototypeSheet.stories.tsx +1 -1
  38. package/src/components/ReviewPanel.stories.tsx +15 -1
  39. package/src/components/ReviewRail.stories.tsx +15 -1
  40. package/src/components/ReviewRail.tsx +13 -6
  41. package/src/components/SettingsDialog.stories.tsx +7 -1
  42. package/src/components/SettingsDialog.tsx +112 -66
  43. package/src/components/SettingsRows.stories.tsx +1 -1
  44. package/src/components/ShellHeader.stories.tsx +50 -0
  45. package/src/components/ShellHeader.tsx +137 -0
  46. package/src/components/ThreadFooter.stories.tsx +30 -0
  47. package/src/components/ThreadFooter.tsx +49 -0
  48. package/src/components/ThreadView.selection.test.tsx +812 -0
  49. package/src/components/ThreadView.slash.test.tsx +113 -0
  50. package/src/components/ThreadView.stories.tsx +26 -0
  51. package/src/components/ThreadView.tsx +1708 -0
  52. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  53. package/src/components/ThreadsSidebar.tsx +57 -0
  54. package/src/components/Toast.stories.tsx +1 -1
  55. package/src/components/TreePane.stories.tsx +106 -0
  56. package/src/components/TreePane.tsx +156 -0
  57. package/src/components/WalkWizard.stories.tsx +1 -1
  58. package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
  59. package/src/components/agent-launcher.stories.tsx +1 -1
  60. package/src/components/file-tree.test.ts +45 -0
  61. package/src/components/file-tree.ts +57 -0
  62. package/src/components/pages/Workspace.stories.tsx +181 -0
  63. package/src/components/primitives/Button.stories.tsx +1 -1
  64. package/src/components/primitives/Card.stories.tsx +1 -1
  65. package/src/components/primitives/Dialog.stories.tsx +1 -1
  66. package/src/components/primitives/IconButton.stories.tsx +36 -0
  67. package/src/components/primitives/IconButton.tsx +43 -0
  68. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  69. package/src/components/primitives/Tabs.stories.tsx +1 -1
  70. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  71. package/src/components/primitives/Tree.stories.tsx +124 -0
  72. package/src/components/primitives/Tree.tsx +87 -0
  73. package/src/components/primitives/icons.ts +38 -0
  74. package/src/components/primitives/tree-model.test.ts +99 -0
  75. package/src/components/primitives/tree-model.ts +137 -0
  76. package/src/components/quick-actions-editor.stories.tsx +1 -1
  77. package/src/components/session-tree.test.ts +114 -0
  78. package/src/components/session-tree.ts +94 -0
  79. package/src/components/stories-app.tsx +142 -82
  80. package/src/components/story-tree.test.ts +100 -0
  81. package/src/components/story.ts +55 -0
  82. package/src/components/theme-context.stories.tsx +1 -1
  83. package/src/diff-hunk-curate.ts +4 -104
  84. package/src/index.ts +1 -0
  85. package/src/intent-dispatch.test.ts +136 -3
  86. package/src/intent-dispatch.ts +92 -24
  87. package/src/key-bindings.test.ts +1 -1
  88. package/src/keymap.test.ts +4 -4
  89. package/src/keymap.ts +14 -7
  90. package/src/session-controller.curate.test.ts +89 -2
  91. package/src/session-controller.share.test.ts +195 -55
  92. package/src/session-controller.tree.test.ts +228 -0
  93. package/src/session-controller.ts +331 -72
  94. package/src/share.ts +114 -16
  95. package/src/test-support.test.ts +193 -1
  96. package/src/test-support.ts +64 -0
  97. package/src/theme.ts +4 -4
  98. package/src/thread-chords.test.ts +154 -0
  99. package/src/thread-chords.ts +144 -0
  100. package/src/thread-selection.test.ts +210 -0
  101. package/src/thread-selection.ts +243 -0
  102. package/src/tree-view.test.ts +128 -0
  103. package/src/tree-view.ts +148 -0
  104. package/src/use-settings-dialog.tsx +9 -0
  105. package/src/view-plan.ts +29 -12
  106. package/src/App.review-panel.test.tsx +0 -143
  107. package/src/components/PlanSheet.stories.tsx +0 -113
  108. package/src/components/PlanSheet.tsx +0 -419
  109. package/src/components/plan-sheet-run-style.ts +0 -88
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
@@ -18,8 +18,8 @@ import React, {
18
18
  useSyncExternalStore,
19
19
  } from "react";
20
20
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
21
- import type { Clock, MouseEvent } from "@opentui/core";
22
- import { displayText, marksByDisplay, spanFromRange, type Mark } from "./view-plan";
21
+ import type { Clock } from "@opentui/core";
22
+ import { marksByDisplay, type Mark } from "./view-plan";
23
23
  import { dimmedTheme } from "./theme";
24
24
  import {
25
25
  DEFAULT_KEYS,
@@ -39,39 +39,37 @@ import {
39
39
  import type { Theme } from "./theme";
40
40
  import { createReviewController, type ShareTransport } from "./session-controller";
41
41
  import type { SessionClient } from "@cueloop/daemon/client";
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";
42
+ import { createIntentDispatch, type Mode } from "./intent-dispatch";
43
+ import { reduceKey, type KeyState } from "./keymap";
44
+ import { KeyBindings, type CheatsheetSection } from "./key-bindings";
46
45
  import { ThemeProvider } from "./components/theme-context";
47
46
  import { Button } from "./components/primitives/Button";
48
47
  import { Toolbar } from "./components/primitives/Toolbar";
48
+ import { NERD } from "./components/primitives/icons";
49
+ import { groupInbox, projectName } from "./components/session-tree";
50
+ import { ThreadsSidebar } from "./components/ThreadsSidebar";
51
+ import { ChangesColumn, DiffChangesToggle, useDiffColumns } from "./components/ChangesColumn";
52
+ import { IconButton } from "./components/primitives/IconButton";
53
+ import { ThreadFooter } from "./components/ThreadFooter";
54
+ import { ConfirmCard } from "./components/ConfirmCard";
49
55
  import { Breadcrumb } from "./components/Breadcrumb";
50
- import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
56
+ import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
57
+ import {
58
+ RAIL_CHORD_ENTRIES,
59
+ resolveThreadChord,
60
+ THREAD_CHORD_ENTRIES,
61
+ TREE_CHORD_ENTRIES,
62
+ } from "./thread-chords";
51
63
  import { DiffSheet } from "./components/DiffSheet";
52
64
  import { PrototypeSheet } from "./components/PrototypeSheet";
53
65
  import type { PrototypeElement } from "./prototype-browser";
54
- import { type ReviewRailHandle } from "./components/ReviewRail";
55
- import type { AgentTerminalHandle } from "./components/agent-launcher";
56
- import { ReviewPanel } from "./components/ReviewPanel";
57
- import {
58
- REVIEW_DEFAULT_WIDTH,
59
- resolveReviewWidth,
60
- toggleReviewPanelMode,
61
- widthFromMouseColumn,
62
- type ReviewPanelMode,
63
- } from "./review-panel";
66
+ import { type RailTab, type ReviewRailHandle } from "./components/ReviewRail";
67
+ import { REVIEW_DEFAULT_WIDTH, type ReviewPanelMode } from "./review-panel";
64
68
  import {
65
- buildActiveSpan,
66
- buildCardEditState,
67
- buildComposeState,
68
69
  buildDiffComposeState,
69
70
  buildHeaderItems,
70
- buildPopoverState,
71
71
  buildRenderFlags,
72
72
  buildSubmitConfirmState,
73
- computePendingCount,
74
- computeRailFootprint,
75
73
  computeRoleCapabilities,
76
74
  deriveReviewFlags,
77
75
  isCompletionOverlayPhase,
@@ -96,7 +94,7 @@ export interface AppProps {
96
94
  home?: string;
97
95
  sessionId?: string;
98
96
  /**
99
- * Observer mode (SSH-served connections): every mutating verb is ignored and
97
+ * Observer mode (SSH-served connections): every mutating primitive is ignored and
100
98
  * answers "observer - read-only" in the status line; navigation still works.
101
99
  */
102
100
  readOnly?: boolean;
@@ -125,6 +123,43 @@ export interface AppProps {
125
123
  appearance?: Appearance;
126
124
  }
127
125
 
126
+ /** True while the drop-up or one of its dialogs is open and owns the keyboard. */
127
+ function menuChromeOpen(menuDialog: "keybinds" | "settings" | null): boolean {
128
+ return menuDialog !== null;
129
+ }
130
+
131
+ /** True while a menu or an overlay owns the keyboard instead of the thread view. */
132
+ function keyboardOwnedElsewhere(menuOwnsKeyboard: boolean, overlay: KeyState["overlay"]): boolean {
133
+ return menuOwnsKeyboard || overlay !== "none";
134
+ }
135
+
136
+ /** The footer submit fires only for the owner of an unresolved review, never an observer. */
137
+ function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean): boolean {
138
+ return isOwner && !resolved && !observer;
139
+ }
140
+
141
+ /** The left panel toggle glyph: the mirrored sidebar icon, filled when the column is open. */
142
+ function sidebarToggleGlyph(open: boolean): string {
143
+ return open ? NERD.sidebarLeft : NERD.sidebarLeftOff;
144
+ }
145
+
146
+ /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
147
+ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
148
+ const base = keyBindings.cheatsheet();
149
+
150
+ if (!threadViewActive) {
151
+ return base;
152
+ }
153
+
154
+ return [
155
+ ...THREAD_VIEW_CHEATSHEET,
156
+ { title: "Session", entries: [...THREAD_CHORD_ENTRIES] },
157
+ { title: "Rail", entries: [...RAIL_CHORD_ENTRIES] },
158
+ { title: "Tree", entries: [...TREE_CHORD_ENTRIES] },
159
+ ...base.filter((section) => section.title === "Agent terminal"),
160
+ ];
161
+ }
162
+
128
163
  export function App({
129
164
  home,
130
165
  sessionId,
@@ -173,7 +208,7 @@ export function App({
173
208
  useEffect(() => {
174
209
  if (!isOwner || !session?.shareId) return;
175
210
 
176
- return controller.startSharePoll();
211
+ return controller.startShareSync();
177
212
  }, [isOwner, session?.id, session?.shareId, controller]);
178
213
  const renderer = useRenderer();
179
214
  const { width: terminalWidth } = useTerminalDimensions();
@@ -181,22 +216,25 @@ export function App({
181
216
  // ── view state ──────────────────────────────
182
217
  const [cursor, setCursor] = useState(0);
183
218
  const [inboxCursor, setInboxCursor] = useState(0);
219
+ // Projects and Threads grouping; ordered is the flat sequence the inbox cursor walks
220
+ const grouped = useMemo(() => groupInbox(inbox ?? []), [inbox]);
221
+ // the left Projects and Threads column; collapsed by default per the context
222
+ // matrix (the thread owns the width), toggled open to jump between threads
223
+ const [sidebarOpen, setSidebarOpen] = useState(false);
184
224
  const [mode, setMode] = useState<Mode>({ type: "normal" });
185
- // the bottom-left menu drop-up and the centered dialog it opens
186
- const [menuOpen, setMenuOpen] = useState(false);
225
+ // the top-left settings gear drop-down and the centered dialog it opens
187
226
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
188
227
  const [autoClose, setAutoClose] = useState<AutoClose>("off");
189
228
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
190
229
  const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
191
- const [railTab, setRailTab] = useState<"review" | "agent">("review");
192
- // A running in-tab agent terminal (embedded harness); while set, keys route to it.
193
- const [agentTerminal, setAgentTerminal] = useState<AgentTerminalHandle | null>(null);
230
+ const [railTab, setRailTab] = useState<RailTab>("review");
231
+ // the tree row the reviewer stands on in the rail's Tree tab
232
+ const [selectedEntryId, setSelectedEntryId] = useState<string | undefined>(undefined);
194
233
  // review panel layout: mode + expanded width are client view state, loaded
195
234
  // from and persisted to the user config so they survive a restart. The ref
196
235
  // mirrors the width so the drag-end persist reads the latest value.
197
236
  const [reviewMode, setReviewMode] = useState<ReviewPanelMode>("expanded");
198
237
  const [reviewWidth, setReviewWidth] = useState(REVIEW_DEFAULT_WIDTH);
199
- const [dividerDragging, setDividerDragging] = useState(false);
200
238
  const reviewWidthRef = useRef(REVIEW_DEFAULT_WIDTH);
201
239
  // ~2s focus pulse on the document highlight when a rail card is activated
202
240
  const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
@@ -204,7 +242,6 @@ export function App({
204
242
  // live mirror of overlay input text: refs commit synchronously, so the
205
243
  // RETURN handler never reads a stale value mid-typing
206
244
  const liveInput = useRef("");
207
- const planSheetRef = useRef<PlanSheetHandle | null>(null);
208
245
  const railRef = useRef<ReviewRailHandle | null>(null);
209
246
  // keymap from layered config; the loaded theme swaps the provider value
210
247
  const keysRef = useRef(DEFAULT_KEYS);
@@ -285,8 +322,8 @@ export function App({
285
322
  // ── derived view model ──────────────────────
286
323
  const display = controller.display();
287
324
  const rows = controller.rows();
325
+ const diffColumns = useDiffColumns({ session, rows, cursor, setCursor });
288
326
  const rejectedRows = controller.rejectedRows();
289
- const curationItems = controller.curationItems();
290
327
  const marks = useMemo(
291
328
  () =>
292
329
  session
@@ -305,97 +342,24 @@ export function App({
305
342
  return ids;
306
343
  }, [marks]);
307
344
  const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
345
+ // plans and replies open in the thread view; diffs and prototypes keep their sheets
346
+ const threadViewActive = session !== null && !isDiff && !isPrototype;
347
+ const [threadComposing, setThreadComposing] = useState(false);
308
348
  const [prototypeComposing, setPrototypeComposing] = useState(false);
309
349
  // sort position per annotation so the rail interleaves annotation and removal
310
350
  // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
311
351
  // annotation resolves to the display index it marked
312
- const annotationPositions = useMemo(() => {
313
- const positions = new Map<string, number>();
314
-
315
- if (!session) return positions;
316
- if (isDiff) {
317
- for (const annotation of session.annotations) {
318
- const blockIndex =
319
- "blockIndex" in annotation.anchor ? annotation.anchor.blockIndex : undefined;
320
-
321
- if (blockIndex !== undefined) positions.set(annotation.id, blockIndex);
322
- }
323
- } else {
324
- for (const [displayIndex, blockMarks] of marks) {
325
- for (const mark of blockMarks)
326
- if (mark.annotationId) positions.set(mark.annotationId, displayIndex);
327
- }
328
- }
329
-
330
- return positions;
331
- }, [session, isDiff, marks]);
332
-
333
352
  // ── the guided walk's view model ────────────
334
353
  const walkFileList = controller.files();
335
354
  const walking = isWalking(isDiff, walk);
336
355
  const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
337
356
 
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
357
  // ── selection symmetry: one selected id, both sides ──
351
- const pulse = (id: string): void => {
352
- setPulsedAnnotationId(id);
353
- if (pulseTimer.current) clearTimeout(pulseTimer.current);
354
- pulseTimer.current = setTimeout(() => setPulsedAnnotationId(null), 2000);
355
- };
356
-
357
- /** Card activation scrolls the document to the anchor and pulses it. */
358
- const revealAnchor = (annotationId: string): void => {
359
- for (const [displayIndex, blockMarks] of marks) {
360
- if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
361
- setCursor(displayIndex);
362
- planSheetRef.current?.revealBlock(displayIndex);
363
-
364
- return;
365
- }
366
- };
367
-
368
358
  const selectCardFromDocument = (annotationId: string): void => {
369
359
  setFocusedAnnotationId(annotationId);
370
360
  railRef.current?.revealCard(annotationId);
371
361
  };
372
362
 
373
- const selectCardFromRail = (annotationId: string): void => {
374
- setFocusedAnnotationId(annotationId);
375
- pulse(annotationId);
376
- revealAnchor(annotationId);
377
- };
378
-
379
- // selecting a removal reveals its source line, so the reader sees what the
380
- // pending undo would bring back: the diff sheet follows the cursor; the plan
381
- // sheet also needs an explicit scroll to the cut block
382
- const selectCurationFromRail = (curationId: string): void => {
383
- setSelectedCurationId(curationId);
384
- const item = curationItems.find((candidate) => candidate.id === curationId);
385
-
386
- if (!item) return;
387
- setCursor(item.revealIndex);
388
- if (item.source === "plan") planSheetRef.current?.revealBlock(item.revealIndex);
389
- };
390
-
391
- // the selected removal card's undo button: same restore path as the u key,
392
- // including the observer read-only guard the keyboard path gets via reduceKey
393
- const undoCurationFromRail = (curationId: string): void => {
394
- if (observer) return controller.setStatus("observer - read-only");
395
- controller.restoreCuration(curationId);
396
- setSelectedCurationId(undefined);
397
- };
398
-
399
363
  const openCardEdit = (annotationId: string): void => {
400
364
  if (observer) return controller.setStatus("observer - read-only");
401
365
  const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
@@ -435,7 +399,7 @@ export function App({
435
399
  display,
436
400
  rows,
437
401
  cursor,
438
- inbox,
402
+ inbox: inbox === null ? null : grouped.ordered,
439
403
  inboxCursor,
440
404
  mode,
441
405
  session,
@@ -444,6 +408,8 @@ export function App({
444
408
  terminalWidth,
445
409
  focusedAnnotationId,
446
410
  selectedCurationId,
411
+ railTab,
412
+ selectedEntryId,
447
413
  authorNames,
448
414
  quickActions,
449
415
  renameAuthor: (id: string, name: string) => {
@@ -452,13 +418,13 @@ export function App({
452
418
  },
453
419
  liveInput,
454
420
  reviewWidthRef,
455
- planSheetRef,
456
421
  setCursor,
457
422
  setInboxCursor,
458
423
  setMode,
459
424
  setReviewMode,
460
425
  setReviewWidth,
461
426
  setRailTab,
427
+ setSelectedEntryId,
462
428
  setFocusedAnnotationId,
463
429
  setSelectedCurationId,
464
430
  setPulsedAnnotationId,
@@ -469,20 +435,33 @@ export function App({
469
435
 
470
436
  const overlay = resolveOverlay(mode, completion.phase, walking);
471
437
 
438
+ const menuOwnsKeyboard = menuChromeOpen(menuDialog);
439
+ // an overlay (submit, walk, prompt, confirm) or the menu takes the keyboard
440
+ // from the thread view; the view suspends its own grammar meanwhile
441
+ const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay);
442
+
472
443
  useKeyboard((key) => {
444
+ // The thread view owns the document grammar while active (its own
445
+ // useKeyboard handles marks, comments, and ctrl+q); the session chords
446
+ // (submit, share, edit, walk, the rail) resolve here, and the keymap only
447
+ // sees keys while an overlay or the menu owns them.
448
+ if (threadViewActive && !threadViewSuspended) {
449
+ const chord = resolveThreadChord(key, {
450
+ composing: threadComposing,
451
+ isOwner,
452
+ resolved,
453
+ treeActive: railTab === "tree",
454
+ });
455
+
456
+ if (chord) dispatch(chord);
457
+
458
+ return;
459
+ }
473
460
  // The prototype compose textarea owns the keyboard while open: let it receive
474
461
  // the typed note instead of the global keymap acting on each letter.
475
462
  if (prototypeComposing) return;
476
- // A running in-tab agent terminal owns the keyboard: forward every key to it,
477
- // with ctrl+] as the detach chord back to the review.
478
- if (agentTerminal) {
479
- if (key.ctrl && key.name === "]") return void agentTerminal.detach();
480
-
481
- return void agentTerminal.write(key.sequence);
482
- }
483
463
  if (menuDialog === "settings") return void handleSettingsKey(key.name);
484
464
  if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
485
- if (menuOpen) return void (key.name === "escape" && setMenuOpen(false));
486
465
  // the toast is non-modal: escape only dismisses it when nothing else owns
487
466
  // escape, so an open overlay (compose, submit, prompt, walk) still cancels
488
467
  if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span")
@@ -505,8 +484,14 @@ export function App({
505
484
  display,
506
485
  });
507
486
 
508
- keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
509
- const action = keyBindings.resolveAction({ name: key.name, shift: !!key.shift });
487
+ keyBindings.setContext({
488
+ overlay: state.overlay,
489
+ spanMode: state.spanMode,
490
+ });
491
+ const action = keyBindings.resolveAction({
492
+ name: key.name,
493
+ shift: !!key.shift,
494
+ });
510
495
 
511
496
  for (const intent of reduceKey(
512
497
  state,
@@ -520,13 +505,9 @@ export function App({
520
505
  // reused by the inbox and by plan/diff review so the two never drift ──
521
506
  const menuChrome = (
522
507
  <MenuChrome
523
- menuOpen={menuOpen}
524
508
  menuDialog={menuDialog}
525
- status={status}
526
509
  theme={theme}
527
- setMenuOpen={setMenuOpen}
528
- setMenuDialog={setMenuDialog}
529
- keybindsSections={keyBindings.cheatsheet()}
510
+ keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
530
511
  settingsCategories={settingsCategories}
531
512
  settingsValues={settingsValues}
532
513
  settingsNav={settingsNav}
@@ -540,13 +521,14 @@ export function App({
540
521
  if (!session)
541
522
  return inbox ? (
542
523
  <InboxScreen
543
- inbox={inbox}
524
+ rows={grouped.rows}
544
525
  inboxCursor={inboxCursor}
545
526
  mode={mode}
546
527
  theme={theme}
547
528
  controller={controller}
548
529
  setMode={setMode}
549
530
  menuChrome={menuChrome}
531
+ onOpenMenu={() => setMenuDialog("settings")}
550
532
  />
551
533
  ) : (
552
534
  <ConnectingScreen theme={theme} />
@@ -565,7 +547,6 @@ export function App({
565
547
  />
566
548
  );
567
549
 
568
- const composeState = buildComposeState({ mode, isDiff, display, liveInput, setMode, dispatch });
569
550
  const diffComposeState = buildDiffComposeState({
570
551
  mode,
571
552
  isDiff,
@@ -574,17 +555,6 @@ export function App({
574
555
  setMode,
575
556
  dispatch,
576
557
  });
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
558
  const submitConfirmState = buildSubmitConfirmState({
589
559
  mode,
590
560
  isDiff,
@@ -595,54 +565,21 @@ export function App({
595
565
  setMode,
596
566
  dispatch,
597
567
  });
598
- const cardEditState = buildCardEditState({ mode, liveInput, setMode, dispatch });
599
- const pendingCount = computePendingCount(activeSession);
600
- const railFootprint = computeRailFootprint(reviewMode, reviewWidth, terminalWidth);
601
- const headerItems = buildHeaderItems({ session: activeSession, resolved, observer, role });
602
- const { showOwnerActions, prototypeCanComment, chromeHidden, prototypePath, railResolvedIds } =
603
- buildRenderFlags({
604
- session: activeSession,
605
- isOwner,
606
- isDiff,
607
- isPrototype,
608
- resolved,
609
- menuOpen,
610
- menuDialog,
611
- resolvedIds,
612
- });
613
-
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
- };
568
+ const headerItems = buildHeaderItems({
569
+ session: activeSession,
570
+ resolved,
571
+ observer,
572
+ role,
573
+ });
574
+ const { showOwnerActions, prototypeCanComment, chromeHidden, prototypePath } = buildRenderFlags({
575
+ session: activeSession,
576
+ isOwner,
577
+ isDiff,
578
+ isPrototype,
579
+ resolved,
580
+ menuDialog,
581
+ resolvedIds,
582
+ });
646
583
 
647
584
  const onEditRequest = (): void => {
648
585
  // A share viewer/observer has no Edit affordance (the button is hidden), so
@@ -666,19 +603,6 @@ export function App({
666
603
  dispatch({ type: "openSubmit" });
667
604
  };
668
605
 
669
- // the clickable chevron toggles expanded <-> compact; the keybinding (b)
670
- // cycles all three including hidden
671
- const onToggleReviewPanel = (): void => {
672
- const next = toggleReviewPanelMode(reviewMode);
673
-
674
- setReviewMode(next);
675
- controller.saveReviewPanel({ mode: next });
676
- };
677
- // grabbing the divider only arms a drag when there is a width to drag
678
- const onDividerGrab = (): void => {
679
- if (reviewMode === "expanded") setDividerDragging(true);
680
- };
681
-
682
606
  return (
683
607
  <ThemeProvider theme={theme}>
684
608
  <box
@@ -688,26 +612,25 @@ export function App({
688
612
  height: "100%",
689
613
  backgroundColor: theme.background,
690
614
  }}
691
- onMouseDrag={(event: MouseEvent) => {
692
- if (!dividerDragging || reviewMode !== "expanded") return;
693
- const next = widthFromMouseColumn(event.x, terminalWidth);
694
-
695
- // many raw mouse-moves land in the same column: only re-render when the
696
- // width actually changes, so the drag does not reconcile per pixel
697
- if (next === reviewWidthRef.current) return;
698
- reviewWidthRef.current = next;
699
- setReviewWidth(next);
700
- }}
701
- onMouseUp={() => {
702
- if (!dividerDragging) return;
703
- setDividerDragging(false);
704
- controller.saveReviewPanel({ width: reviewWidthRef.current });
705
- }}
706
615
  >
707
616
  <box
708
- style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}
617
+ style={{
618
+ flexDirection: "row",
619
+ height: 2,
620
+ paddingTop: 1,
621
+ backgroundColor: theme.panel,
622
+ }}
709
623
  >
710
624
  <box style={{ flexGrow: 1, flexDirection: "row", paddingRight: 1 }}>
625
+ <box onMouseUp={() => setMenuDialog("settings")} style={{ paddingRight: 2 }}>
626
+ <text fg={theme.textMuted}>{NERD.settings}</text>
627
+ </box>
628
+ <IconButton
629
+ glyph={sidebarToggleGlyph(sidebarOpen)}
630
+ onPress={() => setSidebarOpen((open) => !open)}
631
+ marginRight={2}
632
+ theme={theme}
633
+ />
711
634
  <Breadcrumb items={headerItems} />
712
635
  <box style={{ flexGrow: 1 }} />
713
636
  {showOwnerActions ? (
@@ -720,77 +643,98 @@ export function App({
720
643
  </Button>
721
644
  </Toolbar>
722
645
  ) : null}
646
+ <DiffChangesToggle
647
+ isDiff={isDiff}
648
+ open={diffColumns.changesOpen}
649
+ onToggle={diffColumns.toggleChanges}
650
+ theme={theme}
651
+ />
723
652
  </box>
724
- <box style={{ width: railFootprint }} />
725
653
  </box>
726
654
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
727
- {isPrototype ? (
728
- <PrototypeSheet
729
- prototypePath={prototypePath}
730
- quickActions={quickActions}
731
- canComment={prototypeCanComment}
732
- onCommentElement={onCommentPrototype}
733
- onComposingChange={setPrototypeComposing}
734
- hidden={chromeHidden}
735
- />
736
- ) : isDiff ? (
737
- // the sheet dims to reading-quiet colors while the wizard has focus
738
- <DiffSheet
739
- rows={rows}
740
- cursor={cursor}
741
- annotations={activeSession.annotations}
742
- focusedAnnotationId={focusedAnnotationId}
743
- rejectedRows={rejectedRows}
744
- compose={diffComposeState}
745
- theme={walking ? dimmedTheme(theme) : undefined}
746
- />
747
- ) : (
748
- <PlanSheet
749
- ref={planSheetRef}
750
- session={activeSession}
751
- display={display}
752
- marks={marks}
753
- cursor={cursor}
754
- activeSpan={activeSpan}
755
- compose={composeState}
756
- popover={popoverState}
757
- editOrphanCount={editOrphanCount}
758
- onLineActivate={onLineActivate}
759
- onSelectionRelease={onSelectionRelease}
655
+ <ThreadsSidebar
656
+ open={sidebarOpen}
657
+ rows={grouped.rows}
658
+ cursor={inboxCursor}
659
+ activeId={session.id}
660
+ onSelect={(id) => controller.open(id)}
661
+ theme={theme}
662
+ />
663
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
664
+ <box style={{ flexGrow: 1, flexDirection: "row" }}>
665
+ {isPrototype ? (
666
+ <PrototypeSheet
667
+ prototypePath={prototypePath}
668
+ quickActions={quickActions}
669
+ canComment={prototypeCanComment}
670
+ onCommentElement={onCommentPrototype}
671
+ onComposingChange={setPrototypeComposing}
672
+ hidden={chromeHidden}
673
+ />
674
+ ) : isDiff ? (
675
+ // the sheet dims to reading-quiet colors while the wizard has focus
676
+ <DiffSheet
677
+ rows={rows}
678
+ cursor={cursor}
679
+ annotations={activeSession.annotations}
680
+ focusedAnnotationId={focusedAnnotationId}
681
+ rejectedRows={rejectedRows}
682
+ compose={diffComposeState}
683
+ theme={walking ? dimmedTheme(theme) : undefined}
684
+ />
685
+ ) : (
686
+ <ThreadView
687
+ session={activeSession}
688
+ suspended={threadViewSuspended}
689
+ editOrphanCount={editOrphanCount}
690
+ onComposingChange={setThreadComposing}
691
+ resolved={resolved}
692
+ onObserverBlocked={(reason) =>
693
+ controller.setStatus(
694
+ reason === "observer"
695
+ ? "observer - read-only"
696
+ : "review submitted - read-only",
697
+ )
698
+ }
699
+ onCursorChange={setCursor}
700
+ focusedAnnotationId={focusedAnnotationId}
701
+ onFocusAnnotation={setFocusedAnnotationId}
702
+ display={display}
703
+ marks={marks}
704
+ quickActions={quickActions}
705
+ observer={observer}
706
+ onAnnotate={(span, body) =>
707
+ void controller.annotate(
708
+ "comment",
709
+ span.start.blockIndex,
710
+ span.start.char,
711
+ span.end.char,
712
+ body,
713
+ span.end.blockIndex,
714
+ )
715
+ }
716
+ onReply={(rootAnnotationId, body) =>
717
+ void controller.reply(rootAnnotationId, body)
718
+ }
719
+ onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
720
+ onExit={() => onExit?.(0)}
721
+ />
722
+ )}
723
+ </box>
724
+ <ThreadFooter
725
+ repo={projectName(activeSession.workspace)}
726
+ branch={activeSession.workspace.branch}
727
+ onSubmit={onSubmitRequest}
728
+ canSubmit={canSubmitReview(isOwner, resolved, observer)}
729
+ theme={theme}
760
730
  />
761
- )}
762
- <ReviewPanel
763
- mode={reviewMode}
764
- width={resolveReviewWidth(reviewWidth, terminalWidth)}
765
- onDividerGrab={onDividerGrab}
766
- onToggle={onToggleReviewPanel}
767
- railRef={railRef}
768
- rail={{
769
- session: activeSession,
770
- authorNames,
771
- selectedId: focusedAnnotationId,
772
- resolvedIds: railResolvedIds,
773
- curationItems,
774
- selectedCurationId,
775
- annotationPositions,
776
- railTab,
777
- pendingCount,
778
- cardEdit: cardEditState,
779
- submitConfirm: submitConfirmState,
780
- onTabChange: setRailTab,
781
- onSelectCard: selectCardFromRail,
782
- onActivateCard: openCardEdit,
783
- onSelectCuration: selectCurationFromRail,
784
- onUndoCuration: undoCurationFromRail,
785
- onSubmitRequest,
786
- onLaunchHarness: (command, seedText) =>
787
- launchHarnessInSplit({
788
- command,
789
- cwd: activeSession.artifact.meta.cwd ?? process.cwd(),
790
- seedText,
791
- }),
792
- onAgentTerminal: setAgentTerminal,
793
- }}
731
+ </box>
732
+ <ChangesColumn
733
+ open={diffColumns.changesOpen}
734
+ files={activeSession.artifact.files}
735
+ selectedPath={diffColumns.currentFilePath}
736
+ onSelectFile={diffColumns.scrollToFile}
737
+ theme={theme}
794
738
  />
795
739
  </box>
796
740
  <TrailingOverlays
@@ -806,6 +750,21 @@ export function App({
806
750
  setMode={setMode}
807
751
  dispatch={dispatch}
808
752
  />
753
+ {submitConfirmState !== null ? (
754
+ <box
755
+ style={{
756
+ position: "absolute",
757
+ left: 0,
758
+ top: 0,
759
+ width: "100%",
760
+ height: "100%",
761
+ justifyContent: "center",
762
+ alignItems: "center",
763
+ }}
764
+ >
765
+ <ConfirmCard {...submitConfirmState} theme={theme} />
766
+ </box>
767
+ ) : null}
809
768
  {menuChrome}
810
769
  </box>
811
770
  </ThemeProvider>