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

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 (59) hide show
  1. package/package.json +5 -5
  2. package/src/App.focus.test.ts +147 -0
  3. package/src/App.regression.test.tsx +94 -0
  4. package/src/App.test.tsx +11 -8
  5. package/src/App.tsx +476 -220
  6. package/src/App.visual.test.tsx +57 -0
  7. package/src/App.workbench.test.tsx +41 -22
  8. package/src/app-screens.tsx +168 -123
  9. package/src/components/AppShell.brand.test.tsx +1 -1
  10. package/src/components/AppShell.tsx +14 -1
  11. package/src/components/ChangesColumn.tsx +40 -12
  12. package/src/components/ChangesFileTree.test.tsx +61 -0
  13. package/src/components/ConfirmDialog.tsx +8 -1
  14. package/src/components/DiffContentView.tsx +7 -1
  15. package/src/components/DiscussionMarkerRail.tsx +2 -2
  16. package/src/components/EditorGrid.tsx +84 -26
  17. package/src/components/GridTabContent.tsx +10 -1
  18. package/src/components/PanelColumn.tsx +3 -0
  19. package/src/components/ProjectTreeView.test.tsx +39 -12
  20. package/src/components/ProjectTreeView.tsx +38 -10
  21. package/src/components/PromptDialog.test.tsx +61 -0
  22. package/src/components/PromptDialog.tsx +16 -3
  23. package/src/components/RootOverlay.stories.tsx +1 -0
  24. package/src/components/RootOverlay.tsx +19 -6
  25. package/src/components/SettingsDialog.stories.tsx +3 -3
  26. package/src/components/SettingsDialog.tsx +3 -4
  27. package/src/components/ThreadFooter.test.tsx +12 -7
  28. package/src/components/ThreadFooter.tsx +18 -4
  29. package/src/components/ThreadTree.tsx +79 -36
  30. package/src/components/ThreadView.slash.test.tsx +36 -0
  31. package/src/components/ThreadView.tsx +7 -1
  32. package/src/components/WelcomePlayground.tsx +2 -0
  33. package/src/components/__snapshots__/stories.test.tsx.snap +60 -51
  34. package/src/components/menu-control.stories.tsx +35 -0
  35. package/src/components/menu-control.tsx +43 -0
  36. package/src/components/primitives/Dialog.test.tsx +37 -0
  37. package/src/components/primitives/Dialog.tsx +5 -0
  38. package/src/components/primitives/icons.ts +1 -1
  39. package/src/components/quick-actions-editor.stories.tsx +2 -0
  40. package/src/components/quick-actions-editor.test.tsx +35 -1
  41. package/src/components/quick-actions-editor.tsx +23 -22
  42. package/src/components/session-tree.test.ts +3 -3
  43. package/src/components/session-tree.ts +1 -1
  44. package/src/config.ts +1 -0
  45. package/src/diff-intraline.ts +26 -5
  46. package/src/perf/otlp-trace.test.ts +116 -0
  47. package/src/perf/otlp-trace.ts +130 -0
  48. package/src/perf/perf-report.ts +17 -0
  49. package/src/perf/perf-timings.test.ts +18 -0
  50. package/src/perf/perf-timings.ts +38 -0
  51. package/src/run.ts +22 -2
  52. package/src/thread-chords.test.ts +33 -1
  53. package/src/thread-chords.ts +114 -29
  54. package/src/thread-controller.ts +4 -3
  55. package/src/use-annotation-surface.tsx +22 -0
  56. package/src/use-frame-measure.ts +4 -1
  57. package/src/use-settings-dialog.tsx +9 -7
  58. package/src/visual/contrast.test.ts +35 -0
  59. package/src/visual/contrast.ts +33 -0
package/src/App.tsx CHANGED
@@ -42,8 +42,9 @@ import { Toolbar } from "./components/primitives/Toolbar";
42
42
  import { groupInbox, projectName, threadTitle } from "./components/session-tree";
43
43
  import { ThreadTree } from "./components/ThreadTree";
44
44
  import { ChangesFileTree } from "./components/ChangesColumn";
45
+ import { MenuControlProvider, useMenuControlState } from "./components/menu-control";
45
46
  import { ProjectTreeView } from "./components/ProjectTreeView";
46
- import { AppShell, type ProjectPanelMode } from "./components/AppShell";
47
+ import { AppShell, type FocusPane, type ProjectPanelMode } from "./components/AppShell";
47
48
  import { EditorGrid } from "./components/EditorGrid";
48
49
  import { GridTabContent } from "./components/GridTabContent";
49
50
  import { useChangesWorkbench } from "./use-changes-workbench";
@@ -53,11 +54,15 @@ import { ThreadFooter } from "./components/ThreadFooter";
53
54
  import { ConfirmCard } from "./components/ConfirmCard";
54
55
  import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
55
56
  import {
56
- DIFF_CHORD_ENTRIES,
57
- RAIL_CHORD_ENTRIES,
57
+ diffChordEntries,
58
+ dispatchLeaderCommand,
59
+ leaderCombosFor,
60
+ leaderHint,
61
+ matchesLeader,
62
+ railChordEntries,
58
63
  resolveThreadChord,
59
64
  THREAD_CHORD_ENTRIES,
60
- TREE_CHORD_ENTRIES,
65
+ treeChordEntries,
61
66
  } from "./thread-chords";
62
67
  import { type DiffFoldControls } from "./components/DiffContentView";
63
68
  import { commentCountsByFile } from "./view-diff";
@@ -86,7 +91,7 @@ import {
86
91
  TrailingOverlays,
87
92
  } from "./app-screens";
88
93
  /** A toast clears itself after this idle; esc dismisses it sooner. */
89
- const TOAST_DISMISS_MS = 4000;
94
+ const TOAST_DISMISS_MS = 2000;
90
95
 
91
96
  export interface AppProps {
92
97
  home?: string;
@@ -134,6 +139,46 @@ function menuChromeOpen(menuDialog: "keybinds" | "settings" | null): boolean {
134
139
  return menuDialog !== null;
135
140
  }
136
141
 
142
+ /** The drop-up chrome or a floating popover menu (thread actions, editor split) holds the keyboard. */
143
+ function keyboardHeldByMenu(
144
+ menuDialog: "keybinds" | "settings" | null,
145
+ openMenuId: string | null,
146
+ ): boolean {
147
+ return menuChromeOpen(menuDialog) || openMenuId !== null;
148
+ }
149
+
150
+ /** A non-modal toast: escape dismisses it only when nothing else owns escape (no overlay, not span mode). */
151
+ export function toastDismissRequested(
152
+ toastOpen: boolean,
153
+ key: { name: string },
154
+ overlay: KeyState["overlay"],
155
+ spanMode: boolean,
156
+ ): boolean {
157
+ return toastOpen && key.name === "escape" && overlay === "none" && !spanMode;
158
+ }
159
+
160
+ /** ctrl+q quits from anywhere, ahead of every focus/menu/overlay gate, with no confirmation. */
161
+ export function quitKeyHandled(
162
+ key: { name: string; ctrl?: boolean },
163
+ onExit?: (code: number) => void,
164
+ ): boolean {
165
+ if (!key.ctrl || key.name !== "q") return false;
166
+ onExit?.(0);
167
+
168
+ return true;
169
+ }
170
+
171
+ /** A popover menu is open, so the shell keyboard is modal: escape closes it, every other key is swallowed. */
172
+ function menuModalHandled(
173
+ menuControl: { openMenuId: string | null; closeMenu: () => void },
174
+ key: { name: string },
175
+ ): boolean {
176
+ if (menuControl.openMenuId === null) return false;
177
+ if (key.name === "escape") menuControl.closeMenu();
178
+
179
+ return true;
180
+ }
181
+
137
182
  /** True while a menu or an overlay owns the keyboard instead of the thread view. */
138
183
  function keyboardOwnedElsewhere(menuOwnsKeyboard: boolean, overlay: KeyState["overlay"]): boolean {
139
184
  return menuOwnsKeyboard || overlay !== "none";
@@ -153,6 +198,7 @@ function ProjectPanelBody(props: {
153
198
  onOpenChangedFile: (path: string) => void;
154
199
  onOpenProjectFile: (path: string) => void;
155
200
  commentCounts?: ReadonlyMap<string, number>;
201
+ focused?: boolean;
156
202
  theme: Theme;
157
203
  }): React.ReactNode {
158
204
  const [changes, setChanges] = useState<readonly DiffFileContents[]>([]);
@@ -183,6 +229,7 @@ function ProjectPanelBody(props: {
183
229
  files={changes}
184
230
  onSelectFile={props.onOpenChangedFile}
185
231
  commentCounts={props.commentCounts}
232
+ focused={props.focused}
186
233
  theme={props.theme}
187
234
  />
188
235
  );
@@ -192,6 +239,7 @@ function ProjectPanelBody(props: {
192
239
  key={props.reloadKey}
193
240
  loadFiles={props.loadProjectFiles}
194
241
  onSelectFile={props.onOpenProjectFile}
242
+ focused={props.focused}
195
243
  theme={props.theme}
196
244
  />
197
245
  );
@@ -207,7 +255,11 @@ function usableScreenReached(
207
255
  }
208
256
 
209
257
  /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
210
- function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
258
+ function cheatsheetFor(
259
+ keyBindings: KeyBindings,
260
+ threadViewActive: boolean,
261
+ hint: string,
262
+ ): CheatsheetSection[] {
211
263
  const base = keyBindings.cheatsheet();
212
264
 
213
265
  if (!threadViewActive) {
@@ -217,9 +269,9 @@ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): Che
217
269
  return [
218
270
  ...THREAD_VIEW_CHEATSHEET,
219
271
  { 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] },
272
+ { title: "Diff", entries: diffChordEntries(hint) },
273
+ { title: "Rail", entries: railChordEntries(hint) },
274
+ { title: "Tree", entries: treeChordEntries(hint) },
223
275
  ...base.filter((section) => section.title === "Agent terminal"),
224
276
  ];
225
277
  }
@@ -232,6 +284,125 @@ function initialThreadsOpen(
232
284
  return layout ? layout.threads : sessionId === undefined;
233
285
  }
234
286
 
287
+ function initialFocusedPane(sessionId: string | undefined, zoomed: boolean): FocusPane {
288
+ if (sessionId === undefined) return "threads";
289
+
290
+ return zoomed ? "changes" : "thread";
291
+ }
292
+
293
+ function bareShellDefersKeys(
294
+ sessionIsNull: boolean,
295
+ overlayIsNone: boolean,
296
+ focusedPane: FocusPane,
297
+ ): boolean {
298
+ return sessionIsNull && overlayIsNone && focusedPane !== "threads";
299
+ }
300
+
301
+ function surfaceSuspended(base: boolean, focusedPane: FocusPane, pane: FocusPane): boolean {
302
+ return base || focusedPane !== pane;
303
+ }
304
+
305
+ export function threadsNavHandled(params: {
306
+ focusedPane: FocusPane;
307
+ quiet: boolean;
308
+ key: { name: string };
309
+ count: number;
310
+ setInboxCursor: (update: (cursor: number) => number) => void;
311
+ openSession: () => void;
312
+ }): boolean {
313
+ const { focusedPane, quiet, key, count } = params;
314
+
315
+ if (focusedPane !== "threads" || !quiet || count === 0) return false;
316
+ if (key.name === "j" || key.name === "down") {
317
+ params.setInboxCursor((cursor) => Math.min(cursor + 1, count - 1));
318
+
319
+ return true;
320
+ }
321
+ if (key.name === "k" || key.name === "up") {
322
+ params.setInboxCursor((cursor) => Math.max(cursor - 1, 0));
323
+
324
+ return true;
325
+ }
326
+ if (key.name === "return" || key.name === "enter") {
327
+ params.openSession();
328
+
329
+ return true;
330
+ }
331
+
332
+ return false;
333
+ }
334
+
335
+ export function appLeaderHandled(params: {
336
+ focusedPane: FocusPane;
337
+ key: { name: string; shift?: boolean; ctrl?: boolean; meta?: boolean; super?: boolean };
338
+ leaderCombos: readonly string[];
339
+ pending: { current: boolean };
340
+ runLeaderCommand: (key: { name: string; shift?: boolean }) => void;
341
+ }): boolean {
342
+ const { focusedPane, key, leaderCombos, pending, runLeaderCommand } = params;
343
+
344
+ if (focusedPane !== "threads" && focusedPane !== "project") return false;
345
+ if (pending.current) {
346
+ pending.current = false;
347
+ if (key.name !== "escape") runLeaderCommand(key);
348
+
349
+ return true;
350
+ }
351
+ if (matchesLeader(key, leaderCombos)) {
352
+ pending.current = true;
353
+
354
+ return true;
355
+ }
356
+
357
+ return false;
358
+ }
359
+
360
+ export function visiblePanes(
361
+ sidebarOpen: boolean,
362
+ threadShown: boolean,
363
+ changesOpen: boolean,
364
+ projectOpen: boolean,
365
+ ): FocusPane[] {
366
+ const panes: FocusPane[] = [];
367
+
368
+ if (sidebarOpen) panes.push("threads");
369
+ if (threadShown) panes.push("thread");
370
+ if (changesOpen) panes.push("changes");
371
+ if (projectOpen) panes.push("project");
372
+
373
+ return panes;
374
+ }
375
+
376
+ export function nextFocusPane(
377
+ current: FocusPane,
378
+ panes: FocusPane[],
379
+ backward: boolean,
380
+ ): FocusPane {
381
+ if (panes.length === 0) return current;
382
+ const index = panes.indexOf(current);
383
+ const step = backward ? -1 : 1;
384
+
385
+ return panes[(index + step + panes.length) % panes.length] ?? current;
386
+ }
387
+
388
+ /** The panes that can own the keyboard: a diff's Thread pane is an inert placeholder, so it is out. */
389
+ function navigableFocusPanes(
390
+ sidebarOpen: boolean,
391
+ zoomed: boolean,
392
+ isDiff: boolean,
393
+ changesOpen: boolean,
394
+ projectOpen: boolean,
395
+ ): FocusPane[] {
396
+ return visiblePanes(sidebarOpen, !zoomed && !isDiff, changesOpen, projectOpen);
397
+ }
398
+
399
+ /** The layout hid or inert-ed the focused pane: fall back to the first navigable one, else stay put. */
400
+ export function reconciledFocus(navigable: FocusPane[], focusedPane: FocusPane): FocusPane | null {
401
+ if (navigable.length === 0 || navigable.includes(focusedPane)) return null;
402
+
403
+ return navigable[0] ?? null;
404
+ }
405
+
235
406
  export function App({
236
407
  home,
237
408
  sessionId,
@@ -318,6 +489,9 @@ export function App({
318
489
  const [sidebarOpen, setSidebarOpen] = useState(() => initialThreadsOpen(layout, sessionId));
319
490
  // the Changes + Project right region and its editor grid (tabs, splits, zoom)
320
491
  const workbench = useChangesWorkbench({ layout });
492
+ const [focusedPane, setFocusedPane] = useState<FocusPane>(() =>
493
+ initialFocusedPane(sessionId, workbench.zoomed),
494
+ );
321
495
  // only the session view persists from here; the bare shell owns its own (NoThreadShell), so this
322
496
  // workbench stays inactive with no session and never clobbers what the shell saved
323
497
  useRememberLayout(
@@ -570,12 +744,65 @@ export function App({
570
744
 
571
745
  const overlay = resolveOverlay(mode, completion.phase, walking);
572
746
 
573
- const menuOwnsKeyboard = menuChromeOpen(menuDialog);
747
+ const menuControl = useMenuControlState();
748
+ const menuOwnsKeyboard = keyboardHeldByMenu(menuDialog, menuControl.openMenuId);
574
749
  // an overlay (submit, walk, prompt, confirm) or the menu takes the keyboard
575
750
  // from the thread view; the view suspends its own grammar meanwhile
576
751
  const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay);
577
752
 
753
+ const leaderCombos = leaderCombosFor(keysRef.current.leader);
754
+ const leaderPending = useRef(false);
755
+ const navigablePanes = navigableFocusPanes(
756
+ sidebarOpen,
757
+ workbench.zoomed,
758
+ isDiff,
759
+ workbench.changesOpen,
760
+ workbench.projectOpen,
761
+ );
762
+ useEffect(() => {
763
+ // the bare shell drives its panes from its own workbench, so only reconcile against this one
764
+ if (!session) return;
765
+ const next = reconciledFocus(navigablePanes, focusedPane);
766
+
767
+ if (next) setFocusedPane(next);
768
+ }, [session, navigablePanes, focusedPane]);
769
+ const cyclePanes = (backward: boolean): void =>
770
+ setFocusedPane((current) => nextFocusPane(current, navigablePanes, backward));
771
+ const runLeaderCommand = (key: { name: string; shift?: boolean }): void => {
772
+ if (key.name === "tab") return cyclePanes(Boolean(key.shift));
773
+
774
+ dispatchLeaderCommand(
775
+ key,
776
+ { composing: threadComposing, isOwner, resolved, treeActive: railTab === "tree", isDiff },
777
+ dispatch,
778
+ );
779
+ };
780
+ // clicking a sidebar thread moves the cursor onto it too, so the row shows its selected backdrop at once
781
+ const openThread = (id: string): void => {
782
+ const index = grouped.ordered.findIndex((thread) => thread.id === id);
783
+
784
+ if (index >= 0) setInboxCursor(() => index);
785
+ controller.open(id);
786
+ };
787
+
578
788
  useKeyboard((key) => {
789
+ if (quitKeyHandled(key, onExit)) return;
790
+ if (menuModalHandled(menuControl, key)) return;
791
+ if (
792
+ appLeaderHandled({ focusedPane, key, leaderCombos, pending: leaderPending, runLeaderCommand })
793
+ )
794
+ return;
795
+ if (
796
+ threadsNavHandled({
797
+ focusedPane,
798
+ quiet: overlay === "none" && !menuOwnsKeyboard && !threadComposing,
799
+ key,
800
+ count: grouped.ordered.length,
801
+ setInboxCursor,
802
+ openSession: () => dispatch({ type: "openSession" }),
803
+ })
804
+ )
805
+ return;
579
806
  // The thread view owns the document grammar while active (its own
580
807
  // useKeyboard handles marks, comments, and ctrl+q); the session chords
581
808
  // (submit, share, edit, walk, the rail) resolve here, and the keymap only
@@ -600,8 +827,9 @@ export function App({
600
827
  if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
601
828
  // the toast is non-modal: escape only dismisses it when nothing else owns
602
829
  // escape, so an open overlay (compose, submit, prompt, walk) still cancels
603
- if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span")
830
+ if (toastDismissRequested(toast !== null, key, overlay, mode.type === "span"))
604
831
  return controller.dismissToast();
832
+ if (bareShellDefersKeys(session === null, overlay === "none", focusedPane)) return;
605
833
  const state = buildKeyState({
606
834
  keys: keysRef.current,
607
835
  observer,
@@ -646,12 +874,13 @@ export function App({
646
874
  <MenuChrome
647
875
  menuDialog={menuDialog}
648
876
  theme={theme}
649
- keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
877
+ keybindsSections={cheatsheetFor(keyBindings, threadViewActive, leaderHint(leaderCombos))}
650
878
  settingsCategories={settingsCategories}
651
879
  settingsValues={settingsValues}
652
880
  settingsNav={settingsNav}
653
881
  onCategorySelect={onCategorySelect}
654
882
  cycleSetting={cycleSetting}
883
+ onClose={() => setMenuDialog(null)}
655
884
  />
656
885
  );
657
886
 
@@ -664,6 +893,7 @@ export function App({
664
893
  <NoThreadShell
665
894
  rows={grouped.rows}
666
895
  inboxCursor={inboxCursor}
896
+ onOpenThread={openThread}
667
897
  mode={mode}
668
898
  theme={theme}
669
899
  controller={controller}
@@ -672,6 +902,9 @@ export function App({
672
902
  onOpenMenu={() => setMenuDialog("settings")}
673
903
  sidebarOpen={sidebarOpen}
674
904
  onToggleSidebar={() => setSidebarOpen((open) => !open)}
905
+ focusedPane={focusedPane}
906
+ onFocusPane={setFocusedPane}
907
+ menuControl={menuControl}
675
908
  pinnedIds={pinnedIds}
676
909
  onPin={togglePin}
677
910
  onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
@@ -763,221 +996,244 @@ export function App({
763
996
  <SlashSkillsContext.Provider value={skills}>
764
997
  <PaletteNamesContext.Provider value={paletteNames}>
765
998
  <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,
999
+ <MenuControlProvider value={menuControl}>
1000
+ <AppShell
1001
+ sidebarOpen={sidebarOpen}
1002
+ onToggleSidebar={() => setSidebarOpen((open) => !open)}
1003
+ onOpenMenu={() => setMenuDialog("settings")}
1004
+ onFocusPane={setFocusedPane}
1005
+ threadsPanel={
1006
+ <scrollbox style={{ flexGrow: 1 }} focused={false}>
1007
+ <ThreadTree
1008
+ rows={grouped.rows}
1009
+ cursor={inboxCursor}
1010
+ activeId={activeSession.id}
1011
+ focused={focusedPane === "threads"}
1012
+ pinnedIds={pinnedIds}
1013
+ width={30}
1014
+ onSelect={openThread}
1015
+ onPin={togglePin}
1016
+ onRename={(id, title) =>
1017
+ setMode({ type: "renameThread", sessionId: id, text: title })
1018
+ }
1019
+ theme={theme}
1020
+ />
1021
+ </scrollbox>
1022
+ }
1023
+ threadTitle={threadTitle(activeSession)}
1024
+ threadActions={
1025
+ showOwnerActions ? (
1026
+ <Toolbar>
1027
+ <Button onPress={onEditRequest} theme={theme}>
1028
+ {" edit "}
1029
+ </Button>
1030
+ <Button onPress={onShareRequest} theme={theme}>
1031
+ {" share "}
1032
+ </Button>
1033
+ </Toolbar>
1034
+ ) : undefined
1035
+ }
1036
+ threadPanel={
1037
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
1038
+ <box style={{ flexGrow: 1, flexDirection: "row" }}>
1039
+ {isPixelPrototype ? (
1040
+ <PrototypePixels
1041
+ prototypePath={prototypePath}
1042
+ canComment={prototypeCanComment}
1043
+ onCommentElement={onCommentPrototype}
1044
+ onComposingChange={setPrototypeComposing}
1045
+ hidden={chromeHidden}
1046
+ />
1047
+ ) : isDiff ? (
1048
+ <box
1049
+ style={{
1050
+ flexGrow: 1,
1051
+ flexDirection: "column",
1052
+ alignItems: "center",
1053
+ justifyContent: "center",
1054
+ }}
1055
+ >
1056
+ <text fg={theme.textDim}>Select a thread</text>
1057
+ </box>
1058
+ ) : (
1059
+ <ThreadView
1060
+ session={activeSession}
1061
+ suspended={surfaceSuspended(threadViewSuspended, focusedPane, "thread")}
1062
+ editOrphanCount={editOrphanCount}
1063
+ onComposingChange={setThreadComposing}
1064
+ leaderCombos={leaderCombos}
1065
+ onLeaderCommand={runLeaderCommand}
1066
+ resolved={resolved}
1067
+ onObserverBlocked={(reason) =>
1068
+ controller.setStatus(
1069
+ reason === "observer"
1070
+ ? "observer - read-only"
1071
+ : "review submitted - read-only",
1072
+ )
1073
+ }
1074
+ onCursorChange={setCursor}
1075
+ focusedAnnotationId={focusedAnnotationId}
1076
+ onFocusAnnotation={setFocusedAnnotationId}
1077
+ display={display}
1078
+ marks={marks}
1079
+ quickActions={quickActions}
1080
+ observer={observer}
1081
+ onAnnotate={(span, body) =>
1082
+ void controller.annotate(
1083
+ "comment",
1084
+ span.start.blockIndex,
1085
+ span.start.char,
1086
+ span.end.char,
1087
+ body,
1088
+ span.end.blockIndex,
1089
+ )
1090
+ }
1091
+ onReply={(rootAnnotationId, body) =>
1092
+ void controller.reply(rootAnnotationId, body)
1093
+ }
1094
+ onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
1095
+ onExit={() => onExit?.(0)}
1096
+ />
1097
+ )}
1098
+ </box>
1099
+ {threadFooter}
1100
+ </box>
1101
+ }
1102
+ changesOpen={workbench.changesOpen}
1103
+ projectOpen={workbench.projectOpen}
1104
+ onToggleChanges={workbench.toggleChanges}
1105
+ onToggleProject={workbench.toggleProject}
1106
+ onToggleRight={workbench.toggleRight}
1107
+ projectMode={workbench.projectMode}
1108
+ zoomHideThread={workbench.zoomed}
1109
+ changesFooter={threadFooter}
1110
+ changesPanel={
1111
+ <EditorGrid
1112
+ tree={workbench.grid}
1113
+ focusedGroupId={workbench.activeGroup}
1114
+ commentCounts={diffCommentCounts}
1115
+ onFocusGroup={workbench.focusGroup}
1116
+ onActivateTab={workbench.activate}
1117
+ onCloseTab={workbench.close}
1118
+ onSplit={workbench.split}
1119
+ onZoom={workbench.toggleZoom}
1120
+ zoomed={workbench.zoomed}
1121
+ renderTab={(tab, groupFocused) => (
1122
+ <GridTabContent
1123
+ tab={tab}
1124
+ rows={rows}
1125
+ surface={{
1126
+ session: activeSession,
1127
+ quickActions,
1128
+ observer,
1129
+ commentsEnabled: true,
1130
+ resolved,
1131
+ suspended: surfaceSuspended(
1132
+ threadViewSuspended || !groupFocused,
1133
+ focusedPane,
1134
+ "changes",
1135
+ ),
1136
+ onComposingChange: setThreadComposing,
1137
+ onObserverBlocked: (reason) =>
1138
+ controller.setStatus(
1139
+ reason === "observer"
1140
+ ? "observer - read-only"
1141
+ : "review submitted - read-only",
1142
+ ),
1143
+ onCursorChange: setCursor,
1144
+ focusedAnnotationId,
1145
+ onFocusAnnotation: setFocusedAnnotationId,
1146
+ // a diff thread's Changes view is the artifact itself; any other thread's is the
1147
+ // live working-tree diff, so its notes carry a file target and anchor there
1148
+ onAnnotate: (span, body) =>
1149
+ void controller.annotate(
1150
+ "comment",
1151
+ span.start.blockIndex,
1152
+ span.start.char,
1153
+ span.end.char,
1154
+ body,
1155
+ span.end.blockIndex,
1156
+ isDiff
1157
+ ? { kind: "artifact" }
1158
+ : { kind: "file", path: "", rev: "worktree" },
1159
+ ),
1160
+ onReply: (rootAnnotationId, body) =>
1161
+ void controller.reply(rootAnnotationId, body),
1162
+ onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1163
+ leaderCombos,
1164
+ onLeaderCommand: runLeaderCommand,
1165
+ onExit: () => onExit?.(0),
1166
+ }}
1167
+ rejectedRows={rejectedRows}
1168
+ fold={diffFold}
1169
+ fileStats={controller.fileStats()}
1170
+ split={diffView === "split" && workbench.zoomed}
1171
+ dimmed={walking}
1172
+ readFile={(path) => controller.repoReadFile(path)}
1173
+ onAddFileComment={(path, anchor, body) =>
1174
+ void controller.addComment(
1175
+ anchor,
1176
+ { kind: "file", path, rev: "worktree" },
842
1177
  body,
843
- span.end.blockIndex,
844
1178
  )
845
1179
  }
846
- onReply={(rootAnnotationId, body) =>
847
- void controller.reply(rootAnnotationId, body)
848
- }
849
- onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
850
- onExit={() => onExit?.(0)}
1180
+ theme={theme}
851
1181
  />
852
1182
  )}
853
- </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
- )}
932
- theme={theme}
933
- />
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}
1183
+ theme={theme}
1184
+ />
1185
+ }
1186
+ projectPanel={
1187
+ <ProjectPanelBody
1188
+ mode={workbench.projectMode}
1189
+ loadChanges={() => controller.repoChanges()}
1190
+ loadProjectFiles={() => controller.repoFiles()}
1191
+ reloadKey={activeSession.id}
1192
+ // the Changes navigator always opens a changed file as a diff - a diff review shows its
1193
+ // captured snapshot, every other thread the live working-tree diff
1194
+ onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
1195
+ onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
1196
+ commentCounts={diffCommentCounts}
1197
+ focused={focusedPane === "project"}
1198
+ theme={theme}
1199
+ />
1200
+ }
1201
+ theme={theme}
1202
+ >
1203
+ <TrailingOverlays
1204
+ walking={walking}
1205
+ walk={walk}
1206
+ walkFileList={walkFileList}
1207
+ viewedPaths={viewedPaths}
1208
+ session={activeSession}
1209
+ terminalWidth={terminalWidth}
946
1210
  theme={theme}
1211
+ mode={mode}
1212
+ toast={toast}
1213
+ setMode={setMode}
1214
+ dispatch={dispatch}
947
1215
  />
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>
1216
+ {submitConfirmState !== null ? (
1217
+ <box
1218
+ onMouseUp={submitConfirmState.onCancel}
1219
+ style={{
1220
+ position: "absolute",
1221
+ left: 0,
1222
+ top: 0,
1223
+ width: "100%",
1224
+ height: "100%",
1225
+ justifyContent: "center",
1226
+ alignItems: "center",
1227
+ }}
1228
+ >
1229
+ <box onMouseUp={(event) => event.stopPropagation()}>
1230
+ <ConfirmCard {...submitConfirmState} theme={theme} />
1231
+ </box>
1232
+ </box>
1233
+ ) : null}
1234
+ {menuChrome}
1235
+ </AppShell>
1236
+ </MenuControlProvider>
981
1237
  </ThemeProvider>
982
1238
  </PaletteNamesContext.Provider>
983
1239
  </SlashSkillsContext.Provider>