@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70

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 (248) 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 +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1039 -799
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +461 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +286 -0
  41. package/src/components/AppShell.zoom.test.tsx +94 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +151 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
@@ -0,0 +1,1509 @@
1
+ /**
2
+ * The review-session 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 { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
12
+ import {
13
+ applyPathView,
14
+ createBranch,
15
+ cutBlock,
16
+ detectHerdr,
17
+ labelTip,
18
+ MAIN_BRANCH,
19
+ makeAnchor,
20
+ navigateTo,
21
+ newAnnotationId,
22
+ parseBlocks,
23
+ resolveAnchor,
24
+ restoreBlock,
25
+ restoreLine,
26
+ returnPaneFor,
27
+ switchBranch,
28
+ viewOfPath,
29
+ type Anchor,
30
+ type Annotation,
31
+ type AnnotationTarget,
32
+ type DiffFileContents,
33
+ type Thread,
34
+ type SessionHistory,
35
+ type VerdictKind,
36
+ } from "@cueloop/schema";
37
+ import { loadBundledExporters, type BundledExporter } from "./integrations";
38
+ import {
39
+ collaboratorAnnotations,
40
+ mergeFromShare,
41
+ publishShare,
42
+ pullShare,
43
+ pushShare,
44
+ watchShare,
45
+ shareIdFromLine,
46
+ } from "./share";
47
+ import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
48
+ import { entryTarget, treeRows, type TreeRow } from "./tree-view";
49
+ import { diffRowBlocks, diffRows, fileChangeCounts, fileRowRange, type DiffRow } from "./view-diff";
50
+ import { applyFold } from "./diff-fold";
51
+ import { copyToClipboard } from "./clipboard";
52
+ import {
53
+ changeRejectionForRow,
54
+ hunkRejectionForRow,
55
+ isRowRejected,
56
+ parseFileDiff,
57
+ rejectsWholeHunk,
58
+ sameRejection,
59
+ type HunkRejection,
60
+ } from "./diff-hunk-curate";
61
+ import type { FileDiffMetadata } from "@pierre/diffs";
62
+ import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
63
+ import { editInEditor } from "./editor";
64
+ import { focusHerdrPane } from "./herdr";
65
+ import { persistAutoClose, type AutoClose, type CueloopConfig, type QuickAction } from "./config";
66
+ import { slashItemsFrom } from "./slash-palette";
67
+
68
+ /**
69
+ * Post-submit lifecycle (a review pane should hand you back to the agent,
70
+ * not linger): idle → counting (a visible countdown, the default) → exit.
71
+ * esc dismisses to the resolved view; a remembers the countdown as default.
72
+ */
73
+ export type Completion =
74
+ | { phase: "idle" }
75
+ | { phase: "prompt" }
76
+ | { phase: "counting"; remaining: number }
77
+ | { phase: "dismissed" };
78
+
79
+ /** Seconds the completion overlay counts down before it hands back. */
80
+ export const DEFAULT_AUTO_CLOSE = 5;
81
+
82
+ /** How often an open shared plan re-pulls collaborator notes (ADR 0005 stage 2). */
83
+ /** Reconnect backoff for a dropped watch stream: doubles from the floor to the cap. */
84
+ export const SHARE_RECONNECT_MIN_MS = 1000;
85
+ export const SHARE_RECONNECT_MAX_MS = 30_000;
86
+
87
+ /** Shared empty set so "nothing rejected" is a stable identity for renders. */
88
+ const EMPTY_REJECTIONS: HunkRejection[] = [];
89
+ const EMPTY_REJECTED_ROWS: Set<number> = new Set();
90
+
91
+ /** Shared empty list so "nothing curated out" is a stable identity for renders. */
92
+ const EMPTY_CURATION_ITEMS: CurationItem[] = [];
93
+
94
+ /** Status shown when a curated-out removal (diff rejection or plan cut) is restored. */
95
+ const REMOVAL_RESTORED_STATUS = "removal restored";
96
+
97
+ export interface ToastState {
98
+ title?: string;
99
+ body: string;
100
+ }
101
+
102
+ /**
103
+ * One removed piece projected for the review rail, unified across both review
104
+ * kinds: a diff hunk/change the owner curated out, or a plan block they cut.
105
+ * `source` tells restore and reveal which path to take; `preview` is the removed
106
+ * content (a few lines) the card renders struck through; `revealIndex` is the
107
+ * diff row (diff) or display block (plan) to scroll to and restore against.
108
+ */
109
+ export interface CurationItem {
110
+ id: string;
111
+ source: "diff" | "plan";
112
+ preview: string[];
113
+ revealIndex: number;
114
+ }
115
+
116
+ /** Stable id for a rejection's rail item: diff:path#hunk#change (or #hunk for a whole hunk). */
117
+ export function curationItemId(rejection: HunkRejection): string {
118
+ return `diff:${rejection.path}#${rejection.hunkIndex}#${rejection.changeIndex ?? "hunk"}`;
119
+ }
120
+
121
+ /** Stable id for a plan cut's rail item, keyed on its fixed base-content line range. */
122
+ function planCutId(base: { lineStart: number; lineEnd: number }): string {
123
+ return `plan:${base.lineStart}-${base.lineEnd}`;
124
+ }
125
+
126
+ export interface ControllerSnapshot {
127
+ session: Thread | null;
128
+ inbox: Thread[] | null;
129
+ status: string;
130
+ toast: ToastState | null;
131
+ error: string | null;
132
+ completion: Completion;
133
+ /**
134
+ * Annotations whose anchor stopped resolving after the last editor
135
+ * hand-off - the reconciliation banner count. 0 = no banner.
136
+ */
137
+ editOrphanCount: number;
138
+ /**
139
+ * The guided walk's cursor (diff sessions): which wizard step is on
140
+ * screen. index === file count is the end card. null = not walking; the
141
+ * viewed set itself rides the session record, so leaving loses nothing.
142
+ */
143
+ walk: { index: number } | null;
144
+ }
145
+
146
+ export interface ShareTransport {
147
+ publish: typeof publishShare;
148
+ pull: typeof pullShare;
149
+ push: typeof pushShare;
150
+ watch: typeof watchShare;
151
+ parseShareId: typeof shareIdFromLine;
152
+ collaboratorAnnotations: typeof collaboratorAnnotations;
153
+ mergeFromShare: typeof mergeFromShare;
154
+ }
155
+
156
+ const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
157
+ publish: publishShare,
158
+ pull: pullShare,
159
+ watch: watchShare,
160
+ push: pushShare,
161
+ parseShareId: shareIdFromLine,
162
+ collaboratorAnnotations,
163
+ mergeFromShare,
164
+ };
165
+
166
+ export interface ReviewControllerOptions {
167
+ home?: string;
168
+ sessionId?: string;
169
+ /** The directory the client launched in; its git repo backs the no-session welcome tree. Defaults to process.cwd(). */
170
+ cwd?: string;
171
+ /** Observer mode: stored for the key reducer's read-only gate. */
172
+ readOnly?: boolean;
173
+ onExit?: (code: number) => void;
174
+ /** Timer source for the auto-close countdown; tests inject a ManualClock. */
175
+ clock?: Clock;
176
+ /**
177
+ * How the controller gets its session client. Defaults to dialing the local
178
+ * daemon; the sharing gateway injects a blob-backed client so the same <App>
179
+ * renders a decrypted share instead.
180
+ */
181
+ openClient?: () => Promise<SessionClient>;
182
+ shareTransport?: ShareTransport;
183
+ }
184
+
185
+ export interface ReviewController {
186
+ readonly readOnly: boolean;
187
+ /** Snapshot listeners (stable identity - safe for useSyncExternalStore). */
188
+ subscribe(listener: () => void): () => void;
189
+ getSnapshot(): ControllerSnapshot;
190
+ /** Dial the daemon (autostart), subscribe to events, fetch session or inbox. */
191
+ connect(): void;
192
+ close(): void;
193
+ /** Loaded config parts the controller acts on: auto-close and exporters. */
194
+ applyConfig(config: CueloopConfig): void;
195
+ setStatus(message: string): void;
196
+ showToast(body: string, title?: string): void;
197
+ dismissToast(): void;
198
+ /** Derived projections, cached per session identity. */
199
+ display(): DisplayBlock[];
200
+ rows(): DiffRow[];
201
+ /** Fold a file's diff body to just its band, or unfold it; rows() reflects the change. */
202
+ setFileCollapsed(file: string, collapsed: boolean): void;
203
+ isFileCollapsed(file: string): boolean;
204
+ /** Weave a file's full contents inline (unchanged lines as context), or fold back to hunks. */
205
+ setFileExpanded(file: string, expanded: boolean): void;
206
+ isFileExpanded(file: string): boolean;
207
+ /** Whether the file carries the full contents weaving needs (curatable diffs do). */
208
+ canExpandFile(file: string): boolean;
209
+ /** Copy a file's path to the clipboard; reports the outcome via status. */
210
+ copyFilePath(file: string): void;
211
+ /** Per-file added/removed line counts from the base rows (survives collapse). */
212
+ fileStats(): Map<string, { additions: number; deletions: number }>;
213
+ /** The walk's step list, derived from the diff rows. */
214
+ files(): WalkFile[];
215
+ working(): string;
216
+ /** The workspace's tracked files (git ls-files) for the Project tree; empty when unavailable. */
217
+ projectFiles(): Promise<string[]>;
218
+ /** Read a workspace file's contents for a Changes file tab; null when it cannot be read. */
219
+ readFile(path: string): Promise<string | null>;
220
+ /** The launch repo's tracked files for the no-session welcome Project tree; empty when not a repo. */
221
+ repoFiles(): Promise<string[]>;
222
+ /** Read a launch-repo file's contents for a welcome file tab; null when it cannot be read. */
223
+ repoReadFile(path: string): Promise<string | null>;
224
+ /** The launch repo's working-tree changed files for the no-session welcome Changes tree. */
225
+ repoChanges(): Promise<readonly DiffFileContents[]>;
226
+ /** Open a session from the inbox. */
227
+ open(id: string): void;
228
+ /** Delete a session for good (inbox delete); the inbox refreshes on the event. */
229
+ deleteSession(id: string): void;
230
+ /** Rename a session's title; the inbox refreshes on the event. */
231
+ renameSession(id: string, title: string): void;
232
+ /** Record the viewer's own name into the share's participant registry (collaborator self-naming). */
233
+ setSelfName(name: string): void;
234
+ /** Cut the block under the cursor, or restore a cut one. */
235
+ cut(displayIndex: number): void;
236
+ /** Toggle rejection of the whole hunk under the diff cursor (owner curation). */
237
+ toggleRejectHunk(rowIndex: number): void;
238
+ /** Toggle rejection of the single change under the diff cursor (owner curation). */
239
+ toggleRejectChange(rowIndex: number): void;
240
+ /** Rendered row indices dropped by the current reject decisions (for dimming). */
241
+ rejectedRows(): Set<number>;
242
+ /** The curated-out rejections as rail items, in the order they were rejected. */
243
+ curationItems(): CurationItem[];
244
+ /** Undo one curation item by id: drop the rejection and recompute the working copy. */
245
+ restoreCuration(id: string): void;
246
+ /** The $EDITOR hand-off on the working copy. */
247
+ edit(): void;
248
+ /**
249
+ * Anchor and store an annotation; both plan and diff anchor constructions.
250
+ * `end` is an offset within `endDisplayIndex` (the same block by default).
251
+ * Returns the minted annotation id so the view can select the new card.
252
+ */
253
+ annotate(
254
+ kind: "comment",
255
+ displayIndex: number,
256
+ start: number,
257
+ end: number,
258
+ body: string,
259
+ endDisplayIndex?: number,
260
+ /** The surface being annotated; a `file` target anchors into the Changes diff rows. */
261
+ target?: AnnotationTarget,
262
+ ): string | undefined;
263
+ /** Persist a comment whose anchor a surface already built (e.g. a file-contents view). */
264
+ addComment(anchor: Anchor, target: AnnotationTarget, body: string): string | undefined;
265
+ /**
266
+ * Comment from the bare launch shell, where no thread is open: find-or-create the per-repo
267
+ * workbench thread, open it (the shell becomes a thread view), then add the comment. This is the
268
+ * first write a browse-only launch makes - nothing is on disk until it runs.
269
+ */
270
+ commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void>;
271
+ /**
272
+ * Reply to `rootAnnotationId`: the reply shares the root's anchor and names
273
+ * it in replyTo, so the discussion stays one conversation. Returns the minted id.
274
+ */
275
+ reply(rootAnnotationId: string, body: string): string | undefined;
276
+ /** Anchor a prototype comment to a DOM element by its selector. */
277
+ annotatePrototype(selector: string, quote: string, body: string): string | undefined;
278
+ /** Rewrite a stored annotation's body in place (the rail-card edit). */
279
+ updateAnnotation(id: string, body: string): void;
280
+ removeAnnotation(id: string): void;
281
+ setWorkingCopy(content: string | undefined): void;
282
+ /** Enter the guided walk at the first unviewed file (diff sessions). */
283
+ walkStart(): void;
284
+ /** Mark the current file viewed (persists with the session) and advance. */
285
+ walkForward(): void;
286
+ walkBack(): void;
287
+ /** Leave the walk; the viewed set stays on the session record. */
288
+ walkLeave(): void;
289
+ /** Resolve the review, run the export, start the completion hand-back. */
290
+ submit(verdict: VerdictKind, summary: string): void;
291
+ /** Publish the current session as a share; the ssh line lands on the clipboard. */
292
+ share(): void;
293
+ /** The session tree as rows for the rail's Tree tab, cached per session identity. */
294
+ treeRows(): TreeRow[];
295
+ /**
296
+ * Go to an entry: switch to the branch whose tip it is, or move the current
297
+ * branch's tip back to it, with an optional branch summary. Owner only.
298
+ */
299
+ goToEntry(entryId: string, summary?: string): void;
300
+ /** Start a branch at the current tip and switch to it. */
301
+ branch(name: string): void;
302
+ /** Name the current tip as a checkpoint. */
303
+ labelTip(label: string): void;
304
+ /** Copy the current path into a new session and open it. */
305
+ fork(): void;
306
+ /** Fork, then share the fork; the current session stays open and unshared. */
307
+ forkAndShare(): void;
308
+ /** Pull a shared plan's collaborator notes back and union them in (planner only). */
309
+ pullShared(): Promise<void>;
310
+ /** Poll the share for collaborator notes while it is open; returns a stop handle. */
311
+ startShareSync(): () => void;
312
+ /** Close the review and, inside herdr, bounce focus back to the agent. */
313
+ finishReview(): void;
314
+ dismissCompletion(): void;
315
+ /** From the completion prompt: persist auto-close and start the countdown. */
316
+ optInAutoClose(): void;
317
+ }
318
+
319
+ export function createReviewController(options: ReviewControllerOptions): ReviewController {
320
+ return new Controller(options);
321
+ }
322
+
323
+ interface DerivedSessionProjection {
324
+ display: DisplayBlock[];
325
+ rows: DiffRow[];
326
+ files: WalkFile[];
327
+ models: Map<string, FileDiffMetadata>;
328
+ tree: TreeRow[];
329
+ }
330
+
331
+ /** A live working-tree diff: the same patch + per-file contents a diff capture pins. */
332
+ interface LiveWorkingDiff {
333
+ patch: string;
334
+ files: readonly DiffFileContents[];
335
+ }
336
+
337
+ class Controller implements ReviewController {
338
+ readonly readOnly: boolean;
339
+ private client: SessionClient | null = null;
340
+ private closed = false;
341
+ private snapshot: ControllerSnapshot = {
342
+ session: null,
343
+ inbox: null,
344
+ status: "",
345
+ toast: null,
346
+ error: null,
347
+ completion: { phase: "idle" },
348
+ editOrphanCount: 0,
349
+ walk: null,
350
+ };
351
+ private listeners = new Set<() => void>();
352
+ private autoClose: AutoClose = "off";
353
+ private quickActions: QuickAction[] = [];
354
+ private editor: string | undefined;
355
+ private exporters: BundledExporter[] = [];
356
+ private readonly clock: Clock;
357
+ private readonly shareTransport: ShareTransport;
358
+ private countdown: TimerHandle | undefined;
359
+ private shareReconnect: TimerHandle | undefined;
360
+ private shareStop: (() => void) | null = null;
361
+ private shareRun: object | null = null;
362
+ /** Projections keyed by session identity (and the live diff for non-diff threads) so renders reuse one computation. */
363
+ private derivedFor: Thread | null = null;
364
+ /** The launch/workspace repo's live working-tree diff, feeding the Changes view for non-diff threads. */
365
+ private liveDiff: LiveWorkingDiff | null = null;
366
+ private derivedForLiveDiff: LiveWorkingDiff | null = null;
367
+ private derived: DerivedSessionProjection = {
368
+ display: [],
369
+ rows: [],
370
+ files: [],
371
+ models: new Map(),
372
+ tree: [],
373
+ };
374
+ /** File paths whose diff body is folded to just the file band. */
375
+ private collapsedFiles = new Set<string>();
376
+ /** File paths woven to their full contents (unchanged lines shown as context). */
377
+ private expandedFiles = new Set<string>();
378
+ /** The diff's reject decisions live on the session record; the daemon curates from them. */
379
+ private get rejections(): HunkRejection[] {
380
+ return this.snapshot.session?.curation ?? EMPTY_REJECTIONS;
381
+ }
382
+
383
+ constructor(private readonly options: ReviewControllerOptions) {
384
+ this.readOnly = options.readOnly ?? false;
385
+ this.clock = options.clock ?? new SystemClock();
386
+ this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
387
+ }
388
+
389
+ subscribe = (listener: () => void): (() => void) => {
390
+ this.listeners.add(listener);
391
+
392
+ return () => this.listeners.delete(listener);
393
+ };
394
+
395
+ getSnapshot = (): ControllerSnapshot => this.snapshot;
396
+
397
+ private update(patch: Partial<ControllerSnapshot>): void {
398
+ this.snapshot = { ...this.snapshot, ...patch };
399
+ for (const listener of this.listeners) listener();
400
+ }
401
+
402
+ connect(): void {
403
+ void (async () => {
404
+ try {
405
+ const openClient =
406
+ this.options.openClient ??
407
+ (() => DaemonClient.connect({ home: this.options.home, autostart: true }));
408
+ const client = await openClient();
409
+
410
+ if (this.closed) return void client.close();
411
+ this.client = client;
412
+ client.onEvent((event) => {
413
+ // another controller/observer changed state: re-fetch the active session's content
414
+ const session = this.snapshot.session;
415
+
416
+ if (session && event.sessionId === session.id) void this.refreshSession(session.id);
417
+ // only the list-changing events touch the Threads sidebar; refreshing on every
418
+ // content edit (session.updated) would amplify an active review into a request storm
419
+ if (event.event !== "session.updated") void this.refreshInbox();
420
+ });
421
+ await client.subscribe();
422
+ // load the inbox in both cases so the sidebar can jump between threads
423
+ const inbox = await client.sessionList({ status: "pending" });
424
+
425
+ if (this.options.sessionId) {
426
+ this.update({ session: await client.sessionGet(this.options.sessionId), inbox });
427
+ } else {
428
+ this.update({ inbox });
429
+ }
430
+ } catch (err) {
431
+ this.update({ error: err instanceof Error ? err.message : String(err) });
432
+ }
433
+ })();
434
+ }
435
+
436
+ close(): void {
437
+ this.closed = true;
438
+ this.clearCountdown();
439
+ this.stopShareSync();
440
+ this.client?.close();
441
+ }
442
+
443
+ private clearCountdown(): void {
444
+ if (this.countdown !== undefined) this.clock.clearTimeout(this.countdown);
445
+ this.countdown = undefined;
446
+ }
447
+
448
+ applyConfig(config: CueloopConfig): void {
449
+ this.autoClose = config.ui.autoClose;
450
+ this.quickActions = config.actions;
451
+ this.editor = config.ui.editor;
452
+ void loadBundledExporters(config.integrations).then((exporters) => {
453
+ this.exporters = exporters;
454
+ });
455
+ }
456
+
457
+ setStatus(message: string): void {
458
+ // the persistent status bar is gone; transient feedback surfaces as a toast
459
+ this.update({ status: message, toast: message ? { body: message } : null });
460
+ }
461
+
462
+ showToast(body: string, title?: string): void {
463
+ this.update({ toast: { body, title } });
464
+ }
465
+
466
+ dismissToast(): void {
467
+ if (this.snapshot.toast) this.update({ toast: null });
468
+ }
469
+
470
+ // ── derived projections ─────────────────────
471
+ private ensureDerived(): void {
472
+ const session = this.snapshot.session;
473
+ const isDiff = session?.artifact.type === "diff";
474
+ // a diff thread reads its pinned capture; every other thread reflects the live working tree
475
+ const liveDiff = isDiff ? null : this.liveDiff;
476
+
477
+ if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
478
+ // fold state belongs to the session, so a live-tree refresh keeps it; only a new session resets it
479
+ if (this.derivedFor !== session) {
480
+ this.collapsedFiles.clear();
481
+ this.expandedFiles.clear();
482
+ }
483
+ this.derivedFor = session;
484
+ this.derivedForLiveDiff = liveDiff;
485
+
486
+ const content = isDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
487
+ const files = isDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
488
+ const rows = content ? diffRows(content) : [];
489
+ const models = new Map<string, FileDiffMetadata>();
490
+
491
+ for (const file of files) models.set(file.path, parseFileDiff(file));
492
+ this.derived = {
493
+ display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
494
+ rows,
495
+ files: walkFiles(rows),
496
+ models,
497
+ tree: session?.history ? treeRows(session.history) : [],
498
+ };
499
+ }
500
+
501
+ /** The per-file contents that back the current rows: a diff thread's capture, else the live working tree. */
502
+ private foldFiles(): readonly DiffFileContents[] | undefined {
503
+ const session = this.snapshot.session;
504
+
505
+ return session?.artifact.type === "diff" ? session.artifact.files : this.liveDiff?.files;
506
+ }
507
+
508
+ treeRows(): TreeRow[] {
509
+ this.ensureDerived();
510
+
511
+ return this.derived.tree;
512
+ }
513
+
514
+ display(): DisplayBlock[] {
515
+ this.ensureDerived();
516
+
517
+ return this.derived.display;
518
+ }
519
+
520
+ rows(): DiffRow[] {
521
+ this.ensureDerived();
522
+ if (this.collapsedFiles.size === 0 && this.expandedFiles.size === 0) return this.derived.rows;
523
+
524
+ return applyFold(this.derived.rows, this.collapsedFiles, this.expandedFiles, this.foldFiles());
525
+ }
526
+
527
+ setFileCollapsed(file: string, collapsed: boolean): void {
528
+ if (collapsed) {
529
+ this.collapsedFiles.add(file);
530
+ this.expandedFiles.delete(file);
531
+ } else {
532
+ this.collapsedFiles.delete(file);
533
+ }
534
+ this.update({});
535
+ }
536
+
537
+ isFileCollapsed(file: string): boolean {
538
+ return this.collapsedFiles.has(file);
539
+ }
540
+
541
+ setFileExpanded(file: string, expanded: boolean): void {
542
+ if (expanded) {
543
+ this.expandedFiles.add(file);
544
+ this.collapsedFiles.delete(file);
545
+ } else {
546
+ this.expandedFiles.delete(file);
547
+ }
548
+ this.update({});
549
+ }
550
+
551
+ isFileExpanded(file: string): boolean {
552
+ return this.expandedFiles.has(file);
553
+ }
554
+
555
+ canExpandFile(file: string): boolean {
556
+ // a live working-tree diff carries the same per-file contents as a captured one,
557
+ // so a non-diff thread can expand its files too
558
+ return (this.foldFiles() ?? []).some((entry) => entry.path === file);
559
+ }
560
+
561
+ copyFilePath(file: string): void {
562
+ void copyToClipboard(file).then((copied) =>
563
+ this.setStatus(copied ? `copied ${file}` : "no clipboard tool available"),
564
+ );
565
+ }
566
+
567
+ fileStats(): Map<string, { additions: number; deletions: number }> {
568
+ this.ensureDerived();
569
+
570
+ return fileChangeCounts(this.derived.rows);
571
+ }
572
+
573
+ files(): WalkFile[] {
574
+ this.ensureDerived();
575
+
576
+ return this.derived.files;
577
+ }
578
+
579
+ working(): string {
580
+ const session = this.snapshot.session;
581
+
582
+ return session ? (session.workingCopy ?? session.artifact.content) : "";
583
+ }
584
+
585
+ async projectFiles(): Promise<string[]> {
586
+ const session = this.snapshot.session;
587
+ if (this.client?.projectFiles === undefined || !session) return [];
588
+
589
+ return this.client.projectFiles(session.id);
590
+ }
591
+
592
+ async readFile(path: string): Promise<string | null> {
593
+ const session = this.snapshot.session;
594
+ if (this.client?.fileContents === undefined || !session) return null;
595
+
596
+ return this.client.fileContents(session.id, path);
597
+ }
598
+
599
+ /** The repo the sidebar tree/changes point at: the thread's own repo, else the launch directory. */
600
+ private sidebarRepoRoot(): string {
601
+ return this.snapshot.session?.workspace.repoRoot || this.options.cwd || process.cwd();
602
+ }
603
+
604
+ async repoFiles(): Promise<string[]> {
605
+ if (this.client?.repoFiles === undefined) return [];
606
+
607
+ return this.client.repoFiles(this.sidebarRepoRoot());
608
+ }
609
+
610
+ async repoReadFile(path: string): Promise<string | null> {
611
+ if (this.client?.repoFileContents === undefined) return null;
612
+
613
+ return this.client.repoFileContents(this.sidebarRepoRoot(), path);
614
+ }
615
+
616
+ async repoChanges(): Promise<readonly DiffFileContents[]> {
617
+ // a diff review pins its captured snapshot; every other thread reflects the live working tree
618
+ const session = this.snapshot.session;
619
+ if (session?.artifact.type === "diff") return session.artifact.files ?? [];
620
+ // eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
621
+ if (this.client?.repoDiff !== undefined) {
622
+ const diff = await this.client.repoDiff(this.sidebarRepoRoot());
623
+ // a thread switch during the request would let this response overwrite the new
624
+ // thread's diff, showing changes from the wrong repo; drop it when the session moved on
625
+ if (this.snapshot.session !== session) return diff.files;
626
+ this.liveDiff = diff;
627
+ // rows() derive from the fresh patch; re-render so an open diff tab repaints
628
+ this.update({});
629
+
630
+ return diff.files;
631
+ }
632
+ // a peer without the live-diff capability still lists changes, just without a diff body
633
+ if (this.client?.repoChanges === undefined) return [];
634
+ const changes = await this.client.repoChanges(this.sidebarRepoRoot());
635
+
636
+ return changes.map((change) => ({
637
+ path: change.path,
638
+ status: change.status,
639
+ oldContents: "",
640
+ newContents: "",
641
+ }));
642
+ }
643
+
644
+ // Refreshes race the connection teardown: an event can arrive while close()
645
+ // is rejecting in-flight requests, and a fire-and-forget refresh must never
646
+ // surface that as an unhandled rejection.
647
+ private async refreshSession(id: string): Promise<void> {
648
+ try {
649
+ if (this.client) this.update({ session: await this.client.sessionGet(id) });
650
+ } catch (cause) {
651
+ if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
652
+ }
653
+ }
654
+
655
+ private async refreshInbox(): Promise<void> {
656
+ try {
657
+ if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
658
+ } catch (cause) {
659
+ if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
660
+ }
661
+ }
662
+
663
+ /** Optimistic apply: the daemon response is the next session snapshot. */
664
+ private apply(mutation: Promise<Thread>): void {
665
+ mutation
666
+ .then((session) => this.update({ session }))
667
+ .catch((cause: unknown) =>
668
+ this.setStatus(String(cause instanceof Error ? cause.message : cause)),
669
+ );
670
+ }
671
+
672
+ /**
673
+ * Show the expected result before the daemon answers, so a second keypress
674
+ * builds on the first instead of on the stale snapshot. Answers arrive in
675
+ * request order over the socket and replace the guess with the daemon's copy.
676
+ */
677
+ private applyOptimistic(expected: Thread, mutation: Promise<Thread>): void {
678
+ this.update({ session: expected });
679
+ mutation
680
+ .then((session) => this.update({ session }))
681
+ .catch((cause: unknown) => {
682
+ // a refused guess must not stay on screen: the daemon's record replaces it
683
+ this.setStatus(String(cause instanceof Error ? cause.message : cause));
684
+ void this.refreshSession(expected.id);
685
+ });
686
+ }
687
+
688
+ // ── primitives ───────────────────────────────────
689
+ open(id: string): void {
690
+ this.locallyViewed.clear();
691
+ const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
692
+
693
+ if (cached) this.update({ session: cached });
694
+ else void this.refreshSession(id);
695
+ }
696
+
697
+ deleteSession(id: string): void {
698
+ if (this.readOnly) return this.setStatus("observer - read-only");
699
+ this.client
700
+ ?.sessionDelete(id)
701
+ .then(() => this.setStatus("plan deleted"))
702
+ .catch((cause: unknown) =>
703
+ this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
704
+ );
705
+ }
706
+
707
+ renameSession(id: string, title: string): void {
708
+ if (this.readOnly) return this.setStatus("observer - read-only");
709
+ this.client
710
+ ?.sessionSetTitle(id, title)
711
+ .then(() => this.setStatus("thread renamed"))
712
+ .catch((cause: unknown) =>
713
+ this.setStatus(`rename failed: ${cause instanceof Error ? cause.message : String(cause)}`),
714
+ );
715
+ }
716
+
717
+ setSelfName(name: string): void {
718
+ const session = this.snapshot.session;
719
+
720
+ if (!session) return;
721
+ this.apply(this.client!.sessionSetSelfName(session.id, name));
722
+ }
723
+
724
+ cut(displayIndex: number): void {
725
+ const session = this.snapshot.session;
726
+
727
+ if (!session || session.status === "resolved") return;
728
+ const block = this.display()[displayIndex];
729
+
730
+ if (!block) return;
731
+ const working = this.working();
732
+
733
+ if (block.type === "del") {
734
+ this.restoreDelBlock(block, displayIndex);
735
+ } else if (block.work) {
736
+ const workIndex = parseBlocks(working).findIndex(
737
+ (candidate) => candidate.lineStart === block.work!.lineStart,
738
+ );
739
+
740
+ if (workIndex === -1) return;
741
+ this.applyOptimistic(
742
+ { ...session, workingCopy: cutBlock(working, block.work) },
743
+ this.client!.sessionCutBlock(session.id, workIndex),
744
+ );
745
+ this.setStatus("block cut - it serializes into the diff");
746
+ }
747
+ }
748
+
749
+ /** Re-insert a cut plan block at the next surviving block's line (Cut toggle + rail undo). */
750
+ private restoreDelBlock(block: DisplayBlock, displayIndex: number): void {
751
+ const session = this.snapshot.session;
752
+
753
+ if (!session || !block.base) return;
754
+ const working = this.working();
755
+ const line = restoreLine(
756
+ nextWorkBlock(this.display(), displayIndex),
757
+ working.split("\n").length,
758
+ );
759
+ const base = session.artifact.content;
760
+ const baseIndex = parseBlocks(base).findIndex(
761
+ (candidate) => candidate.lineStart === block.base!.lineStart,
762
+ );
763
+
764
+ if (baseIndex === -1) return;
765
+ const expected: Thread = {
766
+ ...session,
767
+ workingCopy: restoreBlock(base, working, block.base, line),
768
+ };
769
+
770
+ if (expected.workingCopy === undefined) delete expected.workingCopy;
771
+ this.applyOptimistic(expected, this.client!.sessionRestoreBlock(session.id, baseIndex, line));
772
+ this.setStatus(REMOVAL_RESTORED_STATUS);
773
+ }
774
+
775
+ // ── diff hunk curation ──────────────────────
776
+ /** The parsed model and row for a curation action, or null with a status set. */
777
+ private curationRow(rowIndex: number): { row: DiffRow; model: FileDiffMetadata } | null {
778
+ const session = this.snapshot.session;
779
+
780
+ if (!session || session.status === "resolved") return null;
781
+ if (!session.artifact.files) {
782
+ this.setStatus("hunk curation needs full file contents (PR diffs cannot be curated)");
783
+
784
+ return null;
785
+ }
786
+ const row = this.rows()[rowIndex];
787
+
788
+ if (!row || row.kind === "file" || row.kind === "hunk") {
789
+ this.setStatus("move to a code line to curate");
790
+
791
+ return null;
792
+ }
793
+ const model = this.derived.models.get(row.file);
794
+
795
+ if (!model) return null;
796
+
797
+ return { row, model };
798
+ }
799
+
800
+ toggleRejectHunk(rowIndex: number): void {
801
+ const located = this.curationRow(rowIndex);
802
+
803
+ if (!located) return;
804
+ const target = hunkRejectionForRow(located.row.file, located.model, located.row);
805
+
806
+ if (!target) return this.setStatus("no hunk under the cursor");
807
+ const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
808
+
809
+ if (this.rejections.some(wholeHunk)) {
810
+ this.curate(this.rejections.filter((rejection) => !wholeHunk(rejection)));
811
+ this.setStatus("hunk restored");
812
+ } else {
813
+ // a whole-hunk reject supersedes any change-level rejects inside it
814
+ const others = this.rejections.filter(
815
+ (rejection) =>
816
+ !(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
817
+ );
818
+
819
+ this.curate([...others, target]);
820
+ this.setStatus("hunk rejected - dropped from the working copy");
821
+ }
822
+ }
823
+
824
+ toggleRejectChange(rowIndex: number): void {
825
+ const located = this.curationRow(rowIndex);
826
+
827
+ if (!located) return;
828
+ if (located.row.kind !== "add" && located.row.kind !== "del")
829
+ return this.setStatus("move to a changed line to reject a change");
830
+ const target = changeRejectionForRow(located.row.file, located.model, located.row);
831
+
832
+ if (!target) return this.setStatus("no change under the cursor");
833
+ const wholeCovers = this.rejections.some((rejection) => rejectsWholeHunk(rejection, target));
834
+
835
+ if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
836
+ if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
837
+ this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
838
+ this.setStatus("change restored");
839
+ } else {
840
+ this.curate([...this.rejections, target]);
841
+ this.setStatus("change rejected - dropped from the working copy");
842
+ }
843
+ }
844
+
845
+ /** Hand the daemon the full set of reject decisions; the working copy follows. */
846
+ private curate(rejections: HunkRejection[]): void {
847
+ const session = this.snapshot.session;
848
+
849
+ if (!session) return;
850
+ const expected: Thread = { ...session, curation: rejections };
851
+
852
+ if (!rejections.length) delete expected.curation;
853
+ this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
854
+ }
855
+
856
+ rejectedRows(): Set<number> {
857
+ this.ensureDerived();
858
+ if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
859
+ const rejected = new Set<number>();
860
+
861
+ this.derived.rows.forEach((row, index) => {
862
+ const model = this.derived.models.get(row.file);
863
+
864
+ if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
865
+ });
866
+
867
+ return rejected;
868
+ }
869
+
870
+ curationItems(): CurationItem[] {
871
+ this.ensureDerived();
872
+ const session = this.snapshot.session;
873
+
874
+ if (!session) return EMPTY_CURATION_ITEMS;
875
+
876
+ return session.artifact.type === "diff" ? this.diffCurationItems() : this.planCurationItems();
877
+ }
878
+
879
+ restoreCuration(id: string): void {
880
+ // diff rejections drop from the reject list; a plan cut re-inserts its block
881
+ if (id.startsWith("diff:")) {
882
+ const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
883
+
884
+ if (kept.length === this.rejections.length) return;
885
+ this.curate(kept);
886
+ this.setStatus(REMOVAL_RESTORED_STATUS);
887
+
888
+ return;
889
+ }
890
+ const display = this.display();
891
+ const displayIndex = display.findIndex(
892
+ (block) => block.type === "del" && block.base && planCutId(block.base) === id,
893
+ );
894
+
895
+ if (displayIndex !== -1) this.restoreDelBlock(display[displayIndex]!, displayIndex);
896
+ }
897
+
898
+ /** The curated-out diff rejections as removal cards (newest decisions last). */
899
+ private diffCurationItems(): CurationItem[] {
900
+ if (!this.rejections.length) return EMPTY_CURATION_ITEMS;
901
+ const items: CurationItem[] = [];
902
+
903
+ for (const rejection of this.rejections) {
904
+ const rows = this.rowsForRejection(rejection);
905
+ const firstRow = rows[0];
906
+
907
+ items.push({
908
+ id: curationItemId(rejection),
909
+ source: "diff",
910
+ preview: rows.map(
911
+ (row) => `${row.kind === "add" ? "+" : "-"} ${row.text.replace(/\n$/, "")}`,
912
+ ),
913
+ revealIndex: firstRow ? this.derived.rows.indexOf(firstRow) : 0,
914
+ });
915
+ }
916
+
917
+ return items;
918
+ }
919
+
920
+ /** The cut plan blocks as removal cards, in document order. */
921
+ private planCurationItems(): CurationItem[] {
922
+ const items: CurationItem[] = [];
923
+
924
+ this.derived.display.forEach((block, displayIndex) => {
925
+ if (block.type !== "del" || !block.base) return;
926
+ items.push({
927
+ id: planCutId(block.base),
928
+ source: "plan",
929
+ preview: block.base.text.split("\n"),
930
+ revealIndex: displayIndex,
931
+ });
932
+ });
933
+
934
+ return items.length ? items : EMPTY_CURATION_ITEMS;
935
+ }
936
+
937
+ /** The change/deletion rows a rejection covers, for its preview and reveal row. */
938
+ private rowsForRejection(rejection: HunkRejection): DiffRow[] {
939
+ const model = this.derived.models.get(rejection.path);
940
+
941
+ if (!model) return [];
942
+ const rows: DiffRow[] = [];
943
+
944
+ for (const row of this.derived.rows) {
945
+ if (row.file !== rejection.path || (row.kind !== "add" && row.kind !== "del")) continue;
946
+ if (rejection.changeIndex === undefined) {
947
+ const target = hunkRejectionForRow(rejection.path, model, row);
948
+
949
+ if (target && target.hunkIndex === rejection.hunkIndex) rows.push(row);
950
+ } else {
951
+ const target = changeRejectionForRow(rejection.path, model, row);
952
+
953
+ if (target && sameRejection(target, rejection)) rows.push(row);
954
+ }
955
+ }
956
+
957
+ return rows;
958
+ }
959
+
960
+ edit(): void {
961
+ const session = this.snapshot.session;
962
+
963
+ if (!session || session.status === "resolved") return;
964
+ try {
965
+ const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
966
+
967
+ if (result.changed) {
968
+ this.setWorkingCopy(result.content);
969
+ this.reconcileAnnotations(session, result.content);
970
+ this.setStatus("edits tracked - one diff");
971
+ } else this.setStatus("no changes");
972
+ } catch (err) {
973
+ this.setStatus(err instanceof Error ? err.message : String(err));
974
+ }
975
+ }
976
+
977
+ /**
978
+ * Edit-exit reconciliation: re-resolve every annotation against the edited
979
+ * working copy with the quote-primary cascade. Annotations that stop
980
+ * resolving stay stored (the feedback serializer flags orphaned anchors);
981
+ * the count feeds the one-line banner above the sheet.
982
+ */
983
+ private reconcileAnnotations(session: Thread, editedContent: string): void {
984
+ const editedBlocks = parseBlocks(editedContent);
985
+ const orphanCount = session.annotations.filter(
986
+ (annotation) => resolveAnchor(annotation.anchor, editedBlocks) === null,
987
+ ).length;
988
+
989
+ this.update({ editOrphanCount: orphanCount });
990
+ }
991
+
992
+ annotate(
993
+ kind: "comment",
994
+ displayIndex: number,
995
+ start: number,
996
+ end: number,
997
+ body: string,
998
+ endDisplayIndex: number = displayIndex,
999
+ target: AnnotationTarget = { kind: "artifact" },
1000
+ ): string | undefined {
1001
+ const session = this.snapshot.session;
1002
+
1003
+ if (!session) return undefined;
1004
+ let anchor;
1005
+ let resolvedTarget = target;
1006
+ // a file target (a Changes-panel diff note) and a diff artifact both anchor into the diff rows
1007
+ const onDiff = target.kind === "file" || session.artifact.type === "diff";
1008
+
1009
+ if (target.kind === "file") {
1010
+ // a Changes-panel note anchors within its own file's rows, so a quote never attaches across
1011
+ // files; a selection that spills into the next file is clamped back to the file it started in
1012
+ const rows = this.rows();
1013
+ const startRow = rows[displayIndex];
1014
+ const path = startRow?.file ?? "";
1015
+ const range = fileRowRange(rows, path);
1016
+ const base = range?.start ?? 0;
1017
+ const fileRows = range ? rows.slice(range.start, range.end) : rows;
1018
+ const lastInFile = fileRows.length - 1;
1019
+ const clamp = (index: number): number => Math.max(0, Math.min(index - base, lastInFile));
1020
+
1021
+ anchor = makeAnchor(
1022
+ diffRowBlocks(fileRows),
1023
+ clamp(displayIndex),
1024
+ start,
1025
+ end,
1026
+ clamp(endDisplayIndex),
1027
+ );
1028
+ resolvedTarget = { kind: "file", path, rev: startRow?.kind === "del" ? "head" : "worktree" };
1029
+ } else if (onDiff) {
1030
+ // rows are the diff's blocks: a span over one or more code rows anchors with the
1031
+ // same quote, context, and position selectors a plan span does
1032
+ anchor = makeAnchor(diffRowBlocks(this.rows()), displayIndex, start, end, endDisplayIndex);
1033
+ } else {
1034
+ const display = this.display();
1035
+ const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
1036
+ const workIndexOf = (index: number): number =>
1037
+ display.slice(0, index + 1).filter((entry) => entry.work).length - 1;
1038
+
1039
+ anchor = makeAnchor(
1040
+ workBlocks,
1041
+ workIndexOf(displayIndex),
1042
+ start,
1043
+ end,
1044
+ workIndexOf(endDisplayIndex),
1045
+ );
1046
+ }
1047
+ // absent target means the reviewed artifact, so keep artifact notes free of the field
1048
+ const wire =
1049
+ resolvedTarget.kind === "artifact"
1050
+ ? { id: newAnnotationId(), kind, anchor, body }
1051
+ : { id: newAnnotationId(), kind, anchor, body, target: resolvedTarget };
1052
+ const persisted = this.client!.sessionComment(session.id, wire);
1053
+
1054
+ this.apply(persisted);
1055
+ this.mirrorAnnotation(persisted, wire);
1056
+
1057
+ return wire.id;
1058
+ }
1059
+
1060
+ addComment(anchor: Anchor, target: AnnotationTarget, body: string): string | undefined {
1061
+ const session = this.snapshot.session;
1062
+
1063
+ if (!session) return undefined;
1064
+ const wire =
1065
+ target.kind === "artifact"
1066
+ ? { id: newAnnotationId(), kind: "comment", anchor, body }
1067
+ : { id: newAnnotationId(), kind: "comment", anchor, body, target };
1068
+ const persisted = this.client!.sessionComment(session.id, wire);
1069
+
1070
+ this.apply(persisted);
1071
+ this.mirrorAnnotation(persisted, wire);
1072
+
1073
+ return wire.id;
1074
+ }
1075
+
1076
+ async commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void> {
1077
+ if (!this.snapshot.session) {
1078
+ if (this.client?.sessionWorkbench === undefined) return;
1079
+ let workbench;
1080
+
1081
+ try {
1082
+ workbench = await this.client.sessionWorkbench(this.options.cwd ?? process.cwd());
1083
+ } catch (cause) {
1084
+ // the composer already closed, so a lost first comment must at least surface, not vanish
1085
+ this.setStatus(String(cause instanceof Error ? cause.message : cause));
1086
+
1087
+ return;
1088
+ }
1089
+ // adopt the thread as active (the shell flips to the thread view) and let the inbox catch up
1090
+ this.update({ session: workbench });
1091
+ void this.refreshInbox();
1092
+ }
1093
+ this.addComment(anchor, target, body);
1094
+ }
1095
+
1096
+ reply(rootAnnotationId: string, body: string): string | undefined {
1097
+ const session = this.snapshot.session;
1098
+ const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
1099
+
1100
+ if (!session || !root) return undefined;
1101
+ // a reply to a reply still hangs off the discussion's root comment, and shares its target
1102
+ // so it renders and resolves on the same surface
1103
+ const base = {
1104
+ id: newAnnotationId(),
1105
+ kind: "comment",
1106
+ anchor: root.anchor,
1107
+ body,
1108
+ replyTo: root.replyTo ?? root.id,
1109
+ };
1110
+ const wire = root.target ? { ...base, target: root.target } : base;
1111
+ const persisted = this.client!.sessionComment(session.id, wire);
1112
+
1113
+ this.apply(persisted);
1114
+ this.mirrorAnnotation(persisted, wire);
1115
+
1116
+ return wire.id;
1117
+ }
1118
+
1119
+ annotatePrototype(selector: string, quote: string, body: string): string | undefined {
1120
+ const session = this.snapshot.session;
1121
+
1122
+ if (!session) return undefined;
1123
+ const anchor = { quote, prefix: "", suffix: "", selector };
1124
+ const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
1125
+ const persisted = this.client!.sessionComment(session.id, wire);
1126
+
1127
+ this.apply(persisted);
1128
+ this.mirrorAnnotation(persisted, wire);
1129
+
1130
+ return wire.id;
1131
+ }
1132
+
1133
+ updateAnnotation(id: string, body: string): void {
1134
+ const session = this.snapshot.session;
1135
+
1136
+ if (!session) return;
1137
+ const existing = session.annotations.find((annotation) => annotation.id === id);
1138
+
1139
+ if (!existing) return;
1140
+ // the daemon's annotate primitive upserts by id: same id, anchor, and
1141
+ // reply link, new body
1142
+ const wire: Omit<Annotation, "createdAt"> = {
1143
+ id: existing.id,
1144
+ kind: existing.kind,
1145
+ anchor: existing.anchor,
1146
+ body,
1147
+ };
1148
+
1149
+ if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
1150
+ const persisted = this.client!.sessionComment(session.id, wire);
1151
+
1152
+ this.apply(persisted);
1153
+ this.mirrorAnnotation(persisted, wire);
1154
+ this.setStatus("annotation updated");
1155
+ }
1156
+
1157
+ // push only after the local write lands, so a rejected write never leaks to the share
1158
+ private mirrorAnnotation(
1159
+ persisted: Promise<Thread>,
1160
+ annotation: Omit<Annotation, "createdAt">,
1161
+ ): void {
1162
+ const session = this.snapshot.session;
1163
+ const shareId = session?.shareId;
1164
+
1165
+ if (!shareId) return;
1166
+ // the share follows one branch: a note left on another stays the owner's
1167
+ if (session.history && session.history.branch !== (session.shareBranch ?? MAIN_BRANCH)) return;
1168
+ void persisted.then(() => this.shareTransport.push(shareId, [annotation])).catch(() => {});
1169
+ }
1170
+
1171
+ removeAnnotation(id: string): void {
1172
+ const session = this.snapshot.session;
1173
+
1174
+ if (!session) return;
1175
+ this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
1176
+ this.setStatus("annotation deleted");
1177
+ }
1178
+
1179
+ setWorkingCopy(content: string | undefined): void {
1180
+ const session = this.snapshot.session;
1181
+
1182
+ if (!session) return;
1183
+ this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
1184
+ }
1185
+
1186
+ // ── the guided walk ─────────────────────────
1187
+ // Marks sent but possibly not yet reflected in the session snapshot; the
1188
+ // union keeps the walk title truthful and rapid advances from losing marks.
1189
+ private locallyViewed = new Set<string>();
1190
+
1191
+ private viewedSet(): Set<string> {
1192
+ return new Set([...(this.snapshot.session?.viewedPaths ?? []), ...this.locallyViewed]);
1193
+ }
1194
+
1195
+ walkStart(): void {
1196
+ const session = this.snapshot.session;
1197
+
1198
+ if (!session || session.artifact.type !== "diff") return;
1199
+ const files = this.files();
1200
+
1201
+ if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
1202
+ // resume at the first unviewed file; a finished walk reopens on the end card
1203
+ this.update({ walk: { index: firstUnviewedIndex(files, this.viewedSet()) } });
1204
+ }
1205
+
1206
+ walkForward(): void {
1207
+ const walk = this.snapshot.walk;
1208
+ const session = this.snapshot.session;
1209
+
1210
+ if (!walk || !session) return;
1211
+ const files = this.files();
1212
+ const current = files[walk.index];
1213
+
1214
+ if (!current) return; // already on the end card
1215
+ // advancing IS the viewed mark: the step is complete once you move past
1216
+ // it; the daemon primitive merges, so only the new path travels
1217
+ if (!this.viewedSet().has(current.path)) {
1218
+ this.locallyViewed.add(current.path);
1219
+ this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
1220
+ }
1221
+ this.update({ walk: { index: walk.index + 1 } });
1222
+ }
1223
+
1224
+ walkBack(): void {
1225
+ const walk = this.snapshot.walk;
1226
+
1227
+ if (!walk) return;
1228
+ this.update({ walk: { index: Math.max(0, walk.index - 1) } });
1229
+ }
1230
+
1231
+ walkLeave(): void {
1232
+ if (this.snapshot.walk) this.update({ walk: null });
1233
+ }
1234
+
1235
+ submit(verdict: VerdictKind, summary: string): void {
1236
+ const session = this.snapshot.session;
1237
+
1238
+ if (!session) return;
1239
+ this.walkLeave();
1240
+ const actionBodies = Object.fromEntries(
1241
+ slashItemsFrom(this.quickActions).map((item) => [item.name, item.body]),
1242
+ );
1243
+ this.client!.sessionResolve(session.id, verdict, summary, actionBodies)
1244
+ .then((resolved) => {
1245
+ // The completion overlay heading already states the verdict, so the
1246
+ // status line stays empty here - only export/error messages fill it.
1247
+ this.update({ session: resolved, status: "" });
1248
+ // notes-vault export: guarded by each exporter's policy (default manual = no-op)
1249
+ for (const exporter of this.exporters) {
1250
+ if (!exporter.runsOn(verdict)) continue;
1251
+ void exporter.run(resolved).then((exportResult) => {
1252
+ this.setStatus(
1253
+ exportResult.success && exportResult.path
1254
+ ? `exported to ${exportResult.path}`
1255
+ : `export failed: ${exportResult.error ?? "unknown"}`,
1256
+ );
1257
+ });
1258
+ }
1259
+ // Hand the reviewer back to the agent. The default is a visible
1260
+ // countdown from DEFAULT_AUTO_CLOSE that closes on its own; esc stays,
1261
+ // a remembers the choice. A configured delay overrides; 0 closes now.
1262
+ const delay = this.autoClose;
1263
+
1264
+ if (delay === "off") this.startCounting(DEFAULT_AUTO_CLOSE);
1265
+ else if (delay === 0) this.finishReview();
1266
+ else this.startCounting(delay);
1267
+ })
1268
+ .catch((cause: unknown) =>
1269
+ this.setStatus(String(cause instanceof Error ? cause.message : cause)),
1270
+ );
1271
+ }
1272
+
1273
+ share(): void {
1274
+ const session = this.snapshot.session;
1275
+
1276
+ if (!session) return;
1277
+ this.setStatus("sharing…");
1278
+ this.shareTransport
1279
+ .publish(session)
1280
+ .then(async ({ line, copied }) => {
1281
+ // Stamp the id back so a later pull knows which share to collect from.
1282
+ const shareId = this.shareTransport.parseShareId(line);
1283
+
1284
+ if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
1285
+ this.setStatus("");
1286
+ this.showToast(line, copied ? "share link copied" : "share link");
1287
+ })
1288
+ .catch((cause: unknown) =>
1289
+ this.setStatus(`share failed: ${cause instanceof Error ? cause.message : String(cause)}`),
1290
+ );
1291
+ }
1292
+
1293
+ goToEntry(entryId: string, summary?: string): void {
1294
+ const session = this.snapshot.session;
1295
+
1296
+ if (!session?.history || !this.client) return;
1297
+ const target = entryTarget(session.history, entryId);
1298
+
1299
+ if (target === null) return this.setStatus("that entry is not on any branch");
1300
+ if (target.kind === "here") return this.setStatus("already at the tip");
1301
+ if (target.kind === "switch") {
1302
+ this.moveTree(session, switchBranch(session.history, target.branch), () =>
1303
+ this.client!.sessionSwitch(session.id, target.branch),
1304
+ );
1305
+ this.setStatus(`on branch ${target.branch}`);
1306
+
1307
+ return;
1308
+ }
1309
+ // an entry another branch reaches: the daemon stands on that branch first, in the same request
1310
+ const onBranch = switchBranch(session.history, target.branch);
1311
+ const options = summary === undefined || summary === "" ? {} : { summary };
1312
+ const moved = navigateTo(onBranch, entryId, options);
1313
+ const branch = target.branch === session.history.branch ? undefined : target.branch;
1314
+
1315
+ this.moveTree(session, moved, () =>
1316
+ this.client!.sessionNavigate(session.id, entryId, options.summary, branch),
1317
+ );
1318
+ this.setStatus(
1319
+ options.summary === undefined
1320
+ ? "moved back - later entries stay in the tree"
1321
+ : "moved back with a summary",
1322
+ );
1323
+ }
1324
+
1325
+ branch(name: string): void {
1326
+ const session = this.snapshot.session;
1327
+ const branchName = name.trim();
1328
+
1329
+ if (!session?.history || !this.client) return;
1330
+ if (!branchName) return this.setStatus("a branch needs a name");
1331
+ if (session.history.tips[branchName] !== undefined)
1332
+ return this.setStatus(`branch ${branchName} exists`);
1333
+ this.moveTree(session, createBranch(session.history, branchName), () =>
1334
+ this.client!.sessionBranch(session.id, branchName),
1335
+ );
1336
+ this.setStatus(`on branch ${branchName}`);
1337
+ }
1338
+
1339
+ labelTip(label: string): void {
1340
+ const session = this.snapshot.session;
1341
+ const name = label.trim();
1342
+
1343
+ if (!session?.history || !this.client) return;
1344
+ if (!name) return this.setStatus("a checkpoint needs a name");
1345
+ this.moveTree(session, labelTip(session.history, name), () =>
1346
+ this.client!.sessionLabel(session.id, name),
1347
+ );
1348
+ this.setStatus(`checkpoint ${name}`);
1349
+ }
1350
+
1351
+ fork(): void {
1352
+ const session = this.snapshot.session;
1353
+
1354
+ if (!session || !this.client) return;
1355
+ this.client
1356
+ .sessionFork(session.id)
1357
+ .then((fork) => {
1358
+ this.update({ session: fork });
1359
+ this.showToast(`forked ${session.id} - you are on the fork now`, "fork");
1360
+ })
1361
+ .catch((cause: unknown) =>
1362
+ this.setStatus(`fork failed: ${cause instanceof Error ? cause.message : String(cause)}`),
1363
+ );
1364
+ }
1365
+
1366
+ forkAndShare(): void {
1367
+ const session = this.snapshot.session;
1368
+
1369
+ if (!session || !this.client) return;
1370
+ const client = this.client;
1371
+
1372
+ this.setStatus("forking and sharing…");
1373
+ client
1374
+ .sessionFork(session.id)
1375
+ .then(async (fork) => {
1376
+ const { line, copied } = await this.shareTransport.publish(fork);
1377
+ const shareId = this.shareTransport.parseShareId(line);
1378
+
1379
+ if (shareId) await client.sessionSetShareId(fork.id, shareId);
1380
+ this.setStatus("");
1381
+ this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
1382
+ })
1383
+ .catch((cause: unknown) =>
1384
+ this.setStatus(
1385
+ `fork and share failed: ${cause instanceof Error ? cause.message : String(cause)}`,
1386
+ ),
1387
+ );
1388
+ }
1389
+
1390
+ /**
1391
+ * Show a moved tree at once - the record re-derived from its new path - and
1392
+ * let the daemon's answer replace it. A refused move surfaces as status.
1393
+ */
1394
+ private moveTree(session: Thread, history: SessionHistory, request: () => Promise<Thread>): void {
1395
+ const expected: Thread = { ...session, history };
1396
+
1397
+ applyPathView(
1398
+ expected,
1399
+ viewOfPath(history, [...session.annotations, ...(session.shelvedAnnotations ?? [])]),
1400
+ );
1401
+ this.applyOptimistic(expected, request());
1402
+ }
1403
+
1404
+ /**
1405
+ * Pull collaborator notes for a shared plan and union them in. The daemon's
1406
+ * merge emits session.updated, so the re-render happens through the normal
1407
+ * event path. Best-effort: a failed refresh is silent.
1408
+ */
1409
+ pullShared(): Promise<void> {
1410
+ const session = this.snapshot.session;
1411
+
1412
+ if (!session?.shareId) return Promise.resolve();
1413
+
1414
+ return this.shareTransport
1415
+ .pull(session.shareId)
1416
+ .then((remote) => this.mergeShared(remote))
1417
+ .catch(() => {});
1418
+ }
1419
+
1420
+ private mergeShared(remote: Thread): Promise<void> {
1421
+ const session = this.snapshot.session;
1422
+
1423
+ if (!session || !this.client) return Promise.resolve();
1424
+
1425
+ return this.client
1426
+ .sessionMergeShared(session.id, this.shareTransport.mergeFromShare(remote))
1427
+ .then(() => {})
1428
+ .catch(() => {});
1429
+ }
1430
+
1431
+ /**
1432
+ * Follow the share live while it is open: one watch stream delivers every
1433
+ * change as it lands; a pull on each (re)connect catches up on anything
1434
+ * missed while the link was down, and a dropped link reconnects with
1435
+ * backoff. Returns a stop handle the caller runs on leave.
1436
+ */
1437
+ startShareSync(): () => void {
1438
+ this.stopShareSync();
1439
+ // per-run token so a stale run's close never re-arms a newer run
1440
+ const run = {};
1441
+ let delay = SHARE_RECONNECT_MIN_MS;
1442
+
1443
+ this.shareRun = run;
1444
+ const connect = (): void => {
1445
+ const shareId = this.snapshot.session?.shareId;
1446
+
1447
+ if (this.shareRun !== run || this.closed || !shareId) return;
1448
+ void this.pullShared();
1449
+ this.shareStop = this.shareTransport.watch(shareId, {
1450
+ onSession: (remote) => {
1451
+ delay = SHARE_RECONNECT_MIN_MS;
1452
+ void this.mergeShared(remote);
1453
+ },
1454
+ onClose: () => {
1455
+ if (this.shareRun !== run || this.closed) return;
1456
+ this.shareStop = null;
1457
+ this.shareReconnect = this.clock.setTimeout(connect, delay);
1458
+ delay = Math.min(delay * 2, SHARE_RECONNECT_MAX_MS);
1459
+ },
1460
+ });
1461
+ };
1462
+
1463
+ connect();
1464
+
1465
+ return () => {
1466
+ if (this.shareRun === run) this.stopShareSync();
1467
+ };
1468
+ }
1469
+
1470
+ private stopShareSync(): void {
1471
+ this.shareRun = null;
1472
+ if (this.shareReconnect !== undefined) this.clock.clearTimeout(this.shareReconnect);
1473
+ this.shareReconnect = undefined;
1474
+ this.shareStop?.();
1475
+ this.shareStop = null;
1476
+ }
1477
+
1478
+ // ── completion hand-back ────────────────────
1479
+ private startCounting(remaining: number): void {
1480
+ if (remaining <= 0) return this.finishReview();
1481
+ this.update({ completion: { phase: "counting", remaining } });
1482
+ this.countdown = this.clock.setTimeout(() => this.startCounting(remaining - 1), 1000);
1483
+ }
1484
+
1485
+ finishReview(): void {
1486
+ this.clearCountdown();
1487
+ const herdr = detectHerdr();
1488
+ const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
1489
+
1490
+ if (herdr && pane) focusHerdrPane(herdr.binPath, pane);
1491
+ this.options.onExit?.(0);
1492
+ }
1493
+
1494
+ dismissCompletion(): void {
1495
+ this.clearCountdown();
1496
+ this.update({ completion: { phase: "dismissed" } });
1497
+ }
1498
+
1499
+ optInAutoClose(): void {
1500
+ // Remember the default countdown for future submits, persisted to the user
1501
+ // config. The countdown is already running; this only makes it the default.
1502
+ try {
1503
+ persistAutoClose(DEFAULT_AUTO_CLOSE);
1504
+ } catch {
1505
+ // a read-only config dir must not block closing the review
1506
+ }
1507
+ this.autoClose = DEFAULT_AUTO_CLOSE;
1508
+ }
1509
+ }