@cueloop/client 0.1.0-alpha.58 → 0.1.0-alpha.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/App.tsx CHANGED
@@ -19,52 +19,34 @@ 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 { type VerdictKind } from "@cueloop/schema";
23
22
  import { displayText, marksByDisplay, spanFromRange, type Mark } from "./view-plan";
24
- import { noteForFile, viewedCount } from "./walk";
25
23
  import { dimmedTheme } from "./theme";
26
24
  import {
27
25
  DEFAULT_KEYS,
28
26
  DEFAULT_QUICK_ACTIONS,
29
27
  loadConfig,
30
- persistActions,
31
28
  persistAuthorName,
32
- persistAutoClose,
33
- persistTheme,
34
29
  type AutoClose,
35
30
  type QuickAction,
36
31
  } from "./config";
37
32
  import {
38
33
  composeTheme,
39
34
  DEFAULT_THEME_NAME,
40
- THEME_LABELS,
41
- THEME_NAMES,
42
35
  themeForName,
43
36
  type Appearance,
44
37
  type ThemeName,
45
38
  } from "./theme-presets";
46
39
  import type { Theme } from "./theme";
47
- import { returnPaneFor } from "@cueloop/schema";
48
40
  import { createReviewController } from "./session-controller";
49
41
  import type { SessionClient } from "@cueloop/daemon/client";
50
42
  import { launchHarnessInSplit } from "@cueloop/daemon/herdr-split";
51
- import {
52
- activeSpanState,
53
- createIntentDispatch,
54
- reviewerAnnotations,
55
- type Mode,
56
- } from "./intent-dispatch";
57
- import { reduceKey, type KeyState } from "./keymap";
43
+ import { activeSpanState, createIntentDispatch, type Mode } from "./intent-dispatch";
44
+ import { reduceKey } from "./keymap";
58
45
  import { KeyBindings } from "./key-bindings";
59
46
  import { ThemeProvider } from "./components/theme-context";
60
47
  import { Button } from "./components/primitives/Button";
61
48
  import { Toolbar } from "./components/primitives/Toolbar";
62
- import { MenuBar } from "./components/MenuBar";
63
- import { KeybindsDialog } from "./components/KeybindsDialog";
64
- import { SettingsDialog, type SettingsCategory } from "./components/SettingsDialog";
65
- import { QuickActionsEditor } from "./components/quick-actions-editor";
66
- import { CLIENT_VERSION } from "./version";
67
- import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
49
+ import { Breadcrumb } from "./components/Breadcrumb";
68
50
  import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
69
51
  import { DiffSheet } from "./components/DiffSheet";
70
52
  import { PrototypeSheet } from "./components/PrototypeSheet";
@@ -73,19 +55,39 @@ import { type ReviewRailHandle } from "./components/ReviewRail";
73
55
  import type { AgentTerminalHandle } from "./components/agent-launcher";
74
56
  import { ReviewPanel } from "./components/ReviewPanel";
75
57
  import {
76
- REVIEW_COMPACT_WIDTH,
77
58
  REVIEW_DEFAULT_WIDTH,
78
59
  resolveReviewWidth,
79
60
  toggleReviewPanelMode,
80
61
  widthFromMouseColumn,
81
62
  type ReviewPanelMode,
82
63
  } from "./review-panel";
83
- import { CompletionOverlay } from "./components/CompletionOverlay";
84
- import { InboxList } from "./components/InboxList";
85
- import { ConfirmDialog } from "./components/ConfirmDialog";
86
- import { PromptDialog } from "./components/PromptDialog";
87
- import { WalkWizard } from "./components/WalkWizard";
88
- import { Toast } from "./components/Toast";
64
+ import {
65
+ buildActiveSpan,
66
+ buildCardEditState,
67
+ buildComposeState,
68
+ buildDiffComposeState,
69
+ buildHeaderItems,
70
+ buildPopoverState,
71
+ buildRenderFlags,
72
+ buildSubmitConfirmState,
73
+ computePendingCount,
74
+ computeRailFootprint,
75
+ computeRoleCapabilities,
76
+ deriveReviewFlags,
77
+ isCompletionOverlayPhase,
78
+ isWalking,
79
+ resolveOverlay,
80
+ } from "./app-view-model";
81
+ import { buildKeyState } from "./app-key-state";
82
+ import { useSettingsDialog } from "./use-settings-dialog";
83
+ import {
84
+ CompletionScreen,
85
+ ConnectingScreen,
86
+ ErrorScreen,
87
+ InboxScreen,
88
+ MenuChrome,
89
+ TrailingOverlays,
90
+ } from "./app-screens";
89
91
 
90
92
  /** A toast clears itself after this idle; esc dismisses it sooner. */
91
93
  const TOAST_DISMISS_MS = 4000;
@@ -133,14 +135,7 @@ export function App({
133
135
  selfAuthor,
134
136
  appearance = "dark",
135
137
  }: AppProps): React.ReactNode {
136
- // Observer stays fully read-only; a collaborator writes annotations but not
137
- // the plan or a verdict. `observer` is what the controller and every write
138
- // gate key off; the two capability flags carve out the collaborator's middle.
139
- const observer = readOnly || role === "observer";
140
- // Editing the plan, submitting a verdict, and sharing are all the owner's
141
- // alone - a collaborator annotates, an observer only reads. One predicate
142
- // feeds all three; split it the day a collaborator earns one of them.
143
- const isOwner = !observer && role === "owner";
138
+ const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
144
139
  const controller = useMemo(
145
140
  () =>
146
141
  createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
@@ -177,15 +172,6 @@ export function App({
177
172
  const [menuOpen, setMenuOpen] = useState(false);
178
173
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
179
174
  const [autoClose, setAutoClose] = useState<AutoClose>("off");
180
- const [settingsNav, setSettingsNav] = useState<{
181
- categoryId: string;
182
- rowIndex: number;
183
- zone: "nav" | "body";
184
- }>({
185
- categoryId: "general",
186
- rowIndex: 0,
187
- zone: "body",
188
- });
189
175
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
190
176
  const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
191
177
  const [railTab, setRailTab] = useState<"review" | "agent">("review");
@@ -214,8 +200,6 @@ export function App({
214
200
  const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
215
201
  const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
216
202
  const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
217
- // the quick-action row whose system-prompt input is open in Settings, or null
218
- const [actionsExpandedIndex, setActionsExpandedIndex] = useState<number | null>(null);
219
203
  useEffect(() => {
220
204
  const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
221
205
  keysRef.current = config.keys;
@@ -254,6 +238,31 @@ export function App({
254
238
  if (!known) setMode({ type: "nameSelf", text: "" });
255
239
  }, [role, selfAuthor, session]);
256
240
 
241
+ // ── settings dialog: config-backed model, navigation, persistence ──
242
+ const {
243
+ settingsNav,
244
+ settingsCategories,
245
+ settingsValues,
246
+ cycleSetting,
247
+ handleSettingsKey,
248
+ onCategorySelect,
249
+ } = useSettingsDialog({
250
+ theme,
251
+ appearance,
252
+ autoClose,
253
+ setAutoClose,
254
+ reviewMode,
255
+ setReviewMode,
256
+ themeName,
257
+ setThemeName,
258
+ themeOverrides,
259
+ setTheme,
260
+ quickActions,
261
+ setQuickActions,
262
+ controller,
263
+ setMenuDialog,
264
+ });
265
+
257
266
  // ── derived view model ──────────────────────
258
267
  const display = controller.display();
259
268
  const rows = controller.rows();
@@ -274,9 +283,7 @@ export function App({
274
283
  }
275
284
  return ids;
276
285
  }, [marks]);
277
- const resolved = session?.status === "resolved";
278
- const isDiff = session?.artifact.type === "diff";
279
- const isPrototype = session?.artifact.type === "prototype";
286
+ const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
280
287
  const [prototypeComposing, setPrototypeComposing] = useState(false);
281
288
  // sort position per annotation so the rail interleaves annotation and removal
282
289
  // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
@@ -300,7 +307,7 @@ export function App({
300
307
 
301
308
  // ── the guided walk's view model ────────────
302
309
  const walkFileList = controller.files();
303
- const walking = isDiff && walk !== null;
310
+ const walking = isWalking(isDiff, walk);
304
311
  const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
305
312
 
306
313
  // driving needs committed layout, so it runs after render; any transition
@@ -430,188 +437,7 @@ export function App({
430
437
  openCardEdit,
431
438
  });
432
439
 
433
- const overlay: KeyState["overlay"] =
434
- mode.type === "compose" || mode.type === "railEdit"
435
- ? "compose"
436
- : mode.type === "submit"
437
- ? "submit"
438
- : mode.type === "confirmDelete"
439
- ? "confirm"
440
- : mode.type === "rename" || mode.type === "nameSelf"
441
- ? "prompt"
442
- : mode.type === "spanActions"
443
- ? "spanActions"
444
- : completion.phase === "prompt"
445
- ? "completion-prompt"
446
- : completion.phase === "counting"
447
- ? "completion-counting"
448
- : walking
449
- ? "walk"
450
- : "none";
451
-
452
- // ── settings dialog: config-backed model, navigation, persistence ──
453
- const commitActions = (next: QuickAction[]): void => {
454
- setQuickActions(next);
455
- persistActions(next);
456
- };
457
- const editActionMetadata = (index: number, metadata: string): void =>
458
- commitActions(
459
- quickActions.map((action, actionIndex) =>
460
- actionIndex === index
461
- ? { ...action, metadata: metadata.trim() ? metadata : undefined }
462
- : action,
463
- ),
464
- );
465
- const resetActions = (): void => {
466
- setActionsExpandedIndex(null);
467
- commitActions(DEFAULT_QUICK_ACTIONS.map((action) => ({ ...action })));
468
- };
469
- const addAction = (): void => {
470
- setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: quickActions.length }));
471
- commitActions([...quickActions, { prompt: "New action" }]);
472
- };
473
- const settingsCategories: SettingsCategory[] = [
474
- {
475
- id: "general",
476
- name: "General",
477
- description: "submission behaviour",
478
- rows: [
479
- {
480
- key: "autoClose",
481
- label: "Auto-close on submit",
482
- kind: "cycle",
483
- options: ["off", "3s", "10s"],
484
- },
485
- ],
486
- },
487
- {
488
- id: "display",
489
- name: "Display",
490
- description: "the review panel",
491
- rows: [
492
- {
493
- key: "reviewPanel",
494
- label: "Review panel",
495
- kind: "cycle",
496
- options: ["expanded", "compact", "hidden"],
497
- },
498
- ],
499
- },
500
- {
501
- id: "appearance",
502
- name: "Appearance",
503
- description: "the color theme",
504
- rows: [
505
- {
506
- key: "theme",
507
- label: "Theme",
508
- kind: "cycle",
509
- options: THEME_NAMES.map((name) => THEME_LABELS[name]),
510
- },
511
- ],
512
- },
513
- {
514
- id: "actions",
515
- name: "Actions",
516
- description: "quick-action comments",
517
- rows: [],
518
- customBody: (
519
- <QuickActionsEditor
520
- actions={quickActions}
521
- selectedIndex={settingsNav.categoryId === "actions" ? settingsNav.rowIndex : -1}
522
- expandedIndex={actionsExpandedIndex}
523
- onToggleExpand={(index) => {
524
- setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: index }));
525
- setActionsExpandedIndex((current) => (current === index ? null : index));
526
- }}
527
- onEditMetadata={editActionMetadata}
528
- onReset={resetActions}
529
- onAdd={addAction}
530
- theme={theme}
531
- />
532
- ),
533
- },
534
- ];
535
- const settingsValues = {
536
- autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
537
- reviewPanel: reviewMode,
538
- theme: THEME_LABELS[themeName],
539
- };
540
- const cycleSetting = (rowKey: string): void => {
541
- if (rowKey === "autoClose") {
542
- const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
543
- setAutoClose(next);
544
- persistAutoClose(next);
545
- } else if (rowKey === "reviewPanel") {
546
- const order: ReviewPanelMode[] = ["expanded", "compact", "hidden"];
547
- const next = order[(order.indexOf(reviewMode) + 1) % order.length]!;
548
- setReviewMode(next);
549
- controller.saveReviewPanel({ mode: next });
550
- } else if (rowKey === "theme") {
551
- const next = THEME_NAMES[(THEME_NAMES.indexOf(themeName) + 1) % THEME_NAMES.length]!;
552
- setThemeName(next);
553
- setTheme(composeTheme(next, themeOverrides, appearance));
554
- persistTheme(next);
555
- }
556
- };
557
- const handleSettingsKey = (name: string): void => {
558
- // an open system-prompt input owns typing; only esc (close it) escapes here
559
- if (actionsExpandedIndex !== null) {
560
- if (name === "escape") setActionsExpandedIndex(null);
561
- return;
562
- }
563
- if (name === "escape") return void setMenuDialog(null);
564
- const categoryIndex = settingsCategories.findIndex(
565
- (category) => category.id === settingsNav.categoryId,
566
- );
567
- const category = settingsCategories[categoryIndex]!;
568
- if (settingsNav.zone === "nav") {
569
- if (name === "j" || name === "down")
570
- setSettingsNav({
571
- categoryId:
572
- settingsCategories[Math.min(settingsCategories.length - 1, categoryIndex + 1)]!.id,
573
- rowIndex: 0,
574
- zone: "nav",
575
- });
576
- else if (name === "k" || name === "up")
577
- setSettingsNav({
578
- categoryId: settingsCategories[Math.max(0, categoryIndex - 1)]!.id,
579
- rowIndex: 0,
580
- zone: "nav",
581
- });
582
- else if (name === "l" || name === "tab" || name === "return")
583
- setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: 0 }));
584
- return;
585
- }
586
- // the Actions category is a list of quick actions plus a trailing "add" row
587
- if (category.id === "actions") {
588
- const rowCount = quickActions.length + 1;
589
- if (name === "j" || name === "down")
590
- setSettingsNav((state) => ({
591
- ...state,
592
- rowIndex: Math.min(rowCount - 1, state.rowIndex + 1),
593
- }));
594
- else if (name === "k" || name === "up")
595
- setSettingsNav((state) => ({ ...state, rowIndex: Math.max(0, state.rowIndex - 1) }));
596
- else if (name === "h" || name === "tab")
597
- setSettingsNav((state) => ({ ...state, zone: "nav" }));
598
- else if (name === "return" || name === "space" || name === "l") {
599
- if (settingsNav.rowIndex === quickActions.length) addAction();
600
- else setActionsExpandedIndex(settingsNav.rowIndex);
601
- }
602
- return;
603
- }
604
- if (name === "j" || name === "down")
605
- setSettingsNav((state) => ({
606
- ...state,
607
- rowIndex: Math.min(category.rows.length - 1, state.rowIndex + 1),
608
- }));
609
- else if (name === "k" || name === "up")
610
- setSettingsNav((state) => ({ ...state, rowIndex: Math.max(0, state.rowIndex - 1) }));
611
- else if (name === "h" || name === "tab") setSettingsNav((state) => ({ ...state, zone: "nav" }));
612
- else if (name === "return" || name === "space")
613
- cycleSetting(category.rows[settingsNav.rowIndex]!.key);
614
- };
440
+ const overlay = resolveOverlay(mode, completion.phase, walking);
615
441
 
616
442
  useKeyboard((key) => {
617
443
  // The prototype compose textarea owns the keyboard while open: let it receive
@@ -630,26 +456,23 @@ export function App({
630
456
  // escape, so an open overlay (compose, submit, prompt, walk) still cancels
631
457
  if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span")
632
458
  return controller.dismissToast();
633
- const state: KeyState = {
459
+ const state = buildKeyState({
634
460
  keys: keysRef.current,
635
- readOnly: observer,
636
- canEditPlan: isOwner,
637
- canSubmitVerdict: isOwner,
638
- canShare: isOwner,
461
+ observer,
462
+ isOwner,
639
463
  overlay,
640
- view: !session ? "inbox" : isDiff ? "diff" : "plan",
641
- spanMode: mode.type === "span",
642
- resolved: !!resolved,
643
- hasInboxItems: !!inbox?.length,
644
- annotationCount: session?.annotations.length ?? 0,
645
- hasFocusedAnnotation: focusedAnnotationId !== undefined,
646
- walkAtEnd: walk !== null && walk.index >= walkFileList.length,
647
- cursorAnnotatable: isDiff
648
- ? rows[cursor] !== undefined &&
649
- rows[cursor]!.kind !== "file" &&
650
- rows[cursor]!.kind !== "hunk"
651
- : !!display[cursor]?.work,
652
- };
464
+ session,
465
+ isDiff,
466
+ mode,
467
+ resolved,
468
+ inbox,
469
+ focusedAnnotationId,
470
+ walk,
471
+ walkFileList,
472
+ rows,
473
+ cursor,
474
+ display,
475
+ });
653
476
  keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
654
477
  const action = keyBindings.resolveAction({ name: key.name, shift: !!key.shift });
655
478
  for (const intent of reduceKey(
@@ -663,225 +486,97 @@ export function App({
663
486
  // ── shared bottom chrome: the menu bar and its drop-up dialogs, one render
664
487
  // reused by the inbox and by plan/diff review so the two never drift ──
665
488
  const menuChrome = (
666
- <>
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
- {menuDialog === "keybinds" ? (
683
- <KeybindsDialog sections={keyBindings.cheatsheet()} theme={theme} />
684
- ) : null}
685
- {menuDialog === "settings" ? (
686
- <SettingsDialog
687
- isOpen
688
- categories={settingsCategories}
689
- values={settingsValues}
690
- activeCategoryId={settingsNav.categoryId}
691
- activeRowIndex={settingsNav.rowIndex}
692
- activeZone={settingsNav.zone}
693
- onCategorySelect={(id) => {
694
- setActionsExpandedIndex(null);
695
- setSettingsNav({ categoryId: id, rowIndex: 0, zone: "body" });
696
- }}
697
- onRowActivate={(row) => cycleSetting(row.key)}
698
- theme={theme}
699
- />
700
- ) : null}
701
- </>
489
+ <MenuChrome
490
+ menuOpen={menuOpen}
491
+ menuDialog={menuDialog}
492
+ status={status}
493
+ theme={theme}
494
+ setMenuOpen={setMenuOpen}
495
+ setMenuDialog={setMenuDialog}
496
+ keybindsSections={keyBindings.cheatsheet()}
497
+ settingsCategories={settingsCategories}
498
+ settingsValues={settingsValues}
499
+ settingsNav={settingsNav}
500
+ onCategorySelect={onCategorySelect}
501
+ cycleSetting={cycleSetting}
502
+ />
702
503
  );
703
504
 
704
505
  // ── render ──────────────────────────────────
705
- if (error) {
706
- return (
707
- <ThemeProvider theme={theme}>
708
- <text fg={theme.red}>cueloop: {error}</text>
709
- </ThemeProvider>
710
- );
711
- }
712
- if (!session && !inbox) {
713
- return (
714
- <ThemeProvider theme={theme}>
715
- <text fg={theme.textDim}>connecting to the daemon…</text>
716
- </ThemeProvider>
717
- );
718
- }
719
- if (!session && inbox) {
720
- const confirming = mode.type === "confirmDelete" ? mode : null;
721
- return (
722
- <ThemeProvider theme={theme}>
723
- <box
724
- style={{
725
- flexDirection: "column",
726
- width: "100%",
727
- height: "100%",
728
- backgroundColor: theme.background,
729
- }}
730
- >
731
- {/* mirrors the review header row: same box, position, and accent product
732
- word, with a " · resume" separator and no Edit/Share toolbar */}
733
- <box
734
- style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}
735
- >
736
- <box
737
- style={{
738
- height: 1,
739
- backgroundColor: theme.panel,
740
- paddingLeft: 1,
741
- flexDirection: "row",
742
- }}
743
- >
744
- <text>
745
- <span fg={theme.accent}>cueloop</span>
746
- <span fg={theme.textDim}> · resume</span>
747
- </text>
748
- </box>
749
- <box style={{ flexGrow: 1 }} />
750
- </box>
751
- <InboxList
752
- inbox={inbox}
753
- cursor={inboxCursor}
754
- onRequestDelete={(id, title) =>
755
- setMode({ type: "confirmDelete", sessionId: id, title })
756
- }
757
- />
758
- {menuChrome}
759
- <ConfirmDialog
760
- isOpen={confirming !== null}
761
- title=" Delete plan "
762
- message={
763
- confirming
764
- ? `Delete "${confirming.title}"? This removes the plan and its review.`
765
- : ""
766
- }
767
- onConfirm={() => {
768
- if (confirming) controller.deleteSession(confirming.sessionId);
769
- setMode({ type: "normal" });
770
- }}
771
- onCancel={() => setMode({ type: "normal" })}
772
- theme={theme}
773
- />
774
- </box>
775
- </ThemeProvider>
506
+ if (error) return <ErrorScreen error={error} theme={theme} />;
507
+ if (!session)
508
+ return inbox ? (
509
+ <InboxScreen
510
+ inbox={inbox}
511
+ inboxCursor={inboxCursor}
512
+ mode={mode}
513
+ theme={theme}
514
+ controller={controller}
515
+ setMode={setMode}
516
+ menuChrome={menuChrome}
517
+ />
518
+ ) : (
519
+ <ConnectingScreen theme={theme} />
776
520
  );
777
- }
778
521
 
779
- const activeSession = session!;
780
- const pendingCount =
781
- reviewerAnnotations(activeSession).length + (activeSession.workingCopy !== undefined ? 1 : 0);
522
+ const activeSession = session;
782
523
 
783
- if ((completion.phase === "prompt" || completion.phase === "counting") && activeSession.verdict) {
524
+ if (isCompletionOverlayPhase(completion) && activeSession.verdict)
784
525
  return (
785
- <ThemeProvider theme={theme}>
786
- <CompletionOverlay
787
- verdict={activeSession.verdict.kind}
788
- completion={completion}
789
- status={status}
790
- returnsTo={
791
- returnPaneFor(activeSession.artifact.meta.herdrPane)
792
- ? (activeSession.artifact.meta.agent ?? "the agent")
793
- : undefined
794
- }
795
- />
796
- </ThemeProvider>
526
+ <CompletionScreen
527
+ theme={theme}
528
+ session={activeSession}
529
+ verdict={activeSession.verdict.kind}
530
+ completion={completion}
531
+ status={status}
532
+ />
797
533
  );
798
- }
799
-
800
- const composeState =
801
- mode.type === "compose" && !isDiff
802
- ? {
803
- kind: mode.kind,
804
- displayIndex: mode.displayIndex,
805
- quote: displayText(display[mode.displayIndex]!).slice(mode.start, mode.end),
806
- draft: {
807
- text: mode.text,
808
- onInput: (text: string) => {
809
- liveInput.current = text;
810
- setMode({ ...mode, text });
811
- },
812
- onSave: () => dispatch({ type: "saveCompose" }),
813
- onCancel: () => dispatch({ type: "closeOverlay" }),
814
- },
815
- }
816
- : null;
817
-
818
- const diffComposeState =
819
- mode.type === "compose" && isDiff
820
- ? {
821
- kind: mode.kind,
822
- rowIndex: mode.displayIndex,
823
- quote: rows[mode.displayIndex]?.text.replace(/\n$/, "") ?? "",
824
- draft: {
825
- text: mode.text,
826
- onInput: (text: string) => {
827
- liveInput.current = text;
828
- setMode({ ...mode, text });
829
- },
830
- onSave: () => dispatch({ type: "saveCompose" }),
831
- onCancel: () => dispatch({ type: "closeOverlay" }),
832
- },
833
- }
834
- : null;
835
-
836
- const markedSpan = activeSpanState(mode);
837
- const activeSpan = markedSpan
838
- ? { displayIndex: markedSpan.displayIndex, start: markedSpan.start, end: markedSpan.end }
839
- : mode.type === "compose" && !isDiff
840
- ? // the compose anchor stays painted selection-style while the box is open
841
- { displayIndex: mode.displayIndex, start: mode.start, end: mode.end }
842
- : null;
843
534
 
844
- // mouse mutations bypass reduceKey, so they replay its gates: an observer or a
845
- // resolved review is read-only. Returns the status to answer with, or null.
846
- const spanMutationBlock = (): string | null =>
847
- observer ? "observer - read-only" : resolved ? "review submitted - read-only" : null;
848
-
849
- // the marker-actions popover is span mode made visible: an inline toolbar at
850
- // the marked block, or its quick-actions list. Cut is owner-only (hidden for a
851
- // collaborator, like every other plan-edit affordance).
852
- const popoverState =
853
- markedSpan && !isDiff
854
- ? {
855
- displayIndex: markedSpan.displayIndex,
856
- view: mode.type === "spanActions" ? ("actions" as const) : ("toolbar" as const),
857
- actions: quickActions,
858
- actionIndex: mode.type === "spanActions" ? mode.index : 0,
859
- canCut: isOwner,
860
- onComment: () => {
861
- const blocked = spanMutationBlock();
862
- if (blocked) return controller.setStatus(blocked);
863
- dispatch({ type: "openCompose", kind: "comment", from: "span" });
864
- },
865
- onCut: () => {
866
- const blocked = spanMutationBlock();
867
- if (blocked) return controller.setStatus(blocked);
868
- if (!isOwner) return;
869
- dispatch({ type: "spanCut" });
870
- },
871
- onOpenActions: () => {
872
- const blocked = spanMutationBlock();
873
- if (blocked) return controller.setStatus(blocked);
874
- dispatch({ type: "openSpanActions" });
875
- },
876
- onClose: () => dispatch({ type: "closeOverlay" }),
877
- onPickAction: (index: number) => {
878
- const blocked = spanMutationBlock();
879
- if (blocked) return controller.setStatus(blocked);
880
- dispatch({ type: "pickSpanAction", index });
881
- },
882
- onBack: () => dispatch({ type: "closeSpanActions" }),
883
- }
884
- : null;
535
+ const composeState = buildComposeState({ mode, isDiff, display, liveInput, setMode, dispatch });
536
+ const diffComposeState = buildDiffComposeState({
537
+ mode,
538
+ isDiff,
539
+ rows,
540
+ liveInput,
541
+ setMode,
542
+ dispatch,
543
+ });
544
+ const activeSpan = buildActiveSpan(mode, isDiff);
545
+ const popoverState = buildPopoverState({
546
+ mode,
547
+ isDiff,
548
+ quickActions,
549
+ isOwner,
550
+ observer,
551
+ resolved,
552
+ controller,
553
+ dispatch,
554
+ });
555
+ const submitConfirmState = buildSubmitConfirmState({
556
+ mode,
557
+ isDiff,
558
+ session: activeSession,
559
+ walkFileList,
560
+ viewedPaths,
561
+ liveInput,
562
+ setMode,
563
+ dispatch,
564
+ });
565
+ const cardEditState = buildCardEditState({ mode, liveInput, setMode, dispatch });
566
+ const pendingCount = computePendingCount(activeSession);
567
+ const railFootprint = computeRailFootprint(reviewMode, reviewWidth, terminalWidth);
568
+ const headerItems = buildHeaderItems({ session: activeSession, resolved, observer, role });
569
+ const { showOwnerActions, prototypeCanComment, chromeHidden, prototypePath, railResolvedIds } =
570
+ buildRenderFlags({
571
+ session: activeSession,
572
+ isOwner,
573
+ isDiff,
574
+ isPrototype,
575
+ resolved,
576
+ menuOpen,
577
+ menuDialog,
578
+ resolvedIds,
579
+ });
885
580
 
886
581
  // a mouse drag leaves a native selection: turn it into a word span so the
887
582
  // marker popover opens at the dragged range, mirroring the `v` grammar.
@@ -947,73 +642,6 @@ export function App({
947
642
  if (reviewMode === "expanded") setDividerDragging(true);
948
643
  };
949
644
 
950
- const submitConfirmState =
951
- mode.type === "submit"
952
- ? {
953
- verdict: mode.verdict,
954
- summary: mode.summary,
955
- // walk coverage keeps partial passes honest at the verdict
956
- viewedSummary:
957
- isDiff && activeSession.viewedPaths !== undefined
958
- ? `${viewedCount(walkFileList, viewedPaths)}/${walkFileList.length} files viewed`
959
- : undefined,
960
- onInput: (summary: string) => {
961
- liveInput.current = summary;
962
- setMode({ ...mode, summary });
963
- },
964
- onSelectVerdict: (verdict: VerdictKind) => setMode({ ...mode, verdict }),
965
- onSubmit: () => dispatch({ type: "submitVerdict" }),
966
- onCancel: () => dispatch({ type: "closeOverlay" }),
967
- }
968
- : null;
969
-
970
- const cardEditState =
971
- mode.type === "railEdit"
972
- ? {
973
- id: mode.id,
974
- text: mode.text,
975
- onInput: (text: string) => {
976
- liveInput.current = text;
977
- setMode({ type: "railEdit", id: mode.id, text });
978
- },
979
- onSave: () => dispatch({ type: "saveCompose" }),
980
- onCancel: () => dispatch({ type: "closeOverlay" }),
981
- }
982
- : null;
983
-
984
- // width the review panel occupies (divider + rail), so the header can reserve
985
- // it and keep Edit/Share pinned to the plan sheet's right edge, not the rail's
986
- const railFootprint =
987
- reviewMode === "hidden"
988
- ? 0
989
- : 1 +
990
- (reviewMode === "compact"
991
- ? REVIEW_COMPACT_WIDTH
992
- : resolveReviewWidth(reviewWidth, terminalWidth));
993
-
994
- // status badges sit right after the product word so they survive a header
995
- // that is too narrow for the whole trail (the rail can eat the width)
996
- const headerItems: BreadcrumbItem[] = [
997
- { label: "cueloop", tone: "accent" },
998
- ...(resolved
999
- ? [
1000
- {
1001
- label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`,
1002
- tone: "green" as const,
1003
- },
1004
- ]
1005
- : []),
1006
- ...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
1007
- ...(role === "collaborator"
1008
- ? [{ label: "shared · your notes save as you go", tone: "dim" as const }]
1009
- : []),
1010
- {
1011
- label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`,
1012
- tone: "dim",
1013
- },
1014
- { label: `submitted by ${activeSession.artifact.meta.agent ?? "unknown"}`, tone: "dim" },
1015
- ];
1016
-
1017
645
  return (
1018
646
  <ThemeProvider theme={theme}>
1019
647
  <box
@@ -1044,7 +672,7 @@ export function App({
1044
672
  <box style={{ flexGrow: 1, flexDirection: "row", paddingRight: 1 }}>
1045
673
  <Breadcrumb items={headerItems} />
1046
674
  <box style={{ flexGrow: 1 }} />
1047
- {isOwner && !isDiff && !resolved ? (
675
+ {showOwnerActions ? (
1048
676
  <Toolbar>
1049
677
  <Button onPress={onEditRequest} theme={theme}>
1050
678
  {" Edit "}
@@ -1060,12 +688,12 @@ export function App({
1060
688
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
1061
689
  {isPrototype ? (
1062
690
  <PrototypeSheet
1063
- prototypePath={activeSession.artifact.meta.prototypePath ?? ""}
691
+ prototypePath={prototypePath}
1064
692
  quickActions={quickActions}
1065
- canComment={isOwner && !resolved}
693
+ canComment={prototypeCanComment}
1066
694
  onCommentElement={onCommentPrototype}
1067
695
  onComposingChange={setPrototypeComposing}
1068
- hidden={menuOpen || menuDialog !== null}
696
+ hidden={chromeHidden}
1069
697
  />
1070
698
  ) : isDiff ? (
1071
699
  // the sheet dims to reading-quiet colors while the wizard has focus
@@ -1103,7 +731,7 @@ export function App({
1103
731
  session: activeSession,
1104
732
  authorNames,
1105
733
  selectedId: focusedAnnotationId,
1106
- resolvedIds: isDiff || isPrototype ? null : resolvedIds,
734
+ resolvedIds: railResolvedIds,
1107
735
  curationItems,
1108
736
  selectedCurationId,
1109
737
  annotationPositions,
@@ -1127,47 +755,19 @@ export function App({
1127
755
  }}
1128
756
  />
1129
757
  </box>
1130
- {walking && walk !== null ? (
1131
- <WalkWizard
1132
- files={walkFileList}
1133
- index={walk.index}
1134
- viewedPaths={viewedPaths}
1135
- note={
1136
- walkFileList[walk.index] !== undefined
1137
- ? noteForFile(activeSession.annotations, walkFileList[walk.index]!.path)
1138
- : undefined
1139
- }
1140
- terminalWidth={terminalWidth}
1141
- onSubmitRequest={() => {
1142
- dispatch({ type: "walkLeave" });
1143
- dispatch({ type: "openSubmit" });
1144
- }}
1145
- onBack={() => dispatch({ type: "walkBack" })}
1146
- />
1147
- ) : null}
1148
- {mode.type === "rename" ? (
1149
- <PromptDialog
1150
- isOpen
1151
- title=" Rename author "
1152
- label="Display name for this collaborator:"
1153
- value={mode.text}
1154
- placeholder="their name"
1155
- onInput={(text) => setMode({ ...mode, text })}
1156
- theme={theme}
1157
- />
1158
- ) : null}
1159
- {mode.type === "nameSelf" ? (
1160
- <PromptDialog
1161
- isOpen
1162
- title=" Welcome "
1163
- label="Your name (optional) - it attributes the notes you leave:"
1164
- value={mode.text}
1165
- placeholder="your name"
1166
- onInput={(text) => setMode({ ...mode, text })}
1167
- theme={theme}
1168
- />
1169
- ) : null}
1170
- {toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
758
+ <TrailingOverlays
759
+ walking={walking}
760
+ walk={walk}
761
+ walkFileList={walkFileList}
762
+ viewedPaths={viewedPaths}
763
+ session={activeSession}
764
+ terminalWidth={terminalWidth}
765
+ theme={theme}
766
+ mode={mode}
767
+ toast={toast}
768
+ setMode={setMode}
769
+ dispatch={dispatch}
770
+ />
1171
771
  {menuChrome}
1172
772
  </box>
1173
773
  </ThemeProvider>