@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.35

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 (94) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +130 -4
  4. package/src/App.export.test.tsx +22 -4
  5. package/src/App.plan-review.test.tsx +127 -27
  6. package/src/App.readonly.test.tsx +31 -8
  7. package/src/App.rendering.test.tsx +12 -3
  8. package/src/App.review-panel.test.tsx +11 -3
  9. package/src/App.submit-confirm.test.tsx +12 -7
  10. package/src/App.test.tsx +46 -9
  11. package/src/App.toast.test.tsx +10 -3
  12. package/src/App.tsx +400 -100
  13. package/src/App.walk.test.tsx +30 -10
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +28 -9
  17. package/src/components/AnnotationCard.tsx +53 -38
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +22 -5
  24. package/src/components/ConfirmDialog.tsx +9 -1
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +18 -2
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +184 -98
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.tsx +19 -3
  32. package/src/components/MarkerPopover.stories.tsx +58 -0
  33. package/src/components/MarkerPopover.tsx +102 -0
  34. package/src/components/MenuBar.stories.tsx +5 -1
  35. package/src/components/MenuBar.tsx +18 -2
  36. package/src/components/PlanSheet.stories.tsx +34 -2
  37. package/src/components/PlanSheet.tsx +141 -32
  38. package/src/components/PromptDialog.tsx +18 -2
  39. package/src/components/ReviewPanel.stories.tsx +13 -3
  40. package/src/components/ReviewPanel.tsx +8 -4
  41. package/src/components/ReviewRail.stories.tsx +145 -72
  42. package/src/components/ReviewRail.tsx +177 -38
  43. package/src/components/SettingsDialog.stories.tsx +51 -3
  44. package/src/components/SettingsDialog.tsx +8 -3
  45. package/src/components/SettingsRows.tsx +19 -3
  46. package/src/components/Toast.tsx +17 -2
  47. package/src/components/WalkWizard.stories.tsx +13 -2
  48. package/src/components/WalkWizard.tsx +9 -3
  49. package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
  50. package/src/components/diff-sheet-layout.test.ts +127 -0
  51. package/src/components/diff-sheet-layout.ts +167 -0
  52. package/src/components/primitives/Button.tsx +5 -1
  53. package/src/components/primitives/Dialog.tsx +9 -1
  54. package/src/components/primitives/Tabs.tsx +24 -4
  55. package/src/components/stories-app.tsx +34 -6
  56. package/src/components/story-fixtures.ts +11 -3
  57. package/src/components/story.ts +3 -1
  58. package/src/components/syntax-highlight.ts +47 -12
  59. package/src/components/theme-context.stories.tsx +6 -1
  60. package/src/components/theme-context.tsx +7 -1
  61. package/src/config.test.ts +178 -2
  62. package/src/config.ts +121 -23
  63. package/src/diff-hunk-curate.test.ts +315 -0
  64. package/src/diff-hunk-curate.ts +233 -0
  65. package/src/diff-intraline.test.ts +164 -0
  66. package/src/diff-intraline.ts +132 -0
  67. package/src/diff-syntax.test.ts +96 -0
  68. package/src/diff-syntax.ts +154 -0
  69. package/src/editor.test.ts +13 -4
  70. package/src/editor.ts +31 -5
  71. package/src/herdr.ts +8 -2
  72. package/src/index.ts +9 -1
  73. package/src/integrations.ts +3 -1
  74. package/src/intent-dispatch.test.ts +193 -9
  75. package/src/intent-dispatch.ts +151 -18
  76. package/src/key-bindings.test.ts +19 -9
  77. package/src/key-bindings.ts +36 -14
  78. package/src/keymap.test.ts +171 -26
  79. package/src/keymap.ts +83 -16
  80. package/src/review-panel.test.ts +3 -1
  81. package/src/review-panel.ts +9 -2
  82. package/src/serve.test.ts +11 -3
  83. package/src/session-controller.curate.test.ts +253 -0
  84. package/src/session-controller.share.test.ts +65 -17
  85. package/src/session-controller.ts +301 -26
  86. package/src/share.ts +41 -8
  87. package/src/test-support.ts +16 -6
  88. package/src/theme-presets.ts +168 -0
  89. package/src/theme.ts +1 -3
  90. package/src/view-diff.ts +15 -4
  91. package/src/view-plan.test.ts +40 -6
  92. package/src/view-plan.ts +71 -28
  93. package/src/walk.test.ts +7 -2
  94. package/src/walk.ts +7 -2
package/src/App.tsx CHANGED
@@ -13,14 +13,36 @@ import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } fro
13
13
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
14
14
  import type { Clock, MouseEvent } from "@opentui/core";
15
15
  import { type VerdictKind } from "@cueloop/schema";
16
- import { displayText, marksByDisplay, type Mark } from "./view-plan";
16
+ import { displayText, marksByDisplay, spanFromRange, type Mark } from "./view-plan";
17
17
  import { noteForFile, viewedCount } from "./walk";
18
18
  import { DARK, dimmedTheme } from "./theme";
19
- import { DEFAULT_KEYS, loadConfig, persistAuthorName, persistAutoClose, type AutoClose } from "./config";
19
+ import {
20
+ DEFAULT_KEYS,
21
+ DEFAULT_QUICK_ACTIONS,
22
+ loadConfig,
23
+ persistAuthorName,
24
+ persistAutoClose,
25
+ persistTheme,
26
+ type AutoClose,
27
+ type QuickAction,
28
+ } from "./config";
29
+ import {
30
+ composeTheme,
31
+ DEFAULT_THEME_NAME,
32
+ THEME_LABELS,
33
+ THEME_NAMES,
34
+ type ThemeName,
35
+ } from "./theme-presets";
36
+ import type { Theme } from "./theme";
20
37
  import { returnPaneFor } from "@cueloop/schema";
21
38
  import { createReviewController } from "./session-controller";
22
39
  import type { SessionClient } from "@cueloop/daemon/client";
23
- import { createIntentDispatch, reviewerAnnotations, type Mode } from "./intent-dispatch";
40
+ import {
41
+ activeSpanState,
42
+ createIntentDispatch,
43
+ reviewerAnnotations,
44
+ type Mode,
45
+ } from "./intent-dispatch";
24
46
  import { reduceKey, type KeyState } from "./keymap";
25
47
  import { KeyBindings } from "./key-bindings";
26
48
  import { ThemeProvider } from "./components/theme-context";
@@ -79,7 +101,16 @@ export interface AppProps {
79
101
  selfAuthor?: string;
80
102
  }
81
103
 
82
- export function App({ home, sessionId, readOnly = false, onExit, clock, openClient, role = "owner", selfAuthor }: AppProps): React.ReactNode {
104
+ export function App({
105
+ home,
106
+ sessionId,
107
+ readOnly = false,
108
+ onExit,
109
+ clock,
110
+ openClient,
111
+ role = "owner",
112
+ selfAuthor,
113
+ }: AppProps): React.ReactNode {
83
114
  // Observer stays fully read-only; a collaborator writes annotations but not
84
115
  // the plan or a verdict. `observer` is what the controller and every write
85
116
  // gate key off; the two capability flags carve out the collaborator's middle.
@@ -89,7 +120,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
89
120
  // feeds all three; split it the day a collaborator earns one of them.
90
121
  const isOwner = !observer && role === "owner";
91
122
  const controller = useMemo(
92
- () => createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
123
+ () =>
124
+ createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
93
125
  // eslint-disable-next-line react-hooks/exhaustive-deps
94
126
  [home, sessionId],
95
127
  );
@@ -97,11 +129,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
97
129
  controller.connect();
98
130
  return () => controller.close();
99
131
  }, [controller]);
100
- const { session, inbox, status, toast, error, completion, editOrphanCount, walk } = useSyncExternalStore(
101
- controller.subscribe,
102
- controller.getSnapshot,
103
- controller.getSnapshot,
104
- );
132
+ const { session, inbox, status, toast, error, completion, editOrphanCount, walk } =
133
+ useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
105
134
  // A shared plan you own polls for collaborator notes while it is open; the
106
135
  // merge refreshes through the normal event path. Stops on leave.
107
136
  useEffect(() => {
@@ -119,12 +148,17 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
119
148
  const [menuOpen, setMenuOpen] = useState(false);
120
149
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
121
150
  const [autoClose, setAutoClose] = useState<AutoClose>("off");
122
- const [settingsNav, setSettingsNav] = useState<{ categoryId: string; rowIndex: number; zone: "nav" | "body" }>({
151
+ const [settingsNav, setSettingsNav] = useState<{
152
+ categoryId: string;
153
+ rowIndex: number;
154
+ zone: "nav" | "body";
155
+ }>({
123
156
  categoryId: "general",
124
157
  rowIndex: 0,
125
158
  zone: "body",
126
159
  });
127
160
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
161
+ const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
128
162
  const [railTab, setRailTab] = useState<"review" | "agent">("review");
129
163
  // review panel layout: mode + expanded width are client view state, loaded
130
164
  // from and persisted to the user config so they survive a restart. The ref
@@ -145,16 +179,22 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
145
179
  const keysRef = useRef(DEFAULT_KEYS);
146
180
  const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
147
181
  const [theme, setTheme] = useState(DARK);
182
+ const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
183
+ const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
148
184
  const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
185
+ const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
149
186
  useEffect(() => {
150
187
  const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
151
188
  keysRef.current = config.keys;
152
189
  keyBindings.setKeys(config.keys);
153
190
  setTheme(config.theme);
191
+ setThemeName(config.ui.theme);
192
+ setThemeOverrides(config.themeOverrides);
154
193
  setReviewMode(config.ui.reviewState);
155
194
  setReviewWidth(config.ui.reviewWidth);
156
195
  reviewWidthRef.current = config.ui.reviewWidth;
157
196
  setAuthorNames(config.authors);
197
+ setQuickActions(config.actions);
158
198
  setAutoClose(config.ui.autoClose);
159
199
  controller.applyConfig(config);
160
200
  }, [session?.workspace.repoRoot, controller, keyBindings]);
@@ -184,8 +224,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
184
224
  // ── derived view model ──────────────────────
185
225
  const display = controller.display();
186
226
  const rows = controller.rows();
227
+ const rejectedRows = controller.rejectedRows();
228
+ const curationItems = controller.curationItems();
187
229
  const marks = useMemo(
188
- () => (session ? marksByDisplay(session.annotations, display, pulsedAnnotationId ?? undefined) : new Map<number, Mark[]>()),
230
+ () =>
231
+ session
232
+ ? marksByDisplay(session.annotations, display, pulsedAnnotationId ?? undefined)
233
+ : new Map<number, Mark[]>(),
189
234
  [session, display, pulsedAnnotationId],
190
235
  );
191
236
  /** Annotation ids whose anchor resolved against the working copy. */
@@ -198,6 +243,25 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
198
243
  }, [marks]);
199
244
  const resolved = session?.status === "resolved";
200
245
  const isDiff = session?.artifact.type === "diff";
246
+ // sort position per annotation so the rail interleaves annotation and removal
247
+ // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
248
+ // annotation resolves to the display index it marked
249
+ const annotationPositions = useMemo(() => {
250
+ const positions = new Map<string, number>();
251
+ if (!session) return positions;
252
+ if (isDiff) {
253
+ for (const annotation of session.annotations) {
254
+ const blockIndex = (annotation.anchor as { blockIndex?: number }).blockIndex;
255
+ if (blockIndex !== undefined) positions.set(annotation.id, blockIndex);
256
+ }
257
+ } else {
258
+ for (const [displayIndex, blockMarks] of marks) {
259
+ for (const mark of blockMarks)
260
+ if (mark.annotationId) positions.set(mark.annotationId, displayIndex);
261
+ }
262
+ }
263
+ return positions;
264
+ }, [session, isDiff, marks]);
201
265
 
202
266
  // ── the guided walk's view model ────────────
203
267
  const walkFileList = controller.files();
@@ -208,8 +272,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
208
272
  // out of span mode clears the renderer selection (compose paints its own
209
273
  // mark, and a mouse drag never changes the mode, so it survives)
210
274
  useEffect(() => {
211
- if (mode.type === "span") planSheetRef.current?.driveSpanSelection(mode.span);
212
- else planSheetRef.current?.clearSelection();
275
+ // one marker at a time: clear any prior selection, then paint the current
276
+ // span (span and its quick-actions sub-mode both keep it painted)
277
+ const span = activeSpanState(mode);
278
+ planSheetRef.current?.clearSelection();
279
+ if (span) planSheetRef.current?.driveSpanSelection(span);
213
280
  }, [mode]);
214
281
 
215
282
  // ── selection symmetry: one selected id, both sides ──
@@ -240,6 +307,25 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
240
307
  revealAnchor(annotationId);
241
308
  };
242
309
 
310
+ // selecting a removal reveals its source line, so the reader sees what the
311
+ // pending undo would bring back: the diff sheet follows the cursor; the plan
312
+ // sheet also needs an explicit scroll to the cut block
313
+ const selectCurationFromRail = (curationId: string): void => {
314
+ setSelectedCurationId(curationId);
315
+ const item = curationItems.find((candidate) => candidate.id === curationId);
316
+ if (!item) return;
317
+ setCursor(item.revealIndex);
318
+ if (item.source === "plan") planSheetRef.current?.revealBlock(item.revealIndex);
319
+ };
320
+
321
+ // the selected removal card's undo button: same restore path as the u key,
322
+ // including the observer read-only guard the keyboard path gets via reduceKey
323
+ const undoCurationFromRail = (curationId: string): void => {
324
+ if (observer) return controller.setStatus("observer - read-only");
325
+ controller.restoreCuration(curationId);
326
+ setSelectedCurationId(undefined);
327
+ };
328
+
243
329
  const openCardEdit = (annotationId: string): void => {
244
330
  if (observer) return controller.setStatus("observer - read-only");
245
331
  const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
@@ -248,7 +334,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
248
334
  // the author rather than editing the body the planner does not own
249
335
  if (annotation.author) {
250
336
  setFocusedAnnotationId(annotationId);
251
- return void setMode({ type: "rename", authorId: annotation.author, text: authorNames[annotation.author] ?? "" });
337
+ return void setMode({
338
+ type: "rename",
339
+ authorId: annotation.author,
340
+ text: authorNames[annotation.author] ?? "",
341
+ });
252
342
  }
253
343
  if (resolved) return controller.setStatus("review submitted - read-only");
254
344
  liveInput.current = annotation.body;
@@ -281,7 +371,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
281
371
  reviewWidth,
282
372
  terminalWidth,
283
373
  focusedAnnotationId,
374
+ selectedCurationId,
284
375
  authorNames,
376
+ quickActions,
285
377
  renameAuthor: (id: string, name: string) => {
286
378
  persistAuthorName(id, name);
287
379
  setAuthorNames((prev) => ({ ...prev, [id]: name }));
@@ -296,6 +388,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
296
388
  setReviewWidth,
297
389
  setRailTab,
298
390
  setFocusedAnnotationId,
391
+ setSelectedCurationId,
299
392
  setPulsedAnnotationId,
300
393
  selectCardFromDocument,
301
394
  runEditorHandOff,
@@ -309,15 +402,17 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
309
402
  ? "submit"
310
403
  : mode.type === "confirmDelete"
311
404
  ? "confirm"
312
- : mode.type === "rename" || mode.type === "nameSelf"
313
- ? "prompt"
314
- : completion.phase === "prompt"
315
- ? "completion-prompt"
316
- : completion.phase === "counting"
317
- ? "completion-counting"
318
- : walking
319
- ? "walk"
320
- : "none";
405
+ : mode.type === "rename" || mode.type === "nameSelf"
406
+ ? "prompt"
407
+ : mode.type === "spanActions"
408
+ ? "spanActions"
409
+ : completion.phase === "prompt"
410
+ ? "completion-prompt"
411
+ : completion.phase === "counting"
412
+ ? "completion-counting"
413
+ : walking
414
+ ? "walk"
415
+ : "none";
321
416
 
322
417
  // ── settings dialog: config-backed model, navigation, persistence ──
323
418
  const settingsCategories: SettingsCategory[] = [
@@ -325,16 +420,47 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
325
420
  id: "general",
326
421
  name: "General",
327
422
  description: "submission behaviour",
328
- rows: [{ key: "autoClose", label: "Auto-close on submit", kind: "cycle", options: ["off", "3s", "10s"] }],
423
+ rows: [
424
+ {
425
+ key: "autoClose",
426
+ label: "Auto-close on submit",
427
+ kind: "cycle",
428
+ options: ["off", "3s", "10s"],
429
+ },
430
+ ],
329
431
  },
330
432
  {
331
433
  id: "display",
332
434
  name: "Display",
333
435
  description: "the review panel",
334
- rows: [{ key: "reviewPanel", label: "Review panel", kind: "cycle", options: ["expanded", "compact", "hidden"] }],
436
+ rows: [
437
+ {
438
+ key: "reviewPanel",
439
+ label: "Review panel",
440
+ kind: "cycle",
441
+ options: ["expanded", "compact", "hidden"],
442
+ },
443
+ ],
444
+ },
445
+ {
446
+ id: "appearance",
447
+ name: "Appearance",
448
+ description: "the color theme",
449
+ rows: [
450
+ {
451
+ key: "theme",
452
+ label: "Theme",
453
+ kind: "cycle",
454
+ options: THEME_NAMES.map((name) => THEME_LABELS[name]),
455
+ },
456
+ ],
335
457
  },
336
458
  ];
337
- const settingsValues = { autoClose: autoClose === "off" ? "off" : `${autoClose}s`, reviewPanel: reviewMode };
459
+ const settingsValues = {
460
+ autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
461
+ reviewPanel: reviewMode,
462
+ theme: THEME_LABELS[themeName],
463
+ };
338
464
  const cycleSetting = (rowKey: string): void => {
339
465
  if (rowKey === "autoClose") {
340
466
  const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
@@ -345,22 +471,47 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
345
471
  const next = order[(order.indexOf(reviewMode) + 1) % order.length]!;
346
472
  setReviewMode(next);
347
473
  controller.saveReviewPanel({ mode: next });
474
+ } else if (rowKey === "theme") {
475
+ const next = THEME_NAMES[(THEME_NAMES.indexOf(themeName) + 1) % THEME_NAMES.length]!;
476
+ setThemeName(next);
477
+ setTheme(composeTheme(next, themeOverrides));
478
+ persistTheme(next);
348
479
  }
349
480
  };
350
481
  const handleSettingsKey = (name: string): void => {
351
482
  if (name === "escape") return void setMenuDialog(null);
352
- const categoryIndex = settingsCategories.findIndex((category) => category.id === settingsNav.categoryId);
483
+ const categoryIndex = settingsCategories.findIndex(
484
+ (category) => category.id === settingsNav.categoryId,
485
+ );
353
486
  const category = settingsCategories[categoryIndex]!;
354
487
  if (settingsNav.zone === "nav") {
355
- if (name === "j" || name === "down") setSettingsNav({ categoryId: settingsCategories[Math.min(settingsCategories.length - 1, categoryIndex + 1)]!.id, rowIndex: 0, zone: "nav" });
356
- else if (name === "k" || name === "up") setSettingsNav({ categoryId: settingsCategories[Math.max(0, categoryIndex - 1)]!.id, rowIndex: 0, zone: "nav" });
357
- else if (name === "l" || name === "tab" || name === "return") setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: 0 }));
488
+ if (name === "j" || name === "down")
489
+ setSettingsNav({
490
+ categoryId:
491
+ settingsCategories[Math.min(settingsCategories.length - 1, categoryIndex + 1)]!.id,
492
+ rowIndex: 0,
493
+ zone: "nav",
494
+ });
495
+ else if (name === "k" || name === "up")
496
+ setSettingsNav({
497
+ categoryId: settingsCategories[Math.max(0, categoryIndex - 1)]!.id,
498
+ rowIndex: 0,
499
+ zone: "nav",
500
+ });
501
+ else if (name === "l" || name === "tab" || name === "return")
502
+ setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: 0 }));
358
503
  return;
359
504
  }
360
- if (name === "j" || name === "down") setSettingsNav((state) => ({ ...state, rowIndex: Math.min(category.rows.length - 1, state.rowIndex + 1) }));
361
- else if (name === "k" || name === "up") setSettingsNav((state) => ({ ...state, rowIndex: Math.max(0, state.rowIndex - 1) }));
505
+ if (name === "j" || name === "down")
506
+ setSettingsNav((state) => ({
507
+ ...state,
508
+ rowIndex: Math.min(category.rows.length - 1, state.rowIndex + 1),
509
+ }));
510
+ else if (name === "k" || name === "up")
511
+ setSettingsNav((state) => ({ ...state, rowIndex: Math.max(0, state.rowIndex - 1) }));
362
512
  else if (name === "h" || name === "tab") setSettingsNav((state) => ({ ...state, zone: "nav" }));
363
- else if (name === "return" || name === "space") cycleSetting(category.rows[settingsNav.rowIndex]!.key);
513
+ else if (name === "return" || name === "space")
514
+ cycleSetting(category.rows[settingsNav.rowIndex]!.key);
364
515
  };
365
516
 
366
517
  useKeyboard((key) => {
@@ -369,7 +520,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
369
520
  if (menuOpen) return void (key.name === "escape" && setMenuOpen(false));
370
521
  // the toast is non-modal: escape only dismisses it when nothing else owns
371
522
  // escape, so an open overlay (compose, submit, prompt, walk) still cancels
372
- if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span") return controller.dismissToast();
523
+ if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span")
524
+ return controller.dismissToast();
373
525
  const state: KeyState = {
374
526
  keys: keysRef.current,
375
527
  readOnly: observer,
@@ -385,15 +537,59 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
385
537
  hasFocusedAnnotation: focusedAnnotationId !== undefined,
386
538
  walkAtEnd: walk !== null && walk.index >= walkFileList.length,
387
539
  cursorAnnotatable: isDiff
388
- ? rows[cursor] !== undefined && rows[cursor]!.kind !== "file" && rows[cursor]!.kind !== "hunk"
540
+ ? rows[cursor] !== undefined &&
541
+ rows[cursor]!.kind !== "file" &&
542
+ rows[cursor]!.kind !== "hunk"
389
543
  : !!display[cursor]?.work,
390
544
  };
391
545
  keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
392
546
  const action = keyBindings.resolveAction({ name: key.name, shift: !!key.shift });
393
- for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift, meta: !!key.meta }, action))
547
+ for (const intent of reduceKey(
548
+ state,
549
+ { name: key.name, shift: !!key.shift, meta: !!key.meta },
550
+ action,
551
+ ))
394
552
  dispatch(intent);
395
553
  });
396
554
 
555
+ // ── shared bottom chrome: the menu bar and its drop-up dialogs, one render
556
+ // reused by the inbox and by plan/diff review so the two never drift ──
557
+ const menuChrome = (
558
+ <>
559
+ <MenuBar
560
+ open={menuOpen}
561
+ version={CLIENT_VERSION}
562
+ status={status}
563
+ onToggle={() => setMenuOpen((isOpen) => !isOpen)}
564
+ onSettings={() => {
565
+ setMenuOpen(false);
566
+ setMenuDialog("settings");
567
+ }}
568
+ onKeybinds={() => {
569
+ setMenuOpen(false);
570
+ setMenuDialog("keybinds");
571
+ }}
572
+ theme={theme}
573
+ />
574
+ {menuDialog === "keybinds" ? (
575
+ <KeybindsDialog sections={keyBindings.cheatsheet()} theme={theme} />
576
+ ) : null}
577
+ {menuDialog === "settings" ? (
578
+ <SettingsDialog
579
+ isOpen
580
+ categories={settingsCategories}
581
+ values={settingsValues}
582
+ activeCategoryId={settingsNav.categoryId}
583
+ activeRowIndex={settingsNav.rowIndex}
584
+ activeZone={settingsNav.zone}
585
+ onCategorySelect={(id) => setSettingsNav({ categoryId: id, rowIndex: 0, zone: "body" })}
586
+ onRowActivate={(row) => cycleSetting(row.key)}
587
+ theme={theme}
588
+ />
589
+ ) : null}
590
+ </>
591
+ );
592
+
397
593
  // ── render ──────────────────────────────────
398
594
  if (error) {
399
595
  return (
@@ -413,28 +609,65 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
413
609
  const confirming = mode.type === "confirmDelete" ? mode : null;
414
610
  return (
415
611
  <ThemeProvider theme={theme}>
416
- <InboxList
417
- inbox={inbox}
418
- cursor={inboxCursor}
419
- onRequestDelete={(id, title) => setMode({ type: "confirmDelete", sessionId: id, title })}
420
- />
421
- <ConfirmDialog
422
- isOpen={confirming !== null}
423
- title=" Delete plan "
424
- message={confirming ? `Delete "${confirming.title}"? This removes the plan and its review.` : ""}
425
- onConfirm={() => {
426
- if (confirming) controller.deleteSession(confirming.sessionId);
427
- setMode({ type: "normal" });
612
+ <box
613
+ style={{
614
+ flexDirection: "column",
615
+ width: "100%",
616
+ height: "100%",
617
+ backgroundColor: theme.background,
428
618
  }}
429
- onCancel={() => setMode({ type: "normal" })}
430
- theme={theme}
431
- />
619
+ >
620
+ {/* mirrors the review header row: same box, position, and accent product
621
+ word, with a " · resume" separator and no Edit/Share toolbar */}
622
+ <box
623
+ style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}
624
+ >
625
+ <box
626
+ style={{
627
+ height: 1,
628
+ backgroundColor: theme.panel,
629
+ paddingLeft: 1,
630
+ flexDirection: "row",
631
+ }}
632
+ >
633
+ <text>
634
+ <span fg={theme.accent}>cueloop</span>
635
+ <span fg={theme.textDim}> · resume</span>
636
+ </text>
637
+ </box>
638
+ <box style={{ flexGrow: 1 }} />
639
+ </box>
640
+ <InboxList
641
+ inbox={inbox}
642
+ cursor={inboxCursor}
643
+ onRequestDelete={(id, title) =>
644
+ setMode({ type: "confirmDelete", sessionId: id, title })
645
+ }
646
+ />
647
+ {menuChrome}
648
+ <ConfirmDialog
649
+ isOpen={confirming !== null}
650
+ title=" Delete plan "
651
+ message={
652
+ confirming
653
+ ? `Delete "${confirming.title}"? This removes the plan and its review.`
654
+ : ""
655
+ }
656
+ onConfirm={() => {
657
+ if (confirming) controller.deleteSession(confirming.sessionId);
658
+ setMode({ type: "normal" });
659
+ }}
660
+ onCancel={() => setMode({ type: "normal" })}
661
+ theme={theme}
662
+ />
663
+ </box>
432
664
  </ThemeProvider>
433
665
  );
434
666
  }
435
667
 
436
668
  const activeSession = session!;
437
- const pendingCount = reviewerAnnotations(activeSession).length + (activeSession.workingCopy !== undefined ? 1 : 0);
669
+ const pendingCount =
670
+ reviewerAnnotations(activeSession).length + (activeSession.workingCopy !== undefined ? 1 : 0);
438
671
 
439
672
  if ((completion.phase === "prompt" || completion.phase === "counting") && activeSession.verdict) {
440
673
  return (
@@ -443,7 +676,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
443
676
  verdict={activeSession.verdict.kind}
444
677
  completion={completion}
445
678
  status={status}
446
- returnsTo={returnPaneFor(activeSession.artifact.meta.herdrPane) ? (activeSession.artifact.meta.agent ?? "the agent") : undefined}
679
+ returnsTo={
680
+ returnPaneFor(activeSession.artifact.meta.herdrPane)
681
+ ? (activeSession.artifact.meta.agent ?? "the agent")
682
+ : undefined
683
+ }
447
684
  />
448
685
  </ThemeProvider>
449
686
  );
@@ -485,17 +722,75 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
485
722
  }
486
723
  : null;
487
724
 
488
- const activeSpan =
489
- mode.type === "span"
490
- ? { displayIndex: mode.span.displayIndex, start: mode.span.start, end: mode.span.end }
491
- : mode.type === "compose" && !isDiff
492
- ? // the compose anchor stays painted selection-style while the box is open
493
- { displayIndex: mode.displayIndex, start: mode.start, end: mode.end }
494
- : null;
725
+ const markedSpan = activeSpanState(mode);
726
+ const activeSpan = markedSpan
727
+ ? { displayIndex: markedSpan.displayIndex, start: markedSpan.start, end: markedSpan.end }
728
+ : mode.type === "compose" && !isDiff
729
+ ? // the compose anchor stays painted selection-style while the box is open
730
+ { displayIndex: mode.displayIndex, start: mode.start, end: mode.end }
731
+ : null;
732
+
733
+ // mouse mutations bypass reduceKey, so they replay its gates: an observer or a
734
+ // resolved review is read-only. Returns the status to answer with, or null.
735
+ const spanMutationBlock = (): string | null =>
736
+ observer ? "observer - read-only" : resolved ? "review submitted - read-only" : null;
737
+
738
+ // the marker-actions popover is span mode made visible: an inline toolbar at
739
+ // the marked block, or its quick-actions list. Cut is owner-only (hidden for a
740
+ // collaborator, like every other plan-edit affordance).
741
+ const popoverState =
742
+ markedSpan && !isDiff
743
+ ? {
744
+ displayIndex: markedSpan.displayIndex,
745
+ view: mode.type === "spanActions" ? ("actions" as const) : ("toolbar" as const),
746
+ actions: quickActions,
747
+ actionIndex: mode.type === "spanActions" ? mode.index : 0,
748
+ canCut: isOwner,
749
+ onComment: () => {
750
+ const blocked = spanMutationBlock();
751
+ if (blocked) return controller.setStatus(blocked);
752
+ dispatch({ type: "openCompose", kind: "comment", from: "span" });
753
+ },
754
+ onCut: () => {
755
+ const blocked = spanMutationBlock();
756
+ if (blocked) return controller.setStatus(blocked);
757
+ if (!isOwner) return;
758
+ dispatch({ type: "spanCut" });
759
+ },
760
+ onOpenActions: () => {
761
+ const blocked = spanMutationBlock();
762
+ if (blocked) return controller.setStatus(blocked);
763
+ dispatch({ type: "openSpanActions" });
764
+ },
765
+ onClose: () => dispatch({ type: "closeOverlay" }),
766
+ onPickAction: (index: number) => {
767
+ const blocked = spanMutationBlock();
768
+ if (blocked) return controller.setStatus(blocked);
769
+ dispatch({ type: "pickSpanAction", index });
770
+ },
771
+ onBack: () => dispatch({ type: "closeSpanActions" }),
772
+ }
773
+ : null;
495
774
 
496
775
  const onLineActivate = (displayIndex: number): void => {
497
- // releasing a drag-selection lands here too; a live selection is not a click
498
- if (renderer?.hasSelection) return;
776
+ // a mouse drag leaves a native selection: turn it into a word span so the
777
+ // marker popover opens at the dragged range, mirroring the `v` grammar
778
+ if (renderer?.hasSelection) {
779
+ // the release block re-anchors the span, so each drag replaces the last
780
+ const selection = planSheetRef.current?.readSelection(displayIndex);
781
+ const block = selection ? display[selection.displayIndex] : undefined;
782
+ const span =
783
+ selection && block
784
+ ? spanFromRange(
785
+ selection.displayIndex,
786
+ displayText(block),
787
+ selection.start,
788
+ selection.end,
789
+ )
790
+ : null;
791
+ if (span) setMode({ type: "span", span });
792
+ return;
793
+ }
499
794
  setCursor(displayIndex);
500
795
  const annotationId = marks.get(displayIndex)?.[0]?.annotationId;
501
796
  if (annotationId) selectCardFromDocument(annotationId);
@@ -576,23 +871,43 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
576
871
  const railFootprint =
577
872
  reviewMode === "hidden"
578
873
  ? 0
579
- : 1 + (reviewMode === "compact" ? REVIEW_COMPACT_WIDTH : resolveReviewWidth(reviewWidth, terminalWidth));
874
+ : 1 +
875
+ (reviewMode === "compact"
876
+ ? REVIEW_COMPACT_WIDTH
877
+ : resolveReviewWidth(reviewWidth, terminalWidth));
580
878
 
581
879
  // status badges sit right after the product word so they survive a header
582
880
  // that is too narrow for the whole trail (the rail can eat the width)
583
881
  const headerItems: BreadcrumbItem[] = [
584
882
  { label: "cueloop", tone: "accent" },
585
- ...(resolved ? [{ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
883
+ ...(resolved
884
+ ? [
885
+ {
886
+ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`,
887
+ tone: "green" as const,
888
+ },
889
+ ]
890
+ : []),
586
891
  ...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
587
- ...(role === "collaborator" ? [{ label: "shared · your notes save as you go", tone: "dim" as const }] : []),
588
- { label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
892
+ ...(role === "collaborator"
893
+ ? [{ label: "shared · your notes save as you go", tone: "dim" as const }]
894
+ : []),
895
+ {
896
+ label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`,
897
+ tone: "dim",
898
+ },
589
899
  { label: `submitted by ${activeSession.artifact.meta.agent ?? "unknown"}`, tone: "dim" },
590
900
  ];
591
901
 
592
902
  return (
593
903
  <ThemeProvider theme={theme}>
594
904
  <box
595
- style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.background }}
905
+ style={{
906
+ flexDirection: "column",
907
+ width: "100%",
908
+ height: "100%",
909
+ backgroundColor: theme.background,
910
+ }}
596
911
  onMouseDrag={(event: MouseEvent) => {
597
912
  if (!dividerDragging || reviewMode !== "expanded") return;
598
913
  const next = widthFromMouseColumn(event.x, terminalWidth);
@@ -605,14 +920,20 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
605
920
  controller.saveReviewPanel({ width: reviewWidthRef.current });
606
921
  }}
607
922
  >
608
- <box style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}>
923
+ <box
924
+ style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}
925
+ >
609
926
  <box style={{ flexGrow: 1, flexDirection: "row", paddingRight: 1 }}>
610
927
  <Breadcrumb items={headerItems} />
611
928
  <box style={{ flexGrow: 1 }} />
612
929
  {isOwner && !isDiff && !resolved ? (
613
930
  <Toolbar>
614
- <Button onPress={onEditRequest} theme={theme}>{" Edit "}</Button>
615
- <Button onPress={onShareRequest} theme={theme}>{" Share "}</Button>
931
+ <Button onPress={onEditRequest} theme={theme}>
932
+ {" Edit "}
933
+ </Button>
934
+ <Button onPress={onShareRequest} theme={theme}>
935
+ {" Share "}
936
+ </Button>
616
937
  </Toolbar>
617
938
  ) : null}
618
939
  </box>
@@ -626,6 +947,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
626
947
  cursor={cursor}
627
948
  annotations={activeSession.annotations}
628
949
  focusedAnnotationId={focusedAnnotationId}
950
+ rejectedRows={rejectedRows}
629
951
  compose={diffComposeState}
630
952
  theme={walking ? dimmedTheme(theme) : undefined}
631
953
  />
@@ -638,6 +960,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
638
960
  cursor={cursor}
639
961
  activeSpan={activeSpan}
640
962
  compose={composeState}
963
+ popover={popoverState}
641
964
  editOrphanCount={editOrphanCount}
642
965
  onLineActivate={onLineActivate}
643
966
  />
@@ -653,6 +976,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
653
976
  authorNames,
654
977
  selectedId: focusedAnnotationId,
655
978
  resolvedIds: isDiff ? null : resolvedIds,
979
+ curationItems,
980
+ selectedCurationId,
981
+ annotationPositions,
656
982
  railTab,
657
983
  pendingCount,
658
984
  cardEdit: cardEditState,
@@ -660,25 +986,12 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
660
986
  onTabChange: setRailTab,
661
987
  onSelectCard: selectCardFromRail,
662
988
  onActivateCard: openCardEdit,
989
+ onSelectCuration: selectCurationFromRail,
990
+ onUndoCuration: undoCurationFromRail,
663
991
  onSubmitRequest,
664
992
  }}
665
993
  />
666
994
  </box>
667
- <MenuBar
668
- open={menuOpen}
669
- version={CLIENT_VERSION}
670
- status={status}
671
- onToggle={() => setMenuOpen((isOpen) => !isOpen)}
672
- onSettings={() => {
673
- setMenuOpen(false);
674
- setMenuDialog("settings");
675
- }}
676
- onKeybinds={() => {
677
- setMenuOpen(false);
678
- setMenuDialog("keybinds");
679
- }}
680
- theme={theme}
681
- />
682
995
  {walking && walk !== null ? (
683
996
  <WalkWizard
684
997
  files={walkFileList}
@@ -720,20 +1033,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
720
1033
  />
721
1034
  ) : null}
722
1035
  {toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
723
- {menuDialog === "keybinds" ? <KeybindsDialog sections={keyBindings.cheatsheet()} theme={theme} /> : null}
724
- {menuDialog === "settings" ? (
725
- <SettingsDialog
726
- isOpen
727
- categories={settingsCategories}
728
- values={settingsValues}
729
- activeCategoryId={settingsNav.categoryId}
730
- activeRowIndex={settingsNav.rowIndex}
731
- activeZone={settingsNav.zone}
732
- onCategorySelect={(id) => setSettingsNav({ categoryId: id, rowIndex: 0, zone: "body" })}
733
- onRowActivate={(row) => cycleSetting(row.key)}
734
- theme={theme}
735
- />
736
- ) : null}
1036
+ {menuChrome}
737
1037
  </box>
738
1038
  </ThemeProvider>
739
1039
  );