@cueloop/client 0.1.0-alpha.65 → 0.1.0-alpha.67

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 (160) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +79 -112
  4. package/src/App.plan-review.test.tsx +23 -205
  5. package/src/App.prototype.test.tsx +1 -8
  6. package/src/App.readonly.test.tsx +9 -27
  7. package/src/App.submit-confirm.test.tsx +19 -72
  8. package/src/App.test.tsx +136 -31
  9. package/src/App.tsx +491 -322
  10. package/src/App.walk.test.tsx +15 -16
  11. package/src/App.workbench.test.tsx +390 -0
  12. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  13. package/src/annotation-palette.ts +61 -0
  14. package/src/app-screens.tsx +197 -77
  15. package/src/app-view-model.ts +9 -69
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +1 -1
  18. package/src/components/AnnotationCards.stories.tsx +87 -0
  19. package/src/components/AnnotationCards.tsx +303 -0
  20. package/src/components/AppHeader.stories.tsx +50 -0
  21. package/src/components/AppHeader.tsx +83 -0
  22. package/src/components/AppShell.stories.tsx +89 -0
  23. package/src/components/AppShell.tsx +239 -0
  24. package/src/components/AppShell.zoom.test.tsx +94 -0
  25. package/src/components/Breadcrumb.stories.tsx +1 -1
  26. package/src/components/ChangesColumn.stories.tsx +39 -0
  27. package/src/components/ChangesColumn.tsx +170 -0
  28. package/src/components/CodeBlock.stories.tsx +1 -1
  29. package/src/components/CompletionOverlay.stories.tsx +1 -1
  30. package/src/components/ConfirmCard.stories.tsx +1 -1
  31. package/src/components/ConfirmCard.tsx +3 -4
  32. package/src/components/ConfirmDialog.stories.tsx +1 -1
  33. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  34. package/src/components/DiffSheet.split.test.tsx +140 -0
  35. package/src/components/DiffSheet.stories.tsx +56 -54
  36. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  37. package/src/components/DiffSheet.tsx +607 -276
  38. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  39. package/src/components/DiscussionMarkerRail.tsx +181 -0
  40. package/src/components/EditorGrid.stories.tsx +59 -0
  41. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  42. package/src/components/EditorGrid.tsx +338 -0
  43. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  44. package/src/components/FileContentsView.stories.tsx +16 -0
  45. package/src/components/FileContentsView.test.tsx +29 -0
  46. package/src/components/FileContentsView.tsx +78 -0
  47. package/src/components/InboxList.stories.tsx +23 -8
  48. package/src/components/InboxList.tsx +188 -33
  49. package/src/components/KeybindsDialog.stories.tsx +1 -1
  50. package/src/components/MarkerPopover.stories.tsx +1 -1
  51. package/src/components/MenuBar.stories.tsx +3 -12
  52. package/src/components/MenuBar.tsx +25 -56
  53. package/src/components/PaneGrid.stories.tsx +84 -0
  54. package/src/components/PaneGrid.tsx +128 -0
  55. package/src/components/PanelColumn.stories.tsx +25 -0
  56. package/src/components/PanelColumn.tsx +95 -0
  57. package/src/components/ProjectTreeView.stories.tsx +25 -0
  58. package/src/components/ProjectTreeView.test.tsx +32 -0
  59. package/src/components/ProjectTreeView.tsx +82 -0
  60. package/src/components/PromptDialog.stories.tsx +1 -1
  61. package/src/components/PromptDialog.tsx +8 -1
  62. package/src/components/PrototypeSheet.stories.tsx +1 -1
  63. package/src/components/RootOverlay.stories.tsx +44 -0
  64. package/src/components/RootOverlay.tsx +35 -0
  65. package/src/components/SettingsDialog.stories.tsx +7 -1
  66. package/src/components/SettingsDialog.tsx +114 -67
  67. package/src/components/SettingsRows.stories.tsx +1 -1
  68. package/src/components/ShellHeader.stories.tsx +50 -0
  69. package/src/components/ShellHeader.tsx +137 -0
  70. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  71. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  72. package/src/components/SurfaceScrollbar.tsx +93 -0
  73. package/src/components/ThreadFooter.stories.tsx +30 -0
  74. package/src/components/ThreadFooter.test.tsx +31 -0
  75. package/src/components/ThreadFooter.tsx +54 -0
  76. package/src/components/ThreadView.slash.test.tsx +113 -0
  77. package/src/components/ThreadView.stories.tsx +1 -1
  78. package/src/components/ThreadView.tsx +62 -1352
  79. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  80. package/src/components/ThreadsSidebar.tsx +70 -0
  81. package/src/components/Toast.stories.tsx +1 -1
  82. package/src/components/Tooltip.stories.tsx +26 -0
  83. package/src/components/Tooltip.tsx +72 -0
  84. package/src/components/TreePane.stories.tsx +1 -1
  85. package/src/components/WalkWizard.stories.tsx +1 -1
  86. package/src/components/WelcomeSurface.stories.tsx +12 -0
  87. package/src/components/WelcomeSurface.tsx +52 -0
  88. package/src/components/__snapshots__/stories.test.tsx.snap +732 -572
  89. package/src/components/agent-launcher.stories.tsx +1 -1
  90. package/src/components/diff-sheet-layout.test.ts +125 -55
  91. package/src/components/diff-sheet-layout.ts +3 -103
  92. package/src/components/editor-grid.test.ts +86 -0
  93. package/src/components/editor-grid.ts +138 -0
  94. package/src/components/fade-title.test.ts +49 -0
  95. package/src/components/fade-title.ts +61 -0
  96. package/src/components/file-tree.test.ts +60 -0
  97. package/src/components/file-tree.ts +112 -0
  98. package/src/components/pages/Workspace.stories.tsx +181 -0
  99. package/src/components/primitives/Button.stories.tsx +1 -1
  100. package/src/components/primitives/Card.stories.tsx +1 -1
  101. package/src/components/primitives/Dialog.stories.tsx +1 -1
  102. package/src/components/primitives/IconButton.stories.tsx +36 -0
  103. package/src/components/primitives/IconButton.tsx +62 -0
  104. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  105. package/src/components/primitives/Tabs.stories.tsx +1 -1
  106. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  107. package/src/components/primitives/Tree.stories.tsx +124 -0
  108. package/src/components/primitives/Tree.tsx +96 -0
  109. package/src/components/primitives/icons.ts +61 -0
  110. package/src/components/primitives/tree-model.test.ts +99 -0
  111. package/src/components/primitives/tree-model.ts +137 -0
  112. package/src/components/quick-actions-editor.stories.tsx +1 -1
  113. package/src/components/session-tree.test.ts +134 -0
  114. package/src/components/session-tree.ts +108 -0
  115. package/src/components/stories-app.tsx +142 -82
  116. package/src/components/story-tree.test.ts +100 -0
  117. package/src/components/story.ts +55 -0
  118. package/src/components/theme-context.stories.tsx +1 -1
  119. package/src/config.test.ts +20 -49
  120. package/src/config.ts +26 -28
  121. package/src/diff-fold.test.ts +78 -0
  122. package/src/diff-fold.ts +126 -0
  123. package/src/diff-syntax.test.ts +16 -0
  124. package/src/diff-syntax.ts +6 -3
  125. package/src/discussions.ts +79 -0
  126. package/src/intent-dispatch.test.ts +31 -11
  127. package/src/intent-dispatch.ts +93 -48
  128. package/src/key-bindings.test.ts +1 -1
  129. package/src/keymap.test.ts +3 -25
  130. package/src/keymap.ts +17 -18
  131. package/src/mark-runs.ts +120 -0
  132. package/src/pty.ts +10 -2
  133. package/src/run.ts +17 -7
  134. package/src/serve.test.ts +4 -8
  135. package/src/session-controller.curate.test.ts +75 -0
  136. package/src/session-controller.share.test.ts +1 -0
  137. package/src/session-controller.tree.test.ts +26 -0
  138. package/src/session-controller.ts +160 -31
  139. package/src/slash-palette.ts +83 -0
  140. package/src/split-diff.test.ts +110 -0
  141. package/src/split-diff.ts +149 -0
  142. package/src/test-support.test.ts +3 -3
  143. package/src/test-support.ts +39 -8
  144. package/src/theme.ts +4 -4
  145. package/src/thread-chords.test.ts +25 -9
  146. package/src/thread-chords.ts +33 -8
  147. package/src/use-annotation-surface.tsx +837 -0
  148. package/src/use-changes-workbench.ts +151 -0
  149. package/src/use-frame-measure.ts +38 -0
  150. package/src/use-settings-dialog.tsx +23 -24
  151. package/src/use-terminal-virtualizer.ts +233 -0
  152. package/src/view-diff.ts +114 -0
  153. package/src/App.review-panel.test.tsx +0 -140
  154. package/src/App.tree.test.tsx +0 -224
  155. package/src/components/ReviewPanel.stories.tsx +0 -100
  156. package/src/components/ReviewPanel.tsx +0 -103
  157. package/src/components/ReviewRail.stories.tsx +0 -232
  158. package/src/components/ReviewRail.tsx +0 -327
  159. package/src/review-panel.test.ts +0 -113
  160. package/src/review-panel.ts +0 -83
package/src/App.tsx CHANGED
@@ -18,7 +18,7 @@ import React, {
18
18
  useSyncExternalStore,
19
19
  } from "react";
20
20
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
21
- import type { Clock, MouseEvent } from "@opentui/core";
21
+ import type { Clock } from "@opentui/core";
22
22
  import { marksByDisplay, type Mark } from "./view-plan";
23
23
  import { dimmedTheme } from "./theme";
24
24
  import {
@@ -26,7 +26,10 @@ import {
26
26
  DEFAULT_QUICK_ACTIONS,
27
27
  loadConfig,
28
28
  persistAuthorName,
29
+ persistDiffView,
30
+ persistPins,
29
31
  type AutoClose,
32
+ type DiffViewMode,
30
33
  type QuickAction,
31
34
  } from "./config";
32
35
  import {
@@ -39,42 +42,39 @@ import {
39
42
  import type { Theme } from "./theme";
40
43
  import { createReviewController, type ShareTransport } from "./session-controller";
41
44
  import type { SessionClient } from "@cueloop/daemon/client";
42
- import { launchHarnessInSplit } from "@cueloop/daemon/herdr-split";
43
- import { createIntentDispatch, type Mode } from "./intent-dispatch";
45
+ import { createIntentDispatch, type Mode, type RailTab } from "./intent-dispatch";
44
46
  import { reduceKey, type KeyState } from "./keymap";
45
47
  import { KeyBindings, type CheatsheetSection } from "./key-bindings";
46
48
  import { ThemeProvider } from "./components/theme-context";
47
49
  import { Button } from "./components/primitives/Button";
48
50
  import { Toolbar } from "./components/primitives/Toolbar";
49
- import { Breadcrumb } from "./components/Breadcrumb";
51
+ import { groupInbox, projectName, threadTitle } from "./components/session-tree";
52
+ import { InboxList } from "./components/InboxList";
53
+ import { ChangesFileTree } from "./components/ChangesColumn";
54
+ import { ProjectTreeView } from "./components/ProjectTreeView";
55
+ import { FileContentsView } from "./components/FileContentsView";
56
+ import { AppShell, type ProjectPanelMode } from "./components/AppShell";
57
+ import { EditorGrid } from "./components/EditorGrid";
58
+ import type { EditorTab } from "./components/editor-grid";
59
+ import { useChangesWorkbench } from "./use-changes-workbench";
60
+ import { ThreadFooter } from "./components/ThreadFooter";
61
+ import { ConfirmCard } from "./components/ConfirmCard";
50
62
  import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
51
63
  import {
64
+ DIFF_CHORD_ENTRIES,
52
65
  RAIL_CHORD_ENTRIES,
53
66
  resolveThreadChord,
54
67
  THREAD_CHORD_ENTRIES,
55
68
  TREE_CHORD_ENTRIES,
56
69
  } from "./thread-chords";
57
- import { DiffSheet } from "./components/DiffSheet";
70
+ import { DiffSheet, type DiffFoldControls, type DiffSheetProps } from "./components/DiffSheet";
71
+ import { commentCountsByFile, marksByRows, type DiffRow } from "./view-diff";
72
+ import type { DiffFileContents } from "@cueloop/schema";
58
73
  import { PrototypeSheet } from "./components/PrototypeSheet";
59
74
  import type { PrototypeElement } from "./prototype-browser";
60
- import { type RailTab, type ReviewRailHandle } from "./components/ReviewRail";
61
- import type { AgentTerminalHandle } from "./components/agent-launcher";
62
- import { ReviewPanel } from "./components/ReviewPanel";
63
75
  import {
64
- REVIEW_DEFAULT_WIDTH,
65
- resolveReviewWidth,
66
- toggleReviewPanelMode,
67
- widthFromMouseColumn,
68
- type ReviewPanelMode,
69
- } from "./review-panel";
70
- import {
71
- buildCardEditState,
72
- buildDiffComposeState,
73
- buildHeaderItems,
74
76
  buildRenderFlags,
75
77
  buildSubmitConfirmState,
76
- computePendingCount,
77
- computeRailFootprint,
78
78
  computeRoleCapabilities,
79
79
  deriveReviewFlags,
80
80
  isCompletionOverlayPhase,
@@ -87,17 +87,18 @@ import {
87
87
  CompletionScreen,
88
88
  ConnectingScreen,
89
89
  ErrorScreen,
90
- InboxScreen,
91
90
  MenuChrome,
91
+ NoThreadShell,
92
92
  TrailingOverlays,
93
93
  } from "./app-screens";
94
-
95
94
  /** A toast clears itself after this idle; esc dismisses it sooner. */
96
95
  const TOAST_DISMISS_MS = 4000;
97
96
 
98
97
  export interface AppProps {
99
98
  home?: string;
100
99
  sessionId?: string;
100
+ /** The launch directory whose git repo backs the no-session welcome tree; defaults to process.cwd(). */
101
+ cwd?: string;
101
102
  /**
102
103
  * Observer mode (SSH-served connections): every mutating primitive is ignored and
103
104
  * answers "observer - read-only" in the status line; navigation still works.
@@ -129,17 +130,193 @@ export interface AppProps {
129
130
  }
130
131
 
131
132
  /** True while the drop-up or one of its dialogs is open and owns the keyboard. */
132
- function menuChromeOpen(menuOpen: boolean, menuDialog: "keybinds" | "settings" | null): boolean {
133
- return menuOpen || menuDialog !== null;
133
+ function menuChromeOpen(menuDialog: "keybinds" | "settings" | null): boolean {
134
+ return menuDialog !== null;
135
+ }
136
+
137
+ /** True while a menu or an overlay owns the keyboard instead of the thread view. */
138
+ function keyboardOwnedElsewhere(menuOwnsKeyboard: boolean, overlay: KeyState["overlay"]): boolean {
139
+ return menuOwnsKeyboard || overlay !== "none";
134
140
  }
135
141
 
136
- /** True while a menu, an overlay, or the agent terminal owns the keyboard instead of the thread view. */
137
- function keyboardOwnedElsewhere(
138
- menuOwnsKeyboard: boolean,
139
- overlay: KeyState["overlay"],
140
- agentTerminal: AgentTerminalHandle | null,
141
- ): boolean {
142
- return menuOwnsKeyboard || overlay !== "none" || agentTerminal !== null;
142
+ /** The footer submit fires only for the owner of an unresolved review, never an observer. */
143
+ function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean): boolean {
144
+ return isOwner && !resolved && !observer;
145
+ }
146
+
147
+ /** The inline-commenting props the diff sheet shares with the thread view, wired once by the app. */
148
+ type DiffSurfaceProps = Pick<
149
+ DiffSheetProps,
150
+ | "session"
151
+ | "quickActions"
152
+ | "observer"
153
+ | "resolved"
154
+ | "suspended"
155
+ | "onComposingChange"
156
+ | "onObserverBlocked"
157
+ | "onCursorChange"
158
+ | "focusedAnnotationId"
159
+ | "onFocusAnnotation"
160
+ | "onAnnotate"
161
+ | "onReply"
162
+ | "onUpdateAnnotation"
163
+ | "onExit"
164
+ >;
165
+
166
+ /** The Changes tab body: the whole diff in one scroll container, or a bare hint when nothing changed. */
167
+ function ChangesTabBody(props: {
168
+ rows: DiffRow[];
169
+ surface: DiffSurfaceProps;
170
+ rejectedRows: Set<number>;
171
+ fold?: DiffFoldControls;
172
+ fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
173
+ split?: boolean;
174
+ dimmed: boolean;
175
+ theme: Theme;
176
+ }): React.ReactNode {
177
+ const marks = useMemo(
178
+ () =>
179
+ marksByRows(props.surface.session.annotations, props.rows, props.surface.focusedAnnotationId),
180
+ [props.surface.session.annotations, props.rows, props.surface.focusedAnnotationId],
181
+ );
182
+
183
+ if (props.rows.length === 0) {
184
+ return (
185
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
186
+ <text fg={props.theme.textDim}>No changes</text>
187
+ </box>
188
+ );
189
+ }
190
+
191
+ return (
192
+ <DiffSheet
193
+ rows={props.rows}
194
+ marks={marks}
195
+ {...props.surface}
196
+ rejectedRows={props.rejectedRows}
197
+ fold={props.fold}
198
+ fileStats={props.fileStats}
199
+ split={props.split}
200
+ theme={props.dimmed ? dimmedTheme(props.theme) : undefined}
201
+ />
202
+ );
203
+ }
204
+
205
+ /** An editor tab's body: the whole diff for the Changes tab, a file's diff or contents for a file tab. */
206
+ function GridTabContent(props: {
207
+ tab: EditorTab;
208
+ rows: DiffRow[];
209
+ surface: DiffSurfaceProps;
210
+ rejectedRows: Set<number>;
211
+ fold?: DiffFoldControls;
212
+ fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
213
+ split?: boolean;
214
+ dimmed: boolean;
215
+ readFile: (path: string) => Promise<string | null>;
216
+ theme: Theme;
217
+ }): React.ReactNode {
218
+ const { tab } = props;
219
+ if (tab.kind === "file" && tab.fileView === "contents" && tab.path !== undefined) {
220
+ return <FileContentsView path={tab.path} loadContents={props.readFile} theme={props.theme} />;
221
+ }
222
+ if (tab.kind !== "file") {
223
+ return (
224
+ <ChangesTabBody
225
+ rows={props.rows}
226
+ surface={props.surface}
227
+ rejectedRows={props.rejectedRows}
228
+ fold={props.fold}
229
+ fileStats={props.fileStats}
230
+ split={props.split}
231
+ dimmed={props.dimmed}
232
+ theme={props.theme}
233
+ />
234
+ );
235
+ }
236
+ // a single-file tab shows that file's rows alone, so its row indices are its own: comments and
237
+ // the caret report back in whole-diff indices, and the fold controls (a band to fold) do not apply
238
+ const fileRowIndices = props.rows.flatMap((row, index) => (row.file === tab.path ? [index] : []));
239
+ const rows = fileRowIndices.map((index) => props.rows[index]!);
240
+ const wholeIndex = (rowIndex: number): number => fileRowIndices[rowIndex] ?? rowIndex;
241
+ const rejectedRows = new Set(
242
+ fileRowIndices.flatMap((index, rowIndex) => (props.rejectedRows.has(index) ? [rowIndex] : [])),
243
+ );
244
+
245
+ return (
246
+ <ChangesTabBody
247
+ rows={rows}
248
+ surface={{
249
+ ...props.surface,
250
+ onCursorChange: (rowIndex) => props.surface.onCursorChange?.(wholeIndex(rowIndex)),
251
+ onAnnotate: (span, body) =>
252
+ props.surface.onAnnotate(
253
+ {
254
+ start: { blockIndex: wholeIndex(span.start.blockIndex), char: span.start.char },
255
+ end: { blockIndex: wholeIndex(span.end.blockIndex), char: span.end.char },
256
+ },
257
+ body,
258
+ ),
259
+ }}
260
+ rejectedRows={rejectedRows}
261
+ fileStats={props.fileStats}
262
+ split={props.split}
263
+ dimmed={props.dimmed}
264
+ theme={props.theme}
265
+ />
266
+ );
267
+ }
268
+
269
+ /** The Project pane body: the resolved repo's changed files in changes mode, its full tree otherwise. */
270
+ function ProjectPanelBody(props: {
271
+ mode: ProjectPanelMode;
272
+ loadChanges: () => Promise<readonly DiffFileContents[]>;
273
+ loadProjectFiles: () => Promise<string[]>;
274
+ reloadKey: string;
275
+ onOpenChangedFile: (path: string) => void;
276
+ onOpenProjectFile: (path: string) => void;
277
+ commentCounts?: ReadonlyMap<string, number>;
278
+ theme: Theme;
279
+ }): React.ReactNode {
280
+ const [changes, setChanges] = useState<readonly DiffFileContents[]>([]);
281
+ const loadRef = useRef(props.loadChanges);
282
+ useEffect(() => {
283
+ loadRef.current = props.loadChanges;
284
+ });
285
+ useEffect(() => {
286
+ let alive = true;
287
+
288
+ void loadRef.current().then(
289
+ (files) => {
290
+ if (alive) setChanges(files);
291
+ },
292
+ () => {
293
+ if (alive) setChanges([]);
294
+ },
295
+ );
296
+
297
+ return () => {
298
+ alive = false;
299
+ };
300
+ }, [props.reloadKey, props.mode]);
301
+
302
+ if (props.mode === "changes") {
303
+ return (
304
+ <ChangesFileTree
305
+ files={changes}
306
+ onSelectFile={props.onOpenChangedFile}
307
+ commentCounts={props.commentCounts}
308
+ theme={props.theme}
309
+ />
310
+ );
311
+ }
312
+ return (
313
+ <ProjectTreeView
314
+ key={props.reloadKey}
315
+ loadFiles={props.loadProjectFiles}
316
+ onSelectFile={props.onOpenProjectFile}
317
+ theme={props.theme}
318
+ />
319
+ );
143
320
  }
144
321
 
145
322
  /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
@@ -153,6 +330,7 @@ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): Che
153
330
  return [
154
331
  ...THREAD_VIEW_CHEATSHEET,
155
332
  { title: "Session", entries: [...THREAD_CHORD_ENTRIES] },
333
+ { title: "Diff", entries: [...DIFF_CHORD_ENTRIES] },
156
334
  { title: "Rail", entries: [...RAIL_CHORD_ENTRIES] },
157
335
  { title: "Tree", entries: [...TREE_CHORD_ENTRIES] },
158
336
  ...base.filter((section) => section.title === "Agent terminal"),
@@ -162,6 +340,7 @@ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): Che
162
340
  export function App({
163
341
  home,
164
342
  sessionId,
343
+ cwd,
165
344
  readOnly = false,
166
345
  onExit,
167
346
  clock,
@@ -177,6 +356,7 @@ export function App({
177
356
  createReviewController({
178
357
  home,
179
358
  sessionId,
359
+ cwd,
180
360
  readOnly: observer,
181
361
  onExit,
182
362
  clock,
@@ -215,32 +395,47 @@ export function App({
215
395
  // ── view state ──────────────────────────────
216
396
  const [cursor, setCursor] = useState(0);
217
397
  const [inboxCursor, setInboxCursor] = useState(0);
398
+ // Projects and Threads grouping; ordered is the flat sequence the inbox cursor walks
399
+ // pinned threads are client-local view state, seeded from the user config and
400
+ // persisted on toggle; a pin lifts the thread into the sidebar's Pinned section
401
+ const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set());
402
+ const grouped = useMemo(() => groupInbox(inbox ?? [], pinnedIds), [inbox, pinnedIds]);
403
+ const togglePin = (id: string): void =>
404
+ setPinnedIds((current) => {
405
+ const next = new Set(current);
406
+
407
+ if (next.has(id)) next.delete(id);
408
+ else next.add(id);
409
+ persistPins([...next]);
410
+
411
+ return next;
412
+ });
413
+ // the left Projects and Threads column; collapsed by default per the context
414
+ // matrix (the thread owns the width), toggled open to jump between threads
415
+ // one sidebar-open state across the no-thread shell and the thread view, so
416
+ // picking a thread keeps the sidebar as it was. It opens when the app lands with
417
+ // nothing selected (pick a thread) and stays collapsed on a direct thread open,
418
+ // where the thread owns the width.
419
+ const [sidebarOpen, setSidebarOpen] = useState(sessionId === undefined);
420
+ // the Changes + Project right region and its editor grid (tabs, splits, zoom)
421
+ const workbench = useChangesWorkbench();
218
422
  const [mode, setMode] = useState<Mode>({ type: "normal" });
219
- // the bottom-left menu drop-up and the centered dialog it opens
220
- const [menuOpen, setMenuOpen] = useState(false);
423
+ // the top-left settings gear drop-down and the centered dialog it opens
221
424
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
222
425
  const [autoClose, setAutoClose] = useState<AutoClose>("off");
426
+ // unified or side-by-side diff; split only lays out when the Changes pane is zoomed
427
+ const [diffView, setDiffView] = useState<DiffViewMode>("unified");
223
428
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
224
429
  const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
225
430
  const [railTab, setRailTab] = useState<RailTab>("review");
226
- // the tree row the reviewer stands on in the rail's Tree tab
431
+ // the tree row the reviewer stands on in the session tree
227
432
  const [selectedEntryId, setSelectedEntryId] = useState<string | undefined>(undefined);
228
- // A running in-tab agent terminal (embedded harness); while set, keys route to it.
229
- const [agentTerminal, setAgentTerminal] = useState<AgentTerminalHandle | null>(null);
230
- // review panel layout: mode + expanded width are client view state, loaded
231
- // from and persisted to the user config so they survive a restart. The ref
232
- // mirrors the width so the drag-end persist reads the latest value.
233
- const [reviewMode, setReviewMode] = useState<ReviewPanelMode>("expanded");
234
- const [reviewWidth, setReviewWidth] = useState(REVIEW_DEFAULT_WIDTH);
235
- const [dividerDragging, setDividerDragging] = useState(false);
236
- const reviewWidthRef = useRef(REVIEW_DEFAULT_WIDTH);
237
- // ~2s focus pulse on the document highlight when a rail card is activated
433
+ // ~2s focus pulse on the document highlight when a card is activated
238
434
  const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
239
435
  const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
240
436
  // live mirror of overlay input text: refs commit synchronously, so the
241
437
  // RETURN handler never reads a stale value mid-typing
242
438
  const liveInput = useRef("");
243
- const railRef = useRef<ReviewRailHandle | null>(null);
244
439
  // keymap from layered config; the loaded theme swaps the provider value
245
440
  const keysRef = useRef(DEFAULT_KEYS);
246
441
  const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
@@ -258,12 +453,11 @@ export function App({
258
453
  setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
259
454
  setThemeName(config.ui.theme);
260
455
  setThemeOverrides(config.themeOverrides);
261
- setReviewMode(config.ui.reviewState);
262
- setReviewWidth(config.ui.reviewWidth);
263
- reviewWidthRef.current = config.ui.reviewWidth;
264
456
  setAuthorNames(config.authors);
265
457
  setQuickActions(config.actions);
266
458
  setAutoClose(config.ui.autoClose);
459
+ setDiffView(config.ui.diffView);
460
+ setPinnedIds(new Set(config.ui.pins));
267
461
  controller.applyConfig(config);
268
462
  }, [session?.workspace.repoRoot, controller, keyBindings, appearance]);
269
463
  useEffect(
@@ -305,15 +499,14 @@ export function App({
305
499
  appearance,
306
500
  autoClose,
307
501
  setAutoClose,
308
- reviewMode,
309
- setReviewMode,
502
+ diffView,
503
+ setDiffView,
310
504
  themeName,
311
505
  setThemeName,
312
506
  themeOverrides,
313
507
  setTheme,
314
508
  quickActions,
315
509
  setQuickActions,
316
- controller,
317
510
  setMenuDialog,
318
511
  });
319
512
 
@@ -321,7 +514,6 @@ export function App({
321
514
  const display = controller.display();
322
515
  const rows = controller.rows();
323
516
  const rejectedRows = controller.rejectedRows();
324
- const curationItems = controller.curationItems();
325
517
  const marks = useMemo(
326
518
  () =>
327
519
  session
@@ -340,84 +532,29 @@ export function App({
340
532
  return ids;
341
533
  }, [marks]);
342
534
  const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
343
- // plans and replies open in the thread view; diffs and prototypes keep their sheets
344
- const threadViewActive = session !== null && !isDiff && !isPrototype;
535
+ // comments per changed-file path, for the changed-files tree and tab badges
536
+ const diffCommentCounts = useMemo(
537
+ () => (session && isDiff ? commentCountsByFile(session, rows) : undefined),
538
+ [session, isDiff, rows],
539
+ );
540
+ // entering a diff opens the right region in changed-files mode; a plan or reply opens it closed
541
+ workbench.syncSession(session?.id, isDiff);
542
+ // plans and replies open in the thread view, diffs in the diff sheet: both drive the shared
543
+ // annotation surface and own the document grammar; only the prototype keeps the keymap
544
+ const threadViewActive = session !== null && !isPrototype;
345
545
  const [threadComposing, setThreadComposing] = useState(false);
346
546
  const [prototypeComposing, setPrototypeComposing] = useState(false);
347
547
  // sort position per annotation so the rail interleaves annotation and removal
348
548
  // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
349
549
  // annotation resolves to the display index it marked
350
- const annotationPositions = useMemo(() => {
351
- const positions = new Map<string, number>();
352
-
353
- if (!session) return positions;
354
- if (isDiff) {
355
- for (const annotation of session.annotations) {
356
- const blockIndex =
357
- "blockIndex" in annotation.anchor ? annotation.anchor.blockIndex : undefined;
358
-
359
- if (blockIndex !== undefined) positions.set(annotation.id, blockIndex);
360
- }
361
- } else {
362
- for (const [displayIndex, blockMarks] of marks) {
363
- for (const mark of blockMarks)
364
- if (mark.annotationId) positions.set(mark.annotationId, displayIndex);
365
- }
366
- }
367
-
368
- return positions;
369
- }, [session, isDiff, marks]);
370
-
371
550
  // ── the guided walk's view model ────────────
372
551
  const walkFileList = controller.files();
373
552
  const walking = isWalking(isDiff, walk);
374
553
  const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
375
554
 
376
555
  // ── selection symmetry: one selected id, both sides ──
377
- const pulse = (id: string): void => {
378
- setPulsedAnnotationId(id);
379
- if (pulseTimer.current) clearTimeout(pulseTimer.current);
380
- pulseTimer.current = setTimeout(() => setPulsedAnnotationId(null), 2000);
381
- };
382
-
383
- /** Card activation scrolls the document to the anchor and pulses it. */
384
- const revealAnchor = (annotationId: string): void => {
385
- for (const [displayIndex, blockMarks] of marks) {
386
- if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
387
- setCursor(displayIndex);
388
-
389
- return;
390
- }
391
- };
392
-
393
556
  const selectCardFromDocument = (annotationId: string): void => {
394
557
  setFocusedAnnotationId(annotationId);
395
- railRef.current?.revealCard(annotationId);
396
- };
397
-
398
- const selectCardFromRail = (annotationId: string): void => {
399
- setFocusedAnnotationId(annotationId);
400
- pulse(annotationId);
401
- revealAnchor(annotationId);
402
- };
403
-
404
- // selecting a removal reveals its source line, so the reader sees what the
405
- // pending undo would bring back: the diff sheet follows the cursor; the plan
406
- // sheet also needs an explicit scroll to the cut block
407
- const selectCurationFromRail = (curationId: string): void => {
408
- setSelectedCurationId(curationId);
409
- const item = curationItems.find((candidate) => candidate.id === curationId);
410
-
411
- if (!item) return;
412
- setCursor(item.revealIndex);
413
- };
414
-
415
- // the selected removal card's undo button: same restore path as the u key,
416
- // including the observer read-only guard the keyboard path gets via reduceKey
417
- const undoCurationFromRail = (curationId: string): void => {
418
- if (observer) return controller.setStatus("observer - read-only");
419
- controller.restoreCuration(curationId);
420
- setSelectedCurationId(undefined);
421
558
  };
422
559
 
423
560
  const openCardEdit = (annotationId: string): void => {
@@ -459,13 +596,10 @@ export function App({
459
596
  display,
460
597
  rows,
461
598
  cursor,
462
- inbox,
599
+ inbox: inbox === null ? null : grouped.ordered,
463
600
  inboxCursor,
464
601
  mode,
465
602
  session,
466
- reviewMode,
467
- reviewWidth,
468
- terminalWidth,
469
603
  focusedAnnotationId,
470
604
  selectedCurationId,
471
605
  railTab,
@@ -476,13 +610,11 @@ export function App({
476
610
  persistAuthorName(id, name);
477
611
  setAuthorNames((prev) => ({ ...prev, [id]: name }));
478
612
  },
613
+ renameThread: (id: string, title: string) => controller.renameSession(id, title),
479
614
  liveInput,
480
- reviewWidthRef,
481
615
  setCursor,
482
616
  setInboxCursor,
483
617
  setMode,
484
- setReviewMode,
485
- setReviewWidth,
486
618
  setRailTab,
487
619
  setSelectedEntryId,
488
620
  setFocusedAnnotationId,
@@ -491,14 +623,22 @@ export function App({
491
623
  selectCardFromDocument,
492
624
  runEditorHandOff,
493
625
  openCardEdit,
626
+ toggleDiffView: () => {
627
+ const next: DiffViewMode = diffView === "unified" ? "split" : "unified";
628
+
629
+ setDiffView(next);
630
+ persistDiffView(next);
631
+ if (next === "split" && !workbench.zoomed)
632
+ controller.setStatus("split diff shows when zoomed");
633
+ },
494
634
  });
495
635
 
496
636
  const overlay = resolveOverlay(mode, completion.phase, walking);
497
637
 
498
- const menuOwnsKeyboard = menuChromeOpen(menuOpen, menuDialog);
638
+ const menuOwnsKeyboard = menuChromeOpen(menuDialog);
499
639
  // an overlay (submit, walk, prompt, confirm) or the menu takes the keyboard
500
640
  // from the thread view; the view suspends its own grammar meanwhile
501
- const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay, agentTerminal);
641
+ const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay);
502
642
 
503
643
  useKeyboard((key) => {
504
644
  // The thread view owns the document grammar while active (its own
@@ -511,6 +651,7 @@ export function App({
511
651
  isOwner,
512
652
  resolved,
513
653
  treeActive: railTab === "tree",
654
+ isDiff,
514
655
  });
515
656
 
516
657
  if (chord) dispatch(chord);
@@ -520,16 +661,8 @@ export function App({
520
661
  // The prototype compose textarea owns the keyboard while open: let it receive
521
662
  // the typed note instead of the global keymap acting on each letter.
522
663
  if (prototypeComposing) return;
523
- // A running in-tab agent terminal owns the keyboard: forward every key to it,
524
- // with ctrl+] as the detach chord back to the review.
525
- if (agentTerminal) {
526
- if (key.ctrl && key.name === "]") return void agentTerminal.detach();
527
-
528
- return void agentTerminal.write(key.sequence);
529
- }
530
664
  if (menuDialog === "settings") return void handleSettingsKey(key.name);
531
665
  if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
532
- if (menuOpen) return void (key.name === "escape" && setMenuOpen(false));
533
666
  // the toast is non-modal: escape only dismisses it when nothing else owns
534
667
  // escape, so an open overlay (compose, submit, prompt, walk) still cancels
535
668
  if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span")
@@ -552,8 +685,14 @@ export function App({
552
685
  display,
553
686
  });
554
687
 
555
- keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
556
- const action = keyBindings.resolveAction({ name: key.name, shift: !!key.shift });
688
+ keyBindings.setContext({
689
+ overlay: state.overlay,
690
+ spanMode: state.spanMode,
691
+ });
692
+ const action = keyBindings.resolveAction({
693
+ name: key.name,
694
+ shift: !!key.shift,
695
+ });
557
696
 
558
697
  for (const intent of reduceKey(
559
698
  state,
@@ -567,12 +706,8 @@ export function App({
567
706
  // reused by the inbox and by plan/diff review so the two never drift ──
568
707
  const menuChrome = (
569
708
  <MenuChrome
570
- menuOpen={menuOpen}
571
709
  menuDialog={menuDialog}
572
- status={status}
573
710
  theme={theme}
574
- setMenuOpen={setMenuOpen}
575
- setMenuDialog={setMenuDialog}
576
711
  keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
577
712
  settingsCategories={settingsCategories}
578
713
  settingsValues={settingsValues}
@@ -586,14 +721,20 @@ export function App({
586
721
  if (error) return <ErrorScreen error={error} theme={theme} />;
587
722
  if (!session)
588
723
  return inbox ? (
589
- <InboxScreen
590
- inbox={inbox}
724
+ <NoThreadShell
725
+ rows={grouped.rows}
591
726
  inboxCursor={inboxCursor}
592
727
  mode={mode}
593
728
  theme={theme}
594
729
  controller={controller}
595
730
  setMode={setMode}
596
731
  menuChrome={menuChrome}
732
+ onOpenMenu={() => setMenuDialog("settings")}
733
+ sidebarOpen={sidebarOpen}
734
+ onToggleSidebar={() => setSidebarOpen((open) => !open)}
735
+ pinnedIds={pinnedIds}
736
+ onPin={togglePin}
737
+ onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
597
738
  />
598
739
  ) : (
599
740
  <ConnectingScreen theme={theme} />
@@ -612,14 +753,15 @@ export function App({
612
753
  />
613
754
  );
614
755
 
615
- const diffComposeState = buildDiffComposeState({
616
- mode,
617
- isDiff,
618
- rows,
619
- liveInput,
620
- setMode,
621
- dispatch,
622
- });
756
+ const diffFold: DiffFoldControls = {
757
+ isCollapsed: (file) => controller.isFileCollapsed(file),
758
+ isExpanded: (file) => controller.isFileExpanded(file),
759
+ canExpand: (file) => controller.canExpandFile(file),
760
+ onToggleCollapse: (file) =>
761
+ controller.setFileCollapsed(file, !controller.isFileCollapsed(file)),
762
+ onToggleExpand: (file) => controller.setFileExpanded(file, !controller.isFileExpanded(file)),
763
+ onCopyPath: (file) => controller.copyFilePath(file),
764
+ };
623
765
  const submitConfirmState = buildSubmitConfirmState({
624
766
  mode,
625
767
  isDiff,
@@ -630,21 +772,15 @@ export function App({
630
772
  setMode,
631
773
  dispatch,
632
774
  });
633
- const cardEditState = buildCardEditState({ mode, liveInput, setMode, dispatch });
634
- const pendingCount = computePendingCount(activeSession);
635
- const railFootprint = computeRailFootprint(reviewMode, reviewWidth, terminalWidth);
636
- const headerItems = buildHeaderItems({ session: activeSession, resolved, observer, role });
637
- const { showOwnerActions, prototypeCanComment, chromeHidden, prototypePath, railResolvedIds } =
638
- buildRenderFlags({
639
- session: activeSession,
640
- isOwner,
641
- isDiff,
642
- isPrototype,
643
- resolved,
644
- menuOpen,
645
- menuDialog,
646
- resolvedIds,
647
- });
775
+ const { showOwnerActions, prototypeCanComment, chromeHidden, prototypePath } = buildRenderFlags({
776
+ session: activeSession,
777
+ isOwner,
778
+ isDiff,
779
+ isPrototype,
780
+ resolved,
781
+ menuDialog,
782
+ resolvedIds,
783
+ });
648
784
 
649
785
  const onEditRequest = (): void => {
650
786
  // A share viewer/observer has no Edit affordance (the button is hidden), so
@@ -668,167 +804,185 @@ export function App({
668
804
  dispatch({ type: "openSubmit" });
669
805
  };
670
806
 
671
- // the clickable chevron toggles expanded <-> compact; the keybinding (b)
672
- // cycles all three including hidden
673
- const onToggleReviewPanel = (): void => {
674
- const next = toggleReviewPanelMode(reviewMode);
675
-
676
- setReviewMode(next);
677
- controller.saveReviewPanel({ mode: next });
678
- };
679
- // grabbing the divider only arms a drag when there is a width to drag
680
- const onDividerGrab = (): void => {
681
- if (reviewMode === "expanded") setDividerDragging(true);
682
- };
683
-
684
807
  return (
685
808
  <ThemeProvider theme={theme}>
686
- <box
687
- style={{
688
- flexDirection: "column",
689
- width: "100%",
690
- height: "100%",
691
- backgroundColor: theme.background,
692
- }}
693
- onMouseDrag={(event: MouseEvent) => {
694
- if (!dividerDragging || reviewMode !== "expanded") return;
695
- const next = widthFromMouseColumn(event.x, terminalWidth);
696
-
697
- // many raw mouse-moves land in the same column: only re-render when the
698
- // width actually changes, so the drag does not reconcile per pixel
699
- if (next === reviewWidthRef.current) return;
700
- reviewWidthRef.current = next;
701
- setReviewWidth(next);
702
- }}
703
- onMouseUp={() => {
704
- if (!dividerDragging) return;
705
- setDividerDragging(false);
706
- controller.saveReviewPanel({ width: reviewWidthRef.current });
707
- }}
708
- >
709
- <box
710
- style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}
711
- >
712
- <box style={{ flexGrow: 1, flexDirection: "row", paddingRight: 1 }}>
713
- <Breadcrumb items={headerItems} />
714
- <box style={{ flexGrow: 1 }} />
715
- {showOwnerActions ? (
716
- <Toolbar>
717
- <Button onPress={onEditRequest} theme={theme}>
718
- {" Edit "}
719
- </Button>
720
- <Button onPress={onShareRequest} theme={theme}>
721
- {" Share "}
722
- </Button>
723
- </Toolbar>
724
- ) : null}
725
- </box>
726
- <box style={{ width: railFootprint }} />
727
- </box>
728
- <box style={{ flexGrow: 1, flexDirection: "row" }}>
729
- {isPrototype ? (
730
- <PrototypeSheet
731
- prototypePath={prototypePath}
732
- quickActions={quickActions}
733
- canComment={prototypeCanComment}
734
- onCommentElement={onCommentPrototype}
735
- onComposingChange={setPrototypeComposing}
736
- hidden={chromeHidden}
737
- />
738
- ) : isDiff ? (
739
- // the sheet dims to reading-quiet colors while the wizard has focus
740
- <DiffSheet
741
- rows={rows}
742
- cursor={cursor}
743
- annotations={activeSession.annotations}
744
- focusedAnnotationId={focusedAnnotationId}
745
- rejectedRows={rejectedRows}
746
- compose={diffComposeState}
747
- theme={walking ? dimmedTheme(theme) : undefined}
748
- />
749
- ) : (
750
- <ThreadView
751
- session={activeSession}
752
- suspended={threadViewSuspended}
753
- editOrphanCount={editOrphanCount}
754
- onComposingChange={setThreadComposing}
755
- resolved={resolved}
756
- onObserverBlocked={(reason) =>
757
- controller.setStatus(
758
- reason === "observer" ? "observer - read-only" : "review submitted - read-only",
759
- )
760
- }
761
- onCursorChange={setCursor}
762
- focusedAnnotationId={focusedAnnotationId}
763
- onFocusAnnotation={setFocusedAnnotationId}
764
- display={display}
765
- marks={marks}
766
- quickActions={quickActions}
767
- observer={observer}
768
- onAnnotate={(span, body) =>
769
- void controller.annotate(
770
- "comment",
771
- span.start.blockIndex,
772
- span.start.char,
773
- span.end.char,
774
- body,
775
- span.end.blockIndex,
776
- )
809
+ <AppShell
810
+ sidebarOpen={sidebarOpen}
811
+ onToggleSidebar={() => setSidebarOpen((open) => !open)}
812
+ onOpenMenu={() => setMenuDialog("settings")}
813
+ threadsPanel={
814
+ <scrollbox style={{ flexGrow: 1 }} focused={false}>
815
+ <InboxList
816
+ rows={grouped.rows}
817
+ cursor={inboxCursor}
818
+ activeId={activeSession.id}
819
+ pinnedIds={pinnedIds}
820
+ width={30}
821
+ onSelect={(id) => controller.open(id)}
822
+ onPin={togglePin}
823
+ onRename={(id, title) =>
824
+ setMode({ type: "renameThread", sessionId: id, text: title })
777
825
  }
778
- onReply={(rootAnnotationId, body) => void controller.reply(rootAnnotationId, body)}
779
- onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
780
- onExit={() => onExit?.(0)}
826
+ theme={theme}
781
827
  />
782
- )}
783
- <ReviewPanel
784
- mode={reviewMode}
785
- width={resolveReviewWidth(reviewWidth, terminalWidth)}
786
- onDividerGrab={onDividerGrab}
787
- onToggle={onToggleReviewPanel}
788
- railRef={railRef}
789
- rail={{
790
- session: activeSession,
791
- authorNames,
792
- selectedId: focusedAnnotationId,
793
- resolvedIds: railResolvedIds,
794
- curationItems,
795
- selectedCurationId,
796
- annotationPositions,
797
- railTab,
798
- pendingCount,
799
- cardEdit: cardEditState,
800
- submitConfirm: submitConfirmState,
801
- onTabChange: setRailTab,
802
- onSelectCard: selectCardFromRail,
803
- onActivateCard: openCardEdit,
804
- onSelectCuration: selectCurationFromRail,
805
- onUndoCuration: undoCurationFromRail,
806
- onSubmitRequest,
807
- onLaunchHarness: (command, seedText) =>
808
- launchHarnessInSplit({
809
- command,
810
- cwd: activeSession.artifact.meta.cwd ?? process.cwd(),
811
- seedText,
812
- }),
813
- onAgentTerminal: setAgentTerminal,
814
- tree: {
815
- rows: controller.treeRows(),
816
- selectedEntryId,
817
- canMove: isOwner && !resolved,
818
- canFork: isOwner,
819
- onSelect: setSelectedEntryId,
820
- onGo: (entryId) => {
821
- setSelectedEntryId(entryId);
822
- dispatch({ type: "treeGo" });
823
- },
824
- onBranch: () => dispatch({ type: "treeBranch" }),
825
- onLabel: () => dispatch({ type: "treeLabel" }),
826
- onFork: () => dispatch({ type: "treeFork" }),
827
- onForkAndShare: () => dispatch({ type: "treeForkShare" }),
828
- },
829
- }}
828
+ </scrollbox>
829
+ }
830
+ threadTitle={threadTitle(activeSession)}
831
+ threadActions={
832
+ showOwnerActions ? (
833
+ <Toolbar>
834
+ <Button onPress={onEditRequest} theme={theme}>
835
+ {" edit "}
836
+ </Button>
837
+ <Button onPress={onShareRequest} theme={theme}>
838
+ {" share "}
839
+ </Button>
840
+ </Toolbar>
841
+ ) : undefined
842
+ }
843
+ threadPanel={
844
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
845
+ <box style={{ flexGrow: 1, flexDirection: "row" }}>
846
+ {isPrototype ? (
847
+ <PrototypeSheet
848
+ prototypePath={prototypePath}
849
+ quickActions={quickActions}
850
+ canComment={prototypeCanComment}
851
+ onCommentElement={onCommentPrototype}
852
+ onComposingChange={setPrototypeComposing}
853
+ hidden={chromeHidden}
854
+ />
855
+ ) : isDiff ? (
856
+ <box style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }}>
857
+ <text fg={theme.textDim}>review the changes on the right</text>
858
+ </box>
859
+ ) : (
860
+ <ThreadView
861
+ session={activeSession}
862
+ suspended={threadViewSuspended}
863
+ editOrphanCount={editOrphanCount}
864
+ onComposingChange={setThreadComposing}
865
+ resolved={resolved}
866
+ onObserverBlocked={(reason) =>
867
+ controller.setStatus(
868
+ reason === "observer"
869
+ ? "observer - read-only"
870
+ : "review submitted - read-only",
871
+ )
872
+ }
873
+ onCursorChange={setCursor}
874
+ focusedAnnotationId={focusedAnnotationId}
875
+ onFocusAnnotation={setFocusedAnnotationId}
876
+ display={display}
877
+ marks={marks}
878
+ quickActions={quickActions}
879
+ observer={observer}
880
+ onAnnotate={(span, body) =>
881
+ void controller.annotate(
882
+ "comment",
883
+ span.start.blockIndex,
884
+ span.start.char,
885
+ span.end.char,
886
+ body,
887
+ span.end.blockIndex,
888
+ )
889
+ }
890
+ onReply={(rootAnnotationId, body) =>
891
+ void controller.reply(rootAnnotationId, body)
892
+ }
893
+ onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
894
+ onExit={() => onExit?.(0)}
895
+ />
896
+ )}
897
+ </box>
898
+ <ThreadFooter
899
+ repo={projectName(activeSession.workspace)}
900
+ branch={activeSession.workspace.branch}
901
+ onSubmit={onSubmitRequest}
902
+ canSubmit={canSubmitReview(isOwner, resolved, observer)}
903
+ theme={theme}
904
+ />
905
+ </box>
906
+ }
907
+ changesOpen={workbench.changesOpen}
908
+ projectOpen={workbench.projectOpen}
909
+ onToggleChanges={workbench.toggleChanges}
910
+ onToggleProject={workbench.toggleProject}
911
+ onToggleRight={workbench.toggleRight}
912
+ projectMode={workbench.projectMode}
913
+ zoomHideThread={workbench.zoomed}
914
+ changesPanel={
915
+ <EditorGrid
916
+ tree={workbench.grid}
917
+ focusedGroupId={workbench.activeGroup}
918
+ commentCounts={diffCommentCounts}
919
+ onFocusGroup={workbench.focusGroup}
920
+ onActivateTab={workbench.activate}
921
+ onCloseTab={workbench.close}
922
+ onSplit={workbench.split}
923
+ onZoom={workbench.toggleZoom}
924
+ zoomed={workbench.zoomed}
925
+ renderTab={(tab, groupFocused) => (
926
+ <GridTabContent
927
+ tab={tab}
928
+ rows={rows}
929
+ surface={{
930
+ session: activeSession,
931
+ quickActions,
932
+ observer,
933
+ resolved,
934
+ suspended: threadViewSuspended || !groupFocused,
935
+ onComposingChange: setThreadComposing,
936
+ onObserverBlocked: (reason) =>
937
+ controller.setStatus(
938
+ reason === "observer"
939
+ ? "observer - read-only"
940
+ : "review submitted - read-only",
941
+ ),
942
+ onCursorChange: setCursor,
943
+ focusedAnnotationId,
944
+ onFocusAnnotation: setFocusedAnnotationId,
945
+ onAnnotate: (span, body) =>
946
+ void controller.annotate(
947
+ "comment",
948
+ span.start.blockIndex,
949
+ span.start.char,
950
+ span.end.char,
951
+ body,
952
+ span.end.blockIndex,
953
+ ),
954
+ onReply: (rootAnnotationId, body) =>
955
+ void controller.reply(rootAnnotationId, body),
956
+ onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
957
+ onExit: () => onExit?.(0),
958
+ }}
959
+ rejectedRows={rejectedRows}
960
+ fold={diffFold}
961
+ fileStats={controller.fileStats()}
962
+ split={diffView === "split" && workbench.zoomed}
963
+ dimmed={walking}
964
+ readFile={(path) => controller.repoReadFile(path)}
965
+ theme={theme}
966
+ />
967
+ )}
968
+ theme={theme}
830
969
  />
831
- </box>
970
+ }
971
+ projectPanel={
972
+ <ProjectPanelBody
973
+ mode={workbench.projectMode}
974
+ loadChanges={() => controller.repoChanges()}
975
+ loadProjectFiles={() => controller.repoFiles()}
976
+ reloadKey={activeSession.id}
977
+ // a diff review opens a changed file as its captured diff; other threads show live contents
978
+ onOpenChangedFile={(path) => workbench.openFile(path, isDiff ? "diff" : "contents")}
979
+ onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
980
+ commentCounts={diffCommentCounts}
981
+ theme={theme}
982
+ />
983
+ }
984
+ theme={theme}
985
+ >
832
986
  <TrailingOverlays
833
987
  walking={walking}
834
988
  walk={walk}
@@ -842,8 +996,23 @@ export function App({
842
996
  setMode={setMode}
843
997
  dispatch={dispatch}
844
998
  />
999
+ {submitConfirmState !== null ? (
1000
+ <box
1001
+ style={{
1002
+ position: "absolute",
1003
+ left: 0,
1004
+ top: 0,
1005
+ width: "100%",
1006
+ height: "100%",
1007
+ justifyContent: "center",
1008
+ alignItems: "center",
1009
+ }}
1010
+ >
1011
+ <ConfirmCard {...submitConfirmState} theme={theme} />
1012
+ </box>
1013
+ ) : null}
845
1014
  {menuChrome}
846
- </box>
1015
+ </AppShell>
847
1016
  </ThemeProvider>
848
1017
  );
849
1018
  }