@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.101

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 (356) 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/annotations/annotation-palette.ts +62 -0
  9. package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
  10. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  11. package/src/annotations/components/AnnotationCards.tsx +369 -0
  12. package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
  13. package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
  14. package/src/annotations/discussions.ts +77 -0
  15. package/src/annotations/thread-selection.test.ts +210 -0
  16. package/src/annotations/thread-selection.ts +242 -0
  17. package/src/annotations/use-annotation-surface.tsx +1243 -0
  18. package/src/app/App.appearance.test.tsx +53 -0
  19. package/src/{App.autoclose.test.tsx → app/App.autoclose.test.tsx} +77 -51
  20. package/src/app/App.deleted-scroll.test.tsx +187 -0
  21. package/src/app/App.diff.test.tsx +313 -0
  22. package/src/app/App.export.test.tsx +105 -0
  23. package/src/app/App.focus.test.ts +89 -0
  24. package/src/app/App.identity.test.tsx +84 -0
  25. package/src/app/App.plan-render.test.tsx +209 -0
  26. package/src/app/App.plan-review.test.tsx +428 -0
  27. package/src/app/App.prototype-markdown.test.tsx +128 -0
  28. package/src/app/App.prototype.test.tsx +176 -0
  29. package/src/app/App.readonly.test.tsx +210 -0
  30. package/src/app/App.regression.test.tsx +99 -0
  31. package/src/app/App.rendering.test.tsx +134 -0
  32. package/src/app/App.share-dialog.test.tsx +177 -0
  33. package/src/app/App.submit-confirm.test.tsx +267 -0
  34. package/src/app/App.test.tsx +496 -0
  35. package/src/app/App.thread-agent.test.ts +20 -0
  36. package/src/app/App.toast.test.tsx +124 -0
  37. package/src/app/App.tsx +1667 -0
  38. package/src/app/App.visual.test.tsx +59 -0
  39. package/src/app/App.walk.test.tsx +299 -0
  40. package/src/app/App.workbench.test.tsx +1149 -0
  41. package/src/app/app-key-state.ts +61 -0
  42. package/src/app/app-screens.tsx +606 -0
  43. package/src/app/app-view-model.ts +168 -0
  44. package/src/app/components/AppHeader.stories.tsx +50 -0
  45. package/src/app/components/AppHeader.tsx +81 -0
  46. package/src/app/components/AppShell.brand.test.tsx +58 -0
  47. package/src/app/components/AppShell.extensions.test.tsx +191 -0
  48. package/src/app/components/AppShell.stories.tsx +89 -0
  49. package/src/app/components/AppShell.tsx +358 -0
  50. package/src/app/components/AppShell.zoom.test.tsx +149 -0
  51. package/src/app/components/Breadcrumb.stories.tsx +19 -0
  52. package/src/app/components/Breadcrumb.tsx +45 -0
  53. package/src/app/components/MenuBar.stories.tsx +28 -0
  54. package/src/app/components/MenuBar.tsx +44 -0
  55. package/src/app/components/PaneGrid.stories.tsx +84 -0
  56. package/src/app/components/PaneGrid.tsx +128 -0
  57. package/src/app/components/PanelColumn.stories.tsx +25 -0
  58. package/src/app/components/PanelColumn.tsx +93 -0
  59. package/src/app/components/ShellHeader.stories.tsx +50 -0
  60. package/src/app/components/ShellHeader.tsx +137 -0
  61. package/src/app/components/WorkspacePanels.stories.tsx +36 -0
  62. package/src/app/components/WorkspacePanels.tsx +120 -0
  63. package/src/app/components/pages/Workspace.stories.tsx +181 -0
  64. package/src/app/intent-dispatch.test.ts +728 -0
  65. package/src/app/intent-dispatch.ts +687 -0
  66. package/src/app/launch-layout.test.ts +78 -0
  67. package/src/app/launch-layout.ts +57 -0
  68. package/src/app/ready-signal.test.ts +85 -0
  69. package/src/app/ready-signal.ts +52 -0
  70. package/src/app/run.ts +109 -0
  71. package/src/{serve.test.ts → app/serve.test.ts} +24 -12
  72. package/src/app/serve.ts +111 -0
  73. package/src/app/version.ts +5 -0
  74. package/src/appearance/components/theme-context.stories.tsx +46 -0
  75. package/src/appearance/components/theme-context.tsx +37 -0
  76. package/src/appearance/theme-presets.test.ts +51 -0
  77. package/src/appearance/theme-presets.ts +219 -0
  78. package/src/appearance/theme.ts +112 -0
  79. package/src/appearance/visual/contrast.test.ts +35 -0
  80. package/src/appearance/visual/contrast.ts +33 -0
  81. package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
  82. package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
  83. package/src/diff/components/AnnotatableFileView.tsx +141 -0
  84. package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
  85. package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
  86. package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
  87. package/src/diff/components/DiffContentView.split.test.tsx +140 -0
  88. package/src/diff/components/DiffContentView.stories.tsx +80 -0
  89. package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
  90. package/src/diff/components/DiffContentView.tsx +1055 -0
  91. package/src/diff/components/WalkWizard.stories.tsx +100 -0
  92. package/src/diff/components/WalkWizard.tsx +146 -0
  93. package/src/diff/components/diff-content-view-layout.test.ts +198 -0
  94. package/src/diff/components/diff-content-view-layout.ts +84 -0
  95. package/src/diff/diff-fold.test.ts +78 -0
  96. package/src/diff/diff-fold.ts +126 -0
  97. package/src/diff/diff-hunk-curate.test.ts +315 -0
  98. package/src/diff/diff-hunk-curate.ts +152 -0
  99. package/src/diff/diff-intraline.test.ts +239 -0
  100. package/src/diff/diff-intraline.ts +238 -0
  101. package/src/diff/diff-syntax.test.ts +125 -0
  102. package/src/diff/diff-syntax.ts +186 -0
  103. package/src/diff/split-diff.test.ts +110 -0
  104. package/src/diff/split-diff.ts +144 -0
  105. package/src/diff/view-diff.test.ts +325 -0
  106. package/src/diff/view-diff.ts +522 -0
  107. package/src/diff/walk.test.ts +121 -0
  108. package/src/diff/walk.ts +73 -0
  109. package/src/index.ts +31 -2
  110. package/src/integrations/attribution.test.ts +31 -0
  111. package/src/integrations/attribution.ts +42 -0
  112. package/src/integrations/client-extension-registry.test.tsx +61 -0
  113. package/src/integrations/client-extension-registry.ts +159 -0
  114. package/src/integrations/clipboard.test.ts +23 -0
  115. package/src/integrations/clipboard.ts +33 -0
  116. package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
  117. package/src/integrations/components/ExtensionZone.tsx +145 -0
  118. package/src/integrations/components/ShareDialog.agent.test.tsx +50 -0
  119. package/src/integrations/components/ShareDialog.stories.tsx +121 -0
  120. package/src/integrations/components/ShareDialog.tsx +574 -0
  121. package/src/integrations/components/share-dialog-store.ts +59 -0
  122. package/src/integrations/editor.test.ts +149 -0
  123. package/src/integrations/editor.ts +160 -0
  124. package/src/integrations/github-identity.test.ts +58 -0
  125. package/src/integrations/github-identity.ts +41 -0
  126. package/src/integrations/herdr.test.ts +43 -0
  127. package/src/integrations/herdr.ts +38 -0
  128. package/src/integrations/integrations.test.ts +41 -0
  129. package/src/integrations/integrations.ts +32 -0
  130. package/src/integrations/pasted-image.test.ts +67 -0
  131. package/src/integrations/pasted-image.ts +60 -0
  132. package/src/integrations/share.ts +238 -0
  133. package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
  134. package/src/keyboard/components/CompletionOverlay.tsx +75 -0
  135. package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
  136. package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
  137. package/src/keyboard/components/HandleChipsInput.tsx +143 -0
  138. package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
  139. package/src/keyboard/components/KeybindsDialog.tsx +103 -0
  140. package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
  141. package/src/keyboard/components/NavModeHint.tsx +31 -0
  142. package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
  143. package/src/keyboard/components/SlashComposer.tsx +87 -0
  144. package/src/keyboard/key-bindings.test.ts +156 -0
  145. package/src/keyboard/key-bindings.ts +459 -0
  146. package/src/keyboard/keymap.test.ts +611 -0
  147. package/src/keyboard/keymap.ts +395 -0
  148. package/src/keyboard/skills.test.ts +74 -0
  149. package/src/keyboard/skills.ts +75 -0
  150. package/src/keyboard/slash-palette.test.ts +103 -0
  151. package/src/keyboard/slash-palette.ts +129 -0
  152. package/src/keyboard/thread-chords.test.ts +79 -0
  153. package/src/keyboard/thread-chords.ts +163 -0
  154. package/src/keyboard/use-shared-keyboard.ts +55 -0
  155. package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  156. package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
  157. package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
  158. package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
  159. package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
  160. package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
  161. package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
  162. package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
  163. package/src/markdown/components/ThreadView.stories.tsx +26 -0
  164. package/src/markdown/components/ThreadView.tsx +560 -0
  165. package/src/markdown/mark-runs.ts +121 -0
  166. package/src/markdown/markdown-editor-syntax-style.ts +50 -0
  167. package/src/markdown/markdown-grid.test.ts +101 -0
  168. package/src/markdown/markdown-grid.ts +203 -0
  169. package/src/markdown/markdown-highlight.test.ts +61 -0
  170. package/src/markdown/markdown-highlight.ts +124 -0
  171. package/src/markdown/thread-code-syntax.ts +45 -0
  172. package/src/markdown/use-thread-body-editing.tsx +54 -0
  173. package/src/markdown/view-plan.regression.test.ts +60 -0
  174. package/src/markdown/view-plan.test.ts +545 -0
  175. package/src/markdown/view-plan.ts +1009 -0
  176. package/src/performance/otlp-trace.test.ts +119 -0
  177. package/src/performance/otlp-trace.ts +134 -0
  178. package/src/performance/perf-report.ts +18 -0
  179. package/src/performance/perf-timings.test.ts +18 -0
  180. package/src/performance/perf-timings.ts +39 -0
  181. package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
  182. package/src/prototype/components/PrototypeContentView.tsx +442 -0
  183. package/src/prototype/prototype-browser.test.ts +81 -0
  184. package/src/prototype/prototype-browser.ts +321 -0
  185. package/src/prototype/prototype-pixels.tsx +35 -0
  186. package/src/settings/components/SettingsDialog.stories.tsx +117 -0
  187. package/src/settings/components/SettingsDialog.tsx +183 -0
  188. package/src/settings/components/SettingsRows.stories.tsx +18 -0
  189. package/src/settings/components/SettingsRows.tsx +112 -0
  190. package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/settings/components/quick-actions-editor.test.tsx +120 -0
  192. package/src/settings/components/quick-actions-editor.tsx +222 -0
  193. package/src/settings/config-identity.test.ts +78 -0
  194. package/src/settings/config.test.ts +676 -0
  195. package/src/settings/config.ts +628 -0
  196. package/src/settings/use-settings-dialog.tsx +351 -0
  197. package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
  198. package/src/stories/stories-app.tsx +196 -0
  199. package/src/stories/stories.test.tsx +74 -0
  200. package/src/stories/story-fixtures.ts +102 -0
  201. package/src/stories/story-tree.test.ts +108 -0
  202. package/src/stories/story.ts +154 -0
  203. package/src/terminal/ansi-screen.test.ts +28 -0
  204. package/src/terminal/ansi-screen.ts +72 -0
  205. package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
  206. package/src/terminal/components/AnsiScreen.tsx +28 -0
  207. package/src/terminal/components/terminal-pane.test.tsx +40 -0
  208. package/src/terminal/components/terminal-pane.ts +196 -0
  209. package/src/terminal/ghostty-terminal.test.ts +85 -0
  210. package/src/terminal/ghostty-terminal.ts +197 -0
  211. package/src/terminal/kitty-image.test.ts +66 -0
  212. package/src/terminal/kitty-image.ts +137 -0
  213. package/src/terminal/pty.test.ts +81 -0
  214. package/src/terminal/pty.ts +246 -0
  215. package/src/testing/test-support.test.ts +264 -0
  216. package/src/testing/test-support.ts +367 -0
  217. package/src/thread/agent-continuation.test.ts +32 -0
  218. package/src/thread/agent-continuation.ts +13 -0
  219. package/src/thread/agent-transcript.test.ts +161 -0
  220. package/src/thread/agent-transcript.ts +336 -0
  221. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  222. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  223. package/src/thread/components/AgentConfigControls.tsx +114 -0
  224. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  225. package/src/thread/components/AgentThreadPane.test.tsx +935 -0
  226. package/src/thread/components/AgentThreadPane.tsx +377 -0
  227. package/src/thread/components/ThreadFooter.stories.tsx +30 -0
  228. package/src/thread/components/ThreadFooter.test.tsx +49 -0
  229. package/src/thread/components/ThreadFooter.tsx +77 -0
  230. package/src/thread/components/ThreadTree.stories.tsx +46 -0
  231. package/src/thread/components/ThreadTree.test.tsx +41 -0
  232. package/src/thread/components/ThreadTree.tsx +267 -0
  233. package/src/thread/components/thread-tree.test.ts +137 -0
  234. package/src/thread/components/thread-tree.ts +112 -0
  235. package/src/thread/thread-controller.curate.test.ts +502 -0
  236. package/src/thread/thread-controller.derivation.test.ts +62 -0
  237. package/src/thread/thread-controller.live-diff.test.ts +357 -0
  238. package/src/thread/thread-controller.optimistic.test.ts +69 -0
  239. package/src/thread/thread-controller.share.test.ts +573 -0
  240. package/src/thread/thread-controller.switch-cache.test.ts +174 -0
  241. package/src/thread/thread-controller.tree.test.ts +263 -0
  242. package/src/thread/thread-controller.ts +2026 -0
  243. package/src/thread/tree-view.test.ts +128 -0
  244. package/src/thread/tree-view.ts +146 -0
  245. package/src/thread/use-thread-agent.test.tsx +208 -0
  246. package/src/thread/use-thread-agent.ts +117 -0
  247. package/src/ui/components/CodeBlock.stories.tsx +42 -0
  248. package/src/ui/components/CodeBlock.tsx +80 -0
  249. package/src/ui/components/ConfirmCard.stories.tsx +37 -0
  250. package/src/ui/components/ConfirmCard.tsx +151 -0
  251. package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
  252. package/src/ui/components/ConfirmDialog.tsx +72 -0
  253. package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
  254. package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
  255. package/src/ui/components/OverlayScrollbar.tsx +129 -0
  256. package/src/ui/components/PromptDialog.stories.tsx +36 -0
  257. package/src/ui/components/PromptDialog.test.tsx +65 -0
  258. package/src/ui/components/PromptDialog.tsx +99 -0
  259. package/src/ui/components/RootOverlay.stories.tsx +45 -0
  260. package/src/ui/components/RootOverlay.tsx +49 -0
  261. package/src/ui/components/ScrollArea.stories.tsx +20 -0
  262. package/src/ui/components/ScrollArea.tsx +61 -0
  263. package/src/ui/components/Toast.stories.tsx +19 -0
  264. package/src/ui/components/Toast.tsx +54 -0
  265. package/src/ui/components/Tooltip.stories.tsx +26 -0
  266. package/src/ui/components/Tooltip.tsx +72 -0
  267. package/src/ui/components/menu-control.stories.tsx +35 -0
  268. package/src/ui/components/menu-control.tsx +43 -0
  269. package/src/ui/components/primitives/Button.stories.tsx +32 -0
  270. package/src/ui/components/primitives/Button.tsx +57 -0
  271. package/src/ui/components/primitives/Card.stories.tsx +25 -0
  272. package/src/ui/components/primitives/Card.tsx +62 -0
  273. package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
  274. package/src/ui/components/primitives/Dialog.test.tsx +38 -0
  275. package/src/ui/components/primitives/Dialog.tsx +69 -0
  276. package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
  277. package/src/ui/components/primitives/DialogActions.tsx +39 -0
  278. package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
  279. package/src/ui/components/primitives/IconButton.tsx +58 -0
  280. package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
  281. package/src/ui/components/primitives/StatusBar.tsx +23 -0
  282. package/src/ui/components/primitives/Switch.stories.tsx +17 -0
  283. package/src/ui/components/primitives/Switch.tsx +32 -0
  284. package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
  285. package/src/ui/components/primitives/Tabs.tsx +101 -0
  286. package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
  287. package/src/ui/components/primitives/Toolbar.tsx +14 -0
  288. package/src/ui/components/primitives/Tree.stories.tsx +124 -0
  289. package/src/ui/components/primitives/Tree.tsx +133 -0
  290. package/src/ui/components/primitives/frame.test.ts +12 -0
  291. package/src/ui/components/primitives/frame.ts +17 -0
  292. package/src/ui/components/primitives/icons.ts +61 -0
  293. package/src/ui/components/primitives/tree-model.test.ts +99 -0
  294. package/src/ui/components/primitives/tree-model.ts +137 -0
  295. package/src/ui/components/syntax-highlight.ts +133 -0
  296. package/src/ui/components/truncate-text.ts +7 -0
  297. package/src/ui/components/truncate-title.test.ts +31 -0
  298. package/src/ui/components/truncate-title.ts +15 -0
  299. package/src/ui/use-frame-measure.ts +74 -0
  300. package/src/ui/use-terminal-virtualizer.ts +259 -0
  301. package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
  302. package/src/workbench/components/ChangesColumn.tsx +203 -0
  303. package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
  304. package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
  305. package/src/workbench/components/EditorGrid.stories.tsx +86 -0
  306. package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
  307. package/src/workbench/components/EditorGrid.tsx +544 -0
  308. package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
  309. package/src/workbench/components/FileContentsView.stories.tsx +16 -0
  310. package/src/workbench/components/FileContentsView.test.tsx +29 -0
  311. package/src/workbench/components/FileContentsView.tsx +100 -0
  312. package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
  313. package/src/workbench/components/GridTabContent.stories.tsx +59 -0
  314. package/src/workbench/components/GridTabContent.tsx +193 -0
  315. package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
  316. package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
  317. package/src/workbench/components/ProjectTreeView.tsx +151 -0
  318. package/src/workbench/components/TreePane.stories.tsx +106 -0
  319. package/src/workbench/components/TreePane.tsx +155 -0
  320. package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
  321. package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
  322. package/src/workbench/components/WelcomePlayground.tsx +138 -0
  323. package/src/workbench/components/editor-grid.test.ts +184 -0
  324. package/src/workbench/components/editor-grid.ts +281 -0
  325. package/src/workbench/components/file-tree.test.ts +60 -0
  326. package/src/workbench/components/file-tree.ts +112 -0
  327. package/src/workbench/use-changes-workbench.ts +205 -0
  328. package/src/workbench/use-remember-layout.ts +31 -0
  329. package/src/workbench/use-repo-changes.ts +91 -0
  330. package/src/workbench/workbench-snapshot.test.ts +99 -0
  331. package/src/workbench/workbench-snapshot.ts +59 -0
  332. package/src/App.diff.test.tsx +0 -99
  333. package/src/App.export.test.tsx +0 -88
  334. package/src/App.plan-review.test.tsx +0 -259
  335. package/src/App.readonly.test.tsx +0 -148
  336. package/src/App.rendering.test.tsx +0 -108
  337. package/src/App.submit-confirm.test.tsx +0 -183
  338. package/src/App.test.tsx +0 -218
  339. package/src/App.tsx +0 -1249
  340. package/src/config.test.ts +0 -116
  341. package/src/config.ts +0 -150
  342. package/src/editor.ts +0 -34
  343. package/src/herdr.test.ts +0 -68
  344. package/src/herdr.ts +0 -34
  345. package/src/keymap.test.ts +0 -270
  346. package/src/keymap.ts +0 -173
  347. package/src/run.ts +0 -30
  348. package/src/serve.ts +0 -75
  349. package/src/session-controller.ts +0 -398
  350. package/src/syntax.test.ts +0 -41
  351. package/src/syntax.ts +0 -88
  352. package/src/theme.ts +0 -46
  353. package/src/view-diff.test.ts +0 -55
  354. package/src/view-diff.ts +0 -86
  355. package/src/view.test.ts +0 -157
  356. package/src/view.ts +0 -364
@@ -0,0 +1,2026 @@
1
+ /**
2
+ * The thread controller: every daemon round-trip and mutation
3
+ * primitive behind one React-free object. It owns connect/autostart/subscribe,
4
+ * the session/inbox/status/error snapshot, optimistic apply, the mutation
5
+ * primitives (cut/edit/annotate/submit/...), and the post-submit completion
6
+ * lifecycle including the notes-vault export and the herdr return-focus.
7
+ * App subscribes to snapshots and keeps only view state.
8
+ */
9
+
10
+ import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
11
+ import { createStore, type StoreApi } from "zustand/vanilla";
12
+ import { DaemonClient, type ThreadClient } from "@cueloop/daemon/client";
13
+ import {
14
+ applyPathView,
15
+ applyTextCuts,
16
+ blockCutSourceRange,
17
+ createBranch,
18
+ cutBlock,
19
+ cutTextRange,
20
+ detectHerdr,
21
+ labelTip,
22
+ MAIN_BRANCH,
23
+ makeAnchor,
24
+ mergeTextCut,
25
+ navigateTo,
26
+ newAnnotationId,
27
+ parseBlocks,
28
+ resolveAnchor,
29
+ restoreBlock,
30
+ restoreLine,
31
+ restoreTextCut,
32
+ sourceOffsetAt,
33
+ returnPaneFor,
34
+ switchBranch,
35
+ viewOfPath,
36
+ type Anchor,
37
+ type Annotation,
38
+ type AnnotationTarget,
39
+ type Artifact,
40
+ type DiffFileContents,
41
+ type ShareLink,
42
+ type SharePolicy,
43
+ type Thread,
44
+ type SessionHistory,
45
+ type MessageOutcome,
46
+ } from "@cueloop/schema";
47
+ import { loadBundledExporters, type BundledExporter } from "../integrations/integrations";
48
+ import {
49
+ collaboratorAnnotations,
50
+ mergeFromShare,
51
+ publishShare,
52
+ pullShare,
53
+ pushShare,
54
+ revokeShare,
55
+ watchShare,
56
+ shareIdFromLine,
57
+ formatShareLine,
58
+ } from "../integrations/share";
59
+ import {
60
+ baseRangeForRendered,
61
+ blockRuns,
62
+ buildDisplay,
63
+ nextWorkBlock,
64
+ renderedSpanToWork,
65
+ renderedText,
66
+ type DisplayBlock,
67
+ } from "../markdown/view-plan";
68
+ import { entryTarget, treeRows, type TreeRow } from "./tree-view";
69
+ import {
70
+ diffRowBlocks,
71
+ diffRows,
72
+ fileChangeCounts,
73
+ fileRowRange,
74
+ readsFrozenDiff,
75
+ type DiffRow,
76
+ } from "../diff/view-diff";
77
+ import { applyFold } from "../diff/diff-fold";
78
+ import { snapshotWorkbench } from "../workbench/workbench-snapshot";
79
+ import { copyToClipboard } from "../integrations/clipboard";
80
+ import {
81
+ changeRejectionForRow,
82
+ hunkRejectionForRow,
83
+ isRowRejected,
84
+ parseFileDiff,
85
+ rejectsWholeHunk,
86
+ sameRejection,
87
+ type HunkRejection,
88
+ } from "../diff/diff-hunk-curate";
89
+ import type { FileDiffMetadata } from "@pierre/diffs";
90
+ import { firstUnviewedIndex, walkFiles, type WalkFile } from "../diff/walk";
91
+ import { editInEditor } from "../integrations/editor";
92
+ import { focusHerdrPane } from "../integrations/herdr";
93
+ import {
94
+ persistAutoClose,
95
+ type AutoClose,
96
+ type CueloopConfig,
97
+ type QuickAction,
98
+ } from "../settings/config";
99
+ import { slashItemsFrom } from "../keyboard/slash-palette";
100
+
101
+ /**
102
+ * Post-submit lifecycle (a review pane should hand you back to the agent,
103
+ * not linger): idle → counting (a visible countdown, the default) → exit.
104
+ * esc dismisses to the resolved view; a remembers the countdown as default.
105
+ */
106
+ export type Completion =
107
+ | { phase: "idle" }
108
+ | { phase: "prompt" }
109
+ | { phase: "counting"; remaining: number }
110
+ | { phase: "dismissed" };
111
+
112
+ /** Seconds the completion overlay counts down before it hands back. */
113
+ export const DEFAULT_AUTO_CLOSE = 5;
114
+
115
+ /** How often an open shared plan re-pulls collaborator notes (ADR 0005 stage 2). */
116
+ /** Reconnect backoff for a dropped watch stream: doubles from the floor to the cap. */
117
+ export const SHARE_RECONNECT_MIN_MS = 1000;
118
+ export const SHARE_RECONNECT_MAX_MS = 30_000;
119
+
120
+ /** Shared empty set so "nothing rejected" is a stable identity for renders. */
121
+ const EMPTY_REJECTIONS: HunkRejection[] = [];
122
+ const EMPTY_REJECTED_ROWS: Set<number> = new Set();
123
+
124
+ /** Shared empty list so "nothing curated out" is a stable identity for renders. */
125
+ const EMPTY_CURATION_ITEMS: CurationItem[] = [];
126
+
127
+ export interface ToastState {
128
+ title?: string;
129
+ body: string;
130
+ }
131
+
132
+ /**
133
+ * One removed piece projected for the review rail, unified across both review
134
+ * kinds: a diff hunk/change the owner curated out, or a plan block they cut.
135
+ * `source` tells restore and reveal which path to take; `preview` is the removed
136
+ * content (a few lines) the card renders struck through; `revealIndex` is the
137
+ * diff row (diff) or display block (plan) to scroll to and restore against.
138
+ */
139
+ export interface CurationItem {
140
+ id: string;
141
+ source: "diff" | "plan";
142
+ preview: string[];
143
+ revealIndex: number;
144
+ }
145
+
146
+ /** Stable id for a rejection's rail item: diff:path#hunk#change (or #hunk for a whole hunk). */
147
+ export function curationItemId(rejection: HunkRejection): string {
148
+ return `diff:${rejection.path}#${rejection.hunkIndex}#${rejection.changeIndex ?? "hunk"}`;
149
+ }
150
+
151
+ /** Stable id for a plan cut's rail item, keyed on its fixed base-content line range. */
152
+ function planCutId(base: { lineStart: number; lineEnd: number }): string {
153
+ return `plan:${base.lineStart}-${base.lineEnd}`;
154
+ }
155
+
156
+ /**
157
+ * Whether two records of the same thread yield the same document projection (display, rows, files,
158
+ * models). Those derive only from the artifact content and the working copy, so an update that
159
+ * touched only annotations, status, or the message leaves them identical - the projection can be
160
+ * reused rather than re-parsed. Content and working copy are strings, compared here by value.
161
+ */
162
+ export function sameDerivationInputs(previous: Thread, next: Thread): boolean {
163
+ const sameCuts =
164
+ previous.textCuts === next.textCuts ||
165
+ ((previous.textCuts?.length ?? 0) === (next.textCuts?.length ?? 0) &&
166
+ (previous.textCuts ?? []).every((cut, index) => {
167
+ const candidate = next.textCuts?.[index];
168
+
169
+ return (
170
+ candidate?.start === cut.start &&
171
+ candidate.end === cut.end &&
172
+ candidate.quote === cut.quote
173
+ );
174
+ }));
175
+
176
+ return (
177
+ previous.id === next.id &&
178
+ previous.artifact.content === next.artifact.content &&
179
+ previous.workingCopy === next.workingCopy &&
180
+ sameCuts
181
+ );
182
+ }
183
+
184
+ /**
185
+ * The session with `annotation` upserted, for an optimistic paint the instant the composer closes
186
+ * rather than after the daemon round-trip - otherwise a saved comment blinks out until the write
187
+ * lands. An edit keeps the note in place and its timestamp; a new note appends. The daemon's record
188
+ * replaces this guess when the write returns.
189
+ */
190
+ export function withAnnotationUpserted(
191
+ session: Thread,
192
+ annotation: Omit<Annotation, "createdAt">,
193
+ ): Thread {
194
+ const index = session.annotations.findIndex((entry) => entry.id === annotation.id);
195
+ const createdAt = index >= 0 ? session.annotations[index]!.createdAt : new Date().toISOString();
196
+ const optimistic: Annotation = { ...annotation, createdAt };
197
+ const annotations =
198
+ index >= 0
199
+ ? session.annotations.map((entry) => (entry.id === annotation.id ? optimistic : entry))
200
+ : [...session.annotations, optimistic];
201
+
202
+ return { ...session, annotations };
203
+ }
204
+
205
+ export interface ControllerSnapshot {
206
+ session: Thread | null;
207
+ inbox: Thread[] | null;
208
+ status: string;
209
+ toast: ToastState | null;
210
+ error: string | null;
211
+ completion: Completion;
212
+ editOrphanCount: number;
213
+ walk: { index: number } | null;
214
+ }
215
+
216
+ export interface ShareTransport {
217
+ publish: typeof publishShare;
218
+ pull: typeof pullShare;
219
+ push: typeof pushShare;
220
+ revoke: typeof revokeShare;
221
+ watch: typeof watchShare;
222
+ parseShareId: typeof shareIdFromLine;
223
+ formatShareLine: typeof formatShareLine;
224
+ collaboratorAnnotations: typeof collaboratorAnnotations;
225
+ mergeFromShare: typeof mergeFromShare;
226
+ }
227
+
228
+ const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
229
+ publish: publishShare,
230
+ pull: pullShare,
231
+ watch: watchShare,
232
+ push: pushShare,
233
+ revoke: revokeShare,
234
+ parseShareId: shareIdFromLine,
235
+ formatShareLine,
236
+ collaboratorAnnotations,
237
+ mergeFromShare,
238
+ };
239
+
240
+ /** The editable fields of a share link, as the share wizard collects them. */
241
+ export interface NewShareLink {
242
+ name?: string;
243
+ requireAuth: boolean;
244
+ allowlist: string[];
245
+ agentEnabled?: boolean;
246
+ }
247
+
248
+ export interface ReviewControllerOptions {
249
+ home?: string;
250
+ sessionId?: string;
251
+ initialSession?: Thread;
252
+ initialDiff?: { patch: string; files: DiffFileContents[] };
253
+ initialClient?: ThreadClient;
254
+ cwd?: string;
255
+ readOnly?: boolean;
256
+ onExit?: (code: number) => void;
257
+ clock?: Clock;
258
+ openClient?: () => Promise<ThreadClient>;
259
+ shareTransport?: ShareTransport;
260
+ servedArtifact?: Artifact;
261
+ }
262
+
263
+ export interface ReviewController {
264
+ readonly readOnly: boolean;
265
+ subscribe(listener: () => void): () => void;
266
+ getSnapshot(): ControllerSnapshot;
267
+ connect(): void;
268
+ close(): void;
269
+ applyConfig(config: CueloopConfig): void;
270
+ setStatus(message: string): void;
271
+ showToast(body: string, title?: string): void;
272
+ dismissToast(): void;
273
+ display(): DisplayBlock[];
274
+ rows(): DiffRow[];
275
+ setFileCollapsed(file: string, collapsed: boolean): void;
276
+ isFileCollapsed(file: string): boolean;
277
+ setFileExpanded(file: string, expanded: boolean): void;
278
+ isFileExpanded(file: string): boolean;
279
+ canExpandFile(file: string): boolean;
280
+ copyFilePath(file: string): Promise<boolean>;
281
+ fileStats(): Map<string, { additions: number; deletions: number }>;
282
+ files(): WalkFile[];
283
+ working(): string;
284
+ projectFiles(): Promise<string[]>;
285
+ readFile(path: string): Promise<string | null>;
286
+ repoFiles(): Promise<string[]>;
287
+ repoReadFile(path: string): Promise<string | null>;
288
+ repoChanges(): Promise<readonly DiffFileContents[]>;
289
+ refreshDiff(): Promise<void>;
290
+ open(id: string): void;
291
+ createEmptyThread(): Promise<void>;
292
+ deleteSession(id: string): void;
293
+ renameSession(id: string, title: string): void;
294
+ setSelfName(name: string): void;
295
+ cut(displayIndex: number, start?: number, end?: number, endDisplayIndex?: number): void;
296
+ toggleRejectHunk(rowIndex: number): void;
297
+ toggleRejectChange(rowIndex: number): void;
298
+ rejectedRows(): Set<number>;
299
+ curationItems(): CurationItem[];
300
+ restoreCuration(id: string): void;
301
+ edit(): void;
302
+ saveEditedBody(content: string): void;
303
+ annotate(
304
+ kind: "comment",
305
+ displayIndex: number,
306
+ start: number,
307
+ end: number,
308
+ body: string,
309
+ endDisplayIndex?: number,
310
+ target?: AnnotationTarget,
311
+ ): string | undefined;
312
+ addComment(anchor: Anchor, target: AnnotationTarget, body: string): string | undefined;
313
+ commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void>;
314
+ commentOnWorkbenchDiff(
315
+ displayIndex: number,
316
+ start: number,
317
+ end: number,
318
+ endDisplayIndex: number,
319
+ body: string,
320
+ ): Promise<void>;
321
+ reply(rootAnnotationId: string, body: string): string | undefined;
322
+ annotatePrototype(selector: string, quote: string, body: string): string | undefined;
323
+ updateAnnotation(id: string, body: string): void;
324
+ flushMutations?(): Promise<void>;
325
+ removeAnnotation(id: string): void;
326
+ setWorkingCopy(content: string | undefined): void;
327
+ walkStart(): void;
328
+ walkForward(): void;
329
+ walkBack(): void;
330
+ walkLeave(): void;
331
+ submit(message: MessageOutcome, summary: string): void;
332
+ share(): void;
333
+ unshare(): void;
334
+ shareLinks(): ShareLink[];
335
+ createShareLink(input: NewShareLink): void;
336
+ updateShareLink(id: string, input: NewShareLink): void;
337
+ deleteShareLink(id: string): void;
338
+ copyShareLink(id: string): void;
339
+ treeRows(): TreeRow[];
340
+ goToEntry(entryId: string, summary?: string): void;
341
+ branch(name: string): void;
342
+ labelTip(label: string): void;
343
+ fork(): void;
344
+ forkAndShare(): void;
345
+ pullShared(): Promise<void>;
346
+ startShareSync(): () => void;
347
+ finishReview(): void;
348
+ dismissCompletion(): void;
349
+ optInAutoClose(): void;
350
+ }
351
+
352
+ export function createReviewController(options: ReviewControllerOptions): ReviewController {
353
+ return new Controller(options);
354
+ }
355
+
356
+ interface DerivedSessionProjection {
357
+ display: DisplayBlock[];
358
+ rows: DiffRow[];
359
+ files: WalkFile[];
360
+ fileContents: Map<string, DiffFileContents>;
361
+ models: Map<string, FileDiffMetadata>;
362
+ tree: TreeRow[];
363
+ }
364
+
365
+ /** A live working-tree diff: the same patch + per-file contents a diff capture pins. */
366
+ interface LiveWorkingDiff {
367
+ patch: string;
368
+ files: readonly DiffFileContents[];
369
+ }
370
+
371
+ interface DerivedCacheEntry {
372
+ derivedFor: Thread;
373
+ derivedForLiveDiff: LiveWorkingDiff | null;
374
+ derived: DerivedSessionProjection;
375
+ }
376
+
377
+ /** Recently-viewed threads whose parsed projection is kept for an instant return; oldest evicted first. */
378
+ const DERIVED_CACHE_LIMIT = 8;
379
+
380
+ class Controller implements ReviewController {
381
+ readonly readOnly: boolean;
382
+ private client: ThreadClient | null = null;
383
+ private pendingMutations = new Set<Promise<Thread>>();
384
+ private closed = false;
385
+ /** The controller's state lives in a zustand store; internal reads go through the snapshot getter. */
386
+ private readonly store: StoreApi<ControllerSnapshot> = createStore<ControllerSnapshot>(() => ({
387
+ session: null,
388
+ inbox: null,
389
+ status: "",
390
+ toast: null,
391
+ error: null,
392
+ completion: { phase: "idle" },
393
+ editOrphanCount: 0,
394
+ walk: null,
395
+ }));
396
+ private get snapshot(): ControllerSnapshot {
397
+ return this.store.getState();
398
+ }
399
+ private autoClose: AutoClose = "off";
400
+ private quickActions: QuickAction[] = [];
401
+ private editor: string | undefined;
402
+ private exporters: BundledExporter[] = [];
403
+ private readonly clock: Clock;
404
+ private readonly shareTransport: ShareTransport;
405
+ private countdown: TimerHandle | undefined;
406
+ private shareReconnect: TimerHandle | undefined;
407
+ private shareStop: (() => void) | null = null;
408
+ private shareRun: object | null = null;
409
+ /** Projections keyed by session identity (and the live diff for non-diff threads) so renders reuse one computation. */
410
+ private derivedFor: Thread | null = null;
411
+ /** The launch/workspace repo's live working-tree diff, feeding the Changes view for non-diff threads. */
412
+ private liveDiff: LiveWorkingDiff | null = null;
413
+ private initialDiffAvailable = false;
414
+ private derivedForLiveDiff: LiveWorkingDiff | null = null;
415
+ private derived: DerivedSessionProjection = {
416
+ display: [],
417
+ rows: [],
418
+ files: [],
419
+ fileContents: new Map(),
420
+ models: new Map(),
421
+ tree: [],
422
+ };
423
+ /** Recently-viewed threads' parsed projections, so returning to a thread reuses its work instead of re-parsing. */
424
+ private derivedCache = new Map<string, DerivedCacheEntry>();
425
+ /** The thread the view is on now; a revalidation for a since-abandoned thread is discarded. */
426
+ private viewingId: string | undefined;
427
+ /** File paths whose diff body is folded to just the file band. */
428
+ private collapsedFiles = new Set<string>();
429
+ /** File paths woven to their full contents (unchanged lines shown as context). */
430
+ private expandedFiles = new Set<string>();
431
+ /** The diff's reject decisions live on the session record; the daemon curates from them. */
432
+ private get rejections(): HunkRejection[] {
433
+ return this.snapshot.session?.curation ?? EMPTY_REJECTIONS;
434
+ }
435
+
436
+ constructor(private readonly options: ReviewControllerOptions) {
437
+ this.client = options.initialClient ?? null;
438
+ if (options.initialSession?.id === options.sessionId && options.initialDiff) {
439
+ this.liveDiff = options.initialDiff;
440
+ this.initialDiffAvailable = true;
441
+ this.store.setState({ session: options.initialSession });
442
+ }
443
+ this.readOnly = options.readOnly ?? false;
444
+ this.clock = options.clock ?? new SystemClock();
445
+ this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
446
+ }
447
+
448
+ subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener);
449
+
450
+ getSnapshot = (): ControllerSnapshot => this.store.getState();
451
+
452
+ private update(patch: Partial<ControllerSnapshot>): void {
453
+ this.store.setState(this.freezeServed(patch));
454
+ }
455
+
456
+ /** Serve mode pins the served thread's diff to a snapshot; its annotations and the rest stay live. */
457
+ private freezeServed(patch: Partial<ControllerSnapshot>): Partial<ControllerSnapshot> {
458
+ const served = this.options.servedArtifact;
459
+
460
+ if (!served || !patch.session || patch.session.id !== this.options.sessionId) return patch;
461
+
462
+ return { ...patch, session: { ...patch.session, artifact: served } };
463
+ }
464
+
465
+ connect(): void {
466
+ void (async () => {
467
+ try {
468
+ const openClient =
469
+ this.options.openClient ??
470
+ (() =>
471
+ this.options.initialClient ??
472
+ DaemonClient.connect({ home: this.options.home, autostart: true }));
473
+ const client = await openClient();
474
+
475
+ if (this.closed) return void client.close();
476
+ this.client = client;
477
+ client.onEvent((event) => {
478
+ // another controller/observer changed state: re-fetch the active session's content
479
+ const session = this.snapshot.session;
480
+
481
+ if (session && event.sessionId === session.id) void this.refreshSession(session.id);
482
+ // only the list-changing events touch the Threads sidebar; refreshing on every
483
+ // content edit (session.updated) would amplify an active review into a request storm
484
+ if (event.event !== "session.updated") void this.refreshInbox();
485
+ });
486
+ await client.subscribe();
487
+ // load the inbox in both cases so the sidebar can jump between threads
488
+ const inbox = await client.sessionList({ status: "pending" });
489
+
490
+ if (this.options.sessionId) {
491
+ this.viewingId = this.options.sessionId;
492
+ this.update({ session: await client.sessionGet(this.options.sessionId), inbox });
493
+ } else {
494
+ this.update({ inbox });
495
+ }
496
+ } catch (err) {
497
+ this.update({ error: err instanceof Error ? err.message : String(err) });
498
+ }
499
+ })();
500
+ }
501
+
502
+ close(): void {
503
+ this.closed = true;
504
+ this.clearCountdown();
505
+ this.stopShareSync();
506
+ this.client?.close();
507
+ }
508
+
509
+ private clearCountdown(): void {
510
+ if (this.countdown !== undefined) this.clock.clearTimeout(this.countdown);
511
+ this.countdown = undefined;
512
+ }
513
+
514
+ applyConfig(config: CueloopConfig): void {
515
+ this.autoClose = config.ui.autoClose;
516
+ this.quickActions = config.actions;
517
+ this.editor = config.ui.editor;
518
+ void loadBundledExporters(config.integrations).then((exporters) => {
519
+ this.exporters = exporters;
520
+ });
521
+ }
522
+
523
+ setStatus(message: string): void {
524
+ // the persistent status bar is gone; transient feedback surfaces as a toast
525
+ this.update({ status: message, toast: message ? { body: message } : null });
526
+ }
527
+
528
+ showToast(body: string, title?: string): void {
529
+ this.update({ toast: { body, title } });
530
+ }
531
+
532
+ dismissToast(): void {
533
+ if (this.snapshot.toast) this.update({ toast: null });
534
+ }
535
+
536
+ // ── derived projections ─────────────────────
537
+ /**
538
+ * The projection reuses when the same thread's derivation inputs are unchanged. display, rows,
539
+ * files, and models derive only from the artifact and working copy - never from annotations,
540
+ * status, or the message - so an update that touched only those produces an all-new session
541
+ * record but an identical projection.
542
+ */
543
+ private reusesDerived(session: Thread, liveDiff: LiveWorkingDiff | null): boolean {
544
+ return (
545
+ this.derivedFor !== null &&
546
+ this.derivedForLiveDiff === liveDiff &&
547
+ sameDerivationInputs(this.derivedFor, session)
548
+ );
549
+ }
550
+
551
+ private buildDerived(
552
+ session: Thread | null,
553
+ frozenDiff: boolean,
554
+ liveDiff: LiveWorkingDiff | null,
555
+ ): DerivedSessionProjection {
556
+ const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
557
+ const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
558
+ const rows = content ? diffRows(content) : [];
559
+ const fileContents = new Map<string, DiffFileContents>();
560
+
561
+ for (const file of files) fileContents.set(file.path, file);
562
+
563
+ return {
564
+ display: session
565
+ ? buildDisplay(session.artifact.content, session.workingCopy, session.textCuts)
566
+ : [],
567
+ rows,
568
+ files: walkFiles(rows),
569
+ fileContents,
570
+ models: new Map<string, FileDiffMetadata>(),
571
+ tree: session?.history ? treeRows(session.history) : [],
572
+ };
573
+ }
574
+
575
+ /** The parsed model for a changed file, computed on first touch and memoized in the projection. */
576
+ private fileModel(path: string): FileDiffMetadata | undefined {
577
+ const cached = this.derived.models.get(path);
578
+
579
+ if (cached) return cached;
580
+ const contents = this.derived.fileContents.get(path);
581
+
582
+ if (!contents) return undefined;
583
+ const model = parseFileDiff(contents);
584
+
585
+ this.derived.models.set(path, model);
586
+
587
+ return model;
588
+ }
589
+
590
+ private ensureDerived(): void {
591
+ const session = this.snapshot.session;
592
+ const frozenDiff = readsFrozenDiff(session);
593
+ // a plain diff thread reads its pinned capture; a workbench thread and every other thread reflect the live working tree
594
+ const liveDiff = frozenDiff ? null : this.liveDiff;
595
+
596
+ if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
597
+ // reuse the parsed projection across an update that left the inputs unchanged; only the cheap tree can differ
598
+ if (session !== null && this.reusesDerived(session, liveDiff)) {
599
+ this.derivedFor = session;
600
+ this.derived = { ...this.derived, tree: session.history ? treeRows(session.history) : [] };
601
+
602
+ return;
603
+ }
604
+ // a different thread resets fold state; a same-thread re-derivation (content edit) keeps it
605
+ if (this.derivedFor?.id !== session?.id) {
606
+ this.collapsedFiles.clear();
607
+ this.expandedFiles.clear();
608
+ }
609
+ // returning to a recently-viewed thread whose inputs are unchanged reuses its parsed projection
610
+ const cached = session !== null ? this.derivedCache.get(session.id) : undefined;
611
+
612
+ if (
613
+ cached &&
614
+ cached.derivedForLiveDiff === liveDiff &&
615
+ sameDerivationInputs(cached.derivedFor, session!)
616
+ ) {
617
+ this.derivedFor = session;
618
+ this.derivedForLiveDiff = liveDiff;
619
+ this.derived = {
620
+ ...cached.derived,
621
+ tree: session!.history ? treeRows(session!.history) : [],
622
+ };
623
+ this.rememberDerived(session!, liveDiff, this.derived);
624
+
625
+ return;
626
+ }
627
+ this.derivedFor = session;
628
+ this.derivedForLiveDiff = liveDiff;
629
+ this.derived = this.buildDerived(session, frozenDiff, liveDiff);
630
+ if (session !== null) this.rememberDerived(session, liveDiff, this.derived);
631
+ }
632
+
633
+ /** Keep the freshest projections per thread, evicting the least-recently-used past the cap. */
634
+ private rememberDerived(
635
+ session: Thread,
636
+ liveDiff: LiveWorkingDiff | null,
637
+ derived: DerivedSessionProjection,
638
+ ): void {
639
+ this.derivedCache.delete(session.id);
640
+ this.derivedCache.set(session.id, {
641
+ derivedFor: session,
642
+ derivedForLiveDiff: liveDiff,
643
+ derived,
644
+ });
645
+ while (this.derivedCache.size > DERIVED_CACHE_LIMIT) {
646
+ const oldest = this.derivedCache.keys().next().value;
647
+
648
+ if (oldest === undefined) break;
649
+ this.derivedCache.delete(oldest);
650
+ }
651
+ }
652
+
653
+ /** The per-file contents that back the current rows: a diff thread's capture, else the live working tree. */
654
+ private foldFiles(): readonly DiffFileContents[] | undefined {
655
+ const session = this.snapshot.session;
656
+
657
+ return readsFrozenDiff(session) ? session!.artifact.files : this.liveDiff?.files;
658
+ }
659
+
660
+ treeRows(): TreeRow[] {
661
+ this.ensureDerived();
662
+
663
+ return this.derived.tree;
664
+ }
665
+
666
+ display(): DisplayBlock[] {
667
+ this.ensureDerived();
668
+
669
+ return this.derived.display;
670
+ }
671
+
672
+ rows(): DiffRow[] {
673
+ this.ensureDerived();
674
+ if (this.collapsedFiles.size === 0 && this.expandedFiles.size === 0) return this.derived.rows;
675
+
676
+ return applyFold(this.derived.rows, this.collapsedFiles, this.expandedFiles, this.foldFiles());
677
+ }
678
+
679
+ setFileCollapsed(file: string, collapsed: boolean): void {
680
+ if (collapsed) {
681
+ this.collapsedFiles.add(file);
682
+ this.expandedFiles.delete(file);
683
+ } else {
684
+ this.collapsedFiles.delete(file);
685
+ }
686
+ this.update({});
687
+ }
688
+
689
+ isFileCollapsed(file: string): boolean {
690
+ return this.collapsedFiles.has(file);
691
+ }
692
+
693
+ setFileExpanded(file: string, expanded: boolean): void {
694
+ if (expanded) {
695
+ this.expandedFiles.add(file);
696
+ this.collapsedFiles.delete(file);
697
+ } else {
698
+ this.expandedFiles.delete(file);
699
+ }
700
+ this.update({});
701
+ }
702
+
703
+ isFileExpanded(file: string): boolean {
704
+ return this.expandedFiles.has(file);
705
+ }
706
+
707
+ canExpandFile(file: string): boolean {
708
+ // a live working-tree diff carries the same per-file contents as a captured one,
709
+ // so a non-diff thread can expand its files too
710
+ return (this.foldFiles() ?? []).some((entry) => entry.path === file);
711
+ }
712
+
713
+ copyFilePath(file: string): Promise<boolean> {
714
+ return copyToClipboard(file);
715
+ }
716
+
717
+ fileStats(): Map<string, { additions: number; deletions: number }> {
718
+ this.ensureDerived();
719
+
720
+ return fileChangeCounts(this.derived.rows);
721
+ }
722
+
723
+ files(): WalkFile[] {
724
+ this.ensureDerived();
725
+
726
+ return this.derived.files;
727
+ }
728
+
729
+ working(): string {
730
+ const session = this.snapshot.session;
731
+
732
+ return session ? (session.workingCopy ?? session.artifact.content) : "";
733
+ }
734
+
735
+ async projectFiles(): Promise<string[]> {
736
+ const session = this.snapshot.session;
737
+
738
+ if (this.client?.projectFiles === undefined || !session) return [];
739
+
740
+ return this.client.projectFiles(session.id);
741
+ }
742
+
743
+ async readFile(path: string): Promise<string | null> {
744
+ const session = this.snapshot.session;
745
+
746
+ if (this.client?.fileContents === undefined || !session) return null;
747
+
748
+ return this.client.fileContents(session.id, path);
749
+ }
750
+
751
+ /** The repo the sidebar tree/changes point at: the thread's own repo, else the launch directory. */
752
+ private sidebarRepoRoot(): string {
753
+ return this.snapshot.session?.workspace.repoRoot || this.options.cwd || process.cwd();
754
+ }
755
+
756
+ async repoFiles(): Promise<string[]> {
757
+ if (this.client?.repoFiles === undefined) return [];
758
+
759
+ return this.client.repoFiles(this.sidebarRepoRoot());
760
+ }
761
+
762
+ async repoReadFile(path: string): Promise<string | null> {
763
+ if (this.client?.repoFileContents === undefined) return null;
764
+
765
+ return this.client.repoFileContents(this.sidebarRepoRoot(), path);
766
+ }
767
+
768
+ async repoChanges(): Promise<readonly DiffFileContents[]> {
769
+ // a plain diff review pins its captured snapshot; a workbench thread and every other thread reflect the live working tree
770
+ const session = this.snapshot.session;
771
+
772
+ if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
773
+ const initialFiles = this.consumeInitialDiffFiles(session);
774
+
775
+ if (initialFiles !== null) return initialFiles;
776
+ // eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
777
+ if (this.client?.repoDiff !== undefined) {
778
+ const diff = await this.client.repoDiff(
779
+ this.sidebarRepoRoot(),
780
+ session?.artifact.meta.workbench ? (session.artifact.meta.vcs ?? "git") : undefined,
781
+ );
782
+
783
+ // a thread switch during the request would let this response overwrite the new thread's diff,
784
+ // showing changes from the wrong repo; compare the thread id, not the object, so an unrelated
785
+ // re-render that replaced the snapshot for the SAME thread does not drop its diff to "No changes"
786
+ if (this.snapshot.session?.id !== session?.id) return diff.files;
787
+ const changed =
788
+ this.liveDiff?.patch !== diff.patch ||
789
+ this.liveDiff.files.length !== diff.files.length ||
790
+ this.liveDiff.files.some(
791
+ (file, index) =>
792
+ file.path !== diff.files[index]?.path ||
793
+ file.status !== diff.files[index]?.status ||
794
+ file.oldContents !== diff.files[index]?.oldContents ||
795
+ file.newContents !== diff.files[index]?.newContents,
796
+ );
797
+
798
+ if (changed) {
799
+ this.liveDiff = diff;
800
+ // rows() derive from the fresh patch; re-render so an open diff tab repaints
801
+ this.update({});
802
+ }
803
+
804
+ return diff.files;
805
+ }
806
+ // a peer without the live-diff capability still lists changes, just without a diff body
807
+ if (this.client?.repoChanges === undefined) return [];
808
+ const changes = await this.client.repoChanges(this.sidebarRepoRoot());
809
+
810
+ return changes.map((change) => ({
811
+ path: change.path,
812
+ status: change.status,
813
+ oldContents: "",
814
+ newContents: "",
815
+ }));
816
+ }
817
+
818
+ private consumeInitialDiffFiles(session: Thread | null): readonly DiffFileContents[] | null {
819
+ if (!this.initialDiffAvailable) return null;
820
+ this.initialDiffAvailable = false;
821
+
822
+ return session?.id === this.options.sessionId ? (this.liveDiff?.files ?? []) : null;
823
+ }
824
+
825
+ async refreshDiff(): Promise<void> {
826
+ const session = this.snapshot.session;
827
+
828
+ if (session?.artifact.type !== "diff" || !this.client?.sessionRefreshDiff) return;
829
+ try {
830
+ await this.client.sessionRefreshDiff(session.id);
831
+ } catch (cause) {
832
+ this.setStatus(cause instanceof Error ? cause.message : String(cause));
833
+ }
834
+ }
835
+
836
+ // Refreshes race the connection teardown: an event can arrive while close()
837
+ // is rejecting in-flight requests, and a fire-and-forget refresh must never
838
+ // surface that as an unhandled rejection.
839
+ private async refreshSession(id: string): Promise<void> {
840
+ try {
841
+ if (!this.client) return;
842
+ const session = await this.client.sessionGet(id);
843
+
844
+ // a rapid switch may have moved on during the fetch; never clobber the now-viewed thread
845
+ if (this.viewingId === session.id) this.update({ session });
846
+ } catch (cause) {
847
+ if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
848
+ }
849
+ }
850
+
851
+ private async refreshInbox(): Promise<void> {
852
+ try {
853
+ if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
854
+ } catch (cause) {
855
+ if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
856
+ }
857
+ }
858
+
859
+ /** Optimistic apply: the daemon response is the next session snapshot. */
860
+ private apply(mutation: Promise<Thread>): void {
861
+ mutation
862
+ .then((session) => this.update({ session }))
863
+ .catch((cause: unknown) =>
864
+ this.setStatus(String(cause instanceof Error ? cause.message : cause)),
865
+ );
866
+ }
867
+
868
+ /**
869
+ * Show the expected result before the daemon answers, so a second keypress
870
+ * builds on the first instead of on the stale snapshot. Answers arrive in
871
+ * request order over the socket and replace the guess with the daemon's copy.
872
+ */
873
+ /** Agent invocation waits until the preceding comments reach the daemon. */
874
+ async flushMutations(): Promise<void> {
875
+ await Promise.all(this.pendingMutations);
876
+ }
877
+
878
+ private applyOptimistic(expected: Thread, mutation: Promise<Thread>): void {
879
+ this.pendingMutations.add(mutation);
880
+ void mutation.finally(() => this.pendingMutations.delete(mutation)).catch(() => {});
881
+ this.update({ session: expected });
882
+ mutation
883
+ .then((session) => this.update({ session }))
884
+ .catch((cause: unknown) => {
885
+ // a refused guess must not stay on screen: the daemon's record replaces it
886
+ this.setStatus(String(cause instanceof Error ? cause.message : cause));
887
+ void this.refreshSession(expected.id);
888
+ });
889
+ }
890
+
891
+ // ── primitives ───────────────────────────────────
892
+ open(id: string): void {
893
+ this.locallyViewed.clear();
894
+ this.viewingId = id;
895
+ const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
896
+
897
+ // paint the sidebar copy at once for a snappy switch, then revalidate: the inbox
898
+ // copy trails the daemon for a live diff, whose session.updated refreshes the open
899
+ // thread but not the list, so a returned-to diff would otherwise show stale content
900
+ if (cached) this.update({ session: cached });
901
+ void this.refreshSession(id);
902
+ }
903
+
904
+ async createEmptyThread(): Promise<void> {
905
+ if (this.readOnly || !this.client?.sessionCreate) return;
906
+ const workspace = this.snapshot.session?.workspace ?? {
907
+ repoRoot: this.options.cwd ?? process.cwd(),
908
+ branch: "detached",
909
+ };
910
+
911
+ try {
912
+ const thread = await this.client.sessionCreate(workspace, {
913
+ type: "plan",
914
+ content: "",
915
+ meta: { title: "New Thread" },
916
+ });
917
+
918
+ this.locallyViewed.clear();
919
+ this.viewingId = thread.id;
920
+ this.update({ session: thread });
921
+ } catch (cause) {
922
+ this.setStatus(
923
+ `New Thread failed: ${cause instanceof Error ? cause.message : String(cause)}`,
924
+ );
925
+ }
926
+ }
927
+
928
+ deleteSession(id: string): void {
929
+ if (this.readOnly) return this.setStatus("observer - read-only");
930
+ // a deleted thread's links must not outlive it; revoke every one first, best-effort, so an
931
+ // unreachable gateway never blocks the local delete (the blob's 30-day TTL is the backstop)
932
+ for (const link of this.linksFor(this.sessionRecord(id)))
933
+ void this.shareTransport.revoke(link.id).catch(() => {});
934
+ void this.client
935
+ ?.sessionDelete(id)
936
+ .catch((cause: unknown) =>
937
+ this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
938
+ );
939
+ }
940
+
941
+ /** The record for a thread id, whether it is the open thread or a sidebar row. */
942
+ private sessionRecord(id: string): Thread | undefined {
943
+ if (this.snapshot.session?.id === id) return this.snapshot.session;
944
+
945
+ return this.snapshot.inbox?.find((candidate) => candidate.id === id);
946
+ }
947
+
948
+ /** A thread's share links; empty when never shared. */
949
+ private linksFor(session: Thread | null | undefined): ShareLink[] {
950
+ return session ? (session.shares ?? []) : [];
951
+ }
952
+
953
+ renameSession(id: string, title: string): void {
954
+ if (this.readOnly) return this.setStatus("observer - read-only");
955
+ void this.client
956
+ ?.sessionSetTitle(id, title)
957
+ .catch((cause: unknown) =>
958
+ this.setStatus(`rename failed: ${cause instanceof Error ? cause.message : String(cause)}`),
959
+ );
960
+ }
961
+
962
+ setSelfName(name: string): void {
963
+ const session = this.snapshot.session;
964
+
965
+ if (!session) return;
966
+ this.apply(this.client!.sessionSetSelfName(session.id, name));
967
+ }
968
+
969
+ cut(displayIndex: number, start?: number, end?: number, endDisplayIndex = displayIndex): void {
970
+ const session = this.snapshot.session;
971
+
972
+ if (!session || session.status === "resolved") return;
973
+ const block = this.display()[displayIndex];
974
+
975
+ if (!block) return;
976
+ const working = this.working();
977
+
978
+ if (start !== undefined && end !== undefined && block.work) {
979
+ const display = this.display();
980
+ const endBlock = display[endDisplayIndex];
981
+
982
+ if (!endBlock?.work) return;
983
+ const exactSourceRange = this.exactCutSourceRange(
984
+ session,
985
+ display,
986
+ displayIndex,
987
+ endDisplayIndex,
988
+ start,
989
+ end,
990
+ );
991
+
992
+ if (exactSourceRange) {
993
+ const existingCuts = session.textCuts ?? [];
994
+ const textCuts =
995
+ restoreTextCut(
996
+ session.artifact.content,
997
+ existingCuts,
998
+ exactSourceRange.start,
999
+ exactSourceRange.end,
1000
+ ) ??
1001
+ mergeTextCut(
1002
+ session.artifact.content,
1003
+ existingCuts,
1004
+ exactSourceRange.start,
1005
+ exactSourceRange.end,
1006
+ );
1007
+ const content = applyTextCuts(session.artifact.content, textCuts);
1008
+
1009
+ if (content === working) return;
1010
+ const expected: Thread = { ...session, workingCopy: content, textCuts };
1011
+
1012
+ if (textCuts.length === 0) {
1013
+ delete expected.workingCopy;
1014
+ delete expected.textCuts;
1015
+ }
1016
+
1017
+ this.applyOptimistic(
1018
+ expected,
1019
+ this.client!.sessionSetWorkingCopy(session.id, content, textCuts),
1020
+ );
1021
+
1022
+ return;
1023
+ }
1024
+ const range = renderedSpanToWork(display, displayIndex, endDisplayIndex, start, end);
1025
+ const content = cutTextRange(working, block.work, range.start, endBlock.work, range.end);
1026
+
1027
+ if (content === working) return;
1028
+ this.setWorkingCopy(content);
1029
+ } else if (block.type === "del") {
1030
+ this.restoreDelBlock(block, displayIndex);
1031
+ } else if (block.work) {
1032
+ this.cutWholeBlock(session, block, working);
1033
+ }
1034
+ }
1035
+
1036
+ private cutWholeBlock(session: Thread, block: DisplayBlock, working: string): void {
1037
+ if (!block.work) return;
1038
+ const base = session.artifact.content;
1039
+ const existingCuts = session.textCuts ?? [];
1040
+
1041
+ if (existingCuts.length > 0 && block.base && working === applyTextCuts(base, existingCuts)) {
1042
+ const range = blockCutSourceRange(base, block.base);
1043
+ const textCuts = mergeTextCut(base, existingCuts, range.start, range.end);
1044
+ const content = applyTextCuts(base, textCuts);
1045
+
1046
+ if (content === working) return;
1047
+ this.applyOptimistic(
1048
+ { ...session, workingCopy: content, textCuts },
1049
+ this.client!.sessionSetWorkingCopy(session.id, content, textCuts),
1050
+ );
1051
+
1052
+ return;
1053
+ }
1054
+ const workIndex = parseBlocks(working).findIndex(
1055
+ (candidate) => candidate.lineStart === block.work!.lineStart,
1056
+ );
1057
+
1058
+ if (workIndex === -1) return;
1059
+ this.applyOptimistic(
1060
+ { ...session, workingCopy: cutBlock(working, block.work) },
1061
+ this.client!.sessionCutBlock(session.id, workIndex),
1062
+ );
1063
+ }
1064
+
1065
+ private exactCutSourceRange(
1066
+ session: Thread,
1067
+ display: DisplayBlock[],
1068
+ displayIndex: number,
1069
+ endDisplayIndex: number,
1070
+ renderedStart: number,
1071
+ renderedEnd: number,
1072
+ ): { start: number; end: number } | null {
1073
+ const base = session.artifact.content;
1074
+ const existingCuts = session.textCuts ?? [];
1075
+
1076
+ if ((session.workingCopy ?? base) !== applyTextCuts(base, existingCuts)) return null;
1077
+ const startBlock = display[displayIndex];
1078
+ const endBlock = display[endDisplayIndex];
1079
+
1080
+ if (!startBlock?.base || !endBlock?.base) return null;
1081
+ if (existingCuts.length === 0) {
1082
+ const workRange = renderedSpanToWork(
1083
+ display,
1084
+ displayIndex,
1085
+ endDisplayIndex,
1086
+ renderedStart,
1087
+ renderedEnd,
1088
+ );
1089
+ const sourceStart = sourceOffsetAt(base, startBlock.base, workRange.start);
1090
+ const sourceEnd = sourceOffsetAt(base, endBlock.base, workRange.end);
1091
+
1092
+ return sourceStart === undefined || sourceEnd === undefined
1093
+ ? null
1094
+ : { start: sourceStart, end: sourceEnd };
1095
+ }
1096
+ const startRange = baseRangeForRendered(
1097
+ blockRuns(startBlock, true),
1098
+ renderedStart,
1099
+ displayIndex === endDisplayIndex ? renderedEnd : renderedText(startBlock).length,
1100
+ );
1101
+ const endRange =
1102
+ displayIndex === endDisplayIndex
1103
+ ? startRange
1104
+ : baseRangeForRendered(blockRuns(endBlock, true), 0, renderedEnd);
1105
+
1106
+ if (!startRange || !endRange) return null;
1107
+
1108
+ const sourceStart = sourceOffsetAt(base, startBlock.base, startRange.start);
1109
+ const sourceEnd = sourceOffsetAt(base, endBlock.base, endRange.end);
1110
+
1111
+ return sourceStart === undefined || sourceEnd === undefined
1112
+ ? null
1113
+ : { start: sourceStart, end: sourceEnd };
1114
+ }
1115
+
1116
+ /** Re-insert a cut plan block at the next surviving block's line (Cut toggle + rail undo). */
1117
+ private restoreDelBlock(block: DisplayBlock, displayIndex: number): void {
1118
+ const session = this.snapshot.session;
1119
+
1120
+ if (!session || !block.base) return;
1121
+ const working = this.working();
1122
+ const line = restoreLine(
1123
+ nextWorkBlock(this.display(), displayIndex),
1124
+ working.split("\n").length,
1125
+ );
1126
+ const base = session.artifact.content;
1127
+ const baseIndex = parseBlocks(base).findIndex(
1128
+ (candidate) => candidate.lineStart === block.base!.lineStart,
1129
+ );
1130
+
1131
+ if (baseIndex === -1) return;
1132
+ const expected: Thread = {
1133
+ ...session,
1134
+ workingCopy: restoreBlock(base, working, block.base, line),
1135
+ };
1136
+
1137
+ if (expected.workingCopy === undefined) delete expected.workingCopy;
1138
+ this.applyOptimistic(expected, this.client!.sessionRestoreBlock(session.id, baseIndex, line));
1139
+ }
1140
+
1141
+ // ── diff hunk curation ──────────────────────
1142
+ /** The parsed model and row for a curation action, or null with a status set. */
1143
+ private curationRow(rowIndex: number): { row: DiffRow; model: FileDiffMetadata } | null {
1144
+ const session = this.snapshot.session;
1145
+
1146
+ if (!session || session.status === "resolved") return null;
1147
+ if (!session.artifact.files?.length) {
1148
+ this.setStatus("hunk curation needs full file contents (PR diffs cannot be curated)");
1149
+
1150
+ return null;
1151
+ }
1152
+ const row = this.rows()[rowIndex];
1153
+
1154
+ if (!row || row.kind === "file" || row.kind === "hunk") {
1155
+ this.setStatus("move to a code line to curate");
1156
+
1157
+ return null;
1158
+ }
1159
+ const model = this.fileModel(row.file);
1160
+
1161
+ if (!model) return null;
1162
+
1163
+ return { row, model };
1164
+ }
1165
+
1166
+ toggleRejectHunk(rowIndex: number): void {
1167
+ const located = this.curationRow(rowIndex);
1168
+
1169
+ if (!located) return;
1170
+ const target = hunkRejectionForRow(located.row.file, located.model, located.row);
1171
+
1172
+ if (!target) return this.setStatus("no hunk under the cursor");
1173
+ const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
1174
+
1175
+ if (this.rejections.some(wholeHunk)) {
1176
+ this.curate(this.rejections.filter((rejection) => !wholeHunk(rejection)));
1177
+ } else {
1178
+ // a whole-hunk reject supersedes any change-level rejects inside it
1179
+ const others = this.rejections.filter(
1180
+ (rejection) =>
1181
+ !(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
1182
+ );
1183
+
1184
+ this.curate([...others, target]);
1185
+ }
1186
+ }
1187
+
1188
+ toggleRejectChange(rowIndex: number): void {
1189
+ const located = this.curationRow(rowIndex);
1190
+
1191
+ if (!located) return;
1192
+ if (located.row.kind !== "add" && located.row.kind !== "del")
1193
+ return this.setStatus("move to a changed line to reject a change");
1194
+ const target = changeRejectionForRow(located.row.file, located.model, located.row);
1195
+
1196
+ if (!target) return this.setStatus("no change under the cursor");
1197
+ const wholeCovers = this.rejections.some((rejection) => rejectsWholeHunk(rejection, target));
1198
+
1199
+ if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
1200
+ if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
1201
+ this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
1202
+ } else {
1203
+ this.curate([...this.rejections, target]);
1204
+ }
1205
+ }
1206
+
1207
+ /** Hand the daemon the full set of reject decisions; the working copy follows. */
1208
+ private curate(rejections: HunkRejection[]): void {
1209
+ const session = this.snapshot.session;
1210
+
1211
+ if (!session) return;
1212
+ const expected: Thread = { ...session, curation: rejections };
1213
+
1214
+ if (!rejections.length) delete expected.curation;
1215
+ this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
1216
+ }
1217
+
1218
+ rejectedRows(): Set<number> {
1219
+ this.ensureDerived();
1220
+ if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
1221
+ const rejected = new Set<number>();
1222
+
1223
+ this.derived.rows.forEach((row, index) => {
1224
+ const model = this.fileModel(row.file);
1225
+
1226
+ if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
1227
+ });
1228
+
1229
+ return rejected;
1230
+ }
1231
+
1232
+ curationItems(): CurationItem[] {
1233
+ this.ensureDerived();
1234
+ const session = this.snapshot.session;
1235
+
1236
+ if (!session) return EMPTY_CURATION_ITEMS;
1237
+
1238
+ return session.artifact.type === "diff" ? this.diffCurationItems() : this.planCurationItems();
1239
+ }
1240
+
1241
+ restoreCuration(id: string): void {
1242
+ // diff rejections drop from the reject list; a plan cut re-inserts its block
1243
+ if (id.startsWith("diff:")) {
1244
+ const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
1245
+
1246
+ if (kept.length === this.rejections.length) return;
1247
+ this.curate(kept);
1248
+
1249
+ return;
1250
+ }
1251
+ const display = this.display();
1252
+ const displayIndex = display.findIndex(
1253
+ (block) => block.type === "del" && block.base && planCutId(block.base) === id,
1254
+ );
1255
+
1256
+ if (displayIndex !== -1) this.restoreDelBlock(display[displayIndex]!, displayIndex);
1257
+ }
1258
+
1259
+ /** The curated-out diff rejections as removal cards (newest decisions last). */
1260
+ private diffCurationItems(): CurationItem[] {
1261
+ if (!this.rejections.length) return EMPTY_CURATION_ITEMS;
1262
+ const items: CurationItem[] = [];
1263
+
1264
+ for (const rejection of this.rejections) {
1265
+ const rows = this.rowsForRejection(rejection);
1266
+ const firstRow = rows[0];
1267
+
1268
+ items.push({
1269
+ id: curationItemId(rejection),
1270
+ source: "diff",
1271
+ preview: rows.map(
1272
+ (row) => `${row.kind === "add" ? "+" : "-"} ${row.text.replace(/\n$/, "")}`,
1273
+ ),
1274
+ revealIndex: firstRow ? this.derived.rows.indexOf(firstRow) : 0,
1275
+ });
1276
+ }
1277
+
1278
+ return items;
1279
+ }
1280
+
1281
+ /** The cut plan blocks as removal cards, in document order. */
1282
+ private planCurationItems(): CurationItem[] {
1283
+ const items: CurationItem[] = [];
1284
+
1285
+ this.derived.display.forEach((block, displayIndex) => {
1286
+ if (block.type !== "del" || !block.base) return;
1287
+ items.push({
1288
+ id: planCutId(block.base),
1289
+ source: "plan",
1290
+ preview: block.base.text.split("\n"),
1291
+ revealIndex: displayIndex,
1292
+ });
1293
+ });
1294
+
1295
+ return items.length ? items : EMPTY_CURATION_ITEMS;
1296
+ }
1297
+
1298
+ /** The change/deletion rows a rejection covers, for its preview and reveal row. */
1299
+ private rowsForRejection(rejection: HunkRejection): DiffRow[] {
1300
+ const model = this.fileModel(rejection.path);
1301
+
1302
+ if (!model) return [];
1303
+ const rows: DiffRow[] = [];
1304
+
1305
+ for (const row of this.derived.rows) {
1306
+ if (row.file !== rejection.path || (row.kind !== "add" && row.kind !== "del")) continue;
1307
+ if (rejection.changeIndex === undefined) {
1308
+ const target = hunkRejectionForRow(rejection.path, model, row);
1309
+
1310
+ if (target && target.hunkIndex === rejection.hunkIndex) rows.push(row);
1311
+ } else {
1312
+ const target = changeRejectionForRow(rejection.path, model, row);
1313
+
1314
+ if (target && sameRejection(target, rejection)) rows.push(row);
1315
+ }
1316
+ }
1317
+
1318
+ return rows;
1319
+ }
1320
+
1321
+ edit(): void {
1322
+ const session = this.snapshot.session;
1323
+
1324
+ if (!session || session.status === "resolved") return;
1325
+ try {
1326
+ const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
1327
+
1328
+ if (result.changed) this.saveEditedBody(result.content);
1329
+ } catch (err) {
1330
+ this.setStatus(err instanceof Error ? err.message : String(err));
1331
+ }
1332
+ }
1333
+
1334
+ // no toast: leaving the inline editor is a quiet return to the read-only view
1335
+ saveEditedBody(content: string): void {
1336
+ const session = this.snapshot.session;
1337
+
1338
+ if (!session || session.status === "resolved") return;
1339
+ if (content === this.working()) return;
1340
+ this.setWorkingCopy(content);
1341
+ this.reconcileAnnotations(session, content);
1342
+ }
1343
+
1344
+ /**
1345
+ * Edit-exit reconciliation: re-resolve every annotation against the edited
1346
+ * working copy with the quote-primary cascade. Annotations that stop
1347
+ * resolving stay stored (the feedback serializer flags orphaned anchors);
1348
+ * the count feeds the one-line banner above the sheet.
1349
+ */
1350
+ private reconcileAnnotations(session: Thread, editedContent: string): void {
1351
+ const editedBlocks = parseBlocks(editedContent);
1352
+ const orphanCount = session.annotations.filter(
1353
+ (annotation) => resolveAnchor(annotation.anchor, editedBlocks) === null,
1354
+ ).length;
1355
+
1356
+ this.update({ editOrphanCount: orphanCount });
1357
+ }
1358
+
1359
+ annotate(
1360
+ kind: "comment",
1361
+ displayIndex: number,
1362
+ start: number,
1363
+ end: number,
1364
+ body: string,
1365
+ endDisplayIndex: number = displayIndex,
1366
+ target: AnnotationTarget = { kind: "artifact" },
1367
+ ): string | undefined {
1368
+ const session = this.snapshot.session;
1369
+
1370
+ if (!session) return undefined;
1371
+ let anchor;
1372
+ let resolvedTarget = target;
1373
+ // a file target (a Changes-panel diff note) and a diff artifact both anchor into the diff rows
1374
+ const onDiff = target.kind === "file" || session.artifact.type === "diff";
1375
+
1376
+ if (target.kind === "file") {
1377
+ // a Changes-panel note anchors within its own file's rows, so a quote never attaches across
1378
+ // files; a selection that spills into the next file is clamped back to the file it started in
1379
+ const rows = this.rows();
1380
+ const startRow = rows[displayIndex];
1381
+ const path = startRow?.file ?? "";
1382
+ const range = fileRowRange(rows, path);
1383
+ const base = range?.start ?? 0;
1384
+ const fileRows = range ? rows.slice(range.start, range.end) : rows;
1385
+ const lastInFile = fileRows.length - 1;
1386
+ const clamp = (index: number): number => Math.max(0, Math.min(index - base, lastInFile));
1387
+
1388
+ anchor = makeAnchor(
1389
+ diffRowBlocks(fileRows),
1390
+ clamp(displayIndex),
1391
+ start,
1392
+ end,
1393
+ clamp(endDisplayIndex),
1394
+ );
1395
+ resolvedTarget = { kind: "file", path, rev: startRow?.kind === "del" ? "head" : "worktree" };
1396
+ } else if (onDiff) {
1397
+ // rows are the diff's blocks: a span over one or more code rows anchors with the
1398
+ // same quote, context, and position selectors a plan span does
1399
+ anchor = makeAnchor(diffRowBlocks(this.rows()), displayIndex, start, end, endDisplayIndex);
1400
+ } else {
1401
+ const display = this.display();
1402
+ const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
1403
+ const workIndexOf = (index: number): number =>
1404
+ display.slice(0, index + 1).filter((entry) => entry.work).length - 1;
1405
+ // the plan surface hands back rendered offsets (inline markers concealed); the quote is cut
1406
+ // from work text, so map the selection onto the work characters it covers first
1407
+ const work = renderedSpanToWork(display, displayIndex, endDisplayIndex, start, end);
1408
+
1409
+ anchor = makeAnchor(
1410
+ workBlocks,
1411
+ workIndexOf(displayIndex),
1412
+ work.start,
1413
+ work.end,
1414
+ workIndexOf(endDisplayIndex),
1415
+ );
1416
+ }
1417
+ // absent target means the reviewed artifact, so keep artifact notes free of the field
1418
+ const wire =
1419
+ resolvedTarget.kind === "artifact"
1420
+ ? { id: newAnnotationId(), kind, anchor, body }
1421
+ : { id: newAnnotationId(), kind, anchor, body, target: resolvedTarget };
1422
+ const persisted = this.client!.sessionComment(session.id, wire);
1423
+
1424
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1425
+ this.mirrorAnnotation(persisted, wire);
1426
+
1427
+ return wire.id;
1428
+ }
1429
+
1430
+ addComment(anchor: Anchor, target: AnnotationTarget, body: string): string | undefined {
1431
+ const session = this.snapshot.session;
1432
+
1433
+ if (!session) return undefined;
1434
+ const wire =
1435
+ target.kind === "artifact"
1436
+ ? { id: newAnnotationId(), kind: "comment", anchor, body }
1437
+ : { id: newAnnotationId(), kind: "comment", anchor, body, target };
1438
+ const persisted = this.client!.sessionComment(session.id, wire);
1439
+
1440
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1441
+ this.mirrorAnnotation(persisted, wire);
1442
+
1443
+ return wire.id;
1444
+ }
1445
+
1446
+ /** Find-or-create the per-repo workbench thread and adopt it as active; true once a session exists. */
1447
+ private async ensureWorkbenchSession(): Promise<boolean> {
1448
+ if (this.snapshot.session) return true;
1449
+ if (this.client?.sessionWorkbench === undefined) return false;
1450
+ let workbench;
1451
+
1452
+ try {
1453
+ workbench = await this.client.sessionWorkbench(this.options.cwd ?? process.cwd());
1454
+ } catch (cause) {
1455
+ // the composer already closed, so a lost first comment must at least surface, not vanish
1456
+ this.setStatus(String(cause instanceof Error ? cause.message : cause));
1457
+
1458
+ return false;
1459
+ }
1460
+ // adopt the thread as active (the shell flips to the thread view) and let the inbox catch up
1461
+ this.update({ session: workbench });
1462
+ void this.refreshInbox();
1463
+
1464
+ return true;
1465
+ }
1466
+
1467
+ async commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void> {
1468
+ if (!(await this.ensureWorkbenchSession())) return;
1469
+ this.addComment(anchor, target, body);
1470
+ }
1471
+
1472
+ /** A first comment on the bare-launch Changes diff: promote to the workbench thread, then anchor it in the diff rows. */
1473
+ async commentOnWorkbenchDiff(
1474
+ displayIndex: number,
1475
+ start: number,
1476
+ end: number,
1477
+ endDisplayIndex: number,
1478
+ body: string,
1479
+ ): Promise<void> {
1480
+ if (!(await this.ensureWorkbenchSession())) return;
1481
+ // annotate re-derives the file path and rev from the diff row it lands on
1482
+ this.annotate("comment", displayIndex, start, end, body, endDisplayIndex, {
1483
+ kind: "file",
1484
+ path: "",
1485
+ rev: "worktree",
1486
+ });
1487
+ }
1488
+
1489
+ reply(rootAnnotationId: string, body: string): string | undefined {
1490
+ const session = this.snapshot.session;
1491
+ const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
1492
+
1493
+ if (!session || !root) return undefined;
1494
+ // a reply to a reply still hangs off the discussion's root comment, and shares its target
1495
+ // so it renders and resolves on the same surface
1496
+ const base = {
1497
+ id: newAnnotationId(),
1498
+ kind: "comment",
1499
+ anchor: root.anchor,
1500
+ body,
1501
+ replyTo: root.replyTo ?? root.id,
1502
+ };
1503
+ const wire = root.target ? { ...base, target: root.target } : base;
1504
+ const persisted = this.client!.sessionComment(session.id, wire);
1505
+
1506
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1507
+ this.mirrorAnnotation(persisted, wire);
1508
+
1509
+ return wire.id;
1510
+ }
1511
+
1512
+ annotatePrototype(selector: string, quote: string, body: string): string | undefined {
1513
+ const session = this.snapshot.session;
1514
+
1515
+ if (!session) return undefined;
1516
+ const anchor = { quote, prefix: "", suffix: "", selector };
1517
+ const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
1518
+ const persisted = this.client!.sessionComment(session.id, wire);
1519
+
1520
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1521
+ this.mirrorAnnotation(persisted, wire);
1522
+
1523
+ return wire.id;
1524
+ }
1525
+
1526
+ updateAnnotation(id: string, body: string): void {
1527
+ const session = this.snapshot.session;
1528
+
1529
+ if (!session) return;
1530
+ const existing = session.annotations.find((annotation) => annotation.id === id);
1531
+
1532
+ if (!existing) return;
1533
+ // the daemon's annotate primitive upserts by id: same id, anchor, and
1534
+ // reply link, new body
1535
+ const wire: Omit<Annotation, "createdAt"> = {
1536
+ id: existing.id,
1537
+ kind: existing.kind,
1538
+ anchor: existing.anchor,
1539
+ body,
1540
+ };
1541
+
1542
+ if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
1543
+ const persisted = this.client!.sessionComment(session.id, wire);
1544
+
1545
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1546
+ this.mirrorAnnotation(persisted, wire);
1547
+ }
1548
+
1549
+ // push only after the local write lands, so a rejected write never leaks to the share
1550
+ private mirrorAnnotation(
1551
+ persisted: Promise<Thread>,
1552
+ annotation: Omit<Annotation, "createdAt">,
1553
+ ): void {
1554
+ const session = this.snapshot.session;
1555
+ const branch = session?.history?.branch;
1556
+ // each link follows one branch: a note lands on the links whose branch is the one being edited
1557
+ const links = this.linksFor(session).filter(
1558
+ (link) => branch === undefined || branch === (link.shareBranch ?? MAIN_BRANCH),
1559
+ );
1560
+
1561
+ if (links.length === 0) return;
1562
+ void persisted
1563
+ .then(() => Promise.all(links.map((link) => this.shareTransport.push(link.id, [annotation]))))
1564
+ .catch(() => {});
1565
+ }
1566
+
1567
+ removeAnnotation(id: string): void {
1568
+ const session = this.snapshot.session;
1569
+
1570
+ if (!session) return;
1571
+ this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
1572
+ }
1573
+
1574
+ setWorkingCopy(content: string | undefined): void {
1575
+ const session = this.snapshot.session;
1576
+
1577
+ if (!session) return;
1578
+ this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
1579
+ }
1580
+
1581
+ // ── the guided walk ─────────────────────────
1582
+ // Marks sent but possibly not yet reflected in the session snapshot; the
1583
+ // union keeps the walk title truthful and rapid advances from losing marks.
1584
+ private locallyViewed = new Set<string>();
1585
+
1586
+ private viewedSet(): Set<string> {
1587
+ return new Set([...(this.snapshot.session?.viewedPaths ?? []), ...this.locallyViewed]);
1588
+ }
1589
+
1590
+ walkStart(): void {
1591
+ const session = this.snapshot.session;
1592
+
1593
+ if (!session || session.artifact.type !== "diff") return;
1594
+ const files = this.files();
1595
+
1596
+ if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
1597
+ // resume at the first unviewed file; a finished walk reopens on the end card
1598
+ this.update({ walk: { index: firstUnviewedIndex(files, this.viewedSet()) } });
1599
+ }
1600
+
1601
+ walkForward(): void {
1602
+ const walk = this.snapshot.walk;
1603
+ const session = this.snapshot.session;
1604
+
1605
+ if (!walk || !session) return;
1606
+ const files = this.files();
1607
+ const current = files[walk.index];
1608
+
1609
+ if (!current) return; // already on the end card
1610
+ // advancing IS the viewed mark: the step is complete once you move past
1611
+ // it; the daemon primitive merges, so only the new path travels
1612
+ if (!this.viewedSet().has(current.path)) {
1613
+ this.locallyViewed.add(current.path);
1614
+ this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
1615
+ }
1616
+ this.update({ walk: { index: walk.index + 1 } });
1617
+ }
1618
+
1619
+ walkBack(): void {
1620
+ const walk = this.snapshot.walk;
1621
+
1622
+ if (!walk) return;
1623
+ this.update({ walk: { index: Math.max(0, walk.index - 1) } });
1624
+ }
1625
+
1626
+ walkLeave(): void {
1627
+ if (this.snapshot.walk) this.update({ walk: null });
1628
+ }
1629
+
1630
+ submit(message: MessageOutcome, summary: string): void {
1631
+ const session = this.snapshot.session;
1632
+
1633
+ if (!session) return;
1634
+ this.walkLeave();
1635
+ const actionBodies = Object.fromEntries(
1636
+ slashItemsFrom(this.quickActions).map((item) => [item.name, item.body]),
1637
+ );
1638
+
1639
+ this.client!.sessionSendMessage(session.id, message, summary, actionBodies)
1640
+ .then((resolved) => {
1641
+ // The completion overlay heading already states the message, so the
1642
+ // status line stays empty here - only export/error messages fill it.
1643
+ this.update({ session: resolved, status: "" });
1644
+ if (message === "comment") {
1645
+ this.setStatus("comment sent - thread stays open");
1646
+
1647
+ return;
1648
+ }
1649
+ // notes-vault export: guarded by each exporter's policy (default manual = no-op)
1650
+ for (const exporter of this.exporters) {
1651
+ if (!exporter.runsOn(message)) continue;
1652
+ void exporter.run(resolved).then((exportResult) => {
1653
+ this.setStatus(
1654
+ exportResult.success && exportResult.path
1655
+ ? `exported to ${exportResult.path}`
1656
+ : `export failed: ${exportResult.error ?? "unknown"}`,
1657
+ );
1658
+ });
1659
+ }
1660
+ // Hand the reviewer back to the agent. The default is a visible
1661
+ // countdown from DEFAULT_AUTO_CLOSE that closes on its own; esc stays,
1662
+ // a remembers the choice. A configured delay overrides; 0 closes now.
1663
+ const delay = this.autoClose;
1664
+
1665
+ if (delay === "off") this.startCounting(DEFAULT_AUTO_CLOSE);
1666
+ else if (delay === 0) this.finishReview();
1667
+ else this.startCounting(delay);
1668
+ })
1669
+ .catch((cause: unknown) =>
1670
+ this.setStatus(String(cause instanceof Error ? cause.message : cause)),
1671
+ );
1672
+ }
1673
+
1674
+ /** A workbench thread renders live locally; freeze its diff so a remote reviewer gets a stable snapshot. */
1675
+ private freezeForShare(session: Thread): Promise<Thread> {
1676
+ const repoDiff = this.client?.repoDiff;
1677
+
1678
+ return repoDiff
1679
+ ? snapshotWorkbench(session, (root, vcs) => repoDiff.call(this.client, root, vcs))
1680
+ : Promise.resolve(session);
1681
+ }
1682
+
1683
+ share(): void {
1684
+ this.createShareLink({ requireAuth: false, allowlist: [] });
1685
+ }
1686
+
1687
+ shareLinks(): ShareLink[] {
1688
+ return this.linksFor(this.snapshot.session);
1689
+ }
1690
+
1691
+ createShareLink(input: NewShareLink): void {
1692
+ if (this.readOnly) return this.setStatus("observer - read-only");
1693
+ const session = this.snapshot.session;
1694
+
1695
+ if (!session || !this.client) return;
1696
+ const client = this.client;
1697
+ const shareBranch = session.history?.branch ?? MAIN_BRANCH;
1698
+
1699
+ this.setStatus("sharing…");
1700
+ this.freezeForShare(session)
1701
+ .then((shared) => {
1702
+ const policy: ShareLink = {
1703
+ id: session.id,
1704
+ requireAuth: input.requireAuth,
1705
+ allowlist: input.allowlist,
1706
+ shareBranch,
1707
+ };
1708
+
1709
+ if (input.agentEnabled !== undefined) policy.agentEnabled = input.agentEnabled;
1710
+
1711
+ return this.shareTransport.publish(shared, {}, policy);
1712
+ })
1713
+ .then(async ({ line, copied }) => {
1714
+ const id = this.shareTransport.parseShareId(line);
1715
+
1716
+ if (!id) throw new Error("gateway returned no share id");
1717
+ const link: ShareLink = {
1718
+ id,
1719
+ name: input.name?.trim() || undefined,
1720
+ requireAuth: input.requireAuth,
1721
+ allowlist: input.requireAuth ? input.allowlist : [],
1722
+ shareBranch,
1723
+ };
1724
+
1725
+ if (input.agentEnabled !== undefined) link.agentEnabled = input.agentEnabled;
1726
+ await client.sessionSetShares(session.id, [...this.linksFor(this.snapshot.session), link]);
1727
+ this.setStatus("");
1728
+ this.showToast(line, copied ? "link copied" : "link created");
1729
+ })
1730
+ .catch((cause: unknown) =>
1731
+ this.setStatus(`share failed: ${cause instanceof Error ? cause.message : String(cause)}`),
1732
+ );
1733
+ }
1734
+
1735
+ updateShareLink(id: string, input: NewShareLink): void {
1736
+ if (this.readOnly) return this.setStatus("observer - read-only");
1737
+ const session = this.snapshot.session;
1738
+
1739
+ if (!session || !this.client) return;
1740
+ const links = this.linksFor(session);
1741
+
1742
+ if (!links.some((link) => link.id === id)) return;
1743
+ const allowlist = input.requireAuth ? input.allowlist : [];
1744
+
1745
+ const policy: SharePolicy = { requireAuth: input.requireAuth, allowlist };
1746
+
1747
+ if (input.agentEnabled !== undefined) policy.agentEnabled = input.agentEnabled;
1748
+ // Publish the same policy stored on the local link.
1749
+ void this.shareTransport.push(id, [], policy).catch(() => {});
1750
+ const next = links.map((link) => {
1751
+ if (link.id !== id) return link;
1752
+ const updated = {
1753
+ ...link,
1754
+ name: input.name?.trim() || undefined,
1755
+ requireAuth: input.requireAuth,
1756
+ allowlist,
1757
+ };
1758
+
1759
+ if (input.agentEnabled !== undefined) updated.agentEnabled = input.agentEnabled;
1760
+
1761
+ return updated;
1762
+ });
1763
+
1764
+ this.applyOptimistic(
1765
+ { ...session, shares: next },
1766
+ this.client.sessionSetShares(session.id, next),
1767
+ );
1768
+ }
1769
+
1770
+ deleteShareLink(id: string): void {
1771
+ if (this.readOnly) return this.setStatus("observer - read-only");
1772
+ const session = this.snapshot.session;
1773
+
1774
+ if (!session || !this.client) return;
1775
+ const remaining = this.linksFor(session).filter((link) => link.id !== id);
1776
+
1777
+ void this.shareTransport.revoke(id).catch(() => {});
1778
+ this.applyOptimistic(
1779
+ { ...session, shares: remaining },
1780
+ this.client.sessionSetShares(session.id, remaining),
1781
+ );
1782
+ }
1783
+
1784
+ copyShareLink(id: string): void {
1785
+ const line = this.shareTransport.formatShareLine(id);
1786
+
1787
+ void copyToClipboard(line).then((copied) =>
1788
+ this.showToast(line, copied ? "link copied" : "link"),
1789
+ );
1790
+ }
1791
+
1792
+ /** Stop sharing the open thread: revoke every link so none of them resolve. */
1793
+ unshare(): void {
1794
+ if (this.readOnly) return this.setStatus("observer - read-only");
1795
+ const session = this.snapshot.session;
1796
+ const links = this.linksFor(session);
1797
+
1798
+ if (!session || links.length === 0) return this.setStatus("this thread is not shared");
1799
+ this.stopShareSync();
1800
+ void Promise.all(links.map((link) => this.shareTransport.revoke(link.id).catch(() => {})));
1801
+ void this.client?.sessionSetShares(session.id, []).catch(() => {});
1802
+ }
1803
+
1804
+ goToEntry(entryId: string, summary?: string): void {
1805
+ const session = this.snapshot.session;
1806
+
1807
+ if (!session?.history || !this.client) return;
1808
+ const target = entryTarget(session.history, entryId);
1809
+
1810
+ if (target === null) return this.setStatus("that entry is not on any branch");
1811
+ if (target.kind === "here") return this.setStatus("already at the tip");
1812
+ if (target.kind === "switch") {
1813
+ this.moveTree(session, switchBranch(session.history, target.branch), () =>
1814
+ this.client!.sessionSwitch(session.id, target.branch),
1815
+ );
1816
+
1817
+ return;
1818
+ }
1819
+ // an entry another branch reaches: the daemon stands on that branch first, in the same request
1820
+ const onBranch = switchBranch(session.history, target.branch);
1821
+ const options = summary === undefined || summary === "" ? {} : { summary };
1822
+ const moved = navigateTo(onBranch, entryId, options);
1823
+ const branch = target.branch === session.history.branch ? undefined : target.branch;
1824
+
1825
+ this.moveTree(session, moved, () =>
1826
+ this.client!.sessionNavigate(session.id, entryId, options.summary, branch),
1827
+ );
1828
+ }
1829
+
1830
+ branch(name: string): void {
1831
+ const session = this.snapshot.session;
1832
+ const branchName = name.trim();
1833
+
1834
+ if (!session?.history || !this.client) return;
1835
+ if (!branchName) return this.setStatus("a branch needs a name");
1836
+ if (session.history.tips[branchName] !== undefined)
1837
+ return this.setStatus(`branch ${branchName} exists`);
1838
+ this.moveTree(session, createBranch(session.history, branchName), () =>
1839
+ this.client!.sessionBranch(session.id, branchName),
1840
+ );
1841
+ }
1842
+
1843
+ labelTip(label: string): void {
1844
+ const session = this.snapshot.session;
1845
+ const name = label.trim();
1846
+
1847
+ if (!session?.history || !this.client) return;
1848
+ if (!name) return this.setStatus("a checkpoint needs a name");
1849
+ this.moveTree(session, labelTip(session.history, name), () =>
1850
+ this.client!.sessionLabel(session.id, name),
1851
+ );
1852
+ }
1853
+
1854
+ fork(): void {
1855
+ const session = this.snapshot.session;
1856
+
1857
+ if (!session || !this.client) return;
1858
+ this.client
1859
+ .sessionFork(session.id)
1860
+ .then((fork) => {
1861
+ this.update({ session: fork });
1862
+ })
1863
+ .catch((cause: unknown) =>
1864
+ this.setStatus(`fork failed: ${cause instanceof Error ? cause.message : String(cause)}`),
1865
+ );
1866
+ }
1867
+
1868
+ forkAndShare(): void {
1869
+ const session = this.snapshot.session;
1870
+
1871
+ if (!session || !this.client) return;
1872
+ const client = this.client;
1873
+
1874
+ this.setStatus("forking and sharing…");
1875
+ client
1876
+ .sessionFork(session.id)
1877
+ .then(async (fork) => {
1878
+ const shareBranch = fork.history?.branch ?? MAIN_BRANCH;
1879
+ const shared = await this.freezeForShare(fork);
1880
+ const { line, copied } = await this.shareTransport.publish(
1881
+ shared,
1882
+ {},
1883
+ { requireAuth: false, allowlist: [], shareBranch },
1884
+ );
1885
+ const id = this.shareTransport.parseShareId(line);
1886
+
1887
+ if (id)
1888
+ await client.sessionSetShares(fork.id, [
1889
+ { id, requireAuth: false, allowlist: [], shareBranch },
1890
+ ]);
1891
+ this.setStatus("");
1892
+ this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
1893
+ })
1894
+ .catch((cause: unknown) =>
1895
+ this.setStatus(
1896
+ `fork and share failed: ${cause instanceof Error ? cause.message : String(cause)}`,
1897
+ ),
1898
+ );
1899
+ }
1900
+
1901
+ /**
1902
+ * Show a moved tree at once - the record re-derived from its new path - and
1903
+ * let the daemon's answer replace it. A refused move surfaces as status.
1904
+ */
1905
+ private moveTree(session: Thread, history: SessionHistory, request: () => Promise<Thread>): void {
1906
+ const expected: Thread = { ...session, history };
1907
+
1908
+ applyPathView(
1909
+ expected,
1910
+ viewOfPath(history, [...session.annotations, ...(session.shelvedAnnotations ?? [])]),
1911
+ );
1912
+ this.applyOptimistic(expected, request());
1913
+ }
1914
+
1915
+ /**
1916
+ * Pull collaborator notes for a shared plan and union them in. The daemon's
1917
+ * merge emits session.updated, so the re-render happens through the normal
1918
+ * event path. Best-effort: a failed refresh is silent.
1919
+ */
1920
+ pullShared(): Promise<void> {
1921
+ const links = this.linksFor(this.snapshot.session);
1922
+
1923
+ if (links.length === 0) return Promise.resolve();
1924
+
1925
+ // pull every link's blob and union its collaborator notes back in
1926
+ return Promise.all(
1927
+ links.map((link) =>
1928
+ this.shareTransport
1929
+ .pull(link.id)
1930
+ .then((remote) => this.mergeShared(remote))
1931
+ .catch(() => {}),
1932
+ ),
1933
+ ).then(() => {});
1934
+ }
1935
+
1936
+ private mergeShared(remote: Thread): Promise<void> {
1937
+ const session = this.snapshot.session;
1938
+
1939
+ if (!session || !this.client) return Promise.resolve();
1940
+
1941
+ return this.client
1942
+ .sessionMergeShared(session.id, this.shareTransport.mergeFromShare(remote))
1943
+ .then(() => {})
1944
+ .catch(() => {});
1945
+ }
1946
+
1947
+ /**
1948
+ * Follow the thread's shares live while it is open. To bound idle cost at one
1949
+ * connection per open thread (not one per link), a single watch stream follows
1950
+ * the primary link for instant updates, while a pull on each (re)connect
1951
+ * catches up every link's collaborator notes. A dropped link reconnects with
1952
+ * backoff. Returns a stop handle the caller runs on leave.
1953
+ */
1954
+ startShareSync(): () => void {
1955
+ this.stopShareSync();
1956
+ // per-run token so a stale run's close never re-arms a newer run
1957
+ const run = {};
1958
+ let delay = SHARE_RECONNECT_MIN_MS;
1959
+
1960
+ this.shareRun = run;
1961
+ const connect = (): void => {
1962
+ const shareId = this.linksFor(this.snapshot.session)[0]?.id;
1963
+
1964
+ if (this.shareRun !== run || this.closed || !shareId) return;
1965
+ void this.pullShared();
1966
+ this.shareStop = this.shareTransport.watch(shareId, {
1967
+ onSession: (remote) => {
1968
+ delay = SHARE_RECONNECT_MIN_MS;
1969
+ void this.mergeShared(remote);
1970
+ },
1971
+ onClose: () => {
1972
+ if (this.shareRun !== run || this.closed) return;
1973
+ this.shareStop = null;
1974
+ this.shareReconnect = this.clock.setTimeout(connect, delay);
1975
+ delay = Math.min(delay * 2, SHARE_RECONNECT_MAX_MS);
1976
+ },
1977
+ });
1978
+ };
1979
+
1980
+ connect();
1981
+
1982
+ return () => {
1983
+ if (this.shareRun === run) this.stopShareSync();
1984
+ };
1985
+ }
1986
+
1987
+ private stopShareSync(): void {
1988
+ this.shareRun = null;
1989
+ if (this.shareReconnect !== undefined) this.clock.clearTimeout(this.shareReconnect);
1990
+ this.shareReconnect = undefined;
1991
+ this.shareStop?.();
1992
+ this.shareStop = null;
1993
+ }
1994
+
1995
+ // ── completion hand-back ────────────────────
1996
+ private startCounting(remaining: number): void {
1997
+ if (remaining <= 0) return this.finishReview();
1998
+ this.update({ completion: { phase: "counting", remaining } });
1999
+ this.countdown = this.clock.setTimeout(() => this.startCounting(remaining - 1), 1000);
2000
+ }
2001
+
2002
+ finishReview(): void {
2003
+ this.clearCountdown();
2004
+ const herdr = detectHerdr();
2005
+ const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
2006
+
2007
+ if (herdr && pane) focusHerdrPane(herdr.binPath, pane);
2008
+ this.options.onExit?.(0);
2009
+ }
2010
+
2011
+ dismissCompletion(): void {
2012
+ this.clearCountdown();
2013
+ this.update({ completion: { phase: "dismissed" } });
2014
+ }
2015
+
2016
+ optInAutoClose(): void {
2017
+ // Remember the default countdown for future submits, persisted to the user
2018
+ // config. The countdown is already running; this only makes it the default.
2019
+ try {
2020
+ persistAutoClose(DEFAULT_AUTO_CLOSE);
2021
+ } catch {
2022
+ // a read-only config dir must not block closing the review
2023
+ }
2024
+ this.autoClose = DEFAULT_AUTO_CLOSE;
2025
+ }
2026
+ }