@cueloop/client 0.1.0-alpha.8 → 0.1.0-alpha.81

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 (307) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +40 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +176 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +428 -0
  16. package/src/App.prototype-markdown.test.tsx +92 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +222 -0
  23. package/src/App.test.tsx +333 -131
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1338 -608
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +701 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +574 -0
  35. package/src/app-view-model.ts +162 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/clipboard.test.ts +23 -0
  39. package/src/clipboard.ts +33 -0
  40. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  41. package/src/components/AnnotatableFileView.test.tsx +102 -0
  42. package/src/components/AnnotatableFileView.tsx +134 -0
  43. package/src/components/AnnotationCards.stories.tsx +86 -0
  44. package/src/components/AnnotationCards.tsx +341 -0
  45. package/src/components/AnsiScreen.stories.tsx +12 -0
  46. package/src/components/AnsiScreen.tsx +28 -0
  47. package/src/components/AppHeader.stories.tsx +50 -0
  48. package/src/components/AppHeader.tsx +83 -0
  49. package/src/components/AppShell.brand.test.tsx +58 -0
  50. package/src/components/AppShell.stories.tsx +89 -0
  51. package/src/components/AppShell.tsx +329 -0
  52. package/src/components/AppShell.zoom.test.tsx +135 -0
  53. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  54. package/src/components/BareWorkbenchFileView.tsx +64 -0
  55. package/src/components/Breadcrumb.stories.tsx +19 -0
  56. package/src/components/Breadcrumb.tsx +45 -0
  57. package/src/components/ChangesColumn.stories.tsx +44 -0
  58. package/src/components/ChangesColumn.tsx +199 -0
  59. package/src/components/ChangesFileTree.test.tsx +61 -0
  60. package/src/components/CodeBlock.stories.tsx +42 -0
  61. package/src/components/CodeBlock.tsx +84 -0
  62. package/src/components/CompletionOverlay.stories.tsx +37 -0
  63. package/src/components/CompletionOverlay.tsx +77 -0
  64. package/src/components/ConfirmCard.stories.tsx +37 -0
  65. package/src/components/ConfirmCard.tsx +153 -0
  66. package/src/components/ConfirmDialog.stories.tsx +21 -0
  67. package/src/components/ConfirmDialog.tsx +72 -0
  68. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  69. package/src/components/DiffContentView.split.test.tsx +140 -0
  70. package/src/components/DiffContentView.stories.tsx +80 -0
  71. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  72. package/src/components/DiffContentView.tsx +876 -0
  73. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  74. package/src/components/DiscussionMarkerRail.tsx +186 -0
  75. package/src/components/EditorGrid.stories.tsx +59 -0
  76. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  77. package/src/components/EditorGrid.tsx +426 -0
  78. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  79. package/src/components/FileContentsView.stories.tsx +16 -0
  80. package/src/components/FileContentsView.test.tsx +29 -0
  81. package/src/components/FileContentsView.tsx +98 -0
  82. package/src/components/GridTabContent.stories.tsx +59 -0
  83. package/src/components/GridTabContent.tsx +182 -0
  84. package/src/components/HandleChipsInput.stories.tsx +24 -0
  85. package/src/components/HandleChipsInput.test.tsx +35 -0
  86. package/src/components/HandleChipsInput.tsx +145 -0
  87. package/src/components/KeybindsDialog.stories.tsx +31 -0
  88. package/src/components/KeybindsDialog.tsx +103 -0
  89. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  90. package/src/components/MarkdownGridBlock.tsx +72 -0
  91. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  92. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  93. package/src/components/MarkdownThreadEditor.tsx +162 -0
  94. package/src/components/MenuBar.stories.tsx +28 -0
  95. package/src/components/MenuBar.tsx +44 -0
  96. package/src/components/NavModeHint.stories.tsx +40 -0
  97. package/src/components/NavModeHint.tsx +31 -0
  98. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  99. package/src/components/OverlayScrollbar.test.tsx +139 -0
  100. package/src/components/OverlayScrollbar.tsx +129 -0
  101. package/src/components/PaneGrid.stories.tsx +84 -0
  102. package/src/components/PaneGrid.tsx +128 -0
  103. package/src/components/PanelColumn.stories.tsx +25 -0
  104. package/src/components/PanelColumn.tsx +98 -0
  105. package/src/components/ProjectTreeView.stories.tsx +25 -0
  106. package/src/components/ProjectTreeView.test.tsx +59 -0
  107. package/src/components/ProjectTreeView.tsx +111 -0
  108. package/src/components/PromptDialog.stories.tsx +36 -0
  109. package/src/components/PromptDialog.test.tsx +61 -0
  110. package/src/components/PromptDialog.tsx +99 -0
  111. package/src/components/PrototypeContentView.stories.tsx +19 -0
  112. package/src/components/PrototypeContentView.tsx +446 -0
  113. package/src/components/RootOverlay.stories.tsx +45 -0
  114. package/src/components/RootOverlay.tsx +48 -0
  115. package/src/components/ScrollArea.stories.tsx +20 -0
  116. package/src/components/ScrollArea.tsx +44 -0
  117. package/src/components/SettingsDialog.stories.tsx +117 -0
  118. package/src/components/SettingsDialog.tsx +186 -0
  119. package/src/components/SettingsRows.stories.tsx +18 -0
  120. package/src/components/SettingsRows.tsx +112 -0
  121. package/src/components/ShareDialog.stories.tsx +121 -0
  122. package/src/components/ShareDialog.tsx +552 -0
  123. package/src/components/ShellHeader.stories.tsx +50 -0
  124. package/src/components/ShellHeader.tsx +137 -0
  125. package/src/components/SlashComposer.stories.tsx +37 -0
  126. package/src/components/SlashComposer.tsx +87 -0
  127. package/src/components/ThreadFooter.stories.tsx +30 -0
  128. package/src/components/ThreadFooter.test.tsx +47 -0
  129. package/src/components/ThreadFooter.tsx +76 -0
  130. package/src/components/ThreadTree.stories.tsx +46 -0
  131. package/src/components/ThreadTree.tsx +255 -0
  132. package/src/components/ThreadView.selection.test.tsx +899 -0
  133. package/src/components/ThreadView.slash.test.tsx +146 -0
  134. package/src/components/ThreadView.stories.tsx +26 -0
  135. package/src/components/ThreadView.tsx +472 -0
  136. package/src/components/Toast.stories.tsx +19 -0
  137. package/src/components/Toast.tsx +52 -0
  138. package/src/components/Tooltip.stories.tsx +26 -0
  139. package/src/components/Tooltip.tsx +72 -0
  140. package/src/components/TreePane.stories.tsx +106 -0
  141. package/src/components/TreePane.tsx +157 -0
  142. package/src/components/WalkWizard.stories.tsx +100 -0
  143. package/src/components/WalkWizard.tsx +150 -0
  144. package/src/components/WelcomePlayground.stories.tsx +12 -0
  145. package/src/components/WelcomePlayground.test.tsx +47 -0
  146. package/src/components/WelcomePlayground.tsx +142 -0
  147. package/src/components/__snapshots__/stories.test.tsx.snap +2238 -0
  148. package/src/components/diff-content-view-layout.test.ts +198 -0
  149. package/src/components/diff-content-view-layout.ts +84 -0
  150. package/src/components/editor-grid.test.ts +86 -0
  151. package/src/components/editor-grid.ts +138 -0
  152. package/src/components/file-tree.test.ts +60 -0
  153. package/src/components/file-tree.ts +112 -0
  154. package/src/components/menu-control.stories.tsx +35 -0
  155. package/src/components/menu-control.tsx +43 -0
  156. package/src/components/pages/Workspace.stories.tsx +181 -0
  157. package/src/components/primitives/Button.stories.tsx +32 -0
  158. package/src/components/primitives/Button.tsx +56 -0
  159. package/src/components/primitives/Card.stories.tsx +25 -0
  160. package/src/components/primitives/Card.tsx +63 -0
  161. package/src/components/primitives/Dialog.stories.tsx +24 -0
  162. package/src/components/primitives/Dialog.test.tsx +37 -0
  163. package/src/components/primitives/Dialog.tsx +71 -0
  164. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  165. package/src/components/primitives/DialogActions.tsx +39 -0
  166. package/src/components/primitives/IconButton.stories.tsx +36 -0
  167. package/src/components/primitives/IconButton.tsx +62 -0
  168. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  169. package/src/components/primitives/StatusBar.tsx +23 -0
  170. package/src/components/primitives/Switch.stories.tsx +17 -0
  171. package/src/components/primitives/Switch.tsx +32 -0
  172. package/src/components/primitives/Tabs.stories.tsx +29 -0
  173. package/src/components/primitives/Tabs.tsx +102 -0
  174. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  175. package/src/components/primitives/Toolbar.tsx +14 -0
  176. package/src/components/primitives/Tree.stories.tsx +124 -0
  177. package/src/components/primitives/Tree.tsx +100 -0
  178. package/src/components/primitives/frame.test.ts +12 -0
  179. package/src/components/primitives/frame.ts +17 -0
  180. package/src/components/primitives/icons.ts +61 -0
  181. package/src/components/primitives/tree-model.test.ts +99 -0
  182. package/src/components/primitives/tree-model.ts +137 -0
  183. package/src/components/quick-actions-editor.stories.tsx +58 -0
  184. package/src/components/quick-actions-editor.test.tsx +117 -0
  185. package/src/components/quick-actions-editor.tsx +226 -0
  186. package/src/components/share-dialog-store.ts +58 -0
  187. package/src/components/stories-app.tsx +193 -0
  188. package/src/components/stories.test.tsx +74 -0
  189. package/src/components/story-fixtures.ts +102 -0
  190. package/src/components/story-tree.test.ts +108 -0
  191. package/src/components/story.ts +159 -0
  192. package/src/components/syntax-highlight.ts +133 -0
  193. package/src/components/terminal-pane.test.tsx +40 -0
  194. package/src/components/terminal-pane.ts +199 -0
  195. package/src/components/theme-context.stories.tsx +46 -0
  196. package/src/components/theme-context.tsx +37 -0
  197. package/src/components/thread-tree.test.ts +134 -0
  198. package/src/components/thread-tree.ts +109 -0
  199. package/src/components/truncate-text.ts +7 -0
  200. package/src/components/truncate-title.test.ts +31 -0
  201. package/src/components/truncate-title.ts +15 -0
  202. package/src/config-identity.test.ts +78 -0
  203. package/src/config.test.ts +517 -33
  204. package/src/config.ts +467 -57
  205. package/src/diff-fold.test.ts +78 -0
  206. package/src/diff-fold.ts +126 -0
  207. package/src/diff-hunk-curate.test.ts +315 -0
  208. package/src/diff-hunk-curate.ts +153 -0
  209. package/src/diff-intraline.test.ts +238 -0
  210. package/src/diff-intraline.ts +236 -0
  211. package/src/diff-syntax.test.ts +112 -0
  212. package/src/diff-syntax.ts +179 -0
  213. package/src/discussions.ts +79 -0
  214. package/src/editor.test.ts +149 -0
  215. package/src/editor.ts +139 -9
  216. package/src/ghostty-terminal.test.ts +85 -0
  217. package/src/ghostty-terminal.ts +199 -0
  218. package/src/github-identity.test.ts +58 -0
  219. package/src/github-identity.ts +41 -0
  220. package/src/herdr.test.ts +9 -34
  221. package/src/herdr.ts +25 -21
  222. package/src/index.ts +13 -0
  223. package/src/integrations.test.ts +41 -0
  224. package/src/integrations.ts +32 -0
  225. package/src/intent-dispatch.test.ts +723 -0
  226. package/src/intent-dispatch.ts +692 -0
  227. package/src/key-bindings.test.ts +156 -0
  228. package/src/key-bindings.ts +459 -0
  229. package/src/keymap.test.ts +461 -113
  230. package/src/keymap.ts +321 -79
  231. package/src/kitty-image.test.ts +66 -0
  232. package/src/kitty-image.ts +138 -0
  233. package/src/launch-layout.test.ts +52 -0
  234. package/src/launch-layout.ts +42 -0
  235. package/src/mark-runs.ts +121 -0
  236. package/src/markdown-editor-syntax-style.ts +50 -0
  237. package/src/markdown-grid.test.ts +101 -0
  238. package/src/markdown-grid.ts +203 -0
  239. package/src/markdown-highlight.test.ts +61 -0
  240. package/src/markdown-highlight.ts +122 -0
  241. package/src/pasted-image.test.ts +67 -0
  242. package/src/pasted-image.ts +60 -0
  243. package/src/perf/otlp-trace.test.ts +116 -0
  244. package/src/perf/otlp-trace.ts +130 -0
  245. package/src/perf/perf-report.ts +17 -0
  246. package/src/perf/perf-timings.test.ts +18 -0
  247. package/src/perf/perf-timings.ts +38 -0
  248. package/src/prototype-browser.test.ts +64 -0
  249. package/src/prototype-browser.ts +329 -0
  250. package/src/prototype-pixels.tsx +35 -0
  251. package/src/pty.test.ts +81 -0
  252. package/src/pty.ts +245 -0
  253. package/src/ready-signal.test.ts +85 -0
  254. package/src/ready-signal.ts +52 -0
  255. package/src/run.ts +78 -13
  256. package/src/serve.test.ts +24 -12
  257. package/src/serve.ts +52 -10
  258. package/src/share.ts +237 -0
  259. package/src/skills.test.ts +74 -0
  260. package/src/skills.ts +75 -0
  261. package/src/slash-palette.test.ts +103 -0
  262. package/src/slash-palette.ts +129 -0
  263. package/src/split-diff.test.ts +110 -0
  264. package/src/split-diff.ts +149 -0
  265. package/src/test-support.test.ts +264 -0
  266. package/src/test-support.ts +369 -0
  267. package/src/theme-presets.test.ts +51 -0
  268. package/src/theme-presets.ts +214 -0
  269. package/src/theme.ts +84 -19
  270. package/src/thread-chords.test.ts +79 -0
  271. package/src/thread-chords.ts +163 -0
  272. package/src/thread-controller.curate.test.ts +450 -0
  273. package/src/thread-controller.derivation.test.ts +62 -0
  274. package/src/thread-controller.live-diff.test.ts +322 -0
  275. package/src/thread-controller.optimistic.test.ts +69 -0
  276. package/src/thread-controller.share.test.ts +561 -0
  277. package/src/thread-controller.switch-cache.test.ts +176 -0
  278. package/src/thread-controller.tree.test.ts +261 -0
  279. package/src/thread-controller.ts +1893 -0
  280. package/src/thread-selection.test.ts +210 -0
  281. package/src/thread-selection.ts +243 -0
  282. package/src/tree-view.test.ts +128 -0
  283. package/src/tree-view.ts +148 -0
  284. package/src/use-annotation-surface.tsx +915 -0
  285. package/src/use-changes-workbench.ts +190 -0
  286. package/src/use-frame-measure.ts +41 -0
  287. package/src/use-remember-layout.ts +31 -0
  288. package/src/use-settings-dialog.tsx +320 -0
  289. package/src/use-terminal-virtualizer.ts +233 -0
  290. package/src/use-thread-body-editing.tsx +58 -0
  291. package/src/version.ts +5 -0
  292. package/src/view-diff.test.ts +137 -9
  293. package/src/view-diff.ts +269 -23
  294. package/src/view-plan.regression.test.ts +60 -0
  295. package/src/view-plan.test.ts +456 -0
  296. package/src/view-plan.ts +719 -0
  297. package/src/visual/contrast.test.ts +35 -0
  298. package/src/visual/contrast.ts +33 -0
  299. package/src/walk.test.ts +121 -0
  300. package/src/walk.ts +74 -0
  301. package/src/workbench-snapshot.test.ts +81 -0
  302. package/src/workbench-snapshot.ts +29 -0
  303. package/src/session-controller.ts +0 -345
  304. package/src/syntax.test.ts +0 -41
  305. package/src/syntax.ts +0 -88
  306. package/src/view.test.ts +0 -157
  307. package/src/view.ts +0 -312
package/src/App.tsx CHANGED
@@ -1,676 +1,1406 @@
1
- /**
2
- * The cueloop TUI (#22): Ledger IA reduced to its terminal shape - header,
3
- * center artifact pane (projection renderer), right review rail, footer hint
4
- * bar. Thin renderer over the review-session controller: daemon IO and the
5
- * mutation verbs live in session-controller.ts, the keyboard grammar in
6
- * keymap.ts; local state is view state only (cursor, span, overlays).
7
- */
8
-
9
- import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
10
- import { useKeyboard } from "@opentui/react";
11
- import { type Annotation, type ReviewSession, type VerdictKind } from "@cueloop/schema";
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ useSyncExternalStore,
8
+ } from "react";
9
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
10
+ import type { Clock, KeyEvent } from "@opentui/core";
11
+ import { marksByDisplay, type Mark } from "./view-plan";
12
12
  import {
13
- blockRuns,
14
- displayText,
15
- marksByDisplay,
16
- overlayMarks,
17
- spanKey,
18
- startSpan,
19
- wrapRuns,
20
- type DisplayBlock,
21
- type SpanState,
22
- type StyleRun,
23
- } from "./view";
24
- import { type DiffRow } from "./view-diff";
25
- import { DARK as T } from "./theme";
26
- import { highlightCode, type CodeToken } from "./syntax";
27
- import { DEFAULT_KEYS, loadConfig } from "./config";
28
- import { returnPaneFor } from "./herdr";
29
- import { createReviewController } from "./session-controller";
30
- import { reduceKey, type Intent, type KeyState } from "./keymap";
13
+ DEFAULT_KEYS,
14
+ DEFAULT_QUICK_ACTIONS,
15
+ loadConfig,
16
+ persistAuthorName,
17
+ persistDiffView,
18
+ persistIdentity,
19
+ persistPins,
20
+ type AutoClose,
21
+ type DiffViewMode,
22
+ type IdentityConfig,
23
+ type QuickAction,
24
+ } from "./config";
25
+ import { resolveDisplayName } from "./attribution";
26
+ import { resolveGithubIdentity } from "./github-identity";
27
+ import { loadSkills, PaletteNamesContext, SlashSkillsContext } from "./skills";
28
+ import { mergeSlashItems, slashItemsFrom, type SlashItem } from "./slash-palette";
29
+ import {
30
+ composeTheme,
31
+ DEFAULT_THEME_NAME,
32
+ themeForName,
33
+ type Appearance,
34
+ type ThemeName,
35
+ } from "./theme-presets";
36
+ import type { Theme } from "./theme";
37
+ import { createReviewController, type ShareTransport } from "./thread-controller";
38
+ import type { ThreadClient } from "@cueloop/daemon/client";
39
+ import { createIntentDispatch, type Mode, type RailTab } from "./intent-dispatch";
40
+ import { reduceKey, type KeyState } from "./keymap";
41
+ import { KeyBindings, type CheatsheetSection } from "./key-bindings";
42
+ import { useReadySignal } from "./ready-signal";
43
+ import { ThemeProvider } from "./components/theme-context";
44
+ import { Button } from "./components/primitives/Button";
45
+ import { ShareDialog } from "./components/ShareDialog";
46
+ import { shareDialogStore } from "./components/share-dialog-store";
47
+ import { Toolbar } from "./components/primitives/Toolbar";
48
+ import { groupInbox, projectName, threadTitle } from "./components/thread-tree";
49
+ import { ThreadTree } from "./components/ThreadTree";
50
+ import { ChangesFileTree } from "./components/ChangesColumn";
51
+ import { MenuControlProvider, useMenuControlState } from "./components/menu-control";
52
+ import { ProjectTreeView } from "./components/ProjectTreeView";
53
+ import { AppShell, type FocusPane, type ProjectPanelMode } from "./components/AppShell";
54
+ import { EditorGrid } from "./components/EditorGrid";
55
+ import { GridTabContent } from "./components/GridTabContent";
56
+ import { useChangesWorkbench } from "./use-changes-workbench";
57
+ import { useThreadBodyEditing } from "./use-thread-body-editing";
58
+ import { useRememberLayout } from "./use-remember-layout";
59
+ import type { LaunchLayout } from "./launch-layout";
60
+ import { ThreadFooter, THREAD_FOOTER_HEIGHT } from "./components/ThreadFooter";
61
+ import { ConfirmCard } from "./components/ConfirmCard";
62
+ import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
63
+ import {
64
+ curationCommandEntries,
65
+ diffCommandEntries,
66
+ resolveNavKey,
67
+ resolveSessionChord,
68
+ sessionCommandEntries,
69
+ treeCommandEntries,
70
+ } from "./thread-chords";
71
+ import { type DiffFoldControls } from "./components/DiffContentView";
72
+ import { commentCountsByFile } from "./view-diff";
73
+ import { annotationTarget, isAddressed, isAgentNote, threadShareLinks } from "@cueloop/schema";
74
+ import type {
75
+ Annotation,
76
+ Artifact,
77
+ DiffFileContents,
78
+ Identity,
79
+ ShareLink,
80
+ Thread,
81
+ MessageOutcome,
82
+ } from "@cueloop/schema";
83
+ import { PrototypePixels } from "./prototype-pixels";
84
+ import type { PrototypeElement } from "./prototype-browser";
85
+ import {
86
+ buildRenderFlags,
87
+ buildSubmitConfirmState,
88
+ computeRoleCapabilities,
89
+ deriveReviewFlags,
90
+ isPixelPrototypeMode,
91
+ isCompletionOverlayPhase,
92
+ isWalking,
93
+ resolveOverlay,
94
+ } from "./app-view-model";
95
+ import { buildKeyState } from "./app-key-state";
96
+ import { useSettingsDialog } from "./use-settings-dialog";
97
+ import {
98
+ CompletionScreen,
99
+ ConnectingScreen,
100
+ ErrorScreen,
101
+ MenuChrome,
102
+ NoThreadShell,
103
+ TrailingOverlays,
104
+ } from "./app-screens";
105
+ /** A toast clears itself after this idle; esc dismisses it sooner. */
106
+ const TOAST_DISMISS_MS = 2000;
31
107
 
32
108
  export interface AppProps {
33
109
  home?: string;
34
110
  sessionId?: string;
111
+ /** The launch directory whose git repo backs the no-session welcome tree; defaults to process.cwd(). */
112
+ cwd?: string;
35
113
  /**
36
- * Observer mode (SSH-served connections): every mutating verb is ignored and
114
+ * Observer mode (SSH-served connections): every mutating primitive is ignored and
37
115
  * answers "observer - read-only" in the status line; navigation still works.
38
116
  */
39
117
  readOnly?: boolean;
40
118
  onExit?: (code: number) => void;
119
+ /** Fired once, after the first frame that paints a usable screen with its keyboard handlers live (ready-signal.ts). */
120
+ onReady?: () => void;
121
+ /** Timer source for the auto-close countdown; tests inject a ManualClock. */
122
+ clock?: Clock;
123
+ /** Session source; the sharing gateway injects a blob-backed client. */
124
+ openClient?: () => Promise<ThreadClient>;
125
+ shareTransport?: ShareTransport;
126
+ /**
127
+ * Who is at the keyboard. `owner` is the local planner (default). `observer`
128
+ * is a passive `cueloop serve` watcher (read-only). `collaborator` is a share
129
+ * viewer: annotates, but cannot edit the plan or submit an agent message.
130
+ */
131
+ role?: "owner" | "observer" | "collaborator";
132
+ /**
133
+ * A collaborator's own SSH fingerprint. On first open of a share it seeds the
134
+ * name prompt so their notes attribute to a name, not a fingerprint.
135
+ */
136
+ selfAuthor?: string;
137
+ /**
138
+ * The terminal's background appearance (from an OSC query at startup). The
139
+ * branded transparent theme darkens its text on a light terminal so it is not
140
+ * light-on-light. Defaults to dark - the historical assumption.
141
+ */
142
+ appearance?: Appearance;
143
+ /** The pane composition to open in; a create-command sets it, a bare launch restores the remembered one. */
144
+ layout?: LaunchLayout;
145
+ /** Serve mode: the frozen working-tree diff an observer reads for the served workbench thread. */
146
+ servedArtifact?: Artifact;
41
147
  }
42
148
 
43
- type Mode =
44
- | { m: "normal" }
45
- | { m: "span"; span: SpanState }
46
- | { m: "compose"; kind: "comment" | "suggestion"; dispIdx: number; start: number; end: number; text: string }
47
- | { m: "submit"; verdict: VerdictKind; summary: string };
149
+ /** True while the drop-up or one of its dialogs is open and owns the keyboard. */
150
+ function menuChromeOpen(menuDialog: "keybinds" | "settings" | null): boolean {
151
+ return menuDialog !== null;
152
+ }
48
153
 
49
- const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
50
- const VERDICT_LABEL: Record<VerdictKind, string> = {
51
- comment: "Comment",
52
- approve: "Approve",
53
- request_changes: "Request changes",
54
- };
154
+ /** Whether a thread already has at least one live share link. */
155
+ function isSharedThread(session: Thread | null): boolean {
156
+ return session !== null && (threadShareLinks(session)?.length ?? 0) > 0;
157
+ }
55
158
 
56
- export function App({ home, sessionId, readOnly = false, onExit }: AppProps): React.ReactNode {
57
- const controller = useMemo(
58
- () => createReviewController({ home, sessionId, readOnly, onExit }),
59
- // eslint-disable-next-line react-hooks/exhaustive-deps
60
- [home, sessionId],
61
- );
62
- useEffect(() => {
63
- controller.connect();
64
- return () => controller.close();
65
- }, [controller]);
66
- const { session, inbox, status, error, completion } = useSyncExternalStore(
67
- controller.subscribe,
68
- controller.getSnapshot,
69
- controller.getSnapshot,
70
- );
159
+ /** A thread's live share links for the share dialog, or an empty list with no session. */
160
+ function shareLinksFor(session: Thread | null): ShareLink[] {
161
+ return session ? (threadShareLinks(session) ?? []) : [];
162
+ }
71
163
 
72
- // ── view state ──────────────────────────────
73
- const [cursor, setCursor] = useState(0);
74
- const [inboxCursor, setInboxCursor] = useState(0);
75
- const [mode, setMode] = useState<Mode>({ m: "normal" });
76
- const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
77
- // live mirror of overlay input text: refs commit synchronously, so the
78
- // RETURN handler never reads a stale value mid-typing
79
- const liveInput = useRef("");
80
- // keymap from layered config; theme overrides land on the shared tokens
81
- const keysRef = useRef(DEFAULT_KEYS);
82
- useEffect(() => {
83
- const cfg = loadConfig({ repoRoot: session?.workspace.repoRoot });
84
- keysRef.current = cfg.keys;
85
- Object.assign(T, cfg.theme);
86
- controller.applyConfig(cfg);
87
- }, [session?.workspace.repoRoot, controller]);
164
+ /** The title the share dialog shows and prefills a new link with, or empty with no session. */
165
+ function shareThreadName(session: Thread | null): string {
166
+ return session ? threadTitle(session) : "";
167
+ }
88
168
 
89
- // ── derived view model ──────────────────────
90
- const display = controller.display();
91
- const rows = controller.rows();
92
- const marks = useMemo(
93
- () => (session ? marksByDisplay(session.annotations, display, focusedAnn) : new Map()),
94
- [session, display, focusedAnn],
95
- );
96
- const resolved = session?.status === "resolved";
97
- const isDiff = session?.artifact.type === "diff";
169
+ /** The drop-up chrome or a floating popover menu (thread actions, editor split) holds the keyboard. */
170
+ function keyboardHeldByMenu(
171
+ menuDialog: "keybinds" | "settings" | null,
172
+ openMenuId: string | null,
173
+ ): boolean {
174
+ return menuChromeOpen(menuDialog) || openMenuId !== null;
175
+ }
98
176
 
99
- // ── keyboard grammar: build state, reduce, dispatch ──
100
- const dispatch = (intent: Intent): void => {
101
- switch (intent.t) {
102
- case "exit":
103
- return void onExit?.(0);
104
- case "status":
105
- return controller.setStatus(intent.msg);
106
- case "move": {
107
- const len = isDiff ? rows.length : display.length;
108
- if (intent.to === "down") setCursor((c) => Math.min(len - 1, c + 1));
109
- else if (intent.to === "up") setCursor((c) => Math.max(0, c - 1));
110
- else if (intent.to === "top") setCursor(0);
111
- else setCursor(len - 1);
112
- return;
113
- }
114
- case "inboxMove": {
115
- const len = inbox?.length ?? 0;
116
- setInboxCursor((c) => (intent.to === "down" ? Math.min(len - 1, c + 1) : Math.max(0, c - 1)));
117
- return;
118
- }
119
- case "openSession": {
120
- const s = inbox?.[inboxCursor];
121
- if (s) controller.open(s.id);
122
- return;
123
- }
124
- case "startSpan": {
125
- const d = display[cursor];
126
- if (!d?.work) return;
127
- const span = startSpan(cursor, displayText(d));
128
- if (span) setMode({ m: "span", span });
129
- return;
130
- }
131
- case "spanKey":
132
- if (mode.m === "span") {
133
- setMode({ m: "span", span: spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!)) });
134
- }
135
- return;
136
- case "openCompose": {
137
- liveInput.current = "";
138
- if (intent.from === "span" && mode.m === "span") {
139
- setMode({
140
- m: "compose",
141
- kind: intent.kind,
142
- dispIdx: mode.span.dispIdx,
143
- start: mode.span.start,
144
- end: mode.span.end,
145
- text: "",
146
- });
147
- } else if (isDiff) {
148
- const row = rows[cursor];
149
- if (row) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: row.text.length, text: "" });
150
- } else {
151
- const d = display[cursor];
152
- if (d) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: displayText(d).length, text: "" });
153
- }
154
- return;
155
- }
156
- case "openSubmit":
157
- if (!session) return;
158
- liveInput.current = "";
159
- return void setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
160
- case "cut":
161
- return controller.cut(cursor);
162
- case "edit":
163
- return controller.edit();
164
- case "nextAnn":
165
- case "prevAnn": {
166
- const anns = session?.annotations ?? [];
167
- if (!anns.length) return;
168
- const idx = anns.findIndex((a) => a.id === focusedAnn);
169
- const next = idx === -1 ? 0 : (idx + (intent.t === "nextAnn" ? 1 : -1) + anns.length) % anns.length;
170
- return void setFocusedAnn(anns[next]!.id);
171
- }
172
- case "removeAnnotation":
173
- if (focusedAnn) {
174
- controller.removeAnnotation(focusedAnn);
175
- setFocusedAnn(undefined);
176
- }
177
- return;
178
- case "closeOverlay":
179
- return void setMode({ m: "normal" });
180
- case "saveCompose": {
181
- if (mode.m !== "compose") return;
182
- const body = liveInput.current.trim();
183
- if (session && body) controller.annotate(mode.kind, mode.dispIdx, mode.start, mode.end, body);
184
- return void setMode({ m: "normal" });
185
- }
186
- case "submitVerdict":
187
- if (mode.m === "submit") controller.submit(mode.verdict, liveInput.current);
188
- return void setMode({ m: "normal" });
189
- case "cycleVerdict": {
190
- if (mode.m !== "submit") return;
191
- const idx = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
192
- return void setMode({ ...mode, verdict: VERDICTS[idx]! });
193
- }
194
- case "finishReview":
195
- return controller.finishReview();
196
- case "optInAutoClose":
197
- return controller.optInAutoClose();
198
- case "dismissCompletion":
199
- return controller.dismissCompletion();
200
- }
201
- };
177
+ /** A non-modal toast: escape dismisses it only when nothing else owns escape (no overlay, not span mode). */
178
+ export function toastDismissRequested(
179
+ toastOpen: boolean,
180
+ key: { name: string },
181
+ overlay: KeyState["overlay"],
182
+ spanMode: boolean,
183
+ ): boolean {
184
+ return toastOpen && key.name === "escape" && overlay === "none" && !spanMode;
185
+ }
202
186
 
203
- useKeyboard((key) => {
204
- const state: KeyState = {
205
- keys: keysRef.current,
206
- readOnly,
207
- overlay:
208
- mode.m === "compose"
209
- ? "compose"
210
- : mode.m === "submit"
211
- ? "submit"
212
- : completion.phase === "prompt"
213
- ? "completion-prompt"
214
- : completion.phase === "counting"
215
- ? "completion-counting"
216
- : "none",
217
- view: !session ? "inbox" : isDiff ? "diff" : "plan",
218
- spanMode: mode.m === "span",
219
- resolved: !!resolved,
220
- hasInboxItems: !!inbox?.length,
221
- annotationCount: session?.annotations.length ?? 0,
222
- hasFocusedAnnotation: focusedAnn !== undefined,
223
- cursorAnnotatable: isDiff
224
- ? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
225
- : !!display[cursor]?.work,
226
- };
227
- for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift })) dispatch(intent);
228
- });
187
+ /** ctrl+q quits from anywhere, ahead of every focus/menu/overlay gate, with no confirmation. */
188
+ export function quitKeyHandled(
189
+ key: { name: string; ctrl?: boolean },
190
+ onExit?: (code: number) => void,
191
+ ): boolean {
192
+ if (!key.ctrl || key.name !== "q") return false;
193
+ onExit?.(0);
229
194
 
230
- // ── render ──────────────────────────────────
231
- if (error) return <text fg={T.red}>cueloop: {error}</text>;
232
- if (!session && !inbox) return <text fg={T.textDim}>connecting to the daemon…</text>;
233
- if (!session && inbox) return <Inbox inbox={inbox} cursor={inboxCursor} />;
195
+ return true;
196
+ }
234
197
 
235
- const s = session!;
236
- const pendingCount = s.annotations.length + (s.workingCopy !== undefined ? 1 : 0);
198
+ /** A popover menu is open, so the shell keyboard is modal: escape closes it, every other key is swallowed. */
199
+ function menuModalHandled(
200
+ menuControl: { openMenuId: string | null; closeMenu: () => void },
201
+ key: { name: string },
202
+ ): boolean {
203
+ if (menuControl.openMenuId === null) return false;
204
+ if (key.name === "escape") menuControl.closeMenu();
237
205
 
238
- if ((completion.phase === "prompt" || completion.phase === "counting") && s.verdict) {
239
- return (
240
- <CompletionOverlay
241
- verdict={s.verdict.kind}
242
- completion={completion}
243
- status={status}
244
- returnsTo={returnPaneFor(s.artifact.meta.herdrPane) ? (s.artifact.meta.agent ?? "the agent") : undefined}
245
- />
246
- );
247
- }
206
+ return true;
207
+ }
208
+
209
+ /** True while a menu, an overlay, or the share dialog owns the keyboard instead of the thread view. */
210
+ function keyboardOwnedElsewhere(
211
+ menuOwnsKeyboard: boolean,
212
+ overlay: KeyState["overlay"],
213
+ shareDialogOpen: boolean,
214
+ ): boolean {
215
+ return menuOwnsKeyboard || overlay !== "none" || shareDialogOpen;
216
+ }
217
+
218
+ /** The footer submit fires only for the owner of an unresolved review, never an observer. */
219
+ function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean): boolean {
220
+ return isOwner && !resolved && !observer;
221
+ }
222
+
223
+ /** The thread body edits inline only for its owner, on an unresolved markdown thread (never a diff). */
224
+ function canEditThreadBody(
225
+ threadViewActive: boolean,
226
+ isDiff: boolean,
227
+ isOwner: boolean,
228
+ resolved: boolean,
229
+ ): boolean {
230
+ return threadViewActive && !isDiff && isOwner && !resolved;
231
+ }
248
232
 
233
+ /** The owner's header actions: an edit/normal toggle that opens or leaves the inline editor, plus share when not editing. */
234
+ function ownerThreadActions(actions: {
235
+ editing: boolean;
236
+ onEdit: () => void;
237
+ onExitEdit: () => void;
238
+ onShare: () => void;
239
+ theme: Theme;
240
+ }): React.ReactNode {
249
241
  return (
250
- <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg }}>
251
- <box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1, flexDirection: "row" }}>
252
- <text fg={T.text}>
253
- <span fg={T.accent}>cueloop</span>
254
- <span fg={T.textDim}> · {s.artifact.meta.title ?? s.artifact.meta.planPath ?? s.id} · rev {s.revisions.length}</span>
255
- {resolved ? <span fg={T.green}> · resolved: {s.verdict!.kind.replace("_", " ")}</span> : null}
256
- {readOnly ? <span fg={T.textDim}> · observer</span> : null}
257
- {status ? <span fg={T.accent}> · {status}</span> : null}
258
- </text>
259
- </box>
260
- <box style={{ flexGrow: 1, flexDirection: "row" }}>
261
- {isDiff ? (
262
- <DiffPane rows={rows} cursor={cursor} annotations={s.annotations} focusedAnn={focusedAnn} />
263
- ) : (
264
- <PlanPane display={display} marks={marks} cursor={cursor} mode={mode} />
265
- )}
266
- <Rail session={s} focusedAnn={focusedAnn} pendingCount={pendingCount} />
267
- </box>
268
- {mode.m === "compose" ? (
269
- <ComposeBar mode={mode} quote={isDiff ? (rows[mode.dispIdx]?.text ?? "") : displayText(display[mode.dispIdx]!).slice(mode.start, mode.end)} onChange={(text) => { liveInput.current = text; setMode({ ...mode, text }); }} />
270
- ) : mode.m === "submit" ? (
271
- <SubmitBar mode={mode} pendingCount={pendingCount} onChange={(summary) => { liveInput.current = summary; setMode({ ...mode, summary }); }} />
272
- ) : (
273
- <box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1 }}>
274
- <text fg={T.textDim}>
275
- {readOnly
276
- ? "observer - read-only · j/k move · n/p annotations · q quit"
277
- : mode.m === "span"
278
- ? "span · l/h grow/shrink · w/b slide · $ end · c comment · s suggest · esc"
279
- : "j/k move · v span · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit"}
280
- </text>
281
- </box>
242
+ <Toolbar>
243
+ <Button onPress={actions.editing ? actions.onExitEdit : actions.onEdit} theme={actions.theme}>
244
+ {actions.editing ? " normal " : " edit "}
245
+ </Button>
246
+ {actions.editing ? null : (
247
+ <Button onPress={actions.onShare} theme={actions.theme}>
248
+ {" share "}
249
+ </Button>
282
250
  )}
283
- </box>
251
+ </Toolbar>
284
252
  );
285
253
  }
286
254
 
287
- // ── panes ─────────────────────────────────────
288
-
289
- function PlanPane({
290
- display,
291
- marks,
292
- cursor,
293
- mode,
294
- }: {
295
- display: DisplayBlock[];
296
- marks: Map<number, { start: number; end: number; role: StyleRun["role"]; annotationId?: string }[]>;
297
- cursor: number;
298
- mode: Mode;
255
+ /** Pick the thread pane's body: the pixel prototype, the diff placeholder, the inline editor, or the read-only view. */
256
+ function chooseThreadBody(choice: {
257
+ isPixelPrototype: boolean;
258
+ isDiff: boolean;
259
+ editingBody: boolean;
260
+ prototype: React.ReactNode;
261
+ diffPlaceholder: React.ReactNode;
262
+ editor: React.ReactNode;
263
+ threadView: React.ReactNode;
299
264
  }): React.ReactNode {
300
- const width = 76;
301
- return (
302
- <scrollbox style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
303
- {display.map((d, i) => {
304
- const isCursor = i === cursor;
305
- const gap = topGap(display[i - 1], d);
306
- if (d.kind === "code") {
307
- return (
308
- <CodeBlock
309
- key={i}
310
- block={d}
311
- isCursor={isCursor}
312
- gap={gap}
313
- annotated={(marks.get(i) ?? []).length > 0}
314
- />
315
- );
316
- }
317
- const blockMarks = [...(marks.get(i) ?? [])];
318
- if (mode.m === "span" && mode.span.dispIdx === i) {
319
- blockMarks.push({ start: mode.span.start, end: mode.span.end, role: "kspan" });
320
- }
321
- const runs = overlayMarks(blockRuns(d, true), blockMarks);
322
- const lines = wrapRuns(runs, width - marker(d).length);
323
- return (
324
- <box key={i} style={{ flexDirection: "column", marginTop: gap }}>
325
- {lines.map((line, li) => (
326
- <text key={li} bg={isCursor ? T.cursorBg : undefined}>
327
- <span fg={isCursor ? T.accent : T.textDim}>{li === 0 ? cursorGlyph(isCursor) : " "}</span>
328
- <span fg={T.textDim}>{li === 0 ? marker(d) : " ".repeat(marker(d).length)}</span>
329
- {line.map((r, ri) => (
330
- <span key={ri} {...runStyle(r, d)}>
331
- {r.text}
332
- </span>
333
- ))}
334
- {li === 0 && d.type !== "same" ? <span fg={tagColor(d)}> [{tagLabel(d)}]</span> : null}
335
- </text>
336
- ))}
337
- </box>
338
- );
339
- })}
340
- </scrollbox>
341
- );
265
+ if (choice.isPixelPrototype) return choice.prototype;
266
+ if (choice.isDiff) return choice.diffPlaceholder;
267
+ if (choice.editingBody) return choice.editor;
268
+
269
+ return choice.threadView;
270
+ }
271
+
272
+ /** The author display name for a comment's hover tooltip: the reviewer's own name for their notes, else the resolved collaborator name. */
273
+ function authorLabelResolver(
274
+ ownName: string | undefined,
275
+ participants: Identity[] | undefined,
276
+ overrides: Record<string, string>,
277
+ ): (annotation: Annotation) => string | undefined {
278
+ return (annotation) =>
279
+ annotation.author === undefined
280
+ ? (ownName ?? "you")
281
+ : resolveDisplayName(annotation.author, participants, overrides);
342
282
  }
343
283
 
344
- function CodeBlock({
345
- block,
346
- isCursor,
347
- gap,
348
- annotated,
349
- }: {
350
- block: DisplayBlock;
351
- isCursor: boolean;
352
- gap: number;
353
- annotated: boolean;
284
+ /** The Project pane body: the resolved repo's changed files in changes mode, its full tree otherwise. */
285
+ function ProjectPanelBody(props: {
286
+ mode: ProjectPanelMode;
287
+ loadChanges: () => Promise<readonly DiffFileContents[]>;
288
+ loadProjectFiles: () => Promise<string[]>;
289
+ reloadKey: string;
290
+ onOpenChangedFile: (path: string) => void;
291
+ onOpenProjectFile: (path: string) => void;
292
+ commentCounts?: ReadonlyMap<string, number>;
293
+ focused?: boolean;
294
+ theme: Theme;
354
295
  }): React.ReactNode {
355
- const lang = (block.work ?? block.base)?.lang;
356
- const content = displayText(block);
357
- // Shiki tokens arrive async; plain verbatim lines render until then
358
- const [tokens, setTokens] = useState<CodeToken[][] | null>(null);
296
+ const [changes, setChanges] = useState<readonly DiffFileContents[]>([]);
297
+ const loadRef = useRef(props.loadChanges);
359
298
  useEffect(() => {
360
- let live = true;
361
- void highlightCode(content, lang).then((t) => {
362
- if (live && t) setTokens(t);
363
- });
299
+ loadRef.current = props.loadChanges;
300
+ });
301
+ useEffect(() => {
302
+ let alive = true;
303
+
304
+ void loadRef.current().then(
305
+ (files) => {
306
+ if (alive) setChanges(files);
307
+ },
308
+ () => {
309
+ if (alive) setChanges([]);
310
+ },
311
+ );
312
+
364
313
  return () => {
365
- live = false;
314
+ alive = false;
366
315
  };
367
- }, [content, lang]);
368
- const lines: CodeToken[][] = tokens ?? content.split("\n").map((l) => [{ content: l }]);
316
+ }, [props.reloadKey, props.mode]);
317
+
318
+ if (props.mode === "changes") {
319
+ return (
320
+ <ChangesFileTree
321
+ files={changes}
322
+ onSelectFile={props.onOpenChangedFile}
323
+ commentCounts={props.commentCounts}
324
+ focused={props.focused}
325
+ theme={props.theme}
326
+ />
327
+ );
328
+ }
369
329
  return (
370
- <box style={{ flexDirection: "column", marginTop: gap }}>
371
- <text>
372
- <span fg={isCursor ? T.accent : T.textDim}>{cursorGlyph(isCursor)}</span>
373
- <span fg={T.textDim}>{lang ?? "code"}</span>
374
- {annotated ? <span fg={T.accent}> ◆</span> : null}
375
- {block.type !== "same" ? <span fg={tagColor(block)}> [{tagLabel(block)}]</span> : null}
376
- </text>
377
- <box
378
- style={{
379
- flexDirection: "column",
380
- backgroundColor: T.elevated,
381
- paddingLeft: 2,
382
- paddingRight: 2,
383
- paddingTop: 1,
384
- paddingBottom: 1,
385
- marginLeft: 2,
386
- }}
387
- >
388
- {lines.map((line, li) => (
389
- <text key={li}>
390
- {line.length === 0 || (line.length === 1 && line[0]!.content === "") ? (
391
- " "
392
- ) : (
393
- line.map((tok, ti) => (
394
- <span key={ti} fg={tok.color ?? T.textMuted}>
395
- {tok.content}
396
- </span>
397
- ))
398
- )}
399
- </text>
400
- ))}
401
- </box>
402
- </box>
330
+ <ProjectTreeView
331
+ key={props.reloadKey}
332
+ loadFiles={props.loadProjectFiles}
333
+ onSelectFile={props.onOpenProjectFile}
334
+ focused={props.focused}
335
+ theme={props.theme}
336
+ />
403
337
  );
404
338
  }
405
339
 
406
- /** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
407
- function topGap(prev: DisplayBlock | undefined, cur: DisplayBlock): number {
408
- if (!prev) return 0;
409
- const tightPair =
410
- (cur.kind === "li" && prev.kind === "li") || (cur.kind === "oli" && prev.kind === "oli");
411
- return tightPair ? 0 : 1;
340
+ /** The render tree has left the connecting screen: an error, a session, or the no-thread shell. */
341
+ function usableScreenReached(
342
+ error: string | null,
343
+ session: Thread | null,
344
+ inbox: Thread[] | null,
345
+ ): boolean {
346
+ return Boolean(error) || session !== null || inbox !== null;
412
347
  }
413
348
 
414
- function Rail({
415
- session,
416
- focusedAnn,
417
- pendingCount,
418
- }: {
419
- session: ReviewSession;
420
- focusedAnn?: string;
421
- pendingCount: number;
422
- }): React.ReactNode {
423
- return (
424
- <box style={{ width: 34, backgroundColor: T.panel, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
425
- <text fg={T.textDim}>REVIEW ({pendingCount})</text>
426
- <text fg={T.textDim}>{session.workingCopy !== undefined ? "± plan edits → one diff" : " no direct edits"}</text>
427
- <text> </text>
428
- {session.annotations.length === 0 ? (
429
- <text fg={T.textDim}>no annotations yet</text>
430
- ) : (
431
- session.annotations.map((a) => <AnnCard key={a.id} a={a} focused={a.id === focusedAnn} />)
432
- )}
433
- <box style={{ flexGrow: 1 }} />
434
- <text fg={session.status === "resolved" ? T.green : T.accent}>
435
- {session.status === "resolved" ? `resolved: ${session.verdict!.kind.replace("_", " ")}` : `Submit review (${pendingCount}) ⏎`}
436
- </text>
437
- </box>
438
- );
349
+ /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
350
+ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
351
+ const base = keyBindings.cheatsheet();
352
+
353
+ if (!threadViewActive) {
354
+ return base;
355
+ }
356
+
357
+ return [
358
+ ...THREAD_VIEW_CHEATSHEET,
359
+ { title: "Nav mode · session", entries: sessionCommandEntries() },
360
+ { title: "Nav mode · diff", entries: diffCommandEntries() },
361
+ { title: "Nav mode · discussion", entries: curationCommandEntries() },
362
+ { title: "Nav mode · history", entries: treeCommandEntries() },
363
+ ];
439
364
  }
440
365
 
441
- function AnnCard({ a, focused }: { a: Annotation; focused: boolean }): React.ReactNode {
442
- const color = a.kind === "suggestion" ? T.green : T.accent;
443
- return (
444
- <box style={{ flexDirection: "column", marginBottom: 1 }}>
445
- <text fg={focused ? T.text : color}>
446
- {focused ? "▸ " : " "}
447
- {a.kind.toUpperCase()}
448
- </text>
449
- <text fg={T.textDim}> “{truncate(a.anchor.quote, 26)}”</text>
450
- <text fg={T.textMuted}> {truncate(a.body, 28)}</text>
451
- </box>
452
- );
366
+ /** The Threads sidebar opens from the launch layout; a bare launch opens it only on the inbox. */
367
+ function initialThreadsOpen(
368
+ layout: LaunchLayout | undefined,
369
+ sessionId: string | undefined,
370
+ ): boolean {
371
+ return layout ? layout.threads : sessionId === undefined;
453
372
  }
454
373
 
455
- function Inbox({ inbox, cursor }: { inbox: ReviewSession[]; cursor: number }): React.ReactNode {
456
- return (
457
- <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg, padding: 1 }}>
458
- <text fg={T.accent}>cueloop · inbox ({inbox.length} pending)</text>
459
- <text> </text>
460
- {inbox.length === 0 ? (
461
- <text fg={T.textDim}>nothing waiting for review</text>
462
- ) : (
463
- inbox.map((s, i) => (
464
- <text key={s.id} fg={i === cursor ? T.text : T.textMuted} bg={i === cursor ? T.cursorBg : undefined}>
465
- {i === cursor ? "▸ " : " "}
466
- {s.artifact.meta.title ?? s.id} · {s.workspace.branch} · {s.artifact.type}
467
- </text>
468
- ))
469
- )}
470
- <box style={{ flexGrow: 1 }} />
471
- <text fg={T.textDim}>j/k move · ⏎ open · q quit</text>
472
- </box>
473
- );
374
+ function initialFocusedPane(sessionId: string | undefined, zoomed: boolean): FocusPane {
375
+ if (sessionId === undefined) return "threads";
376
+
377
+ return zoomed ? "changes" : "thread";
474
378
  }
475
379
 
476
- function ComposeBar({
477
- mode,
478
- quote,
479
- onChange,
480
- }: {
481
- mode: Extract<Mode, { m: "compose" }>;
482
- quote: string;
483
- onChange: (text: string) => void;
484
- }): React.ReactNode {
485
- return (
486
- <box style={{ height: 2, backgroundColor: T.elevated, flexDirection: "column", paddingLeft: 1 }}>
487
- <text fg={mode.kind === "suggestion" ? T.green : T.accent}>
488
- {mode.kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncate(quote, 60)}” · ⏎ save · esc cancel
489
- </text>
490
- <input focused value={mode.text} onInput={onChange} />
491
- </box>
492
- );
380
+ function bareShellDefersKeys(
381
+ sessionIsNull: boolean,
382
+ overlayIsNone: boolean,
383
+ focusedPane: FocusPane,
384
+ ): boolean {
385
+ return sessionIsNull && overlayIsNone && focusedPane !== "threads";
493
386
  }
494
387
 
495
- function SubmitBar({
496
- mode,
497
- pendingCount,
498
- onChange,
499
- }: {
500
- mode: Extract<Mode, { m: "submit" }>;
501
- pendingCount: number;
502
- onChange: (summary: string) => void;
503
- }): React.ReactNode {
504
- return (
505
- <box style={{ height: 2, backgroundColor: T.elevated, flexDirection: "column", paddingLeft: 1 }}>
506
- <text>
507
- <span fg={T.textDim}>verdict ←/→ : </span>
508
- {VERDICTS.map((v, i) => (
509
- <span key={v} fg={v === mode.verdict ? verdictColor(v) : T.textDim}>
510
- {v === mode.verdict ? `[${VERDICT_LABEL[v]}]` : ` ${VERDICT_LABEL[v]} `}
511
- {i < VERDICTS.length - 1 ? " " : ""}
512
- </span>
513
- ))}
514
- <span fg={T.textDim}> · Submit review ({pendingCount}) on ⏎ · esc cancel</span>
515
- </text>
516
- <input focused value={mode.summary} onInput={onChange} placeholder="summary for the agent (optional)" />
517
- </box>
518
- );
388
+ function surfaceSuspended(base: boolean, focusedPane: FocusPane, pane: FocusPane): boolean {
389
+ return base || focusedPane !== pane;
519
390
  }
520
391
 
521
- function DiffPane({
522
- rows,
523
- cursor,
524
- annotations,
525
- focusedAnn,
526
- }: {
527
- rows: DiffRow[];
528
- cursor: number;
529
- annotations: Annotation[];
530
- focusedAnn?: string;
531
- }): React.ReactNode {
532
- // simple windowing: keep the cursor in view without a scroll dependency
533
- const windowStart = Math.max(0, cursor - 12);
534
- const visible = rows.slice(windowStart, windowStart + 200);
535
- const annotated = new Map<number, Annotation>();
536
- for (const a of annotations) {
537
- const idx = rows.findIndex((r) => r.text === a.anchor.quote && (r.t === "ctx" || r.t === "add" || r.t === "del"));
538
- if (idx !== -1) annotated.set(idx, a);
392
+ export function threadsNavHandled(params: {
393
+ focusedPane: FocusPane;
394
+ quiet: boolean;
395
+ key: { name: string };
396
+ count: number;
397
+ setInboxCursor: (update: (cursor: number) => number) => void;
398
+ openSession: () => void;
399
+ }): boolean {
400
+ const { focusedPane, quiet, key, count } = params;
401
+
402
+ if (focusedPane !== "threads" || !quiet || count === 0) return false;
403
+ if (key.name === "j" || key.name === "down") {
404
+ params.setInboxCursor((cursor) => Math.min(cursor + 1, count - 1));
405
+
406
+ return true;
539
407
  }
540
- return (
541
- <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
542
- {visible.map((row, vi) => {
543
- const i = windowStart + vi;
544
- const isCursor = i === cursor;
545
- const ann = annotated.get(i);
546
- const gutter = row.t === "del" ? (row.oldLine?.toString() ?? "") : (row.newLine?.toString() ?? "");
547
- const sign = row.t === "add" ? "+" : row.t === "del" ? "-" : " ";
548
- if (row.t === "file") {
549
- return (
550
- <text key={i} fg={T.text} bg={isCursor ? T.cursorBg : T.panel}>
551
- {isCursor ? "▎" : " "}■ {row.text}
552
- </text>
553
- );
554
- }
555
- if (row.t === "hunk") {
556
- return (
557
- <text key={i} fg={T.blue} bg={isCursor ? T.cursorBg : undefined}>
558
- {isCursor ? "▎" : " "}{row.text}
559
- </text>
560
- );
561
- }
562
- const fg = row.t === "add" ? T.insFg : row.t === "del" ? T.delFg : T.textMuted;
563
- return (
564
- <box key={i} style={{ flexDirection: "column" }}>
565
- <text bg={isCursor ? T.cursorBg : ann ? T.markCommentBg : undefined}>
566
- <span fg={T.textDim}>{isCursor ? "▎" : " "}{gutter.padStart(4)} </span>
567
- <span fg={fg}>{sign}{row.text}</span>
568
- </text>
569
- {ann ? (
570
- <text>
571
- <span fg={T.textDim}>{" "}</span>
572
- <span fg={ann.id === focusedAnn ? T.text : T.accent}>◆ {truncate(ann.body, 70)}</span>
573
- </text>
574
- ) : null}
575
- </box>
576
- );
577
- })}
578
- </box>
579
- );
580
- }
408
+ if (key.name === "k" || key.name === "up") {
409
+ params.setInboxCursor((cursor) => Math.max(cursor - 1, 0));
581
410
 
582
- function CompletionOverlay({
583
- verdict,
584
- completion,
585
- status,
586
- returnsTo,
587
- }: {
588
- verdict: VerdictKind;
589
- completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
590
- /** Latest status line (e.g. the vault-export path) stays visible here. */
591
- status: string;
592
- /** Where focus goes on close (the agent's herdr pane), when known. */
593
- returnsTo?: string;
594
- }): React.ReactNode {
595
- const approved = verdict === "approve";
596
- return (
597
- <box
598
- style={{
599
- width: "100%",
600
- height: "100%",
601
- backgroundColor: T.bg,
602
- flexDirection: "column",
603
- justifyContent: "center",
604
- alignItems: "center",
605
- }}
606
- >
607
- <text fg={approved ? T.green : T.accent}>{approved ? "✓ review approved" : "✎ feedback sent"}</text>
608
- <text> </text>
609
- <text fg={T.text}>The agent has your {approved ? "approval" : "feedback"} and is unblocked.</text>
610
- {status ? <text fg={T.textDim}>{status}</text> : null}
611
- <text> </text>
612
- {completion.phase === "counting" ? (
613
- <text fg={T.textDim}>
614
- closing in {completion.remaining}s{returnsTo ? ` - returning to ${returnsTo}` : ""} · ⏎ now · esc stay
615
- </text>
616
- ) : (
617
- <text fg={T.textDim}>⏎ close · a always close after submit (3s) · esc stay</text>
618
- )}
619
- </box>
620
- );
621
- }
411
+ return true;
412
+ }
413
+ if (key.name === "return" || key.name === "enter") {
414
+ params.openSession();
622
415
 
623
- // ── small helpers ─────────────────────────────
416
+ return true;
417
+ }
624
418
 
625
- function marker(d: DisplayBlock): string {
626
- if (d.kind === "li") return "- ";
627
- if (d.kind === "oli") return `${d.oliNum ?? 1}. `;
628
- if (d.kind === "quote") return "▏ ";
629
- return "";
419
+ return false;
630
420
  }
631
421
 
632
- function cursorGlyph(isCursor: boolean): string {
633
- return isCursor ? "▎ " : " ";
422
+ export function paneCycleRequested(
423
+ key: { name: string },
424
+ overlay: string,
425
+ menuOwnsKeyboard: boolean,
426
+ threadComposing: boolean,
427
+ ): boolean {
428
+ return key.name === "tab" && overlay === "none" && !menuOwnsKeyboard && !threadComposing;
634
429
  }
635
430
 
636
- function tagLabel(d: DisplayBlock): string {
637
- return d.type === "del" ? "cut" : d.type === "add" ? "new" : "edited";
638
- }
431
+ export function visiblePanes(
432
+ sidebarOpen: boolean,
433
+ threadShown: boolean,
434
+ changesOpen: boolean,
435
+ projectOpen: boolean,
436
+ ): FocusPane[] {
437
+ const panes: FocusPane[] = [];
438
+
439
+ if (sidebarOpen) panes.push("threads");
440
+ if (threadShown) panes.push("thread");
441
+ if (changesOpen) panes.push("changes");
442
+ if (projectOpen) panes.push("project");
639
443
 
640
- function tagColor(d: DisplayBlock): string {
641
- return d.type === "del" ? T.red : d.type === "add" ? T.green : T.accent;
444
+ return panes;
642
445
  }
643
446
 
644
- function verdictColor(v: VerdictKind): string {
645
- return v === "approve" ? T.green : v === "request_changes" ? T.red : T.blue;
447
+ export function nextFocusPane(
448
+ current: FocusPane,
449
+ panes: FocusPane[],
450
+ backward: boolean,
451
+ ): FocusPane {
452
+ if (panes.length === 0) return current;
453
+ const index = panes.indexOf(current);
454
+ const step = backward ? -1 : 1;
455
+
456
+ return panes[(index + step + panes.length) % panes.length] ?? current;
646
457
  }
647
458
 
648
- function defaultVerdict(s: ReviewSession): VerdictKind {
649
- return s.annotations.length || s.workingCopy !== undefined ? "request_changes" : "approve";
459
+ /** The panes that can own the keyboard: a diff's Thread pane is an inert placeholder, so it is out. */
460
+ function navigableFocusPanes(
461
+ sidebarOpen: boolean,
462
+ zoomed: boolean,
463
+ isDiff: boolean,
464
+ changesOpen: boolean,
465
+ projectOpen: boolean,
466
+ ): FocusPane[] {
467
+ return visiblePanes(sidebarOpen, !zoomed && !isDiff, changesOpen, projectOpen);
650
468
  }
651
469
 
652
- function runStyle(r: StyleRun, d: DisplayBlock): { fg?: string; bg?: string; attributes?: number } {
653
- const headingFg = d.kind === "h1" ? T.text : d.kind === "h2" || d.kind === "h3" ? T.accent : undefined;
654
- const struck = d.type === "del";
655
- switch (r.role) {
656
- case "ins":
657
- return { fg: T.insFg };
658
- case "del":
659
- return { fg: T.delFg };
660
- case "mark-comment":
661
- return { fg: T.text, bg: T.markCommentBg };
662
- case "mark-suggestion":
663
- return { fg: T.text, bg: T.markSuggestionBg };
664
- case "mark-focus":
665
- return { fg: T.accentInk, bg: T.accent };
666
- case "kspan":
667
- return { fg: T.accentInk, bg: T.accent };
668
- default:
669
- return { fg: struck ? T.red : (headingFg ?? (d.kind === "code" ? T.textMuted : T.text)) };
670
- }
470
+ /** The layout hid or inert-ed the focused pane: fall back to the first navigable one, else stay put. */
471
+ export function reconciledFocus(navigable: FocusPane[], focusedPane: FocusPane): FocusPane | null {
472
+ if (navigable.length === 0 || navigable.includes(focusedPane)) return null;
473
+
474
+ return navigable[0] ?? null;
671
475
  }
672
476
 
673
- function truncate(s: string, n: number): string {
674
- const one = s.replace(/\n/g, " ");
675
- return one.length > n ? one.slice(0, n - 1) + "…" : one;
477
+ export function App({
478
+ home,
479
+ sessionId,
480
+ cwd,
481
+ readOnly = false,
482
+ onExit,
483
+ onReady,
484
+ clock,
485
+ openClient,
486
+ shareTransport,
487
+ role = "owner",
488
+ selfAuthor,
489
+ appearance = "dark",
490
+ layout,
491
+ servedArtifact,
492
+ }: AppProps): React.ReactNode {
493
+ const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
494
+ const controller = useMemo(
495
+ () =>
496
+ createReviewController({
497
+ home,
498
+ sessionId,
499
+ cwd,
500
+ readOnly: observer,
501
+ onExit,
502
+ clock,
503
+ openClient,
504
+ shareTransport,
505
+ servedArtifact,
506
+ }),
507
+ // eslint-disable-next-line react-hooks/exhaustive-deps
508
+ [home, sessionId],
509
+ );
510
+
511
+ useEffect(() => {
512
+ controller.connect();
513
+
514
+ return () => controller.close();
515
+ }, [controller]);
516
+ // stable across renders so the memoized PrototypeContentView is not
517
+ // re-rendered by unrelated App state (status ticks, a rail-width drag)
518
+ const onCommentPrototype = useCallback(
519
+ (element: PrototypeElement, body: string) =>
520
+ controller.annotatePrototype(element.selector, element.quote, body),
521
+ [controller],
522
+ );
523
+ const { session, inbox, status, toast, error, completion, editOrphanCount, walk } =
524
+ useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
525
+
526
+ // A shared plan you own polls for collaborator notes while it is open; the
527
+ // merge refreshes through the normal event path. Stops on leave.
528
+ const threadShared = isSharedThread(session);
529
+ useEffect(() => {
530
+ if (!isOwner || !threadShared) return;
531
+
532
+ return controller.startShareSync();
533
+ }, [isOwner, session?.id, threadShared, controller]);
534
+ const renderer = useRenderer();
535
+ const { width: terminalWidth } = useTerminalDimensions();
536
+
537
+ // ── view state ──────────────────────────────
538
+ const [cursor, setCursor] = useState(0);
539
+ const [inboxCursor, setInboxCursor] = useState(0);
540
+ // Projects and Threads grouping; ordered is the flat sequence the inbox cursor walks
541
+ // pinned threads are client-local view state, seeded from the user config and
542
+ // persisted on toggle; a pin lifts the thread into the sidebar's Pinned section
543
+ const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set());
544
+ const grouped = useMemo(() => groupInbox(inbox ?? [], pinnedIds), [inbox, pinnedIds]);
545
+ const togglePin = (id: string): void =>
546
+ setPinnedIds((current) => {
547
+ const next = new Set(current);
548
+
549
+ if (next.has(id)) next.delete(id);
550
+ else next.add(id);
551
+ persistPins([...next]);
552
+
553
+ return next;
554
+ });
555
+ // the left Projects and Threads column; collapsed by default per the context
556
+ // matrix (the thread owns the width), toggled open to jump between threads
557
+ // one sidebar-open state across the no-thread shell and the thread view, so
558
+ // picking a thread keeps the sidebar as it was. It opens when the app lands with
559
+ // nothing selected (pick a thread) and stays collapsed on a direct thread open,
560
+ // where the thread owns the width.
561
+ const [sidebarOpen, setSidebarOpen] = useState(() => initialThreadsOpen(layout, sessionId));
562
+ // the Changes + Project right region and its editor grid (tabs, splits, zoom)
563
+ const workbench = useChangesWorkbench({ layout });
564
+ const [focusedPane, setFocusedPane] = useState<FocusPane>(() =>
565
+ initialFocusedPane(sessionId, workbench.zoomed),
566
+ );
567
+ // only the session view persists from here; the bare shell owns its own (NoThreadShell), so this
568
+ // workbench stays inactive with no session and never clobbers what the shell saved
569
+ useRememberLayout(
570
+ layout,
571
+ session !== null,
572
+ sidebarOpen,
573
+ workbench.changesOpen,
574
+ workbench.projectOpen,
575
+ workbench.zoomed,
576
+ );
577
+ const [mode, setMode] = useState<Mode>({ type: "normal" });
578
+ // the top-left settings gear drop-down and the centered dialog it opens
579
+ const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
580
+ const [shareDialogOpen, setShareDialogOpen] = useState(false);
581
+ const [autoClose, setAutoClose] = useState<AutoClose>("off");
582
+ // unified or side-by-side diff; split only lays out when the Changes pane is zoomed
583
+ const [diffView, setDiffView] = useState<DiffViewMode>("split");
584
+ const [defaultMessage, setDefaultMessage] = useState<MessageOutcome>("approved");
585
+ const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
586
+ const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
587
+ const [railTab, setRailTab] = useState<RailTab>("review");
588
+ // the tree row the reviewer stands on in the session tree
589
+ const [selectedEntryId, setSelectedEntryId] = useState<string | undefined>(undefined);
590
+ // ~2s focus pulse on the document highlight when a card is activated
591
+ const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
592
+ const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
593
+ // live mirror of overlay input text: refs commit synchronously, so the
594
+ // RETURN handler never reads a stale value mid-typing
595
+ const liveInput = useRef("");
596
+ // keymap from layered config; the loaded theme swaps the provider value
597
+ const keysRef = useRef(DEFAULT_KEYS);
598
+ const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
599
+ const [theme, setTheme] = useState(() => themeForName(DEFAULT_THEME_NAME, appearance));
600
+ const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
601
+ const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
602
+ const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
603
+ const [identity, setIdentity] = useState<IdentityConfig>({ provider: "typed" });
604
+ const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
605
+ const [skills, setSkills] = useState<SlashItem[]>([]);
606
+ const paletteNames = useMemo(
607
+ () => new Set(mergeSlashItems(slashItemsFrom(quickActions), skills).map((item) => item.name)),
608
+ [quickActions, skills],
609
+ );
610
+ // opt-in: render a prototype as a kitty pixel mockup instead of the markdown doc
611
+ const [prototypePixels, setPrototypePixels] = useState(false);
612
+
613
+ useEffect(() => {
614
+ const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
615
+
616
+ setPrototypePixels(config.experimental.prototypePixels);
617
+ keysRef.current = config.keys;
618
+ keyBindings.setKeys(config.keys);
619
+ setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
620
+ setThemeName(config.ui.theme);
621
+ setThemeOverrides(config.themeOverrides);
622
+ setAuthorNames(config.authors);
623
+ setIdentity(config.identity);
624
+ setQuickActions(config.actions);
625
+ setSkills(loadSkills(config.skillsPath));
626
+ setAutoClose(config.ui.autoClose);
627
+ setDiffView(config.ui.diffView);
628
+ setDefaultMessage(config.ui.defaultMessage);
629
+ setPinnedIds(new Set(config.ui.pins));
630
+ controller.applyConfig(config);
631
+ }, [session?.workspace.repoRoot, controller, keyBindings, appearance]);
632
+ useEffect(
633
+ () => () => {
634
+ if (pulseTimer.current) clearTimeout(pulseTimer.current);
635
+ },
636
+ [],
637
+ );
638
+
639
+ useEffect(() => {
640
+ if (!toast) return;
641
+ const timer = setTimeout(() => controller.dismissToast(), TOAST_DISMISS_MS);
642
+
643
+ return () => clearTimeout(timer);
644
+ }, [toast, controller]);
645
+
646
+ // First open of a share: ask the collaborator for a display name once, unless
647
+ // a past visit already recorded one. esc skips and their notes read anonymous.
648
+ const promptedSelfRef = useRef(false);
649
+
650
+ useEffect(() => {
651
+ if (promptedSelfRef.current || role !== "collaborator" || !selfAuthor || !session) return;
652
+ promptedSelfRef.current = true;
653
+ const known = session.participants?.find((participant) => participant.id === selfAuthor)?.name;
654
+
655
+ if (!known) setMode({ type: "nameSelf", text: "" });
656
+ }, [role, selfAuthor, session]);
657
+
658
+ // Bumped on every identity change so a slow GitHub sync that lands after a newer rename is discarded.
659
+ const identityGenerationRef = useRef(0);
660
+ const applyIdentity = (next: IdentityConfig): void => {
661
+ identityGenerationRef.current += 1;
662
+ setIdentity(next);
663
+ try {
664
+ persistIdentity(next);
665
+ } catch {
666
+ controller.setStatus("could not save identity to config");
667
+ }
668
+ };
669
+ const syncGithubIdentity = (): void => {
670
+ const generation = (identityGenerationRef.current += 1);
671
+ void resolveGithubIdentity().then((github) => {
672
+ if (identityGenerationRef.current !== generation) return;
673
+ if (!github) return controller.setStatus("GitHub not connected - run gh auth login");
674
+ const name = github.name?.trim() || github.login;
675
+
676
+ applyIdentity({ name, provider: "github" });
677
+ controller.setStatus(`identity synced from GitHub - ${name}`);
678
+ });
679
+ };
680
+
681
+ // ── settings dialog: config-backed model, navigation, persistence ──
682
+ const {
683
+ settingsNav,
684
+ settingsCategories,
685
+ settingsValues,
686
+ cycleSetting,
687
+ handleSettingsKey,
688
+ onCategorySelect,
689
+ openSettings,
690
+ } = useSettingsDialog({
691
+ theme,
692
+ appearance,
693
+ autoClose,
694
+ setAutoClose,
695
+ diffView,
696
+ setDiffView,
697
+ themeName,
698
+ setThemeName,
699
+ themeOverrides,
700
+ setTheme,
701
+ quickActions,
702
+ setQuickActions,
703
+ setMenuDialog,
704
+ identityName: identity.name,
705
+ identityProvider: identity.provider,
706
+ onSyncGithubIdentity: syncGithubIdentity,
707
+ onRenameDisplayName: () => {
708
+ setMenuDialog(null);
709
+ setMode({ type: "renameSelf", text: identity.name ?? "" });
710
+ },
711
+ });
712
+
713
+ // ── derived view model ──────────────────────
714
+ const display = controller.display();
715
+ const rows = controller.rows();
716
+ const rejectedRows = controller.rejectedRows();
717
+ const marks = useMemo(
718
+ () =>
719
+ session
720
+ ? marksByDisplay(
721
+ // the plan thread shows only artifact notes; Changes-diff notes render on that surface
722
+ session.annotations.filter(
723
+ (annotation) => annotationTarget(annotation).kind === "artifact",
724
+ ),
725
+ display,
726
+ pulsedAnnotationId ?? undefined,
727
+ )
728
+ : new Map<number, Mark[]>(),
729
+ [session, display, pulsedAnnotationId],
730
+ );
731
+ /** Annotation ids whose anchor resolved against the working copy. */
732
+ const resolvedIds = useMemo(() => {
733
+ const ids = new Set<string>();
734
+
735
+ for (const blockMarks of marks.values()) {
736
+ for (const mark of blockMarks) if (mark.annotationId) ids.add(mark.annotationId);
737
+ }
738
+
739
+ return ids;
740
+ }, [marks]);
741
+ const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
742
+ // a prototype is a markdown design doc by default; only the opt-in experimental
743
+ // pixel mode (flag on + an HTML entry) renders it as a kitty mockup instead
744
+ const isPixelPrototype = isPixelPrototypeMode(
745
+ isPrototype,
746
+ prototypePixels,
747
+ session?.artifact.meta.prototypePath,
748
+ );
749
+ // comments per changed-file path, for the changed-files tree and tab badges
750
+ const diffCommentCounts = useMemo(
751
+ () => (session && isDiff ? commentCountsByFile(session, rows) : undefined),
752
+ [session, isDiff, rows],
753
+ );
754
+ // entering a diff opens the right region in changed-files mode; a plan or reply opens it closed
755
+ workbench.syncSession(session?.id, isDiff);
756
+ // plans, replies, and the default (markdown) prototype open in the thread view, diffs in the
757
+ // diff sheet: both drive the shared annotation surface; only the pixel prototype keeps the keymap
758
+ const threadViewActive = session !== null && !isPixelPrototype;
759
+ const [threadComposing, setThreadComposing] = useState(false);
760
+ const [prototypeComposing, setPrototypeComposing] = useState(false);
761
+ const [welcomeComposing, setWelcomeComposing] = useState(false);
762
+ // inline body edit: the markdown editor owns the thread pane and all keys while open
763
+ const canEditBody = canEditThreadBody(threadViewActive, isDiff, isOwner, resolved);
764
+ const bodyEditing = useThreadBodyEditing({
765
+ controller,
766
+ sessionId: session?.id,
767
+ canEdit: canEditBody,
768
+ });
769
+ // sort position per annotation so the rail interleaves annotation and removal
770
+ // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
771
+ // annotation resolves to the display index it marked
772
+ // ── the guided walk's view model ────────────
773
+ const walkFileList = controller.files();
774
+ const walking = isWalking(isDiff, walk);
775
+ const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
776
+
777
+ // ── selection symmetry: one selected id, both sides ──
778
+ const selectCardFromDocument = (annotationId: string): void => {
779
+ setFocusedAnnotationId(annotationId);
780
+ };
781
+
782
+ const openCardEdit = (annotationId: string): void => {
783
+ if (observer) return controller.setStatus("observer - read-only");
784
+ const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
785
+
786
+ if (!annotation) return;
787
+ // a collaborator's note is theirs to word: activating it (click or e) renames
788
+ // the author rather than editing the body the planner does not own
789
+ if (annotation.author) {
790
+ setFocusedAnnotationId(annotationId);
791
+
792
+ return void setMode({
793
+ type: "rename",
794
+ authorId: annotation.author,
795
+ text: authorNames[annotation.author] ?? "",
796
+ });
797
+ }
798
+ if (resolved) return controller.setStatus("review submitted - read-only");
799
+ liveInput.current = annotation.body;
800
+ setMode({ type: "railEdit", id: annotation.id, text: annotation.body });
801
+ };
802
+
803
+ // a markdown thread edits inline; a diff is not markdown, so it keeps the external-editor hand-off
804
+ const openBodyEditor = (): void => {
805
+ if (canEditBody) {
806
+ bodyEditing.openEditor();
807
+
808
+ return;
809
+ }
810
+ renderer?.suspend();
811
+ try {
812
+ controller.edit();
813
+ } finally {
814
+ renderer?.resume();
815
+ }
816
+ };
817
+
818
+ // ── keyboard grammar: build state, reduce, dispatch ──
819
+ const dispatch = createIntentDispatch({
820
+ controller,
821
+ onExit,
822
+ isDiff,
823
+ display,
824
+ rows,
825
+ cursor,
826
+ inbox: inbox === null ? null : grouped.ordered,
827
+ inboxCursor,
828
+ mode,
829
+ session,
830
+ defaultMessage,
831
+ focusedAnnotationId,
832
+ selectedCurationId,
833
+ railTab,
834
+ selectedEntryId,
835
+ authorNames,
836
+ quickActions,
837
+ renameAuthor: (id: string, name: string) => {
838
+ persistAuthorName(id, name);
839
+ setAuthorNames((prev) => ({ ...prev, [id]: name }));
840
+ },
841
+ renameThread: (id: string, title: string) => controller.renameSession(id, title),
842
+ setLocalIdentityName: (name: string) => {
843
+ applyIdentity(name ? { name, provider: "typed" } : { provider: "typed" });
844
+ },
845
+ liveInput,
846
+ setCursor,
847
+ setInboxCursor,
848
+ setMode,
849
+ setRailTab,
850
+ setSelectedEntryId,
851
+ setFocusedAnnotationId,
852
+ setSelectedCurationId,
853
+ setPulsedAnnotationId,
854
+ selectCardFromDocument,
855
+ openBodyEditor,
856
+ openCardEdit,
857
+ toggleDiffView: () => {
858
+ const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
859
+
860
+ setDiffView(next);
861
+ persistDiffView(next);
862
+ // every toggle names the new mode; picking split on a narrow pane also says it needs the wide layout
863
+ if (next === "stacked") controller.setStatus("stacked diff");
864
+ else if (workbench.zoomed) controller.setStatus("split diff");
865
+ else controller.setStatus("split diff shows when zoomed");
866
+ },
867
+ openShareDialog: () => {
868
+ if (!isOwner) return controller.setStatus("only the plan owner can share");
869
+ shareDialogStore.getState().reset();
870
+ setShareDialogOpen(true);
871
+ },
872
+ });
873
+
874
+ const overlay = resolveOverlay(mode, completion.phase, walking);
875
+
876
+ const menuControl = useMenuControlState();
877
+ const menuOwnsKeyboard = keyboardHeldByMenu(menuDialog, menuControl.openMenuId);
878
+ // an overlay (submit, walk, prompt, confirm, share choice), the manage-access
879
+ // dialog, or the menu takes the keyboard from the thread view; the view suspends
880
+ // its own grammar meanwhile so typing lands in the dialog, not the thread
881
+ const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay, shareDialogOpen);
882
+
883
+ const navigablePanes = navigableFocusPanes(
884
+ sidebarOpen,
885
+ workbench.zoomed,
886
+ isDiff,
887
+ workbench.changesOpen,
888
+ workbench.projectOpen,
889
+ );
890
+ useEffect(() => {
891
+ // the bare shell drives its panes from its own workbench, so only reconcile against this one
892
+ if (!session) return;
893
+ const next = reconciledFocus(navigablePanes, focusedPane);
894
+
895
+ if (next) setFocusedPane(next);
896
+ }, [session, navigablePanes, focusedPane]);
897
+ const cyclePanes = (backward: boolean): void =>
898
+ setFocusedPane((current) => nextFocusPane(current, navigablePanes, backward));
899
+ const runNavCommand = (key: { name: string; shift?: boolean }): boolean => {
900
+ const intent = resolveNavKey(key, {
901
+ isOwner,
902
+ resolved,
903
+ treeActive: railTab === "tree",
904
+ isDiff,
905
+ });
906
+
907
+ if (!intent) return false;
908
+ dispatch(intent);
909
+
910
+ return true;
911
+ };
912
+ // clicking a sidebar thread moves the cursor onto it too, so the row shows its selected backdrop at once
913
+ const openThread = (id: string): void => {
914
+ // save the open editor into the leaving thread's working copy before the switch, so a click away never drops edits
915
+ if (bodyEditing.editing) bodyEditing.requestExit();
916
+ const index = grouped.ordered.findIndex((thread) => thread.id === id);
917
+
918
+ if (index >= 0) setInboxCursor(() => index);
919
+ controller.open(id);
920
+ };
921
+
922
+ const threadSurfaceHandledKey = (key: KeyEvent): boolean => {
923
+ if (!threadViewActive || threadViewSuspended) return false;
924
+ if (!threadComposing) {
925
+ const chord = resolveSessionChord(key, { isOwner, resolved });
926
+
927
+ if (chord) dispatch(chord);
928
+ }
929
+
930
+ return true;
931
+ };
932
+
933
+ useKeyboard((key) => {
934
+ if (quitKeyHandled(key, onExit)) return;
935
+ // the inline body editor owns the pane and every key while open
936
+ if (bodyEditing.editing) return;
937
+ // the share dialog owns its own keys while open; the shell grammar stands down
938
+ if (shareDialogOpen) return;
939
+ if (menuModalHandled(menuControl, key)) return;
940
+ if (paneCycleRequested(key, overlay, menuOwnsKeyboard, threadComposing)) {
941
+ return cyclePanes(Boolean(key.shift));
942
+ }
943
+ if (
944
+ threadsNavHandled({
945
+ focusedPane,
946
+ quiet: overlay === "none" && !menuOwnsKeyboard && !threadComposing,
947
+ key,
948
+ count: grouped.ordered.length,
949
+ setInboxCursor,
950
+ openSession: () => dispatch({ type: "openSession" }),
951
+ })
952
+ )
953
+ return;
954
+ if (threadSurfaceHandledKey(key)) return;
955
+ // A compose textarea owns the keyboard while open: let it receive the typed note instead of the
956
+ // global keymap acting on each letter (the prototype, and the bare-shell welcome playground).
957
+ if (prototypeComposing || welcomeComposing) return;
958
+ if (menuDialog === "settings") return void handleSettingsKey(key.name);
959
+ if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
960
+ // the toast is non-modal: escape only dismisses it when nothing else owns
961
+ // escape, so an open overlay (compose, submit, prompt, walk) still cancels
962
+ if (toastDismissRequested(toast !== null, key, overlay, mode.type === "span"))
963
+ return controller.dismissToast();
964
+ if (bareShellDefersKeys(session === null, overlay === "none", focusedPane)) return;
965
+ const state = buildKeyState({
966
+ keys: keysRef.current,
967
+ observer,
968
+ isOwner,
969
+ overlay,
970
+ session,
971
+ isDiff,
972
+ mode,
973
+ resolved,
974
+ inbox,
975
+ focusedAnnotationId,
976
+ walk,
977
+ walkFileList,
978
+ rows,
979
+ cursor,
980
+ display,
981
+ });
982
+
983
+ keyBindings.setContext({
984
+ overlay: state.overlay,
985
+ spanMode: state.spanMode,
986
+ });
987
+ const action = keyBindings.resolveAction({
988
+ name: key.name,
989
+ shift: !!key.shift,
990
+ });
991
+
992
+ for (const intent of reduceKey(
993
+ state,
994
+ { name: key.name, shift: !!key.shift, meta: !!key.meta },
995
+ action,
996
+ ))
997
+ dispatch(intent);
998
+ });
999
+
1000
+ // after every keyboard hook above, so their subscriptions precede the signal
1001
+ useReadySignal(usableScreenReached(error, session, inbox), onReady);
1002
+
1003
+ // ── shared bottom chrome: the menu bar and its drop-up dialogs, one render
1004
+ // reused by the inbox and by plan/diff review so the two never drift ──
1005
+ const menuChrome = (
1006
+ <MenuChrome
1007
+ menuDialog={menuDialog}
1008
+ theme={theme}
1009
+ keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
1010
+ settingsCategories={settingsCategories}
1011
+ settingsValues={settingsValues}
1012
+ settingsNav={settingsNav}
1013
+ onCategorySelect={onCategorySelect}
1014
+ cycleSetting={cycleSetting}
1015
+ onClose={() => setMenuDialog(null)}
1016
+ />
1017
+ );
1018
+
1019
+ // ── render ──────────────────────────────────
1020
+ if (error) return <ErrorScreen error={error} theme={theme} />;
1021
+ if (!session)
1022
+ return inbox ? (
1023
+ <SlashSkillsContext.Provider value={skills}>
1024
+ <PaletteNamesContext.Provider value={paletteNames}>
1025
+ <NoThreadShell
1026
+ rows={grouped.rows}
1027
+ inboxCursor={inboxCursor}
1028
+ onOpenThread={openThread}
1029
+ mode={mode}
1030
+ theme={theme}
1031
+ controller={controller}
1032
+ setMode={setMode}
1033
+ menuChrome={menuChrome}
1034
+ onOpenMenu={openSettings}
1035
+ sidebarOpen={sidebarOpen}
1036
+ onToggleSidebar={() => setSidebarOpen((open) => !open)}
1037
+ focusedPane={focusedPane}
1038
+ onFocusPane={setFocusedPane}
1039
+ menuControl={menuControl}
1040
+ pinnedIds={pinnedIds}
1041
+ onPin={togglePin}
1042
+ onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
1043
+ quickActions={quickActions}
1044
+ onWelcomeComposingChange={setWelcomeComposing}
1045
+ layout={layout}
1046
+ />
1047
+ </PaletteNamesContext.Provider>
1048
+ </SlashSkillsContext.Provider>
1049
+ ) : (
1050
+ <ConnectingScreen theme={theme} />
1051
+ );
1052
+
1053
+ const activeSession = session;
1054
+ const resolveAuthorLabel = authorLabelResolver(
1055
+ identity.name,
1056
+ activeSession.participants,
1057
+ authorNames,
1058
+ );
1059
+
1060
+ if (isCompletionOverlayPhase(completion) && activeSession.message)
1061
+ return (
1062
+ <CompletionScreen
1063
+ theme={theme}
1064
+ session={activeSession}
1065
+ message={activeSession.message.outcome}
1066
+ completion={completion}
1067
+ status={status}
1068
+ onClose={() => dispatch({ type: "finishReview" })}
1069
+ onBackToPlan={() => dispatch({ type: "dismissCompletion" })}
1070
+ onAlways={() => dispatch({ type: "optInAutoClose" })}
1071
+ />
1072
+ );
1073
+
1074
+ const diffFold: DiffFoldControls = {
1075
+ isCollapsed: (file) => controller.isFileCollapsed(file),
1076
+ isExpanded: (file) => controller.isFileExpanded(file),
1077
+ canExpand: (file) => controller.canExpandFile(file),
1078
+ onToggleCollapse: (file) =>
1079
+ controller.setFileCollapsed(file, !controller.isFileCollapsed(file)),
1080
+ onToggleExpand: (file) => controller.setFileExpanded(file, !controller.isFileExpanded(file)),
1081
+ onCopyPath: (file) => controller.copyFilePath(file),
1082
+ };
1083
+ const submitConfirmState = buildSubmitConfirmState({
1084
+ mode,
1085
+ isDiff,
1086
+ session: activeSession,
1087
+ walkFileList,
1088
+ viewedPaths,
1089
+ quickActions,
1090
+ liveInput,
1091
+ setMode,
1092
+ dispatch,
1093
+ });
1094
+ const { showOwnerActions, prototypeCanComment, chromeHidden, prototypePath } = buildRenderFlags({
1095
+ session: activeSession,
1096
+ isOwner,
1097
+ isDiff,
1098
+ isPixelPrototype,
1099
+ resolved,
1100
+ menuDialog,
1101
+ resolvedIds,
1102
+ });
1103
+
1104
+ const onEditRequest = (): void => {
1105
+ // A share viewer/observer has no Edit affordance (the button is hidden), so
1106
+ // this is owner-only; stay silent rather than nag if it is ever reached.
1107
+ if (!isOwner) return;
1108
+ if (resolved) return controller.setStatus("review submitted - read-only");
1109
+ openBodyEditor();
1110
+ };
1111
+
1112
+ // clicking the rail Submit button: same read-only answer as the submit key
1113
+ const onSubmitRequest = (): void => {
1114
+ if (observer) return controller.setStatus("observer - read-only");
1115
+ if (!isOwner) return controller.setStatus("shared view - your notes save as you go");
1116
+ if (resolved) return;
1117
+ dispatch({ type: "openSubmit" });
1118
+ };
1119
+
1120
+ const threadFooter = (
1121
+ <ThreadFooter
1122
+ repo={projectName(activeSession.workspace)}
1123
+ branch={activeSession.workspace.branch}
1124
+ onSubmit={onSubmitRequest}
1125
+ canSubmit={canSubmitReview(isOwner, resolved, observer)}
1126
+ pendingAnnotations={
1127
+ activeSession.annotations.filter(
1128
+ (annotation) => !isAddressed(annotation) && !isAgentNote(annotation),
1129
+ ).length
1130
+ }
1131
+ theme={theme}
1132
+ />
1133
+ );
1134
+
1135
+ return (
1136
+ <SlashSkillsContext.Provider value={skills}>
1137
+ <PaletteNamesContext.Provider value={paletteNames}>
1138
+ <ThemeProvider theme={theme}>
1139
+ <MenuControlProvider value={menuControl}>
1140
+ <AppShell
1141
+ sidebarOpen={sidebarOpen}
1142
+ onToggleSidebar={() => setSidebarOpen((open) => !open)}
1143
+ onOpenMenu={openSettings}
1144
+ onFocusPane={setFocusedPane}
1145
+ threadsPanel={
1146
+ <scrollbox style={{ flexGrow: 1 }} focused={false}>
1147
+ <ThreadTree
1148
+ rows={grouped.rows}
1149
+ cursor={inboxCursor}
1150
+ activeId={activeSession.id}
1151
+ focused={focusedPane === "threads"}
1152
+ pinnedIds={pinnedIds}
1153
+ width={30}
1154
+ onSelect={openThread}
1155
+ onPin={togglePin}
1156
+ onRename={(id, title) =>
1157
+ setMode({ type: "renameThread", sessionId: id, text: title })
1158
+ }
1159
+ onRequestDelete={(id, title) =>
1160
+ setMode({ type: "confirmDelete", sessionId: id, title })
1161
+ }
1162
+ theme={theme}
1163
+ />
1164
+ </scrollbox>
1165
+ }
1166
+ threadTitle={threadTitle(activeSession)}
1167
+ threadActions={
1168
+ showOwnerActions
1169
+ ? ownerThreadActions({
1170
+ editing: bodyEditing.editing,
1171
+ onEdit: onEditRequest,
1172
+ onExitEdit: bodyEditing.requestExit,
1173
+ onShare: () => dispatch({ type: "share" }),
1174
+ theme,
1175
+ })
1176
+ : undefined
1177
+ }
1178
+ threadPanel={
1179
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
1180
+ <box style={{ flexGrow: 1, flexDirection: "row" }}>
1181
+ {chooseThreadBody({
1182
+ isPixelPrototype,
1183
+ isDiff,
1184
+ editingBody: bodyEditing.editing,
1185
+ prototype: (
1186
+ <PrototypePixels
1187
+ prototypePath={prototypePath}
1188
+ canComment={prototypeCanComment}
1189
+ onCommentElement={onCommentPrototype}
1190
+ onComposingChange={setPrototypeComposing}
1191
+ hidden={chromeHidden}
1192
+ />
1193
+ ),
1194
+ diffPlaceholder: (
1195
+ <box
1196
+ style={{
1197
+ flexGrow: 1,
1198
+ flexDirection: "column",
1199
+ alignItems: "center",
1200
+ justifyContent: "center",
1201
+ }}
1202
+ >
1203
+ <text fg={theme.textDim}>Select a thread</text>
1204
+ </box>
1205
+ ),
1206
+ editor: bodyEditing.renderEditor(theme),
1207
+ threadView: (
1208
+ <ThreadView
1209
+ session={activeSession}
1210
+ suspended={surfaceSuspended(threadViewSuspended, focusedPane, "thread")}
1211
+ editOrphanCount={editOrphanCount}
1212
+ onComposingChange={setThreadComposing}
1213
+ onNavCommand={runNavCommand}
1214
+ resolved={resolved}
1215
+ onObserverBlocked={(reason) =>
1216
+ controller.setStatus(
1217
+ reason === "observer"
1218
+ ? "observer - read-only"
1219
+ : "review submitted - read-only",
1220
+ )
1221
+ }
1222
+ onCursorChange={setCursor}
1223
+ focusedAnnotationId={focusedAnnotationId}
1224
+ onFocusAnnotation={setFocusedAnnotationId}
1225
+ display={display}
1226
+ marks={marks}
1227
+ quickActions={quickActions}
1228
+ observer={observer}
1229
+ onAnnotate={(span, body) =>
1230
+ void controller.annotate(
1231
+ "comment",
1232
+ span.start.blockIndex,
1233
+ span.start.char,
1234
+ span.end.char,
1235
+ body,
1236
+ span.end.blockIndex,
1237
+ )
1238
+ }
1239
+ onReply={(rootAnnotationId, body) =>
1240
+ void controller.reply(rootAnnotationId, body)
1241
+ }
1242
+ onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
1243
+ resolveAuthorLabel={resolveAuthorLabel}
1244
+ onExit={() => onExit?.(0)}
1245
+ />
1246
+ ),
1247
+ })}
1248
+ </box>
1249
+ {threadFooter}
1250
+ </box>
1251
+ }
1252
+ changesOpen={workbench.changesOpen}
1253
+ projectOpen={workbench.projectOpen}
1254
+ onToggleChanges={workbench.toggleChanges}
1255
+ onToggleProject={workbench.toggleProject}
1256
+ onToggleRight={workbench.toggleRight}
1257
+ projectMode={workbench.projectMode}
1258
+ zoomHideThread={workbench.zoomed}
1259
+ changesFooter={threadFooter}
1260
+ changesPanel={
1261
+ <EditorGrid
1262
+ tree={workbench.grid}
1263
+ focusedGroupId={workbench.activeGroup}
1264
+ commentCounts={diffCommentCounts}
1265
+ onFocusGroup={workbench.focusGroup}
1266
+ onActivateTab={workbench.activate}
1267
+ onCloseTab={workbench.close}
1268
+ onSplit={workbench.split}
1269
+ onZoom={workbench.toggleZoom}
1270
+ zoomed={workbench.zoomed}
1271
+ renderTab={(tab, groupFocused) => (
1272
+ <GridTabContent
1273
+ tab={tab}
1274
+ rows={rows}
1275
+ surface={{
1276
+ session: activeSession,
1277
+ quickActions,
1278
+ observer,
1279
+ commentsEnabled: true,
1280
+ resolved,
1281
+ suspended: surfaceSuspended(
1282
+ threadViewSuspended || !groupFocused,
1283
+ focusedPane,
1284
+ "changes",
1285
+ ),
1286
+ onComposingChange: setThreadComposing,
1287
+ onObserverBlocked: (reason) =>
1288
+ controller.setStatus(
1289
+ reason === "observer"
1290
+ ? "observer - read-only"
1291
+ : "review submitted - read-only",
1292
+ ),
1293
+ onCursorChange: setCursor,
1294
+ focusedAnnotationId,
1295
+ onFocusAnnotation: setFocusedAnnotationId,
1296
+ // a diff thread's Changes view is the artifact itself; any other thread's is the
1297
+ // live working-tree diff, so its notes carry a file target and anchor there
1298
+ onAnnotate: (span, body) =>
1299
+ void controller.annotate(
1300
+ "comment",
1301
+ span.start.blockIndex,
1302
+ span.start.char,
1303
+ span.end.char,
1304
+ body,
1305
+ span.end.blockIndex,
1306
+ isDiff
1307
+ ? { kind: "artifact" }
1308
+ : { kind: "file", path: "", rev: "worktree" },
1309
+ ),
1310
+ onReply: (rootAnnotationId, body) =>
1311
+ void controller.reply(rootAnnotationId, body),
1312
+ onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1313
+ resolveAuthorLabel,
1314
+ onNavCommand: runNavCommand,
1315
+ onExit: () => onExit?.(0),
1316
+ }}
1317
+ rejectedRows={rejectedRows}
1318
+ fold={diffFold}
1319
+ fileStats={controller.fileStats()}
1320
+ split={diffView === "split" && workbench.zoomed}
1321
+ dimmed={walking}
1322
+ readFile={(path) => controller.repoReadFile(path)}
1323
+ onAddFileComment={(path, anchor, body) =>
1324
+ void controller.addComment(
1325
+ anchor,
1326
+ { kind: "file", path, rev: "worktree" },
1327
+ body,
1328
+ )
1329
+ }
1330
+ // zoomed, the footer already rides the Changes pane, so no extra offset is needed
1331
+ emptyBottomPadding={workbench.zoomed ? 0 : THREAD_FOOTER_HEIGHT}
1332
+ theme={theme}
1333
+ />
1334
+ )}
1335
+ theme={theme}
1336
+ />
1337
+ }
1338
+ projectPanel={
1339
+ <ProjectPanelBody
1340
+ mode={workbench.projectMode}
1341
+ loadChanges={() => controller.repoChanges()}
1342
+ loadProjectFiles={() => controller.repoFiles()}
1343
+ reloadKey={activeSession.id}
1344
+ // the Changes navigator always opens a changed file as a diff - a diff review shows its
1345
+ // captured snapshot, every other thread the live working-tree diff
1346
+ onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
1347
+ onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
1348
+ commentCounts={diffCommentCounts}
1349
+ focused={focusedPane === "project"}
1350
+ theme={theme}
1351
+ />
1352
+ }
1353
+ theme={theme}
1354
+ >
1355
+ <TrailingOverlays
1356
+ walking={walking}
1357
+ walk={walk}
1358
+ walkFileList={walkFileList}
1359
+ viewedPaths={viewedPaths}
1360
+ session={activeSession}
1361
+ terminalWidth={terminalWidth}
1362
+ theme={theme}
1363
+ mode={mode}
1364
+ toast={toast}
1365
+ setMode={setMode}
1366
+ dispatch={dispatch}
1367
+ />
1368
+ {submitConfirmState !== null ? (
1369
+ <box
1370
+ onMouseUp={submitConfirmState.onCancel}
1371
+ style={{
1372
+ position: "absolute",
1373
+ left: 0,
1374
+ top: 0,
1375
+ width: "100%",
1376
+ height: "100%",
1377
+ justifyContent: "center",
1378
+ alignItems: "center",
1379
+ }}
1380
+ >
1381
+ <box onMouseUp={(event) => event.stopPropagation()}>
1382
+ <ConfirmCard {...submitConfirmState} theme={theme} />
1383
+ </box>
1384
+ </box>
1385
+ ) : null}
1386
+ <ShareDialog
1387
+ key={String(shareDialogOpen)}
1388
+ isOpen={shareDialogOpen}
1389
+ threadName={shareThreadName(session)}
1390
+ links={shareLinksFor(session)}
1391
+ isOwner={isOwner}
1392
+ onCreateLink={(input) => controller.createShareLink(input)}
1393
+ onUpdateLink={(id, input) => controller.updateShareLink(id, input)}
1394
+ onDeleteLink={(id) => controller.deleteShareLink(id)}
1395
+ onCopyLink={(id) => controller.copyShareLink(id)}
1396
+ onClose={() => setShareDialogOpen(false)}
1397
+ theme={theme}
1398
+ />
1399
+ {menuChrome}
1400
+ </AppShell>
1401
+ </MenuControlProvider>
1402
+ </ThemeProvider>
1403
+ </PaletteNamesContext.Provider>
1404
+ </SlashSkillsContext.Provider>
1405
+ );
676
1406
  }