@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.79

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 (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
package/src/App.tsx CHANGED
@@ -7,7 +7,7 @@ import React, {
7
7
  useSyncExternalStore,
8
8
  } from "react";
9
9
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
10
- import type { Clock } from "@opentui/core";
10
+ import type { Clock, KeyEvent } from "@opentui/core";
11
11
  import { marksByDisplay, type Mark } from "./view-plan";
12
12
  import {
13
13
  DEFAULT_KEYS,
@@ -42,7 +42,8 @@ import { KeyBindings, type CheatsheetSection } from "./key-bindings";
42
42
  import { useReadySignal } from "./ready-signal";
43
43
  import { ThemeProvider } from "./components/theme-context";
44
44
  import { Button } from "./components/primitives/Button";
45
- import { ShareChoiceDialog, SHARE_CHOICES } from "./components/ShareChoiceDialog";
45
+ import { ShareDialog } from "./components/ShareDialog";
46
+ import { shareDialogStore } from "./components/share-dialog-store";
46
47
  import { Toolbar } from "./components/primitives/Toolbar";
47
48
  import { groupInbox, projectName, threadTitle } from "./components/session-tree";
48
49
  import { ThreadTree } from "./components/ThreadTree";
@@ -53,27 +54,32 @@ import { AppShell, type FocusPane, type ProjectPanelMode } from "./components/Ap
53
54
  import { EditorGrid } from "./components/EditorGrid";
54
55
  import { GridTabContent } from "./components/GridTabContent";
55
56
  import { useChangesWorkbench } from "./use-changes-workbench";
57
+ import { useThreadBodyEditing } from "./use-thread-body-editing";
56
58
  import { useRememberLayout } from "./use-remember-layout";
57
59
  import type { LaunchLayout } from "./launch-layout";
58
- import { ThreadFooter } from "./components/ThreadFooter";
60
+ import { ThreadFooter, THREAD_FOOTER_HEIGHT } from "./components/ThreadFooter";
59
61
  import { ConfirmCard } from "./components/ConfirmCard";
60
- import { ManageAccessDialog } from "./components/ManageAccessDialog";
61
62
  import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
62
63
  import {
63
- diffChordEntries,
64
- dispatchLeaderCommand,
65
- leaderCombosFor,
66
- leaderHint,
67
- matchesLeader,
68
- railChordEntries,
69
- resolveThreadChord,
70
- THREAD_CHORD_ENTRIES,
71
- treeChordEntries,
64
+ curationCommandEntries,
65
+ diffCommandEntries,
66
+ resolveNavKey,
67
+ resolveSessionChord,
68
+ sessionCommandEntries,
69
+ treeCommandEntries,
72
70
  } from "./thread-chords";
73
71
  import { type DiffFoldControls } from "./components/DiffContentView";
74
72
  import { commentCountsByFile } from "./view-diff";
75
- import { annotationTarget } from "@cueloop/schema";
76
- import type { Annotation, Artifact, DiffFileContents, Identity, Thread } from "@cueloop/schema";
73
+ import { annotationTarget, threadShareLinks } from "@cueloop/schema";
74
+ import type {
75
+ Annotation,
76
+ Artifact,
77
+ DiffFileContents,
78
+ Identity,
79
+ ShareLink,
80
+ Thread,
81
+ VerdictKind,
82
+ } from "@cueloop/schema";
77
83
  import { PrototypePixels } from "./prototype-pixels";
78
84
  import type { PrototypeElement } from "./prototype-browser";
79
85
  import {
@@ -145,9 +151,19 @@ function menuChromeOpen(menuDialog: "keybinds" | "settings" | null): boolean {
145
151
  return menuDialog !== null;
146
152
  }
147
153
 
148
- /** The private-share allowlist for a session, or an empty list for a public one. */
149
- function allowedGithubLogins(session: Thread | null): string[] {
150
- return session?.access?.githubLogins ?? [];
154
+ /** Whether a thread already has at least one live share link. */
155
+ function isSharedThread(session: Thread | null): boolean {
156
+ return session !== null && (threadShareLinks(session)?.length ?? 0) > 0;
157
+ }
158
+
159
+ /** A thread's live share links for the share dialog, or an empty list with no session. */
160
+ function shareLinksFor(session: Thread | null): ShareLink[] {
161
+ return session ? (threadShareLinks(session) ?? []) : [];
162
+ }
163
+
164
+ /** The title the share dialog shows and prefills a new link with, or empty with no session. */
165
+ function shareThreadName(session: Thread | null): string {
166
+ return session ? threadTitle(session) : "";
151
167
  }
152
168
 
153
169
  /** The drop-up chrome or a floating popover menu (thread actions, editor split) holds the keyboard. */
@@ -190,80 +206,67 @@ function menuModalHandled(
190
206
  return true;
191
207
  }
192
208
 
193
- /** True while a menu or an overlay owns the keyboard instead of the thread view. */
194
- function keyboardOwnedElsewhere(menuOwnsKeyboard: boolean, overlay: KeyState["overlay"]): boolean {
195
- return menuOwnsKeyboard || overlay !== "none";
196
- }
197
-
198
- /** The highlighted share option, or the public default when the choice is closed. */
199
- function shareChoiceIndex(mode: Mode): number {
200
- return mode.type === "shareChoice" ? mode.index : 0;
201
- }
202
-
203
- interface ShareChoiceActions {
204
- setMode: (mode: Mode) => void;
205
- isOwner: boolean;
206
- publish: () => void;
207
- openManageAccess: () => void;
209
+ /** True while a menu, an overlay, or the share dialog owns the keyboard instead of the thread view. */
210
+ function keyboardOwnedElsewhere(
211
+ menuOwnsKeyboard: boolean,
212
+ overlay: KeyState["overlay"],
213
+ shareDialogOpen: boolean,
214
+ ): boolean {
215
+ return menuOwnsKeyboard || overlay !== "none" || shareDialogOpen;
208
216
  }
209
217
 
210
- /** Move the share-choice highlight by one row, clamped to the list. */
211
- function movedShareChoice(index: number, delta: number): Mode {
212
- const next = Math.min(SHARE_CHOICES.length - 1, Math.max(0, index + delta));
213
-
214
- return { type: "shareChoice", index: next };
218
+ /** The footer submit fires only for the owner of an unresolved review, never an observer. */
219
+ function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean): boolean {
220
+ return isOwner && !resolved && !observer;
215
221
  }
216
222
 
217
- /** Commit the highlighted share option: public publishes a link, private opens manage-access. */
218
- function commitShareChoice(index: number, actions: ShareChoiceActions): void {
219
- const picked = SHARE_CHOICES[index]?.choice;
220
-
221
- actions.setMode({ type: "normal" });
222
-
223
- if (!actions.isOwner) return;
224
-
225
- if (picked === "private") actions.openManageAccess();
226
- else actions.publish();
223
+ /** The thread body edits inline only for its owner, on an unresolved markdown thread (never a diff). */
224
+ function canEditThreadBody(
225
+ threadViewActive: boolean,
226
+ isDiff: boolean,
227
+ isOwner: boolean,
228
+ resolved: boolean,
229
+ ): boolean {
230
+ return threadViewActive && !isDiff && isOwner && !resolved;
227
231
  }
228
232
 
229
- /** Route one key to the open share choice: escape cancels, arrows move, enter commits. */
230
- function handleShareChoiceKey(
231
- index: number,
232
- key: { name: string },
233
- actions: ShareChoiceActions,
234
- ): void {
235
- if (key.name === "escape") return actions.setMode({ type: "normal" });
236
-
237
- if (key.name === "down" || key.name === "j") return actions.setMode(movedShareChoice(index, 1));
238
-
239
- if (key.name === "up" || key.name === "k") return actions.setMode(movedShareChoice(index, -1));
240
-
241
- if (key.name === "return" || key.name === "enter") commitShareChoice(index, actions);
233
+ /** The owner's header actions: an edit/normal toggle that opens or leaves the inline editor, plus share when not editing. */
234
+ function ownerThreadActions(actions: {
235
+ editing: boolean;
236
+ onEdit: () => void;
237
+ onExitEdit: () => void;
238
+ onShare: () => void;
239
+ theme: Theme;
240
+ }): React.ReactNode {
241
+ return (
242
+ <Toolbar>
243
+ <Button onPress={actions.editing ? actions.onExitEdit : actions.onEdit} theme={actions.theme}>
244
+ {actions.editing ? " normal " : " edit "}
245
+ </Button>
246
+ {actions.editing ? null : (
247
+ <Button onPress={actions.onShare} theme={actions.theme}>
248
+ {" share "}
249
+ </Button>
250
+ )}
251
+ </Toolbar>
252
+ );
242
253
  }
243
254
 
244
- /**
245
- * An app-level dialog owns the keyboard while open: the manage-access dialog takes its own keys,
246
- * and the share choice moves the highlight, commits on enter, and cancels on escape. Returns true
247
- * when a dialog consumed the key so the caller stops the grammar.
248
- */
249
- function appDialogOwnsKey(
250
- accessDialogOpen: boolean,
251
- mode: Mode,
252
- key: { name: string },
253
- actions: ShareChoiceActions,
254
- ): boolean {
255
- if (accessDialogOpen) return true;
256
-
257
- if (mode.type !== "shareChoice") return false;
258
-
259
- handleShareChoiceKey(mode.index, key, actions);
260
-
261
- return true;
262
- }
255
+ /** Pick the thread pane's body: the pixel prototype, the diff placeholder, the inline editor, or the read-only view. */
256
+ function chooseThreadBody(choice: {
257
+ isPixelPrototype: boolean;
258
+ isDiff: boolean;
259
+ editingBody: boolean;
260
+ prototype: React.ReactNode;
261
+ diffPlaceholder: React.ReactNode;
262
+ editor: React.ReactNode;
263
+ threadView: React.ReactNode;
264
+ }): React.ReactNode {
265
+ if (choice.isPixelPrototype) return choice.prototype;
266
+ if (choice.isDiff) return choice.diffPlaceholder;
267
+ if (choice.editingBody) return choice.editor;
263
268
 
264
- /** The footer submit fires only for the owner of an unresolved review, never an observer. */
265
- function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean): boolean {
266
- return isOwner && !resolved && !observer;
269
+ return choice.threadView;
267
270
  }
268
271
 
269
272
  /** The author display name for a comment's hover tooltip: the reviewer's own name for their notes, else the resolved collaborator name. */
@@ -344,11 +347,7 @@ function usableScreenReached(
344
347
  }
345
348
 
346
349
  /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
347
- function cheatsheetFor(
348
- keyBindings: KeyBindings,
349
- threadViewActive: boolean,
350
- hint: string,
351
- ): CheatsheetSection[] {
350
+ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
352
351
  const base = keyBindings.cheatsheet();
353
352
 
354
353
  if (!threadViewActive) {
@@ -357,11 +356,10 @@ function cheatsheetFor(
357
356
 
358
357
  return [
359
358
  ...THREAD_VIEW_CHEATSHEET,
360
- { title: "Session", entries: [...THREAD_CHORD_ENTRIES] },
361
- { title: "Diff", entries: diffChordEntries(hint) },
362
- { title: "Rail", entries: railChordEntries(hint) },
363
- { title: "Tree", entries: treeChordEntries(hint) },
364
- ...base.filter((section) => section.title === "Agent terminal"),
359
+ { title: "Nav mode · session", entries: sessionCommandEntries() },
360
+ { title: "Nav mode · diff", entries: diffCommandEntries() },
361
+ { title: "Nav mode · discussion", entries: curationCommandEntries() },
362
+ { title: "Nav mode · history", entries: treeCommandEntries() },
365
363
  ];
366
364
  }
367
365
 
@@ -421,29 +419,13 @@ export function threadsNavHandled(params: {
421
419
  return false;
422
420
  }
423
421
 
424
- export function appLeaderHandled(params: {
425
- focusedPane: FocusPane;
426
- key: { name: string; shift?: boolean; ctrl?: boolean; meta?: boolean; super?: boolean };
427
- leaderCombos: readonly string[];
428
- pending: { current: boolean };
429
- runLeaderCommand: (key: { name: string; shift?: boolean }) => void;
430
- }): boolean {
431
- const { focusedPane, key, leaderCombos, pending, runLeaderCommand } = params;
432
-
433
- if (focusedPane !== "threads" && focusedPane !== "project") return false;
434
- if (pending.current) {
435
- pending.current = false;
436
- if (key.name !== "escape") runLeaderCommand(key);
437
-
438
- return true;
439
- }
440
- if (matchesLeader(key, leaderCombos)) {
441
- pending.current = true;
442
-
443
- return true;
444
- }
445
-
446
- return false;
422
+ export function paneCycleRequested(
423
+ key: { name: string },
424
+ overlay: string,
425
+ menuOwnsKeyboard: boolean,
426
+ threadComposing: boolean,
427
+ ): boolean {
428
+ return key.name === "tab" && overlay === "none" && !menuOwnsKeyboard && !threadComposing;
447
429
  }
448
430
 
449
431
  export function visiblePanes(
@@ -543,11 +525,12 @@ export function App({
543
525
 
544
526
  // A shared plan you own polls for collaborator notes while it is open; the
545
527
  // merge refreshes through the normal event path. Stops on leave.
528
+ const threadShared = isSharedThread(session);
546
529
  useEffect(() => {
547
- if (!isOwner || !session?.shareId) return;
530
+ if (!isOwner || !threadShared) return;
548
531
 
549
532
  return controller.startShareSync();
550
- }, [isOwner, session?.id, session?.shareId, controller]);
533
+ }, [isOwner, session?.id, threadShared, controller]);
551
534
  const renderer = useRenderer();
552
535
  const { width: terminalWidth } = useTerminalDimensions();
553
536
 
@@ -594,10 +577,11 @@ export function App({
594
577
  const [mode, setMode] = useState<Mode>({ type: "normal" });
595
578
  // the top-left settings gear drop-down and the centered dialog it opens
596
579
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
597
- const [accessDialogOpen, setAccessDialogOpen] = useState(false);
580
+ const [shareDialogOpen, setShareDialogOpen] = useState(false);
598
581
  const [autoClose, setAutoClose] = useState<AutoClose>("off");
599
582
  // unified or side-by-side diff; split only lays out when the Changes pane is zoomed
600
583
  const [diffView, setDiffView] = useState<DiffViewMode>("split");
584
+ const [defaultVerdict, setDefaultVerdict] = useState<VerdictKind>("approve");
601
585
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
602
586
  const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
603
587
  const [railTab, setRailTab] = useState<RailTab>("review");
@@ -641,6 +625,7 @@ export function App({
641
625
  setSkills(loadSkills(config.skillsPath));
642
626
  setAutoClose(config.ui.autoClose);
643
627
  setDiffView(config.ui.diffView);
628
+ setDefaultVerdict(config.ui.defaultVerdict);
644
629
  setPinnedIds(new Set(config.ui.pins));
645
630
  controller.applyConfig(config);
646
631
  }, [session?.workspace.repoRoot, controller, keyBindings, appearance]);
@@ -701,6 +686,7 @@ export function App({
701
686
  cycleSetting,
702
687
  handleSettingsKey,
703
688
  onCategorySelect,
689
+ openSettings,
704
690
  } = useSettingsDialog({
705
691
  theme,
706
692
  appearance,
@@ -773,6 +759,13 @@ export function App({
773
759
  const [threadComposing, setThreadComposing] = useState(false);
774
760
  const [prototypeComposing, setPrototypeComposing] = useState(false);
775
761
  const [welcomeComposing, setWelcomeComposing] = useState(false);
762
+ // inline body edit: the markdown editor owns the thread pane and all keys while open
763
+ const canEditBody = canEditThreadBody(threadViewActive, isDiff, isOwner, resolved);
764
+ const bodyEditing = useThreadBodyEditing({
765
+ controller,
766
+ sessionId: session?.id,
767
+ canEdit: canEditBody,
768
+ });
776
769
  // sort position per annotation so the rail interleaves annotation and removal
777
770
  // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
778
771
  // annotation resolves to the display index it marked
@@ -807,8 +800,13 @@ export function App({
807
800
  setMode({ type: "railEdit", id: annotation.id, text: annotation.body });
808
801
  };
809
802
 
810
- /** The $EDITOR hand-off releases the terminal: suspend, edit, resume. */
811
- const runEditorHandOff = (): void => {
803
+ // a markdown thread edits inline; a diff is not markdown, so it keeps the external-editor hand-off
804
+ const openBodyEditor = (): void => {
805
+ if (canEditBody) {
806
+ bodyEditing.openEditor();
807
+
808
+ return;
809
+ }
812
810
  renderer?.suspend();
813
811
  try {
814
812
  controller.edit();
@@ -829,6 +827,7 @@ export function App({
829
827
  inboxCursor,
830
828
  mode,
831
829
  session,
830
+ defaultVerdict,
832
831
  focusedAnnotationId,
833
832
  selectedCurationId,
834
833
  railTab,
@@ -853,7 +852,7 @@ export function App({
853
852
  setSelectedCurationId,
854
853
  setPulsedAnnotationId,
855
854
  selectCardFromDocument,
856
- runEditorHandOff,
855
+ openBodyEditor,
857
856
  openCardEdit,
858
857
  toggleDiffView: () => {
859
858
  const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
@@ -865,18 +864,22 @@ export function App({
865
864
  else if (workbench.zoomed) controller.setStatus("split diff");
866
865
  else controller.setStatus("split diff shows when zoomed");
867
866
  },
867
+ openShareDialog: () => {
868
+ if (!isOwner) return controller.setStatus("only the plan owner can share");
869
+ shareDialogStore.getState().reset();
870
+ setShareDialogOpen(true);
871
+ },
868
872
  });
869
873
 
870
874
  const overlay = resolveOverlay(mode, completion.phase, walking);
871
875
 
872
876
  const menuControl = useMenuControlState();
873
877
  const menuOwnsKeyboard = keyboardHeldByMenu(menuDialog, menuControl.openMenuId);
874
- // an overlay (submit, walk, prompt, confirm, share choice) or the menu takes the
875
- // keyboard from the thread view; the view suspends its own grammar meanwhile
876
- const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay);
878
+ // an overlay (submit, walk, prompt, confirm, share choice), the manage-access
879
+ // dialog, or the menu takes the keyboard from the thread view; the view suspends
880
+ // its own grammar meanwhile so typing lands in the dialog, not the thread
881
+ const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay, shareDialogOpen);
877
882
 
878
- const leaderCombos = leaderCombosFor(keysRef.current.leader);
879
- const leaderPending = useRef(false);
880
883
  const navigablePanes = navigableFocusPanes(
881
884
  sidebarOpen,
882
885
  workbench.zoomed,
@@ -893,40 +896,50 @@ export function App({
893
896
  }, [session, navigablePanes, focusedPane]);
894
897
  const cyclePanes = (backward: boolean): void =>
895
898
  setFocusedPane((current) => nextFocusPane(current, navigablePanes, backward));
896
- const runLeaderCommand = (key: { name: string; shift?: boolean }): void => {
897
- if (key.name === "tab") return cyclePanes(Boolean(key.shift));
899
+ const runNavCommand = (key: { name: string; shift?: boolean }): boolean => {
900
+ const intent = resolveNavKey(key, {
901
+ isOwner,
902
+ resolved,
903
+ treeActive: railTab === "tree",
904
+ isDiff,
905
+ });
898
906
 
899
- dispatchLeaderCommand(
900
- key,
901
- { composing: threadComposing, isOwner, resolved, treeActive: railTab === "tree", isDiff },
902
- dispatch,
903
- );
907
+ if (!intent) return false;
908
+ dispatch(intent);
909
+
910
+ return true;
904
911
  };
905
912
  // clicking a sidebar thread moves the cursor onto it too, so the row shows its selected backdrop at once
906
913
  const openThread = (id: string): void => {
914
+ // save the open editor into the leaving thread's working copy before the switch, so a click away never drops edits
915
+ if (bodyEditing.editing) bodyEditing.requestExit();
907
916
  const index = grouped.ordered.findIndex((thread) => thread.id === id);
908
917
 
909
918
  if (index >= 0) setInboxCursor(() => index);
910
919
  controller.open(id);
911
920
  };
912
921
 
922
+ const threadSurfaceHandledKey = (key: KeyEvent): boolean => {
923
+ if (!threadViewActive || threadViewSuspended) return false;
924
+ if (!threadComposing) {
925
+ const chord = resolveSessionChord(key, { isOwner, resolved });
926
+
927
+ if (chord) dispatch(chord);
928
+ }
929
+
930
+ return true;
931
+ };
932
+
913
933
  useKeyboard((key) => {
914
934
  if (quitKeyHandled(key, onExit)) return;
915
- // an app-level dialog (manage-access, share choice) owns the keyboard while open
916
- if (
917
- appDialogOwnsKey(accessDialogOpen, mode, key, {
918
- setMode,
919
- isOwner,
920
- publish: () => controller.share(),
921
- openManageAccess: () => setAccessDialogOpen(true),
922
- })
923
- )
924
- return;
935
+ // the inline body editor owns the pane and every key while open
936
+ if (bodyEditing.editing) return;
937
+ // the share dialog owns its own keys while open; the shell grammar stands down
938
+ if (shareDialogOpen) return;
925
939
  if (menuModalHandled(menuControl, key)) return;
926
- if (
927
- appLeaderHandled({ focusedPane, key, leaderCombos, pending: leaderPending, runLeaderCommand })
928
- )
929
- return;
940
+ if (paneCycleRequested(key, overlay, menuOwnsKeyboard, threadComposing)) {
941
+ return cyclePanes(Boolean(key.shift));
942
+ }
930
943
  if (
931
944
  threadsNavHandled({
932
945
  focusedPane,
@@ -938,23 +951,7 @@ export function App({
938
951
  })
939
952
  )
940
953
  return;
941
- // The thread view owns the document grammar while active (its own
942
- // useKeyboard handles marks, comments, and ctrl+q); the session chords
943
- // (submit, share, edit, walk, the rail) resolve here, and the keymap only
944
- // sees keys while an overlay or the menu owns them.
945
- if (threadViewActive && !threadViewSuspended) {
946
- const chord = resolveThreadChord(key, {
947
- composing: threadComposing,
948
- isOwner,
949
- resolved,
950
- treeActive: railTab === "tree",
951
- isDiff,
952
- });
953
-
954
- if (chord) dispatch(chord);
955
-
956
- return;
957
- }
954
+ if (threadSurfaceHandledKey(key)) return;
958
955
  // A compose textarea owns the keyboard while open: let it receive the typed note instead of the
959
956
  // global keymap acting on each letter (the prototype, and the bare-shell welcome playground).
960
957
  if (prototypeComposing || welcomeComposing) return;
@@ -1009,7 +1006,7 @@ export function App({
1009
1006
  <MenuChrome
1010
1007
  menuDialog={menuDialog}
1011
1008
  theme={theme}
1012
- keybindsSections={cheatsheetFor(keyBindings, threadViewActive, leaderHint(leaderCombos))}
1009
+ keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
1013
1010
  settingsCategories={settingsCategories}
1014
1011
  settingsValues={settingsValues}
1015
1012
  settingsNav={settingsNav}
@@ -1034,7 +1031,7 @@ export function App({
1034
1031
  controller={controller}
1035
1032
  setMode={setMode}
1036
1033
  menuChrome={menuChrome}
1037
- onOpenMenu={() => setMenuDialog("settings")}
1034
+ onOpenMenu={openSettings}
1038
1035
  sidebarOpen={sidebarOpen}
1039
1036
  onToggleSidebar={() => setSidebarOpen((open) => !open)}
1040
1037
  focusedPane={focusedPane}
@@ -1068,6 +1065,9 @@ export function App({
1068
1065
  verdict={activeSession.verdict.kind}
1069
1066
  completion={completion}
1070
1067
  status={status}
1068
+ onClose={() => dispatch({ type: "finishReview" })}
1069
+ onBackToPlan={() => dispatch({ type: "dismissCompletion" })}
1070
+ onAlways={() => dispatch({ type: "optInAutoClose" })}
1071
1071
  />
1072
1072
  );
1073
1073
 
@@ -1086,6 +1086,7 @@ export function App({
1086
1086
  session: activeSession,
1087
1087
  walkFileList,
1088
1088
  viewedPaths,
1089
+ quickActions,
1089
1090
  liveInput,
1090
1091
  setMode,
1091
1092
  dispatch,
@@ -1105,22 +1106,9 @@ export function App({
1105
1106
  // this is owner-only; stay silent rather than nag if it is ever reached.
1106
1107
  if (!isOwner) return;
1107
1108
  if (resolved) return controller.setStatus("review submitted - read-only");
1108
- runEditorHandOff();
1109
- };
1110
-
1111
- // the share choice's public option: publish the plan, copy the ssh line
1112
- const onShareRequest = (): void => {
1113
- if (!isOwner) return controller.setStatus("only the plan owner can share");
1114
- controller.share();
1109
+ openBodyEditor();
1115
1110
  };
1116
1111
 
1117
- // the share choice's private option opens the manage-access allowlist surface
1118
- const onPrivateShareRequest = (): void => {
1119
- if (!isOwner) return controller.setStatus("only the plan owner can share");
1120
- setAccessDialogOpen(true);
1121
- };
1122
- const allowedLogins = allowedGithubLogins(session);
1123
-
1124
1112
  // clicking the rail Submit button: same read-only answer as the submit key
1125
1113
  const onSubmitRequest = (): void => {
1126
1114
  if (observer) return controller.setStatus("observer - read-only");
@@ -1147,7 +1135,7 @@ export function App({
1147
1135
  <AppShell
1148
1136
  sidebarOpen={sidebarOpen}
1149
1137
  onToggleSidebar={() => setSidebarOpen((open) => !open)}
1150
- onOpenMenu={() => setMenuDialog("settings")}
1138
+ onOpenMenu={openSettings}
1151
1139
  onFocusPane={setFocusedPane}
1152
1140
  threadsPanel={
1153
1141
  <scrollbox style={{ flexGrow: 1 }} focused={false}>
@@ -1163,86 +1151,95 @@ export function App({
1163
1151
  onRename={(id, title) =>
1164
1152
  setMode({ type: "renameThread", sessionId: id, text: title })
1165
1153
  }
1154
+ onRequestDelete={(id, title) =>
1155
+ setMode({ type: "confirmDelete", sessionId: id, title })
1156
+ }
1166
1157
  theme={theme}
1167
1158
  />
1168
1159
  </scrollbox>
1169
1160
  }
1170
1161
  threadTitle={threadTitle(activeSession)}
1171
1162
  threadActions={
1172
- showOwnerActions ? (
1173
- <Toolbar>
1174
- <Button onPress={onEditRequest} theme={theme}>
1175
- {" edit "}
1176
- </Button>
1177
- <Button onPress={() => dispatch({ type: "share" })} theme={theme}>
1178
- {" share "}
1179
- </Button>
1180
- </Toolbar>
1181
- ) : undefined
1163
+ showOwnerActions
1164
+ ? ownerThreadActions({
1165
+ editing: bodyEditing.editing,
1166
+ onEdit: onEditRequest,
1167
+ onExitEdit: bodyEditing.requestExit,
1168
+ onShare: () => dispatch({ type: "share" }),
1169
+ theme,
1170
+ })
1171
+ : undefined
1182
1172
  }
1183
1173
  threadPanel={
1184
1174
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
1185
1175
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
1186
- {isPixelPrototype ? (
1187
- <PrototypePixels
1188
- prototypePath={prototypePath}
1189
- canComment={prototypeCanComment}
1190
- onCommentElement={onCommentPrototype}
1191
- onComposingChange={setPrototypeComposing}
1192
- hidden={chromeHidden}
1193
- />
1194
- ) : isDiff ? (
1195
- <box
1196
- style={{
1197
- flexGrow: 1,
1198
- flexDirection: "column",
1199
- alignItems: "center",
1200
- justifyContent: "center",
1201
- }}
1202
- >
1203
- <text fg={theme.textDim}>Select a thread</text>
1204
- </box>
1205
- ) : (
1206
- <ThreadView
1207
- session={activeSession}
1208
- suspended={surfaceSuspended(threadViewSuspended, focusedPane, "thread")}
1209
- editOrphanCount={editOrphanCount}
1210
- onComposingChange={setThreadComposing}
1211
- leaderCombos={leaderCombos}
1212
- onLeaderCommand={runLeaderCommand}
1213
- resolved={resolved}
1214
- onObserverBlocked={(reason) =>
1215
- controller.setStatus(
1216
- reason === "observer"
1217
- ? "observer - read-only"
1218
- : "review submitted - read-only",
1219
- )
1220
- }
1221
- onCursorChange={setCursor}
1222
- focusedAnnotationId={focusedAnnotationId}
1223
- onFocusAnnotation={setFocusedAnnotationId}
1224
- display={display}
1225
- marks={marks}
1226
- quickActions={quickActions}
1227
- observer={observer}
1228
- onAnnotate={(span, body) =>
1229
- void controller.annotate(
1230
- "comment",
1231
- span.start.blockIndex,
1232
- span.start.char,
1233
- span.end.char,
1234
- body,
1235
- span.end.blockIndex,
1236
- )
1237
- }
1238
- onReply={(rootAnnotationId, body) =>
1239
- void controller.reply(rootAnnotationId, body)
1240
- }
1241
- onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
1242
- resolveAuthorLabel={resolveAuthorLabel}
1243
- onExit={() => onExit?.(0)}
1244
- />
1245
- )}
1176
+ {chooseThreadBody({
1177
+ isPixelPrototype,
1178
+ isDiff,
1179
+ editingBody: bodyEditing.editing,
1180
+ prototype: (
1181
+ <PrototypePixels
1182
+ prototypePath={prototypePath}
1183
+ canComment={prototypeCanComment}
1184
+ onCommentElement={onCommentPrototype}
1185
+ onComposingChange={setPrototypeComposing}
1186
+ hidden={chromeHidden}
1187
+ />
1188
+ ),
1189
+ diffPlaceholder: (
1190
+ <box
1191
+ style={{
1192
+ flexGrow: 1,
1193
+ flexDirection: "column",
1194
+ alignItems: "center",
1195
+ justifyContent: "center",
1196
+ }}
1197
+ >
1198
+ <text fg={theme.textDim}>Select a thread</text>
1199
+ </box>
1200
+ ),
1201
+ editor: bodyEditing.renderEditor(theme),
1202
+ threadView: (
1203
+ <ThreadView
1204
+ session={activeSession}
1205
+ suspended={surfaceSuspended(threadViewSuspended, focusedPane, "thread")}
1206
+ editOrphanCount={editOrphanCount}
1207
+ onComposingChange={setThreadComposing}
1208
+ onNavCommand={runNavCommand}
1209
+ resolved={resolved}
1210
+ onObserverBlocked={(reason) =>
1211
+ controller.setStatus(
1212
+ reason === "observer"
1213
+ ? "observer - read-only"
1214
+ : "review submitted - read-only",
1215
+ )
1216
+ }
1217
+ onCursorChange={setCursor}
1218
+ focusedAnnotationId={focusedAnnotationId}
1219
+ onFocusAnnotation={setFocusedAnnotationId}
1220
+ display={display}
1221
+ marks={marks}
1222
+ quickActions={quickActions}
1223
+ observer={observer}
1224
+ onAnnotate={(span, body) =>
1225
+ void controller.annotate(
1226
+ "comment",
1227
+ span.start.blockIndex,
1228
+ span.start.char,
1229
+ span.end.char,
1230
+ body,
1231
+ span.end.blockIndex,
1232
+ )
1233
+ }
1234
+ onReply={(rootAnnotationId, body) =>
1235
+ void controller.reply(rootAnnotationId, body)
1236
+ }
1237
+ onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
1238
+ resolveAuthorLabel={resolveAuthorLabel}
1239
+ onExit={() => onExit?.(0)}
1240
+ />
1241
+ ),
1242
+ })}
1246
1243
  </box>
1247
1244
  {threadFooter}
1248
1245
  </box>
@@ -1309,8 +1306,7 @@ export function App({
1309
1306
  void controller.reply(rootAnnotationId, body),
1310
1307
  onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1311
1308
  resolveAuthorLabel,
1312
- leaderCombos,
1313
- onLeaderCommand: runLeaderCommand,
1309
+ onNavCommand: runNavCommand,
1314
1310
  onExit: () => onExit?.(0),
1315
1311
  }}
1316
1312
  rejectedRows={rejectedRows}
@@ -1326,6 +1322,8 @@ export function App({
1326
1322
  body,
1327
1323
  )
1328
1324
  }
1325
+ // zoomed, the footer already rides the Changes pane, so no extra offset is needed
1326
+ emptyBottomPadding={workbench.zoomed ? 0 : THREAD_FOOTER_HEIGHT}
1329
1327
  theme={theme}
1330
1328
  />
1331
1329
  )}
@@ -1380,26 +1378,17 @@ export function App({
1380
1378
  </box>
1381
1379
  </box>
1382
1380
  ) : null}
1383
- <ManageAccessDialog
1384
- isOpen={accessDialogOpen}
1385
- logins={allowedLogins}
1386
- onAdd={(login) => controller.setShareAccess([...allowedLogins, login])}
1387
- onRemove={(login) =>
1388
- controller.setShareAccess(allowedLogins.filter((entry) => entry !== login))
1389
- }
1390
- onCreateLink={() => {
1391
- setAccessDialogOpen(false);
1392
- controller.share();
1393
- }}
1394
- onClose={() => setAccessDialogOpen(false)}
1395
- theme={theme}
1396
- />
1397
- <ShareChoiceDialog
1398
- isOpen={mode.type === "shareChoice"}
1399
- selectedIndex={shareChoiceIndex(mode)}
1400
- onPublicShare={onShareRequest}
1401
- onPrivateShare={onPrivateShareRequest}
1402
- onClose={() => setMode({ type: "normal" })}
1381
+ <ShareDialog
1382
+ key={String(shareDialogOpen)}
1383
+ isOpen={shareDialogOpen}
1384
+ threadName={shareThreadName(session)}
1385
+ links={shareLinksFor(session)}
1386
+ isOwner={isOwner}
1387
+ onCreateLink={(input) => controller.createShareLink(input)}
1388
+ onUpdateLink={(id, input) => controller.updateShareLink(id, input)}
1389
+ onDeleteLink={(id) => controller.deleteShareLink(id)}
1390
+ onCopyLink={(id) => controller.copyShareLink(id)}
1391
+ onClose={() => setShareDialogOpen(false)}
1403
1392
  theme={theme}
1404
1393
  />
1405
1394
  {menuChrome}