@cueloop/client 0.1.0-alpha.78 → 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 (92) hide show
  1. package/package.json +14 -13
  2. package/src/App.diff.test.tsx +9 -4
  3. package/src/App.focus.test.ts +1 -59
  4. package/src/App.identity.test.tsx +3 -3
  5. package/src/App.plan-render.test.tsx +209 -0
  6. package/src/App.plan-review.test.tsx +75 -26
  7. package/src/App.readonly.test.tsx +6 -5
  8. package/src/App.share-dialog.test.tsx +172 -0
  9. package/src/App.submit-confirm.test.tsx +6 -4
  10. package/src/App.test.tsx +31 -28
  11. package/src/App.toast.test.tsx +11 -4
  12. package/src/App.tsx +259 -274
  13. package/src/App.walk.test.tsx +9 -8
  14. package/src/App.workbench.test.tsx +86 -0
  15. package/src/annotation-palette.ts +1 -0
  16. package/src/app-screens.tsx +12 -6
  17. package/src/app-view-model.ts +0 -1
  18. package/src/components/ConfirmDialog.stories.tsx +3 -3
  19. package/src/components/DiffContentView.tsx +14 -7
  20. package/src/components/DiscussionMarkerRail.tsx +9 -4
  21. package/src/components/GridTabContent.tsx +8 -2
  22. package/src/components/HandleChipsInput.stories.tsx +24 -0
  23. package/src/components/HandleChipsInput.test.tsx +35 -0
  24. package/src/components/HandleChipsInput.tsx +145 -0
  25. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  26. package/src/components/MarkdownGridBlock.tsx +72 -0
  27. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  28. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  29. package/src/components/MarkdownThreadEditor.tsx +162 -0
  30. package/src/components/NavModeHint.stories.tsx +40 -0
  31. package/src/components/NavModeHint.tsx +31 -0
  32. package/src/components/SettingsDialog.tsx +1 -0
  33. package/src/components/ShareDialog.stories.tsx +121 -0
  34. package/src/components/ShareDialog.tsx +552 -0
  35. package/src/components/ThreadFooter.tsx +4 -1
  36. package/src/components/ThreadTree.tsx +1 -1
  37. package/src/components/ThreadView.slash.test.tsx +0 -35
  38. package/src/components/ThreadView.tsx +142 -68
  39. package/src/components/Toast.stories.tsx +7 -0
  40. package/src/components/Toast.tsx +15 -3
  41. package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
  42. package/src/components/primitives/Button.tsx +6 -2
  43. package/src/components/primitives/Card.tsx +2 -2
  44. package/src/components/primitives/Dialog.tsx +2 -2
  45. package/src/components/primitives/Switch.stories.tsx +17 -0
  46. package/src/components/primitives/Switch.tsx +32 -0
  47. package/src/components/primitives/Tree.tsx +5 -1
  48. package/src/components/primitives/frame.ts +5 -0
  49. package/src/components/share-dialog-store.ts +58 -0
  50. package/src/components/stories.test.tsx +4 -3
  51. package/src/config.test.ts +18 -0
  52. package/src/config.ts +8 -1
  53. package/src/diff-fold.ts +1 -1
  54. package/src/intent-dispatch.test.ts +12 -3
  55. package/src/intent-dispatch.ts +10 -25
  56. package/src/key-bindings.ts +0 -6
  57. package/src/keymap.ts +0 -1
  58. package/src/markdown-editor-syntax-style.ts +50 -0
  59. package/src/markdown-grid.test.ts +101 -0
  60. package/src/markdown-grid.ts +203 -0
  61. package/src/markdown-highlight.test.ts +61 -0
  62. package/src/markdown-highlight.ts +122 -0
  63. package/src/prototype-browser.test.ts +9 -1
  64. package/src/prototype-browser.ts +22 -0
  65. package/src/run.ts +8 -6
  66. package/src/share.ts +15 -1
  67. package/src/test-support.ts +14 -0
  68. package/src/thread-chords.test.ts +55 -178
  69. package/src/thread-chords.ts +78 -169
  70. package/src/thread-controller.curate.test.ts +1 -0
  71. package/src/thread-controller.live-diff.test.ts +3 -0
  72. package/src/thread-controller.share.test.ts +60 -5
  73. package/src/thread-controller.switch-cache.test.ts +30 -0
  74. package/src/thread-controller.tree.test.ts +6 -1
  75. package/src/thread-controller.ts +229 -54
  76. package/src/use-annotation-surface.tsx +45 -36
  77. package/src/use-settings-dialog.tsx +14 -3
  78. package/src/use-thread-body-editing.tsx +58 -0
  79. package/src/view-plan.test.ts +56 -6
  80. package/src/view-plan.ts +154 -5
  81. package/src/App.share-choice.test.tsx +0 -105
  82. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  83. package/src/components/ManageAccessDialog.test.tsx +0 -94
  84. package/src/components/ManageAccessDialog.tsx +0 -122
  85. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  86. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  87. package/src/components/ShareChoiceDialog.tsx +0 -80
  88. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  89. package/src/components/ThreadsSidebar.tsx +0 -71
  90. package/src/components/agent-launcher.stories.tsx +0 -26
  91. package/src/components/agent-launcher.test.tsx +0 -50
  92. 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}
@@ -1109,22 +1106,9 @@ export function App({
1109
1106
  // this is owner-only; stay silent rather than nag if it is ever reached.
1110
1107
  if (!isOwner) return;
1111
1108
  if (resolved) return controller.setStatus("review submitted - read-only");
1112
- runEditorHandOff();
1113
- };
1114
-
1115
- // the share choice's public option: publish the plan, copy the ssh line
1116
- const onShareRequest = (): void => {
1117
- if (!isOwner) return controller.setStatus("only the plan owner can share");
1118
- controller.share();
1109
+ openBodyEditor();
1119
1110
  };
1120
1111
 
1121
- // the share choice's private option opens the manage-access allowlist surface
1122
- const onPrivateShareRequest = (): void => {
1123
- if (!isOwner) return controller.setStatus("only the plan owner can share");
1124
- setAccessDialogOpen(true);
1125
- };
1126
- const allowedLogins = allowedGithubLogins(session);
1127
-
1128
1112
  // clicking the rail Submit button: same read-only answer as the submit key
1129
1113
  const onSubmitRequest = (): void => {
1130
1114
  if (observer) return controller.setStatus("observer - read-only");
@@ -1151,7 +1135,7 @@ export function App({
1151
1135
  <AppShell
1152
1136
  sidebarOpen={sidebarOpen}
1153
1137
  onToggleSidebar={() => setSidebarOpen((open) => !open)}
1154
- onOpenMenu={() => setMenuDialog("settings")}
1138
+ onOpenMenu={openSettings}
1155
1139
  onFocusPane={setFocusedPane}
1156
1140
  threadsPanel={
1157
1141
  <scrollbox style={{ flexGrow: 1 }} focused={false}>
@@ -1167,86 +1151,95 @@ export function App({
1167
1151
  onRename={(id, title) =>
1168
1152
  setMode({ type: "renameThread", sessionId: id, text: title })
1169
1153
  }
1154
+ onRequestDelete={(id, title) =>
1155
+ setMode({ type: "confirmDelete", sessionId: id, title })
1156
+ }
1170
1157
  theme={theme}
1171
1158
  />
1172
1159
  </scrollbox>
1173
1160
  }
1174
1161
  threadTitle={threadTitle(activeSession)}
1175
1162
  threadActions={
1176
- showOwnerActions ? (
1177
- <Toolbar>
1178
- <Button onPress={onEditRequest} theme={theme}>
1179
- {" edit "}
1180
- </Button>
1181
- <Button onPress={() => dispatch({ type: "share" })} theme={theme}>
1182
- {" share "}
1183
- </Button>
1184
- </Toolbar>
1185
- ) : 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
1186
1172
  }
1187
1173
  threadPanel={
1188
1174
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
1189
1175
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
1190
- {isPixelPrototype ? (
1191
- <PrototypePixels
1192
- prototypePath={prototypePath}
1193
- canComment={prototypeCanComment}
1194
- onCommentElement={onCommentPrototype}
1195
- onComposingChange={setPrototypeComposing}
1196
- hidden={chromeHidden}
1197
- />
1198
- ) : isDiff ? (
1199
- <box
1200
- style={{
1201
- flexGrow: 1,
1202
- flexDirection: "column",
1203
- alignItems: "center",
1204
- justifyContent: "center",
1205
- }}
1206
- >
1207
- <text fg={theme.textDim}>Select a thread</text>
1208
- </box>
1209
- ) : (
1210
- <ThreadView
1211
- session={activeSession}
1212
- suspended={surfaceSuspended(threadViewSuspended, focusedPane, "thread")}
1213
- editOrphanCount={editOrphanCount}
1214
- onComposingChange={setThreadComposing}
1215
- leaderCombos={leaderCombos}
1216
- onLeaderCommand={runLeaderCommand}
1217
- resolved={resolved}
1218
- onObserverBlocked={(reason) =>
1219
- controller.setStatus(
1220
- reason === "observer"
1221
- ? "observer - read-only"
1222
- : "review submitted - read-only",
1223
- )
1224
- }
1225
- onCursorChange={setCursor}
1226
- focusedAnnotationId={focusedAnnotationId}
1227
- onFocusAnnotation={setFocusedAnnotationId}
1228
- display={display}
1229
- marks={marks}
1230
- quickActions={quickActions}
1231
- observer={observer}
1232
- onAnnotate={(span, body) =>
1233
- void controller.annotate(
1234
- "comment",
1235
- span.start.blockIndex,
1236
- span.start.char,
1237
- span.end.char,
1238
- body,
1239
- span.end.blockIndex,
1240
- )
1241
- }
1242
- onReply={(rootAnnotationId, body) =>
1243
- void controller.reply(rootAnnotationId, body)
1244
- }
1245
- onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
1246
- resolveAuthorLabel={resolveAuthorLabel}
1247
- onExit={() => onExit?.(0)}
1248
- />
1249
- )}
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
+ })}
1250
1243
  </box>
1251
1244
  {threadFooter}
1252
1245
  </box>
@@ -1313,8 +1306,7 @@ export function App({
1313
1306
  void controller.reply(rootAnnotationId, body),
1314
1307
  onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1315
1308
  resolveAuthorLabel,
1316
- leaderCombos,
1317
- onLeaderCommand: runLeaderCommand,
1309
+ onNavCommand: runNavCommand,
1318
1310
  onExit: () => onExit?.(0),
1319
1311
  }}
1320
1312
  rejectedRows={rejectedRows}
@@ -1330,6 +1322,8 @@ export function App({
1330
1322
  body,
1331
1323
  )
1332
1324
  }
1325
+ // zoomed, the footer already rides the Changes pane, so no extra offset is needed
1326
+ emptyBottomPadding={workbench.zoomed ? 0 : THREAD_FOOTER_HEIGHT}
1333
1327
  theme={theme}
1334
1328
  />
1335
1329
  )}
@@ -1384,26 +1378,17 @@ export function App({
1384
1378
  </box>
1385
1379
  </box>
1386
1380
  ) : null}
1387
- <ManageAccessDialog
1388
- isOpen={accessDialogOpen}
1389
- logins={allowedLogins}
1390
- onAdd={(login) => controller.setShareAccess([...allowedLogins, login])}
1391
- onRemove={(login) =>
1392
- controller.setShareAccess(allowedLogins.filter((entry) => entry !== login))
1393
- }
1394
- onCreateLink={() => {
1395
- setAccessDialogOpen(false);
1396
- controller.share();
1397
- }}
1398
- onClose={() => setAccessDialogOpen(false)}
1399
- theme={theme}
1400
- />
1401
- <ShareChoiceDialog
1402
- isOpen={mode.type === "shareChoice"}
1403
- selectedIndex={shareChoiceIndex(mode)}
1404
- onPublicShare={onShareRequest}
1405
- onPrivateShare={onPrivateShareRequest}
1406
- 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)}
1407
1392
  theme={theme}
1408
1393
  />
1409
1394
  {menuChrome}