@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.68

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 (117) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +71 -39
  4. package/src/App.plan-review.test.tsx +20 -21
  5. package/src/App.readonly.test.tsx +7 -3
  6. package/src/App.test.tsx +73 -14
  7. package/src/App.tsx +375 -129
  8. package/src/App.walk.test.tsx +14 -15
  9. package/src/App.workbench.test.tsx +390 -0
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/annotation-palette.ts +61 -0
  12. package/src/app-screens.tsx +176 -45
  13. package/src/app-view-model.ts +7 -65
  14. package/src/components/AnnotationCard.tsx +1 -1
  15. package/src/components/AnnotationCards.stories.tsx +87 -0
  16. package/src/components/AnnotationCards.tsx +303 -0
  17. package/src/components/AppHeader.stories.tsx +50 -0
  18. package/src/components/AppHeader.tsx +83 -0
  19. package/src/components/AppShell.stories.tsx +47 -42
  20. package/src/components/AppShell.tsx +212 -100
  21. package/src/components/AppShell.zoom.test.tsx +94 -0
  22. package/src/components/ChangesColumn.tsx +49 -26
  23. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  24. package/src/components/DiffSheet.split.test.tsx +140 -0
  25. package/src/components/DiffSheet.stories.tsx +55 -53
  26. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  27. package/src/components/DiffSheet.tsx +607 -276
  28. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  29. package/src/components/DiscussionMarkerRail.tsx +181 -0
  30. package/src/components/EditorGrid.stories.tsx +59 -0
  31. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  32. package/src/components/EditorGrid.tsx +338 -0
  33. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  34. package/src/components/FileContentsView.stories.tsx +16 -0
  35. package/src/components/FileContentsView.test.tsx +29 -0
  36. package/src/components/FileContentsView.tsx +78 -0
  37. package/src/components/InboxList.tsx +159 -30
  38. package/src/components/PaneGrid.stories.tsx +84 -0
  39. package/src/components/PaneGrid.tsx +128 -0
  40. package/src/components/PanelColumn.stories.tsx +25 -0
  41. package/src/components/PanelColumn.tsx +95 -0
  42. package/src/components/ProjectTreeView.stories.tsx +25 -0
  43. package/src/components/ProjectTreeView.test.tsx +32 -0
  44. package/src/components/ProjectTreeView.tsx +82 -0
  45. package/src/components/PromptDialog.tsx +8 -1
  46. package/src/components/RootOverlay.stories.tsx +44 -0
  47. package/src/components/RootOverlay.tsx +35 -0
  48. package/src/components/SettingsDialog.tsx +2 -1
  49. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  50. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  51. package/src/components/SurfaceScrollbar.tsx +93 -0
  52. package/src/components/ThreadFooter.stories.tsx +2 -2
  53. package/src/components/ThreadFooter.test.tsx +31 -0
  54. package/src/components/ThreadFooter.tsx +12 -7
  55. package/src/components/ThreadView.tsx +62 -1440
  56. package/src/components/ThreadsSidebar.tsx +14 -1
  57. package/src/components/Tooltip.stories.tsx +26 -0
  58. package/src/components/Tooltip.tsx +72 -0
  59. package/src/components/WelcomeSurface.stories.tsx +12 -0
  60. package/src/components/WelcomeSurface.tsx +52 -0
  61. package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
  62. package/src/components/diff-sheet-layout.test.ts +125 -55
  63. package/src/components/diff-sheet-layout.ts +3 -103
  64. package/src/components/editor-grid.test.ts +86 -0
  65. package/src/components/editor-grid.ts +138 -0
  66. package/src/components/fade-title.test.ts +49 -0
  67. package/src/components/fade-title.ts +61 -0
  68. package/src/components/file-tree.test.ts +15 -0
  69. package/src/components/file-tree.ts +59 -4
  70. package/src/components/pages/Workspace.stories.tsx +2 -2
  71. package/src/components/primitives/IconButton.tsx +21 -2
  72. package/src/components/primitives/Tree.tsx +13 -4
  73. package/src/components/primitives/icons.ts +27 -4
  74. package/src/components/session-tree.test.ts +20 -0
  75. package/src/components/session-tree.ts +16 -2
  76. package/src/components/stories-app.tsx +2 -2
  77. package/src/config.test.ts +20 -49
  78. package/src/config.ts +26 -28
  79. package/src/diff-fold.test.ts +78 -0
  80. package/src/diff-fold.ts +126 -0
  81. package/src/diff-syntax.test.ts +16 -0
  82. package/src/diff-syntax.ts +6 -3
  83. package/src/discussions.ts +79 -0
  84. package/src/ghostty-terminal.test.ts +2 -1
  85. package/src/ghostty-terminal.ts +27 -4
  86. package/src/intent-dispatch.test.ts +31 -11
  87. package/src/intent-dispatch.ts +93 -48
  88. package/src/key-bindings.test.ts +1 -1
  89. package/src/keymap.test.ts +3 -25
  90. package/src/keymap.ts +17 -18
  91. package/src/mark-runs.ts +120 -0
  92. package/src/pty.ts +10 -2
  93. package/src/run.ts +17 -7
  94. package/src/serve.test.ts +4 -8
  95. package/src/session-controller.curate.test.ts +75 -0
  96. package/src/session-controller.share.test.ts +1 -0
  97. package/src/session-controller.tree.test.ts +26 -0
  98. package/src/session-controller.ts +153 -29
  99. package/src/slash-palette.ts +83 -0
  100. package/src/split-diff.test.ts +110 -0
  101. package/src/split-diff.ts +149 -0
  102. package/src/test-support.test.ts +15 -3
  103. package/src/test-support.ts +66 -15
  104. package/src/thread-chords.test.ts +25 -9
  105. package/src/thread-chords.ts +33 -8
  106. package/src/use-annotation-surface.tsx +837 -0
  107. package/src/use-changes-workbench.ts +151 -0
  108. package/src/use-frame-measure.ts +38 -0
  109. package/src/use-settings-dialog.tsx +14 -24
  110. package/src/use-terminal-virtualizer.ts +233 -0
  111. package/src/view-diff.ts +114 -0
  112. package/src/components/ReviewPanel.stories.tsx +0 -100
  113. package/src/components/ReviewPanel.tsx +0 -103
  114. package/src/components/ReviewRail.stories.tsx +0 -232
  115. package/src/components/ReviewRail.tsx +0 -327
  116. package/src/review-panel.test.ts +0 -113
  117. package/src/review-panel.ts +0 -83
package/src/App.tsx CHANGED
@@ -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,35 +42,37 @@ 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 { createIntentDispatch, type Mode } from "./intent-dispatch";
45
+ import { createIntentDispatch, type Mode, type RailTab } from "./intent-dispatch";
43
46
  import { reduceKey, type KeyState } from "./keymap";
44
47
  import { KeyBindings, type CheatsheetSection } from "./key-bindings";
45
48
  import { ThemeProvider } from "./components/theme-context";
46
49
  import { Button } from "./components/primitives/Button";
47
50
  import { Toolbar } from "./components/primitives/Toolbar";
48
- import { NERD } from "./components/primitives/icons";
49
- import { groupInbox, projectName } from "./components/session-tree";
50
- import { ThreadsSidebar } from "./components/ThreadsSidebar";
51
- import { ChangesColumn, DiffChangesToggle, useDiffColumns } from "./components/ChangesColumn";
52
- import { IconButton } from "./components/primitives/IconButton";
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";
53
60
  import { ThreadFooter } from "./components/ThreadFooter";
54
61
  import { ConfirmCard } from "./components/ConfirmCard";
55
- import { Breadcrumb } from "./components/Breadcrumb";
56
62
  import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
57
63
  import {
64
+ DIFF_CHORD_ENTRIES,
58
65
  RAIL_CHORD_ENTRIES,
59
66
  resolveThreadChord,
60
67
  THREAD_CHORD_ENTRIES,
61
68
  TREE_CHORD_ENTRIES,
62
69
  } from "./thread-chords";
63
- 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";
64
73
  import { PrototypeSheet } from "./components/PrototypeSheet";
65
74
  import type { PrototypeElement } from "./prototype-browser";
66
- import { type RailTab, type ReviewRailHandle } from "./components/ReviewRail";
67
- import { REVIEW_DEFAULT_WIDTH, type ReviewPanelMode } from "./review-panel";
68
75
  import {
69
- buildDiffComposeState,
70
- buildHeaderItems,
71
76
  buildRenderFlags,
72
77
  buildSubmitConfirmState,
73
78
  computeRoleCapabilities,
@@ -82,17 +87,18 @@ import {
82
87
  CompletionScreen,
83
88
  ConnectingScreen,
84
89
  ErrorScreen,
85
- InboxScreen,
86
90
  MenuChrome,
91
+ NoThreadShell,
87
92
  TrailingOverlays,
88
93
  } from "./app-screens";
89
-
90
94
  /** A toast clears itself after this idle; esc dismisses it sooner. */
91
95
  const TOAST_DISMISS_MS = 4000;
92
96
 
93
97
  export interface AppProps {
94
98
  home?: string;
95
99
  sessionId?: string;
100
+ /** The launch directory whose git repo backs the no-session welcome tree; defaults to process.cwd(). */
101
+ cwd?: string;
96
102
  /**
97
103
  * Observer mode (SSH-served connections): every mutating primitive is ignored and
98
104
  * answers "observer - read-only" in the status line; navigation still works.
@@ -138,9 +144,179 @@ function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean)
138
144
  return isOwner && !resolved && !observer;
139
145
  }
140
146
 
141
- /** The left panel toggle glyph: the mirrored sidebar icon, filled when the column is open. */
142
- function sidebarToggleGlyph(open: boolean): string {
143
- return open ? NERD.sidebarLeft : NERD.sidebarLeftOff;
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
+ );
144
320
  }
145
321
 
146
322
  /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
@@ -154,6 +330,7 @@ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): Che
154
330
  return [
155
331
  ...THREAD_VIEW_CHEATSHEET,
156
332
  { title: "Session", entries: [...THREAD_CHORD_ENTRIES] },
333
+ { title: "Diff", entries: [...DIFF_CHORD_ENTRIES] },
157
334
  { title: "Rail", entries: [...RAIL_CHORD_ENTRIES] },
158
335
  { title: "Tree", entries: [...TREE_CHORD_ENTRIES] },
159
336
  ...base.filter((section) => section.title === "Agent terminal"),
@@ -163,6 +340,7 @@ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): Che
163
340
  export function App({
164
341
  home,
165
342
  sessionId,
343
+ cwd,
166
344
  readOnly = false,
167
345
  onExit,
168
346
  clock,
@@ -178,6 +356,7 @@ export function App({
178
356
  createReviewController({
179
357
  home,
180
358
  sessionId,
359
+ cwd,
181
360
  readOnly: observer,
182
361
  onExit,
183
362
  clock,
@@ -217,32 +396,46 @@ export function App({
217
396
  const [cursor, setCursor] = useState(0);
218
397
  const [inboxCursor, setInboxCursor] = useState(0);
219
398
  // Projects and Threads grouping; ordered is the flat sequence the inbox cursor walks
220
- const grouped = useMemo(() => groupInbox(inbox ?? []), [inbox]);
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
+ });
221
413
  // the left Projects and Threads column; collapsed by default per the context
222
414
  // matrix (the thread owns the width), toggled open to jump between threads
223
- const [sidebarOpen, setSidebarOpen] = useState(false);
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();
224
422
  const [mode, setMode] = useState<Mode>({ type: "normal" });
225
423
  // the top-left settings gear drop-down and the centered dialog it opens
226
424
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
227
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");
228
428
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
229
429
  const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
230
430
  const [railTab, setRailTab] = useState<RailTab>("review");
231
- // 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
232
432
  const [selectedEntryId, setSelectedEntryId] = useState<string | undefined>(undefined);
233
- // review panel layout: mode + expanded width are client view state, loaded
234
- // from and persisted to the user config so they survive a restart. The ref
235
- // mirrors the width so the drag-end persist reads the latest value.
236
- const [reviewMode, setReviewMode] = useState<ReviewPanelMode>("expanded");
237
- const [reviewWidth, setReviewWidth] = useState(REVIEW_DEFAULT_WIDTH);
238
- const reviewWidthRef = useRef(REVIEW_DEFAULT_WIDTH);
239
- // ~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
240
434
  const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
241
435
  const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
242
436
  // live mirror of overlay input text: refs commit synchronously, so the
243
437
  // RETURN handler never reads a stale value mid-typing
244
438
  const liveInput = useRef("");
245
- const railRef = useRef<ReviewRailHandle | null>(null);
246
439
  // keymap from layered config; the loaded theme swaps the provider value
247
440
  const keysRef = useRef(DEFAULT_KEYS);
248
441
  const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
@@ -260,12 +453,11 @@ export function App({
260
453
  setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
261
454
  setThemeName(config.ui.theme);
262
455
  setThemeOverrides(config.themeOverrides);
263
- setReviewMode(config.ui.reviewState);
264
- setReviewWidth(config.ui.reviewWidth);
265
- reviewWidthRef.current = config.ui.reviewWidth;
266
456
  setAuthorNames(config.authors);
267
457
  setQuickActions(config.actions);
268
458
  setAutoClose(config.ui.autoClose);
459
+ setDiffView(config.ui.diffView);
460
+ setPinnedIds(new Set(config.ui.pins));
269
461
  controller.applyConfig(config);
270
462
  }, [session?.workspace.repoRoot, controller, keyBindings, appearance]);
271
463
  useEffect(
@@ -307,22 +499,20 @@ export function App({
307
499
  appearance,
308
500
  autoClose,
309
501
  setAutoClose,
310
- reviewMode,
311
- setReviewMode,
502
+ diffView,
503
+ setDiffView,
312
504
  themeName,
313
505
  setThemeName,
314
506
  themeOverrides,
315
507
  setTheme,
316
508
  quickActions,
317
509
  setQuickActions,
318
- controller,
319
510
  setMenuDialog,
320
511
  });
321
512
 
322
513
  // ── derived view model ──────────────────────
323
514
  const display = controller.display();
324
515
  const rows = controller.rows();
325
- const diffColumns = useDiffColumns({ session, rows, cursor, setCursor });
326
516
  const rejectedRows = controller.rejectedRows();
327
517
  const marks = useMemo(
328
518
  () =>
@@ -342,8 +532,16 @@ export function App({
342
532
  return ids;
343
533
  }, [marks]);
344
534
  const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
345
- // plans and replies open in the thread view; diffs and prototypes keep their sheets
346
- 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;
347
545
  const [threadComposing, setThreadComposing] = useState(false);
348
546
  const [prototypeComposing, setPrototypeComposing] = useState(false);
349
547
  // sort position per annotation so the rail interleaves annotation and removal
@@ -357,7 +555,6 @@ export function App({
357
555
  // ── selection symmetry: one selected id, both sides ──
358
556
  const selectCardFromDocument = (annotationId: string): void => {
359
557
  setFocusedAnnotationId(annotationId);
360
- railRef.current?.revealCard(annotationId);
361
558
  };
362
559
 
363
560
  const openCardEdit = (annotationId: string): void => {
@@ -403,9 +600,6 @@ export function App({
403
600
  inboxCursor,
404
601
  mode,
405
602
  session,
406
- reviewMode,
407
- reviewWidth,
408
- terminalWidth,
409
603
  focusedAnnotationId,
410
604
  selectedCurationId,
411
605
  railTab,
@@ -416,13 +610,11 @@ export function App({
416
610
  persistAuthorName(id, name);
417
611
  setAuthorNames((prev) => ({ ...prev, [id]: name }));
418
612
  },
613
+ renameThread: (id: string, title: string) => controller.renameSession(id, title),
419
614
  liveInput,
420
- reviewWidthRef,
421
615
  setCursor,
422
616
  setInboxCursor,
423
617
  setMode,
424
- setReviewMode,
425
- setReviewWidth,
426
618
  setRailTab,
427
619
  setSelectedEntryId,
428
620
  setFocusedAnnotationId,
@@ -431,6 +623,14 @@ export function App({
431
623
  selectCardFromDocument,
432
624
  runEditorHandOff,
433
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
+ },
434
634
  });
435
635
 
436
636
  const overlay = resolveOverlay(mode, completion.phase, walking);
@@ -451,6 +651,7 @@ export function App({
451
651
  isOwner,
452
652
  resolved,
453
653
  treeActive: railTab === "tree",
654
+ isDiff,
454
655
  });
455
656
 
456
657
  if (chord) dispatch(chord);
@@ -520,7 +721,7 @@ export function App({
520
721
  if (error) return <ErrorScreen error={error} theme={theme} />;
521
722
  if (!session)
522
723
  return inbox ? (
523
- <InboxScreen
724
+ <NoThreadShell
524
725
  rows={grouped.rows}
525
726
  inboxCursor={inboxCursor}
526
727
  mode={mode}
@@ -529,6 +730,11 @@ export function App({
529
730
  setMode={setMode}
530
731
  menuChrome={menuChrome}
531
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 })}
532
738
  />
533
739
  ) : (
534
740
  <ConnectingScreen theme={theme} />
@@ -547,14 +753,15 @@ export function App({
547
753
  />
548
754
  );
549
755
 
550
- const diffComposeState = buildDiffComposeState({
551
- mode,
552
- isDiff,
553
- rows,
554
- liveInput,
555
- setMode,
556
- dispatch,
557
- });
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
+ };
558
765
  const submitConfirmState = buildSubmitConfirmState({
559
766
  mode,
560
767
  isDiff,
@@ -565,12 +772,6 @@ export function App({
565
772
  setMode,
566
773
  dispatch,
567
774
  });
568
- const headerItems = buildHeaderItems({
569
- session: activeSession,
570
- resolved,
571
- observer,
572
- role,
573
- });
574
775
  const { showOwnerActions, prototypeCanComment, chromeHidden, prototypePath } = buildRenderFlags({
575
776
  session: activeSession,
576
777
  isOwner,
@@ -605,61 +806,41 @@ export function App({
605
806
 
606
807
  return (
607
808
  <ThemeProvider theme={theme}>
608
- <box
609
- style={{
610
- flexDirection: "column",
611
- width: "100%",
612
- height: "100%",
613
- backgroundColor: theme.background,
614
- }}
615
- >
616
- <box
617
- style={{
618
- flexDirection: "row",
619
- height: 2,
620
- paddingTop: 1,
621
- backgroundColor: theme.panel,
622
- }}
623
- >
624
- <box style={{ flexGrow: 1, flexDirection: "row", paddingRight: 1 }}>
625
- <box onMouseUp={() => setMenuDialog("settings")} style={{ paddingRight: 2 }}>
626
- <text fg={theme.textMuted}>{NERD.settings}</text>
627
- </box>
628
- <IconButton
629
- glyph={sidebarToggleGlyph(sidebarOpen)}
630
- onPress={() => setSidebarOpen((open) => !open)}
631
- marginRight={2}
632
- theme={theme}
633
- />
634
- <Breadcrumb items={headerItems} />
635
- <box style={{ flexGrow: 1 }} />
636
- {showOwnerActions ? (
637
- <Toolbar>
638
- <Button onPress={onEditRequest} theme={theme}>
639
- {" Edit "}
640
- </Button>
641
- <Button onPress={onShareRequest} theme={theme}>
642
- {" Share "}
643
- </Button>
644
- </Toolbar>
645
- ) : null}
646
- <DiffChangesToggle
647
- isDiff={isDiff}
648
- open={diffColumns.changesOpen}
649
- onToggle={diffColumns.toggleChanges}
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 })
825
+ }
650
826
  theme={theme}
651
827
  />
652
- </box>
653
- </box>
654
- <box style={{ flexGrow: 1, flexDirection: "row" }}>
655
- <ThreadsSidebar
656
- open={sidebarOpen}
657
- rows={grouped.rows}
658
- cursor={inboxCursor}
659
- activeId={session.id}
660
- onSelect={(id) => controller.open(id)}
661
- theme={theme}
662
- />
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={
663
844
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
664
845
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
665
846
  {isPrototype ? (
@@ -672,16 +853,9 @@ export function App({
672
853
  hidden={chromeHidden}
673
854
  />
674
855
  ) : isDiff ? (
675
- // the sheet dims to reading-quiet colors while the wizard has focus
676
- <DiffSheet
677
- rows={rows}
678
- cursor={cursor}
679
- annotations={activeSession.annotations}
680
- focusedAnnotationId={focusedAnnotationId}
681
- rejectedRows={rejectedRows}
682
- compose={diffComposeState}
683
- theme={walking ? dimmedTheme(theme) : undefined}
684
- />
856
+ <box style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }}>
857
+ <text fg={theme.textDim}>review the changes on the right</text>
858
+ </box>
685
859
  ) : (
686
860
  <ThreadView
687
861
  session={activeSession}
@@ -729,14 +903,86 @@ export function App({
729
903
  theme={theme}
730
904
  />
731
905
  </box>
732
- <ChangesColumn
733
- open={diffColumns.changesOpen}
734
- files={activeSession.artifact.files}
735
- selectedPath={diffColumns.currentFilePath}
736
- onSelectFile={diffColumns.scrollToFile}
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
+ )}
737
968
  theme={theme}
738
969
  />
739
- </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
+ >
740
986
  <TrailingOverlays
741
987
  walking={walking}
742
988
  walk={walk}
@@ -766,7 +1012,7 @@ export function App({
766
1012
  </box>
767
1013
  ) : null}
768
1014
  {menuChrome}
769
- </box>
1015
+ </AppShell>
770
1016
  </ThemeProvider>
771
1017
  );
772
1018
  }