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

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 (82) hide show
  1. package/package.json +5 -5
  2. package/src/App.focus.test.ts +147 -0
  3. package/src/App.identity.test.tsx +78 -0
  4. package/src/App.regression.test.tsx +94 -0
  5. package/src/App.share-choice.test.tsx +105 -0
  6. package/src/App.test.tsx +11 -8
  7. package/src/App.toast.test.tsx +3 -1
  8. package/src/App.tsx +648 -221
  9. package/src/App.visual.test.tsx +57 -0
  10. package/src/App.workbench.test.tsx +41 -22
  11. package/src/app-screens.tsx +181 -123
  12. package/src/app-view-model.ts +1 -0
  13. package/src/components/AnnotatableFileView.tsx +9 -1
  14. package/src/components/AnnotationCards.tsx +22 -4
  15. package/src/components/AppShell.brand.test.tsx +1 -1
  16. package/src/components/AppShell.tsx +14 -1
  17. package/src/components/ChangesColumn.tsx +40 -12
  18. package/src/components/ChangesFileTree.test.tsx +61 -0
  19. package/src/components/ConfirmDialog.tsx +8 -1
  20. package/src/components/DiffContentView.tsx +12 -2
  21. package/src/components/DiscussionMarkerRail.tsx +2 -2
  22. package/src/components/EditorGrid.tsx +84 -26
  23. package/src/components/GridTabContent.tsx +12 -1
  24. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  25. package/src/components/ManageAccessDialog.test.tsx +94 -0
  26. package/src/components/ManageAccessDialog.tsx +120 -0
  27. package/src/components/PanelColumn.tsx +3 -0
  28. package/src/components/ProjectTreeView.test.tsx +39 -12
  29. package/src/components/ProjectTreeView.tsx +38 -10
  30. package/src/components/PromptDialog.test.tsx +61 -0
  31. package/src/components/PromptDialog.tsx +16 -3
  32. package/src/components/RootOverlay.stories.tsx +1 -0
  33. package/src/components/RootOverlay.tsx +19 -6
  34. package/src/components/SettingsDialog.stories.tsx +3 -3
  35. package/src/components/SettingsDialog.tsx +3 -4
  36. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  37. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  38. package/src/components/ShareChoiceDialog.tsx +82 -0
  39. package/src/components/ThreadFooter.test.tsx +12 -7
  40. package/src/components/ThreadFooter.tsx +18 -4
  41. package/src/components/ThreadTree.tsx +79 -36
  42. package/src/components/ThreadView.slash.test.tsx +36 -0
  43. package/src/components/ThreadView.tsx +12 -2
  44. package/src/components/WelcomePlayground.tsx +2 -0
  45. package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
  46. package/src/components/menu-control.stories.tsx +35 -0
  47. package/src/components/menu-control.tsx +43 -0
  48. package/src/components/primitives/Dialog.test.tsx +37 -0
  49. package/src/components/primitives/Dialog.tsx +5 -0
  50. package/src/components/primitives/icons.ts +1 -1
  51. package/src/components/quick-actions-editor.stories.tsx +2 -0
  52. package/src/components/quick-actions-editor.test.tsx +35 -1
  53. package/src/components/quick-actions-editor.tsx +23 -22
  54. package/src/components/session-tree.test.ts +3 -3
  55. package/src/components/session-tree.ts +1 -1
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +45 -1
  58. package/src/diff-intraline.ts +26 -5
  59. package/src/github-identity.test.ts +58 -0
  60. package/src/github-identity.ts +41 -0
  61. package/src/intent-dispatch.test.ts +17 -2
  62. package/src/intent-dispatch.ts +7 -1
  63. package/src/keymap.ts +1 -0
  64. package/src/perf/otlp-trace.test.ts +116 -0
  65. package/src/perf/otlp-trace.ts +130 -0
  66. package/src/perf/perf-report.ts +17 -0
  67. package/src/perf/perf-timings.test.ts +18 -0
  68. package/src/perf/perf-timings.ts +38 -0
  69. package/src/run.ts +22 -2
  70. package/src/share.ts +10 -2
  71. package/src/thread-chords.test.ts +33 -1
  72. package/src/thread-chords.ts +114 -29
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.live-diff.test.ts +1 -0
  75. package/src/thread-controller.share.test.ts +36 -2
  76. package/src/thread-controller.tree.test.ts +1 -0
  77. package/src/thread-controller.ts +18 -3
  78. package/src/use-annotation-surface.tsx +32 -2
  79. package/src/use-frame-measure.ts +4 -1
  80. package/src/use-settings-dialog.tsx +33 -7
  81. package/src/visual/contrast.test.ts +35 -0
  82. package/src/visual/contrast.ts +33 -0
package/src/App.tsx CHANGED
@@ -15,11 +15,15 @@ import {
15
15
  loadConfig,
16
16
  persistAuthorName,
17
17
  persistDiffView,
18
+ persistIdentity,
18
19
  persistPins,
19
20
  type AutoClose,
20
21
  type DiffViewMode,
22
+ type IdentityConfig,
21
23
  type QuickAction,
22
24
  } from "./config";
25
+ import { resolveDisplayName } from "./attribution";
26
+ import { resolveGithubIdentity } from "./github-identity";
23
27
  import { loadSkills, PaletteNamesContext, SlashSkillsContext } from "./skills";
24
28
  import { mergeSlashItems, slashItemsFrom, type SlashItem } from "./slash-palette";
25
29
  import {
@@ -38,12 +42,14 @@ import { KeyBindings, type CheatsheetSection } from "./key-bindings";
38
42
  import { useReadySignal } from "./ready-signal";
39
43
  import { ThemeProvider } from "./components/theme-context";
40
44
  import { Button } from "./components/primitives/Button";
45
+ import { ShareChoiceDialog, SHARE_CHOICES } from "./components/ShareChoiceDialog";
41
46
  import { Toolbar } from "./components/primitives/Toolbar";
42
47
  import { groupInbox, projectName, threadTitle } from "./components/session-tree";
43
48
  import { ThreadTree } from "./components/ThreadTree";
44
49
  import { ChangesFileTree } from "./components/ChangesColumn";
50
+ import { MenuControlProvider, useMenuControlState } from "./components/menu-control";
45
51
  import { ProjectTreeView } from "./components/ProjectTreeView";
46
- import { AppShell, type ProjectPanelMode } from "./components/AppShell";
52
+ import { AppShell, type FocusPane, type ProjectPanelMode } from "./components/AppShell";
47
53
  import { EditorGrid } from "./components/EditorGrid";
48
54
  import { GridTabContent } from "./components/GridTabContent";
49
55
  import { useChangesWorkbench } from "./use-changes-workbench";
@@ -51,18 +57,23 @@ import { useRememberLayout } from "./use-remember-layout";
51
57
  import type { LaunchLayout } from "./launch-layout";
52
58
  import { ThreadFooter } from "./components/ThreadFooter";
53
59
  import { ConfirmCard } from "./components/ConfirmCard";
60
+ import { ManageAccessDialog } from "./components/ManageAccessDialog";
54
61
  import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
55
62
  import {
56
- DIFF_CHORD_ENTRIES,
57
- RAIL_CHORD_ENTRIES,
63
+ diffChordEntries,
64
+ dispatchLeaderCommand,
65
+ leaderCombosFor,
66
+ leaderHint,
67
+ matchesLeader,
68
+ railChordEntries,
58
69
  resolveThreadChord,
59
70
  THREAD_CHORD_ENTRIES,
60
- TREE_CHORD_ENTRIES,
71
+ treeChordEntries,
61
72
  } from "./thread-chords";
62
73
  import { type DiffFoldControls } from "./components/DiffContentView";
63
74
  import { commentCountsByFile } from "./view-diff";
64
75
  import { annotationTarget } from "@cueloop/schema";
65
- import type { Artifact, DiffFileContents, Thread } from "@cueloop/schema";
76
+ import type { Annotation, Artifact, DiffFileContents, Identity, Thread } from "@cueloop/schema";
66
77
  import { PrototypePixels } from "./prototype-pixels";
67
78
  import type { PrototypeElement } from "./prototype-browser";
68
79
  import {
@@ -86,7 +97,7 @@ import {
86
97
  TrailingOverlays,
87
98
  } from "./app-screens";
88
99
  /** A toast clears itself after this idle; esc dismisses it sooner. */
89
- const TOAST_DISMISS_MS = 4000;
100
+ const TOAST_DISMISS_MS = 2000;
90
101
 
91
102
  export interface AppProps {
92
103
  home?: string;
@@ -134,16 +145,139 @@ function menuChromeOpen(menuDialog: "keybinds" | "settings" | null): boolean {
134
145
  return menuDialog !== null;
135
146
  }
136
147
 
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 ?? [];
151
+ }
152
+
153
+ /** The drop-up chrome or a floating popover menu (thread actions, editor split) holds the keyboard. */
154
+ function keyboardHeldByMenu(
155
+ menuDialog: "keybinds" | "settings" | null,
156
+ openMenuId: string | null,
157
+ ): boolean {
158
+ return menuChromeOpen(menuDialog) || openMenuId !== null;
159
+ }
160
+
161
+ /** A non-modal toast: escape dismisses it only when nothing else owns escape (no overlay, not span mode). */
162
+ export function toastDismissRequested(
163
+ toastOpen: boolean,
164
+ key: { name: string },
165
+ overlay: KeyState["overlay"],
166
+ spanMode: boolean,
167
+ ): boolean {
168
+ return toastOpen && key.name === "escape" && overlay === "none" && !spanMode;
169
+ }
170
+
171
+ /** ctrl+q quits from anywhere, ahead of every focus/menu/overlay gate, with no confirmation. */
172
+ export function quitKeyHandled(
173
+ key: { name: string; ctrl?: boolean },
174
+ onExit?: (code: number) => void,
175
+ ): boolean {
176
+ if (!key.ctrl || key.name !== "q") return false;
177
+ onExit?.(0);
178
+
179
+ return true;
180
+ }
181
+
182
+ /** A popover menu is open, so the shell keyboard is modal: escape closes it, every other key is swallowed. */
183
+ function menuModalHandled(
184
+ menuControl: { openMenuId: string | null; closeMenu: () => void },
185
+ key: { name: string },
186
+ ): boolean {
187
+ if (menuControl.openMenuId === null) return false;
188
+ if (key.name === "escape") menuControl.closeMenu();
189
+
190
+ return true;
191
+ }
192
+
137
193
  /** True while a menu or an overlay owns the keyboard instead of the thread view. */
138
194
  function keyboardOwnedElsewhere(menuOwnsKeyboard: boolean, overlay: KeyState["overlay"]): boolean {
139
195
  return menuOwnsKeyboard || overlay !== "none";
140
196
  }
141
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;
208
+ }
209
+
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 };
215
+ }
216
+
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();
227
+ }
228
+
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);
242
+ }
243
+
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
+ }
263
+
142
264
  /** The footer submit fires only for the owner of an unresolved review, never an observer. */
143
265
  function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean): boolean {
144
266
  return isOwner && !resolved && !observer;
145
267
  }
146
268
 
269
+ /** The author display name for a comment's hover tooltip: the reviewer's own name for their notes, else the resolved collaborator name. */
270
+ function authorLabelResolver(
271
+ ownName: string | undefined,
272
+ participants: Identity[] | undefined,
273
+ overrides: Record<string, string>,
274
+ ): (annotation: Annotation) => string | undefined {
275
+ return (annotation) =>
276
+ annotation.author === undefined
277
+ ? (ownName ?? "you")
278
+ : resolveDisplayName(annotation.author, participants, overrides);
279
+ }
280
+
147
281
  /** The Project pane body: the resolved repo's changed files in changes mode, its full tree otherwise. */
148
282
  function ProjectPanelBody(props: {
149
283
  mode: ProjectPanelMode;
@@ -153,6 +287,7 @@ function ProjectPanelBody(props: {
153
287
  onOpenChangedFile: (path: string) => void;
154
288
  onOpenProjectFile: (path: string) => void;
155
289
  commentCounts?: ReadonlyMap<string, number>;
290
+ focused?: boolean;
156
291
  theme: Theme;
157
292
  }): React.ReactNode {
158
293
  const [changes, setChanges] = useState<readonly DiffFileContents[]>([]);
@@ -183,6 +318,7 @@ function ProjectPanelBody(props: {
183
318
  files={changes}
184
319
  onSelectFile={props.onOpenChangedFile}
185
320
  commentCounts={props.commentCounts}
321
+ focused={props.focused}
186
322
  theme={props.theme}
187
323
  />
188
324
  );
@@ -192,6 +328,7 @@ function ProjectPanelBody(props: {
192
328
  key={props.reloadKey}
193
329
  loadFiles={props.loadProjectFiles}
194
330
  onSelectFile={props.onOpenProjectFile}
331
+ focused={props.focused}
195
332
  theme={props.theme}
196
333
  />
197
334
  );
@@ -207,7 +344,11 @@ function usableScreenReached(
207
344
  }
208
345
 
209
346
  /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
210
- function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
347
+ function cheatsheetFor(
348
+ keyBindings: KeyBindings,
349
+ threadViewActive: boolean,
350
+ hint: string,
351
+ ): CheatsheetSection[] {
211
352
  const base = keyBindings.cheatsheet();
212
353
 
213
354
  if (!threadViewActive) {
@@ -217,9 +358,9 @@ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): Che
217
358
  return [
218
359
  ...THREAD_VIEW_CHEATSHEET,
219
360
  { title: "Session", entries: [...THREAD_CHORD_ENTRIES] },
220
- { title: "Diff", entries: [...DIFF_CHORD_ENTRIES] },
221
- { title: "Rail", entries: [...RAIL_CHORD_ENTRIES] },
222
- { title: "Tree", entries: [...TREE_CHORD_ENTRIES] },
361
+ { title: "Diff", entries: diffChordEntries(hint) },
362
+ { title: "Rail", entries: railChordEntries(hint) },
363
+ { title: "Tree", entries: treeChordEntries(hint) },
223
364
  ...base.filter((section) => section.title === "Agent terminal"),
224
365
  ];
225
366
  }
@@ -232,6 +373,125 @@ function initialThreadsOpen(
232
373
  return layout ? layout.threads : sessionId === undefined;
233
374
  }
234
375
 
376
+ function initialFocusedPane(sessionId: string | undefined, zoomed: boolean): FocusPane {
377
+ if (sessionId === undefined) return "threads";
378
+
379
+ return zoomed ? "changes" : "thread";
380
+ }
381
+
382
+ function bareShellDefersKeys(
383
+ sessionIsNull: boolean,
384
+ overlayIsNone: boolean,
385
+ focusedPane: FocusPane,
386
+ ): boolean {
387
+ return sessionIsNull && overlayIsNone && focusedPane !== "threads";
388
+ }
389
+
390
+ function surfaceSuspended(base: boolean, focusedPane: FocusPane, pane: FocusPane): boolean {
391
+ return base || focusedPane !== pane;
392
+ }
393
+
394
+ export function threadsNavHandled(params: {
395
+ focusedPane: FocusPane;
396
+ quiet: boolean;
397
+ key: { name: string };
398
+ count: number;
399
+ setInboxCursor: (update: (cursor: number) => number) => void;
400
+ openSession: () => void;
401
+ }): boolean {
402
+ const { focusedPane, quiet, key, count } = params;
403
+
404
+ if (focusedPane !== "threads" || !quiet || count === 0) return false;
405
+ if (key.name === "j" || key.name === "down") {
406
+ params.setInboxCursor((cursor) => Math.min(cursor + 1, count - 1));
407
+
408
+ return true;
409
+ }
410
+ if (key.name === "k" || key.name === "up") {
411
+ params.setInboxCursor((cursor) => Math.max(cursor - 1, 0));
412
+
413
+ return true;
414
+ }
415
+ if (key.name === "return" || key.name === "enter") {
416
+ params.openSession();
417
+
418
+ return true;
419
+ }
420
+
421
+ return false;
422
+ }
423
+
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;
447
+ }
448
+
449
+ export function visiblePanes(
450
+ sidebarOpen: boolean,
451
+ threadShown: boolean,
452
+ changesOpen: boolean,
453
+ projectOpen: boolean,
454
+ ): FocusPane[] {
455
+ const panes: FocusPane[] = [];
456
+
457
+ if (sidebarOpen) panes.push("threads");
458
+ if (threadShown) panes.push("thread");
459
+ if (changesOpen) panes.push("changes");
460
+ if (projectOpen) panes.push("project");
461
+
462
+ return panes;
463
+ }
464
+
465
+ export function nextFocusPane(
466
+ current: FocusPane,
467
+ panes: FocusPane[],
468
+ backward: boolean,
469
+ ): FocusPane {
470
+ if (panes.length === 0) return current;
471
+ const index = panes.indexOf(current);
472
+ const step = backward ? -1 : 1;
473
+
474
+ return panes[(index + step + panes.length) % panes.length] ?? current;
475
+ }
476
+
477
+ /** The panes that can own the keyboard: a diff's Thread pane is an inert placeholder, so it is out. */
478
+ function navigableFocusPanes(
479
+ sidebarOpen: boolean,
480
+ zoomed: boolean,
481
+ isDiff: boolean,
482
+ changesOpen: boolean,
483
+ projectOpen: boolean,
484
+ ): FocusPane[] {
485
+ return visiblePanes(sidebarOpen, !zoomed && !isDiff, changesOpen, projectOpen);
486
+ }
487
+
488
+ /** The layout hid or inert-ed the focused pane: fall back to the first navigable one, else stay put. */
489
+ export function reconciledFocus(navigable: FocusPane[], focusedPane: FocusPane): FocusPane | null {
490
+ if (navigable.length === 0 || navigable.includes(focusedPane)) return null;
491
+
492
+ return navigable[0] ?? null;
493
+ }
494
+
235
495
  export function App({
236
496
  home,
237
497
  sessionId,
@@ -318,6 +578,9 @@ export function App({
318
578
  const [sidebarOpen, setSidebarOpen] = useState(() => initialThreadsOpen(layout, sessionId));
319
579
  // the Changes + Project right region and its editor grid (tabs, splits, zoom)
320
580
  const workbench = useChangesWorkbench({ layout });
581
+ const [focusedPane, setFocusedPane] = useState<FocusPane>(() =>
582
+ initialFocusedPane(sessionId, workbench.zoomed),
583
+ );
321
584
  // only the session view persists from here; the bare shell owns its own (NoThreadShell), so this
322
585
  // workbench stays inactive with no session and never clobbers what the shell saved
323
586
  useRememberLayout(
@@ -331,6 +594,7 @@ export function App({
331
594
  const [mode, setMode] = useState<Mode>({ type: "normal" });
332
595
  // the top-left settings gear drop-down and the centered dialog it opens
333
596
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
597
+ const [accessDialogOpen, setAccessDialogOpen] = useState(false);
334
598
  const [autoClose, setAutoClose] = useState<AutoClose>("off");
335
599
  // unified or side-by-side diff; split only lays out when the Changes pane is zoomed
336
600
  const [diffView, setDiffView] = useState<DiffViewMode>("split");
@@ -352,6 +616,7 @@ export function App({
352
616
  const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
353
617
  const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
354
618
  const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
619
+ const [identity, setIdentity] = useState<IdentityConfig>({ provider: "typed" });
355
620
  const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
356
621
  const [skills, setSkills] = useState<SlashItem[]>([]);
357
622
  const paletteNames = useMemo(
@@ -371,6 +636,7 @@ export function App({
371
636
  setThemeName(config.ui.theme);
372
637
  setThemeOverrides(config.themeOverrides);
373
638
  setAuthorNames(config.authors);
639
+ setIdentity(config.identity);
374
640
  setQuickActions(config.actions);
375
641
  setSkills(loadSkills(config.skillsPath));
376
642
  setAutoClose(config.ui.autoClose);
@@ -404,6 +670,29 @@ export function App({
404
670
  if (!known) setMode({ type: "nameSelf", text: "" });
405
671
  }, [role, selfAuthor, session]);
406
672
 
673
+ // Bumped on every identity change so a slow GitHub sync that lands after a newer rename is discarded.
674
+ const identityGenerationRef = useRef(0);
675
+ const applyIdentity = (next: IdentityConfig): void => {
676
+ identityGenerationRef.current += 1;
677
+ setIdentity(next);
678
+ try {
679
+ persistIdentity(next);
680
+ } catch {
681
+ controller.setStatus("could not save identity to config");
682
+ }
683
+ };
684
+ const syncGithubIdentity = (): void => {
685
+ const generation = (identityGenerationRef.current += 1);
686
+ void resolveGithubIdentity().then((github) => {
687
+ if (identityGenerationRef.current !== generation) return;
688
+ if (!github) return controller.setStatus("GitHub not connected - run gh auth login");
689
+ const name = github.name?.trim() || github.login;
690
+
691
+ applyIdentity({ name, provider: "github" });
692
+ controller.setStatus(`identity synced from GitHub - ${name}`);
693
+ });
694
+ };
695
+
407
696
  // ── settings dialog: config-backed model, navigation, persistence ──
408
697
  const {
409
698
  settingsNav,
@@ -426,6 +715,13 @@ export function App({
426
715
  quickActions,
427
716
  setQuickActions,
428
717
  setMenuDialog,
718
+ identityName: identity.name,
719
+ identityProvider: identity.provider,
720
+ onSyncGithubIdentity: syncGithubIdentity,
721
+ onRenameDisplayName: () => {
722
+ setMenuDialog(null);
723
+ setMode({ type: "renameSelf", text: identity.name ?? "" });
724
+ },
429
725
  });
430
726
 
431
727
  // ── derived view model ──────────────────────
@@ -544,6 +840,9 @@ export function App({
544
840
  setAuthorNames((prev) => ({ ...prev, [id]: name }));
545
841
  },
546
842
  renameThread: (id: string, title: string) => controller.renameSession(id, title),
843
+ setLocalIdentityName: (name: string) => {
844
+ applyIdentity(name ? { name, provider: "typed" } : { provider: "typed" });
845
+ },
547
846
  liveInput,
548
847
  setCursor,
549
848
  setInboxCursor,
@@ -570,12 +869,75 @@ export function App({
570
869
 
571
870
  const overlay = resolveOverlay(mode, completion.phase, walking);
572
871
 
573
- const menuOwnsKeyboard = menuChromeOpen(menuDialog);
574
- // an overlay (submit, walk, prompt, confirm) or the menu takes the keyboard
575
- // from the thread view; the view suspends its own grammar meanwhile
872
+ const menuControl = useMenuControlState();
873
+ 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
576
876
  const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay);
577
877
 
878
+ const leaderCombos = leaderCombosFor(keysRef.current.leader);
879
+ const leaderPending = useRef(false);
880
+ const navigablePanes = navigableFocusPanes(
881
+ sidebarOpen,
882
+ workbench.zoomed,
883
+ isDiff,
884
+ workbench.changesOpen,
885
+ workbench.projectOpen,
886
+ );
887
+ useEffect(() => {
888
+ // the bare shell drives its panes from its own workbench, so only reconcile against this one
889
+ if (!session) return;
890
+ const next = reconciledFocus(navigablePanes, focusedPane);
891
+
892
+ if (next) setFocusedPane(next);
893
+ }, [session, navigablePanes, focusedPane]);
894
+ const cyclePanes = (backward: boolean): void =>
895
+ 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));
898
+
899
+ dispatchLeaderCommand(
900
+ key,
901
+ { composing: threadComposing, isOwner, resolved, treeActive: railTab === "tree", isDiff },
902
+ dispatch,
903
+ );
904
+ };
905
+ // clicking a sidebar thread moves the cursor onto it too, so the row shows its selected backdrop at once
906
+ const openThread = (id: string): void => {
907
+ const index = grouped.ordered.findIndex((thread) => thread.id === id);
908
+
909
+ if (index >= 0) setInboxCursor(() => index);
910
+ controller.open(id);
911
+ };
912
+
578
913
  useKeyboard((key) => {
914
+ 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;
925
+ if (menuModalHandled(menuControl, key)) return;
926
+ if (
927
+ appLeaderHandled({ focusedPane, key, leaderCombos, pending: leaderPending, runLeaderCommand })
928
+ )
929
+ return;
930
+ if (
931
+ threadsNavHandled({
932
+ focusedPane,
933
+ quiet: overlay === "none" && !menuOwnsKeyboard && !threadComposing,
934
+ key,
935
+ count: grouped.ordered.length,
936
+ setInboxCursor,
937
+ openSession: () => dispatch({ type: "openSession" }),
938
+ })
939
+ )
940
+ return;
579
941
  // The thread view owns the document grammar while active (its own
580
942
  // useKeyboard handles marks, comments, and ctrl+q); the session chords
581
943
  // (submit, share, edit, walk, the rail) resolve here, and the keymap only
@@ -600,8 +962,9 @@ export function App({
600
962
  if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
601
963
  // the toast is non-modal: escape only dismisses it when nothing else owns
602
964
  // escape, so an open overlay (compose, submit, prompt, walk) still cancels
603
- if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span")
965
+ if (toastDismissRequested(toast !== null, key, overlay, mode.type === "span"))
604
966
  return controller.dismissToast();
967
+ if (bareShellDefersKeys(session === null, overlay === "none", focusedPane)) return;
605
968
  const state = buildKeyState({
606
969
  keys: keysRef.current,
607
970
  observer,
@@ -646,12 +1009,13 @@ export function App({
646
1009
  <MenuChrome
647
1010
  menuDialog={menuDialog}
648
1011
  theme={theme}
649
- keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
1012
+ keybindsSections={cheatsheetFor(keyBindings, threadViewActive, leaderHint(leaderCombos))}
650
1013
  settingsCategories={settingsCategories}
651
1014
  settingsValues={settingsValues}
652
1015
  settingsNav={settingsNav}
653
1016
  onCategorySelect={onCategorySelect}
654
1017
  cycleSetting={cycleSetting}
1018
+ onClose={() => setMenuDialog(null)}
655
1019
  />
656
1020
  );
657
1021
 
@@ -664,6 +1028,7 @@ export function App({
664
1028
  <NoThreadShell
665
1029
  rows={grouped.rows}
666
1030
  inboxCursor={inboxCursor}
1031
+ onOpenThread={openThread}
667
1032
  mode={mode}
668
1033
  theme={theme}
669
1034
  controller={controller}
@@ -672,6 +1037,9 @@ export function App({
672
1037
  onOpenMenu={() => setMenuDialog("settings")}
673
1038
  sidebarOpen={sidebarOpen}
674
1039
  onToggleSidebar={() => setSidebarOpen((open) => !open)}
1040
+ focusedPane={focusedPane}
1041
+ onFocusPane={setFocusedPane}
1042
+ menuControl={menuControl}
675
1043
  pinnedIds={pinnedIds}
676
1044
  onPin={togglePin}
677
1045
  onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
@@ -686,6 +1054,11 @@ export function App({
686
1054
  );
687
1055
 
688
1056
  const activeSession = session;
1057
+ const resolveAuthorLabel = authorLabelResolver(
1058
+ identity.name,
1059
+ activeSession.participants,
1060
+ authorNames,
1061
+ );
689
1062
 
690
1063
  if (isCompletionOverlayPhase(completion) && activeSession.verdict)
691
1064
  return (
@@ -735,12 +1108,19 @@ export function App({
735
1108
  runEditorHandOff();
736
1109
  };
737
1110
 
738
- // clicking the header Share button: publish the plan, copy the ssh line
1111
+ // the share choice's public option: publish the plan, copy the ssh line
739
1112
  const onShareRequest = (): void => {
740
1113
  if (!isOwner) return controller.setStatus("only the plan owner can share");
741
1114
  controller.share();
742
1115
  };
743
1116
 
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
+
744
1124
  // clicking the rail Submit button: same read-only answer as the submit key
745
1125
  const onSubmitRequest = (): void => {
746
1126
  if (observer) return controller.setStatus("observer - read-only");
@@ -763,221 +1143,268 @@ export function App({
763
1143
  <SlashSkillsContext.Provider value={skills}>
764
1144
  <PaletteNamesContext.Provider value={paletteNames}>
765
1145
  <ThemeProvider theme={theme}>
766
- <AppShell
767
- sidebarOpen={sidebarOpen}
768
- onToggleSidebar={() => setSidebarOpen((open) => !open)}
769
- onOpenMenu={() => setMenuDialog("settings")}
770
- threadsPanel={
771
- <scrollbox style={{ flexGrow: 1 }} focused={false}>
772
- <ThreadTree
773
- rows={grouped.rows}
774
- cursor={inboxCursor}
775
- activeId={activeSession.id}
776
- pinnedIds={pinnedIds}
777
- width={30}
778
- onSelect={(id) => controller.open(id)}
779
- onPin={togglePin}
780
- onRename={(id, title) =>
781
- setMode({ type: "renameThread", sessionId: id, text: title })
782
- }
783
- theme={theme}
784
- />
785
- </scrollbox>
786
- }
787
- threadTitle={threadTitle(activeSession)}
788
- threadActions={
789
- showOwnerActions ? (
790
- <Toolbar>
791
- <Button onPress={onEditRequest} theme={theme}>
792
- {" edit "}
793
- </Button>
794
- <Button onPress={onShareRequest} theme={theme}>
795
- {" share "}
796
- </Button>
797
- </Toolbar>
798
- ) : undefined
799
- }
800
- threadPanel={
801
- <box style={{ flexGrow: 1, flexDirection: "column" }}>
802
- <box style={{ flexGrow: 1, flexDirection: "row" }}>
803
- {isPixelPrototype ? (
804
- <PrototypePixels
805
- prototypePath={prototypePath}
806
- canComment={prototypeCanComment}
807
- onCommentElement={onCommentPrototype}
808
- onComposingChange={setPrototypeComposing}
809
- hidden={chromeHidden}
810
- />
811
- ) : isDiff ? (
812
- <box style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }}>
813
- <text fg={theme.textDim}>review the changes on the right</text>
814
- </box>
815
- ) : (
816
- <ThreadView
817
- session={activeSession}
818
- suspended={threadViewSuspended}
819
- editOrphanCount={editOrphanCount}
820
- onComposingChange={setThreadComposing}
821
- resolved={resolved}
822
- onObserverBlocked={(reason) =>
823
- controller.setStatus(
824
- reason === "observer"
825
- ? "observer - read-only"
826
- : "review submitted - read-only",
827
- )
828
- }
829
- onCursorChange={setCursor}
830
- focusedAnnotationId={focusedAnnotationId}
831
- onFocusAnnotation={setFocusedAnnotationId}
832
- display={display}
833
- marks={marks}
834
- quickActions={quickActions}
835
- observer={observer}
836
- onAnnotate={(span, body) =>
837
- void controller.annotate(
838
- "comment",
839
- span.start.blockIndex,
840
- span.start.char,
841
- span.end.char,
1146
+ <MenuControlProvider value={menuControl}>
1147
+ <AppShell
1148
+ sidebarOpen={sidebarOpen}
1149
+ onToggleSidebar={() => setSidebarOpen((open) => !open)}
1150
+ onOpenMenu={() => setMenuDialog("settings")}
1151
+ onFocusPane={setFocusedPane}
1152
+ threadsPanel={
1153
+ <scrollbox style={{ flexGrow: 1 }} focused={false}>
1154
+ <ThreadTree
1155
+ rows={grouped.rows}
1156
+ cursor={inboxCursor}
1157
+ activeId={activeSession.id}
1158
+ focused={focusedPane === "threads"}
1159
+ pinnedIds={pinnedIds}
1160
+ width={30}
1161
+ onSelect={openThread}
1162
+ onPin={togglePin}
1163
+ onRename={(id, title) =>
1164
+ setMode({ type: "renameThread", sessionId: id, text: title })
1165
+ }
1166
+ theme={theme}
1167
+ />
1168
+ </scrollbox>
1169
+ }
1170
+ threadTitle={threadTitle(activeSession)}
1171
+ 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
1182
+ }
1183
+ threadPanel={
1184
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
1185
+ <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
+ )}
1246
+ </box>
1247
+ {threadFooter}
1248
+ </box>
1249
+ }
1250
+ changesOpen={workbench.changesOpen}
1251
+ projectOpen={workbench.projectOpen}
1252
+ onToggleChanges={workbench.toggleChanges}
1253
+ onToggleProject={workbench.toggleProject}
1254
+ onToggleRight={workbench.toggleRight}
1255
+ projectMode={workbench.projectMode}
1256
+ zoomHideThread={workbench.zoomed}
1257
+ changesFooter={threadFooter}
1258
+ changesPanel={
1259
+ <EditorGrid
1260
+ tree={workbench.grid}
1261
+ focusedGroupId={workbench.activeGroup}
1262
+ commentCounts={diffCommentCounts}
1263
+ onFocusGroup={workbench.focusGroup}
1264
+ onActivateTab={workbench.activate}
1265
+ onCloseTab={workbench.close}
1266
+ onSplit={workbench.split}
1267
+ onZoom={workbench.toggleZoom}
1268
+ zoomed={workbench.zoomed}
1269
+ renderTab={(tab, groupFocused) => (
1270
+ <GridTabContent
1271
+ tab={tab}
1272
+ rows={rows}
1273
+ surface={{
1274
+ session: activeSession,
1275
+ quickActions,
1276
+ observer,
1277
+ commentsEnabled: true,
1278
+ resolved,
1279
+ suspended: surfaceSuspended(
1280
+ threadViewSuspended || !groupFocused,
1281
+ focusedPane,
1282
+ "changes",
1283
+ ),
1284
+ onComposingChange: setThreadComposing,
1285
+ onObserverBlocked: (reason) =>
1286
+ controller.setStatus(
1287
+ reason === "observer"
1288
+ ? "observer - read-only"
1289
+ : "review submitted - read-only",
1290
+ ),
1291
+ onCursorChange: setCursor,
1292
+ focusedAnnotationId,
1293
+ onFocusAnnotation: setFocusedAnnotationId,
1294
+ // a diff thread's Changes view is the artifact itself; any other thread's is the
1295
+ // live working-tree diff, so its notes carry a file target and anchor there
1296
+ onAnnotate: (span, body) =>
1297
+ void controller.annotate(
1298
+ "comment",
1299
+ span.start.blockIndex,
1300
+ span.start.char,
1301
+ span.end.char,
1302
+ body,
1303
+ span.end.blockIndex,
1304
+ isDiff
1305
+ ? { kind: "artifact" }
1306
+ : { kind: "file", path: "", rev: "worktree" },
1307
+ ),
1308
+ onReply: (rootAnnotationId, body) =>
1309
+ void controller.reply(rootAnnotationId, body),
1310
+ onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1311
+ resolveAuthorLabel,
1312
+ leaderCombos,
1313
+ onLeaderCommand: runLeaderCommand,
1314
+ onExit: () => onExit?.(0),
1315
+ }}
1316
+ rejectedRows={rejectedRows}
1317
+ fold={diffFold}
1318
+ fileStats={controller.fileStats()}
1319
+ split={diffView === "split" && workbench.zoomed}
1320
+ dimmed={walking}
1321
+ readFile={(path) => controller.repoReadFile(path)}
1322
+ onAddFileComment={(path, anchor, body) =>
1323
+ void controller.addComment(
1324
+ anchor,
1325
+ { kind: "file", path, rev: "worktree" },
842
1326
  body,
843
- span.end.blockIndex,
844
1327
  )
845
1328
  }
846
- onReply={(rootAnnotationId, body) =>
847
- void controller.reply(rootAnnotationId, body)
848
- }
849
- onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
850
- onExit={() => onExit?.(0)}
1329
+ theme={theme}
851
1330
  />
852
1331
  )}
1332
+ theme={theme}
1333
+ />
1334
+ }
1335
+ projectPanel={
1336
+ <ProjectPanelBody
1337
+ mode={workbench.projectMode}
1338
+ loadChanges={() => controller.repoChanges()}
1339
+ loadProjectFiles={() => controller.repoFiles()}
1340
+ reloadKey={activeSession.id}
1341
+ // the Changes navigator always opens a changed file as a diff - a diff review shows its
1342
+ // captured snapshot, every other thread the live working-tree diff
1343
+ onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
1344
+ onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
1345
+ commentCounts={diffCommentCounts}
1346
+ focused={focusedPane === "project"}
1347
+ theme={theme}
1348
+ />
1349
+ }
1350
+ theme={theme}
1351
+ >
1352
+ <TrailingOverlays
1353
+ walking={walking}
1354
+ walk={walk}
1355
+ walkFileList={walkFileList}
1356
+ viewedPaths={viewedPaths}
1357
+ session={activeSession}
1358
+ terminalWidth={terminalWidth}
1359
+ theme={theme}
1360
+ mode={mode}
1361
+ toast={toast}
1362
+ setMode={setMode}
1363
+ dispatch={dispatch}
1364
+ />
1365
+ {submitConfirmState !== null ? (
1366
+ <box
1367
+ onMouseUp={submitConfirmState.onCancel}
1368
+ style={{
1369
+ position: "absolute",
1370
+ left: 0,
1371
+ top: 0,
1372
+ width: "100%",
1373
+ height: "100%",
1374
+ justifyContent: "center",
1375
+ alignItems: "center",
1376
+ }}
1377
+ >
1378
+ <box onMouseUp={(event) => event.stopPropagation()}>
1379
+ <ConfirmCard {...submitConfirmState} theme={theme} />
1380
+ </box>
853
1381
  </box>
854
- {threadFooter}
855
- </box>
856
- }
857
- changesOpen={workbench.changesOpen}
858
- projectOpen={workbench.projectOpen}
859
- onToggleChanges={workbench.toggleChanges}
860
- onToggleProject={workbench.toggleProject}
861
- onToggleRight={workbench.toggleRight}
862
- projectMode={workbench.projectMode}
863
- zoomHideThread={workbench.zoomed}
864
- changesFooter={threadFooter}
865
- changesPanel={
866
- <EditorGrid
867
- tree={workbench.grid}
868
- focusedGroupId={workbench.activeGroup}
869
- commentCounts={diffCommentCounts}
870
- onFocusGroup={workbench.focusGroup}
871
- onActivateTab={workbench.activate}
872
- onCloseTab={workbench.close}
873
- onSplit={workbench.split}
874
- onZoom={workbench.toggleZoom}
875
- zoomed={workbench.zoomed}
876
- renderTab={(tab, groupFocused) => (
877
- <GridTabContent
878
- tab={tab}
879
- rows={rows}
880
- surface={{
881
- session: activeSession,
882
- quickActions,
883
- observer,
884
- commentsEnabled: true,
885
- resolved,
886
- suspended: threadViewSuspended || !groupFocused,
887
- onComposingChange: setThreadComposing,
888
- onObserverBlocked: (reason) =>
889
- controller.setStatus(
890
- reason === "observer"
891
- ? "observer - read-only"
892
- : "review submitted - read-only",
893
- ),
894
- onCursorChange: setCursor,
895
- focusedAnnotationId,
896
- onFocusAnnotation: setFocusedAnnotationId,
897
- // a diff thread's Changes view is the artifact itself; any other thread's is the
898
- // live working-tree diff, so its notes carry a file target and anchor there
899
- onAnnotate: (span, body) =>
900
- void controller.annotate(
901
- "comment",
902
- span.start.blockIndex,
903
- span.start.char,
904
- span.end.char,
905
- body,
906
- span.end.blockIndex,
907
- isDiff
908
- ? { kind: "artifact" }
909
- : { kind: "file", path: "", rev: "worktree" },
910
- ),
911
- onReply: (rootAnnotationId, body) =>
912
- void controller.reply(rootAnnotationId, body),
913
- onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
914
- onExit: () => onExit?.(0),
915
- }}
916
- rejectedRows={rejectedRows}
917
- fold={diffFold}
918
- fileStats={controller.fileStats()}
919
- split={diffView === "split" && workbench.zoomed}
920
- dimmed={walking}
921
- readFile={(path) => controller.repoReadFile(path)}
922
- onAddFileComment={(path, anchor, body) =>
923
- void controller.addComment(
924
- anchor,
925
- { kind: "file", path, rev: "worktree" },
926
- body,
927
- )
928
- }
929
- theme={theme}
930
- />
931
- )}
1382
+ ) : 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)}
932
1395
  theme={theme}
933
1396
  />
934
- }
935
- projectPanel={
936
- <ProjectPanelBody
937
- mode={workbench.projectMode}
938
- loadChanges={() => controller.repoChanges()}
939
- loadProjectFiles={() => controller.repoFiles()}
940
- reloadKey={activeSession.id}
941
- // the Changes navigator always opens a changed file as a diff - a diff review shows its
942
- // captured snapshot, every other thread the live working-tree diff
943
- onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
944
- onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
945
- commentCounts={diffCommentCounts}
1397
+ <ShareChoiceDialog
1398
+ isOpen={mode.type === "shareChoice"}
1399
+ selectedIndex={shareChoiceIndex(mode)}
1400
+ onPublicShare={onShareRequest}
1401
+ onPrivateShare={onPrivateShareRequest}
1402
+ onClose={() => setMode({ type: "normal" })}
946
1403
  theme={theme}
947
1404
  />
948
- }
949
- theme={theme}
950
- >
951
- <TrailingOverlays
952
- walking={walking}
953
- walk={walk}
954
- walkFileList={walkFileList}
955
- viewedPaths={viewedPaths}
956
- session={activeSession}
957
- terminalWidth={terminalWidth}
958
- theme={theme}
959
- mode={mode}
960
- toast={toast}
961
- setMode={setMode}
962
- dispatch={dispatch}
963
- />
964
- {submitConfirmState !== null ? (
965
- <box
966
- style={{
967
- position: "absolute",
968
- left: 0,
969
- top: 0,
970
- width: "100%",
971
- height: "100%",
972
- justifyContent: "center",
973
- alignItems: "center",
974
- }}
975
- >
976
- <ConfirmCard {...submitConfirmState} theme={theme} />
977
- </box>
978
- ) : null}
979
- {menuChrome}
980
- </AppShell>
1405
+ {menuChrome}
1406
+ </AppShell>
1407
+ </MenuControlProvider>
981
1408
  </ThemeProvider>
982
1409
  </PaletteNamesContext.Provider>
983
1410
  </SlashSkillsContext.Provider>