@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.91

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