@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.50
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.
- package/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +36 -31
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +672 -0
- package/src/App.prototype.test.tsx +155 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +218 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +289 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +241 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +127 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +108 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +456 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +358 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +317 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +49 -0
- package/src/components/agent-launcher.tsx +147 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +51 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +181 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/ghostty-terminal.test.ts +82 -0
- package/src/ghostty-terminal.ts +168 -0
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +493 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +430 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/kitty-image.test.ts +20 -0
- package/src/kitty-image.ts +75 -0
- package/src/prototype-browser.test.ts +55 -0
- package/src/prototype-browser.ts +243 -0
- package/src/pty.test.ts +78 -0
- package/src/pty.ts +222 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +914 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +38 -17
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +399 -0
- package/src/view-plan.ts +495 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review-session controller: every daemon round-trip and mutation
|
|
3
|
+
* verb behind one React-free object. It owns connect/autostart/subscribe,
|
|
4
|
+
* the session/inbox/status/error snapshot, optimistic apply, the mutation
|
|
5
|
+
* verbs (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
|
+
cutBlock,
|
|
14
|
+
detectHerdr,
|
|
15
|
+
makeAnchor,
|
|
16
|
+
newAnnotationId,
|
|
17
|
+
parseBlocks,
|
|
18
|
+
resolveAnchor,
|
|
19
|
+
restoreBlock,
|
|
20
|
+
restoreLine,
|
|
21
|
+
returnPaneFor,
|
|
22
|
+
type Annotation,
|
|
23
|
+
type ReviewSession,
|
|
24
|
+
type VerdictKind,
|
|
25
|
+
} from "@cueloop/schema";
|
|
26
|
+
import { loadBundledExporters, type BundledExporter } from "./integrations";
|
|
27
|
+
import {
|
|
28
|
+
collaboratorAnnotations,
|
|
29
|
+
publishShare,
|
|
30
|
+
pullShare,
|
|
31
|
+
pushShare,
|
|
32
|
+
shareIdFromLine,
|
|
33
|
+
} from "./share";
|
|
34
|
+
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
|
|
35
|
+
import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
36
|
+
import {
|
|
37
|
+
changeRejectionForRow,
|
|
38
|
+
curateDiff,
|
|
39
|
+
hunkRejectionForRow,
|
|
40
|
+
isRowRejected,
|
|
41
|
+
parseFileDiff,
|
|
42
|
+
rejectsWholeHunk,
|
|
43
|
+
sameRejection,
|
|
44
|
+
type HunkRejection,
|
|
45
|
+
} from "./diff-hunk-curate";
|
|
46
|
+
import type { FileDiffMetadata } from "@pierre/diffs";
|
|
47
|
+
import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
|
|
48
|
+
import { editInEditor } from "./editor";
|
|
49
|
+
import { focusHerdrPane } from "./herdr";
|
|
50
|
+
import {
|
|
51
|
+
persistAutoClose,
|
|
52
|
+
persistReviewState,
|
|
53
|
+
persistReviewWidth,
|
|
54
|
+
type AutoClose,
|
|
55
|
+
type CueloopConfig,
|
|
56
|
+
} from "./config";
|
|
57
|
+
import type { ReviewPanelMode } from "./review-panel";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Post-submit lifecycle (a review pane should hand you back to the agent,
|
|
61
|
+
* not linger): idle → counting (a visible countdown, the default) → exit.
|
|
62
|
+
* esc dismisses to the resolved view; a remembers the countdown as default.
|
|
63
|
+
*/
|
|
64
|
+
export type Completion =
|
|
65
|
+
| { phase: "idle" }
|
|
66
|
+
| { phase: "prompt" }
|
|
67
|
+
| { phase: "counting"; remaining: number }
|
|
68
|
+
| { phase: "dismissed" };
|
|
69
|
+
|
|
70
|
+
/** Seconds the completion overlay counts down before it hands back. */
|
|
71
|
+
export const DEFAULT_AUTO_CLOSE = 5;
|
|
72
|
+
|
|
73
|
+
/** How often an open shared plan re-pulls collaborator notes (ADR 0005 stage 2). */
|
|
74
|
+
export const SHARE_POLL_MS = 4000;
|
|
75
|
+
|
|
76
|
+
/** Shared empty set so "nothing rejected" is a stable identity for renders. */
|
|
77
|
+
const EMPTY_REJECTED_ROWS: Set<number> = new Set();
|
|
78
|
+
|
|
79
|
+
/** Shared empty list so "nothing curated out" is a stable identity for renders. */
|
|
80
|
+
const EMPTY_CURATION_ITEMS: CurationItem[] = [];
|
|
81
|
+
|
|
82
|
+
/** Status shown when a curated-out removal (diff rejection or plan cut) is restored. */
|
|
83
|
+
const REMOVAL_RESTORED_STATUS = "removal restored";
|
|
84
|
+
|
|
85
|
+
export interface ToastState {
|
|
86
|
+
title?: string;
|
|
87
|
+
body: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One removed piece projected for the review rail, unified across both review
|
|
92
|
+
* kinds: a diff hunk/change the owner curated out, or a plan block they cut.
|
|
93
|
+
* `source` tells restore and reveal which path to take; `preview` is the removed
|
|
94
|
+
* content (a few lines) the card renders struck through; `revealIndex` is the
|
|
95
|
+
* diff row (diff) or display block (plan) to scroll to and restore against.
|
|
96
|
+
*/
|
|
97
|
+
export interface CurationItem {
|
|
98
|
+
id: string;
|
|
99
|
+
source: "diff" | "plan";
|
|
100
|
+
preview: string[];
|
|
101
|
+
revealIndex: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Stable id for a rejection's rail item: diff:path#hunk#change (or #hunk for a whole hunk). */
|
|
105
|
+
export function curationItemId(rejection: HunkRejection): string {
|
|
106
|
+
return `diff:${rejection.path}#${rejection.hunkIndex}#${rejection.changeIndex ?? "hunk"}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Stable id for a plan cut's rail item, keyed on its fixed base-content line range. */
|
|
110
|
+
function planCutId(base: { lineStart: number; lineEnd: number }): string {
|
|
111
|
+
return `plan:${base.lineStart}-${base.lineEnd}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ControllerSnapshot {
|
|
115
|
+
session: ReviewSession | null;
|
|
116
|
+
inbox: ReviewSession[] | null;
|
|
117
|
+
status: string;
|
|
118
|
+
toast: ToastState | null;
|
|
119
|
+
error: string | null;
|
|
120
|
+
completion: Completion;
|
|
121
|
+
/**
|
|
122
|
+
* Annotations whose anchor stopped resolving after the last editor
|
|
123
|
+
* hand-off - the reconciliation banner count. 0 = no banner.
|
|
124
|
+
*/
|
|
125
|
+
editOrphanCount: number;
|
|
126
|
+
/**
|
|
127
|
+
* The guided walk's cursor (diff sessions): which wizard step is on
|
|
128
|
+
* screen. index === file count is the end card. null = not walking; the
|
|
129
|
+
* viewed set itself rides the session record, so leaving loses nothing.
|
|
130
|
+
*/
|
|
131
|
+
walk: { index: number } | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ReviewControllerOptions {
|
|
135
|
+
home?: string;
|
|
136
|
+
sessionId?: string;
|
|
137
|
+
/** Observer mode: stored for the key reducer's read-only gate. */
|
|
138
|
+
readOnly?: boolean;
|
|
139
|
+
onExit?: (code: number) => void;
|
|
140
|
+
/** Timer source for the auto-close countdown; tests inject a ManualClock. */
|
|
141
|
+
clock?: Clock;
|
|
142
|
+
/**
|
|
143
|
+
* How the controller gets its session client. Defaults to dialing the local
|
|
144
|
+
* daemon; the sharing gateway injects a blob-backed client so the same <App>
|
|
145
|
+
* renders a decrypted share instead.
|
|
146
|
+
*/
|
|
147
|
+
openClient?: () => Promise<SessionClient>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ReviewController {
|
|
151
|
+
readonly readOnly: boolean;
|
|
152
|
+
/** Snapshot listeners (stable identity - safe for useSyncExternalStore). */
|
|
153
|
+
subscribe(listener: () => void): () => void;
|
|
154
|
+
getSnapshot(): ControllerSnapshot;
|
|
155
|
+
/** Dial the daemon (autostart), subscribe to events, fetch session or inbox. */
|
|
156
|
+
connect(): void;
|
|
157
|
+
close(): void;
|
|
158
|
+
/** Loaded config parts the controller acts on: auto-close and exporters. */
|
|
159
|
+
applyConfig(config: CueloopConfig): void;
|
|
160
|
+
setStatus(message: string): void;
|
|
161
|
+
showToast(body: string, title?: string): void;
|
|
162
|
+
dismissToast(): void;
|
|
163
|
+
/** Derived projections, cached per session identity. */
|
|
164
|
+
display(): DisplayBlock[];
|
|
165
|
+
rows(): DiffRow[];
|
|
166
|
+
/** The walk's step list, derived from the diff rows. */
|
|
167
|
+
files(): WalkFile[];
|
|
168
|
+
working(): string;
|
|
169
|
+
/** Open a session from the inbox. */
|
|
170
|
+
open(id: string): void;
|
|
171
|
+
/** Delete a session for good (inbox delete); the inbox refreshes on the event. */
|
|
172
|
+
deleteSession(id: string): void;
|
|
173
|
+
/** Record the viewer's own name into the share's participant registry (collaborator self-naming). */
|
|
174
|
+
setSelfName(name: string): void;
|
|
175
|
+
/** Cut the block under the cursor, or restore a cut one. */
|
|
176
|
+
cut(displayIndex: number): void;
|
|
177
|
+
/** Toggle rejection of the whole hunk under the diff cursor (owner curation). */
|
|
178
|
+
toggleRejectHunk(rowIndex: number): void;
|
|
179
|
+
/** Toggle rejection of the single change under the diff cursor (owner curation). */
|
|
180
|
+
toggleRejectChange(rowIndex: number): void;
|
|
181
|
+
/** Rendered row indices dropped by the current reject decisions (for dimming). */
|
|
182
|
+
rejectedRows(): Set<number>;
|
|
183
|
+
/** The curated-out rejections as rail items, in the order they were rejected. */
|
|
184
|
+
curationItems(): CurationItem[];
|
|
185
|
+
/** Undo one curation item by id: drop the rejection and recompute the working copy. */
|
|
186
|
+
restoreCuration(id: string): void;
|
|
187
|
+
/** The $EDITOR hand-off on the working copy. */
|
|
188
|
+
edit(): void;
|
|
189
|
+
/**
|
|
190
|
+
* Anchor and store an annotation; both plan and diff anchor constructions.
|
|
191
|
+
* Returns the minted annotation id so the view can select the new card.
|
|
192
|
+
*/
|
|
193
|
+
annotate(
|
|
194
|
+
kind: "comment",
|
|
195
|
+
displayIndex: number,
|
|
196
|
+
start: number,
|
|
197
|
+
end: number,
|
|
198
|
+
body: string,
|
|
199
|
+
): string | undefined;
|
|
200
|
+
/** Anchor a prototype comment to a DOM element by its selector. */
|
|
201
|
+
annotatePrototype(selector: string, quote: string, body: string): string | undefined;
|
|
202
|
+
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
203
|
+
updateAnnotation(id: string, body: string): void;
|
|
204
|
+
removeAnnotation(id: string): void;
|
|
205
|
+
setWorkingCopy(content: string | undefined): void;
|
|
206
|
+
/** Enter the guided walk at the first unviewed file (diff sessions). */
|
|
207
|
+
walkStart(): void;
|
|
208
|
+
/** Mark the current file viewed (persists with the session) and advance. */
|
|
209
|
+
walkForward(): void;
|
|
210
|
+
walkBack(): void;
|
|
211
|
+
/** Leave the walk; the viewed set stays on the session record. */
|
|
212
|
+
walkLeave(): void;
|
|
213
|
+
/** Resolve the review, run the export, start the completion hand-back. */
|
|
214
|
+
submit(verdict: VerdictKind, summary: string): void;
|
|
215
|
+
/** Publish the current session as a share; the ssh line lands on the clipboard. */
|
|
216
|
+
share(): void;
|
|
217
|
+
/** Pull a shared plan's collaborator notes back and union them in (planner only). */
|
|
218
|
+
pullShared(): Promise<void>;
|
|
219
|
+
/** Poll the share for collaborator notes while it is open; returns a stop handle. */
|
|
220
|
+
startSharePoll(): () => void;
|
|
221
|
+
/** Close the review and, inside herdr, bounce focus back to the agent. */
|
|
222
|
+
finishReview(): void;
|
|
223
|
+
dismissCompletion(): void;
|
|
224
|
+
/** From the completion prompt: persist auto-close and start the countdown. */
|
|
225
|
+
optInAutoClose(): void;
|
|
226
|
+
/**
|
|
227
|
+
* Persist the review-panel layout (client view state) to the user config so
|
|
228
|
+
* the collapse mode and rail width survive a restart. A read-only config dir
|
|
229
|
+
* never blocks the review.
|
|
230
|
+
*/
|
|
231
|
+
saveReviewPanel(layout: { mode?: ReviewPanelMode; width?: number }): void;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function createReviewController(options: ReviewControllerOptions): ReviewController {
|
|
235
|
+
return new Controller(options);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
class Controller implements ReviewController {
|
|
239
|
+
readonly readOnly: boolean;
|
|
240
|
+
private client: SessionClient | null = null;
|
|
241
|
+
private closed = false;
|
|
242
|
+
private snapshot: ControllerSnapshot = {
|
|
243
|
+
session: null,
|
|
244
|
+
inbox: null,
|
|
245
|
+
status: "",
|
|
246
|
+
toast: null,
|
|
247
|
+
error: null,
|
|
248
|
+
completion: { phase: "idle" },
|
|
249
|
+
editOrphanCount: 0,
|
|
250
|
+
walk: null,
|
|
251
|
+
};
|
|
252
|
+
private listeners = new Set<() => void>();
|
|
253
|
+
private autoClose: AutoClose = "off";
|
|
254
|
+
private editor: string | undefined;
|
|
255
|
+
private exporters: BundledExporter[] = [];
|
|
256
|
+
private readonly clock: Clock;
|
|
257
|
+
private countdown: TimerHandle | undefined;
|
|
258
|
+
private sharePoll: TimerHandle | undefined;
|
|
259
|
+
private shareRun: object | null = null;
|
|
260
|
+
/** Projections keyed by session identity so renders reuse one computation. */
|
|
261
|
+
private derivedFor: ReviewSession | null = null;
|
|
262
|
+
private derived: {
|
|
263
|
+
display: DisplayBlock[];
|
|
264
|
+
rows: DiffRow[];
|
|
265
|
+
files: WalkFile[];
|
|
266
|
+
/** Per-path parsed diff models, for hunk curation; empty when files absent. */
|
|
267
|
+
models: Map<string, FileDiffMetadata>;
|
|
268
|
+
} = {
|
|
269
|
+
display: [],
|
|
270
|
+
rows: [],
|
|
271
|
+
files: [],
|
|
272
|
+
models: new Map(),
|
|
273
|
+
};
|
|
274
|
+
/** The owner's per-hunk/change reject decisions for the open diff session. */
|
|
275
|
+
private rejections: HunkRejection[] = [];
|
|
276
|
+
|
|
277
|
+
constructor(private readonly options: ReviewControllerOptions) {
|
|
278
|
+
this.readOnly = options.readOnly ?? false;
|
|
279
|
+
this.clock = options.clock ?? new SystemClock();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
283
|
+
this.listeners.add(listener);
|
|
284
|
+
return () => this.listeners.delete(listener);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
getSnapshot = (): ControllerSnapshot => this.snapshot;
|
|
288
|
+
|
|
289
|
+
private update(patch: Partial<ControllerSnapshot>): void {
|
|
290
|
+
this.snapshot = { ...this.snapshot, ...patch };
|
|
291
|
+
for (const listener of this.listeners) listener();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
connect(): void {
|
|
295
|
+
void (async () => {
|
|
296
|
+
try {
|
|
297
|
+
const openClient =
|
|
298
|
+
this.options.openClient ??
|
|
299
|
+
(() => DaemonClient.connect({ home: this.options.home, autostart: true }));
|
|
300
|
+
const client = await openClient();
|
|
301
|
+
if (this.closed) return void client.close();
|
|
302
|
+
this.client = client;
|
|
303
|
+
client.onEvent((event) => {
|
|
304
|
+
// another controller/observer changed state: re-fetch
|
|
305
|
+
const session = this.snapshot.session;
|
|
306
|
+
if (session && event.sessionId === session.id) void this.refreshSession(session.id);
|
|
307
|
+
if (!this.options.sessionId) void this.refreshInbox();
|
|
308
|
+
});
|
|
309
|
+
await client.subscribe();
|
|
310
|
+
if (this.options.sessionId) {
|
|
311
|
+
this.update({ session: await client.sessionGet(this.options.sessionId) });
|
|
312
|
+
} else {
|
|
313
|
+
this.update({ inbox: await client.sessionList({ status: "pending" }) });
|
|
314
|
+
}
|
|
315
|
+
} catch (err) {
|
|
316
|
+
this.update({ error: err instanceof Error ? err.message : String(err) });
|
|
317
|
+
}
|
|
318
|
+
})();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
close(): void {
|
|
322
|
+
this.closed = true;
|
|
323
|
+
this.clearCountdown();
|
|
324
|
+
this.stopSharePoll();
|
|
325
|
+
this.client?.close();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private clearCountdown(): void {
|
|
329
|
+
if (this.countdown !== undefined) this.clock.clearTimeout(this.countdown);
|
|
330
|
+
this.countdown = undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
applyConfig(config: CueloopConfig): void {
|
|
334
|
+
this.autoClose = config.ui.autoClose;
|
|
335
|
+
this.editor = config.ui.editor;
|
|
336
|
+
void loadBundledExporters(config.integrations).then((exporters) => {
|
|
337
|
+
this.exporters = exporters;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
setStatus(message: string): void {
|
|
342
|
+
this.update({ status: message });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
showToast(body: string, title?: string): void {
|
|
346
|
+
this.update({ toast: { body, title } });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
dismissToast(): void {
|
|
350
|
+
if (this.snapshot.toast) this.update({ toast: null });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── derived projections ─────────────────────
|
|
354
|
+
private ensureDerived(): void {
|
|
355
|
+
const session = this.snapshot.session;
|
|
356
|
+
if (this.derivedFor === session) return;
|
|
357
|
+
this.derivedFor = session;
|
|
358
|
+
const rows =
|
|
359
|
+
session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
|
|
360
|
+
const models = new Map<string, FileDiffMetadata>();
|
|
361
|
+
for (const file of session?.artifact.files ?? []) models.set(file.path, parseFileDiff(file));
|
|
362
|
+
this.derived = {
|
|
363
|
+
display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
|
|
364
|
+
rows,
|
|
365
|
+
files: walkFiles(rows),
|
|
366
|
+
models,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
display(): DisplayBlock[] {
|
|
371
|
+
this.ensureDerived();
|
|
372
|
+
return this.derived.display;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
rows(): DiffRow[] {
|
|
376
|
+
this.ensureDerived();
|
|
377
|
+
return this.derived.rows;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
files(): WalkFile[] {
|
|
381
|
+
this.ensureDerived();
|
|
382
|
+
return this.derived.files;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
working(): string {
|
|
386
|
+
const session = this.snapshot.session;
|
|
387
|
+
return session ? (session.workingCopy ?? session.artifact.content) : "";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Refreshes race the connection teardown: an event can arrive while close()
|
|
391
|
+
// is rejecting in-flight requests, and a fire-and-forget refresh must never
|
|
392
|
+
// surface that as an unhandled rejection.
|
|
393
|
+
private async refreshSession(id: string): Promise<void> {
|
|
394
|
+
try {
|
|
395
|
+
if (this.client) this.update({ session: await this.client.sessionGet(id) });
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private async refreshInbox(): Promise<void> {
|
|
402
|
+
try {
|
|
403
|
+
if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Optimistic apply: the daemon response is the next session snapshot. */
|
|
410
|
+
private apply(mutation: Promise<ReviewSession>): void {
|
|
411
|
+
mutation
|
|
412
|
+
.then((session) => this.update({ session }))
|
|
413
|
+
.catch((error: unknown) =>
|
|
414
|
+
this.setStatus(String(error instanceof Error ? error.message : error)),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── verbs ───────────────────────────────────
|
|
419
|
+
open(id: string): void {
|
|
420
|
+
this.locallyViewed.clear();
|
|
421
|
+
this.rejections = [];
|
|
422
|
+
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
423
|
+
if (cached) this.update({ session: cached });
|
|
424
|
+
else void this.refreshSession(id);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
deleteSession(id: string): void {
|
|
428
|
+
this.client
|
|
429
|
+
?.sessionDelete(id)
|
|
430
|
+
.then(() => this.setStatus("plan deleted"))
|
|
431
|
+
.catch((error: unknown) =>
|
|
432
|
+
this.setStatus(`delete failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
setSelfName(name: string): void {
|
|
437
|
+
const session = this.snapshot.session;
|
|
438
|
+
if (!session) return;
|
|
439
|
+
this.apply(this.client!.sessionSetSelfName(session.id, name));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
cut(displayIndex: number): void {
|
|
443
|
+
const session = this.snapshot.session;
|
|
444
|
+
if (!session || session.status === "resolved") return;
|
|
445
|
+
const block = this.display()[displayIndex];
|
|
446
|
+
if (!block) return;
|
|
447
|
+
const working = this.working();
|
|
448
|
+
if (block.type === "del") {
|
|
449
|
+
this.restoreDelBlock(block, displayIndex);
|
|
450
|
+
} else if (block.work) {
|
|
451
|
+
this.setWorkingCopy(cutBlock(working, block.work));
|
|
452
|
+
this.setStatus("block cut - it serializes into the diff");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Re-insert a cut plan block at the next surviving block's line (Cut toggle + rail undo). */
|
|
457
|
+
private restoreDelBlock(block: DisplayBlock, displayIndex: number): void {
|
|
458
|
+
const session = this.snapshot.session;
|
|
459
|
+
if (!session || !block.base) return;
|
|
460
|
+
const working = this.working();
|
|
461
|
+
const line = restoreLine(
|
|
462
|
+
nextWorkBlock(this.display(), displayIndex),
|
|
463
|
+
working.split("\n").length,
|
|
464
|
+
);
|
|
465
|
+
// restoreBlock returns undefined when the block structure round-trips to the
|
|
466
|
+
// submitted revision - the working copy is back to pristine and is dropped
|
|
467
|
+
const restored = restoreBlock(session.artifact.content, working, block.base, line);
|
|
468
|
+
this.setWorkingCopy(restored);
|
|
469
|
+
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── diff hunk curation ──────────────────────
|
|
473
|
+
/** The parsed model and row for a curation action, or null with a status set. */
|
|
474
|
+
private curationRow(rowIndex: number): { row: DiffRow; model: FileDiffMetadata } | null {
|
|
475
|
+
const session = this.snapshot.session;
|
|
476
|
+
if (!session || session.status === "resolved") return null;
|
|
477
|
+
if (!session.artifact.files) {
|
|
478
|
+
this.setStatus("hunk curation needs full file contents (PR diffs cannot be curated)");
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
const row = this.rows()[rowIndex];
|
|
482
|
+
if (!row || row.kind === "file" || row.kind === "hunk") {
|
|
483
|
+
this.setStatus("move to a code line to curate");
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
const model = this.derived.models.get(row.file);
|
|
487
|
+
if (!model) return null;
|
|
488
|
+
return { row, model };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
toggleRejectHunk(rowIndex: number): void {
|
|
492
|
+
const located = this.curationRow(rowIndex);
|
|
493
|
+
if (!located) return;
|
|
494
|
+
const target = hunkRejectionForRow(located.row.file, located.model, located.row);
|
|
495
|
+
if (!target) return this.setStatus("no hunk under the cursor");
|
|
496
|
+
const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
|
|
497
|
+
if (this.rejections.some(wholeHunk)) {
|
|
498
|
+
this.rejections = this.rejections.filter((rejection) => !wholeHunk(rejection));
|
|
499
|
+
this.setStatus("hunk restored");
|
|
500
|
+
} else {
|
|
501
|
+
// a whole-hunk reject supersedes any change-level rejects inside it
|
|
502
|
+
this.rejections = this.rejections.filter(
|
|
503
|
+
(rejection) =>
|
|
504
|
+
!(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
|
|
505
|
+
);
|
|
506
|
+
this.rejections.push(target);
|
|
507
|
+
this.setStatus("hunk rejected - dropped from the working copy");
|
|
508
|
+
}
|
|
509
|
+
this.recomputeCuration();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
toggleRejectChange(rowIndex: number): void {
|
|
513
|
+
const located = this.curationRow(rowIndex);
|
|
514
|
+
if (!located) return;
|
|
515
|
+
if (located.row.kind !== "add" && located.row.kind !== "del")
|
|
516
|
+
return this.setStatus("move to a changed line to reject a change");
|
|
517
|
+
const target = changeRejectionForRow(located.row.file, located.model, located.row);
|
|
518
|
+
if (!target) return this.setStatus("no change under the cursor");
|
|
519
|
+
const wholeCovers = this.rejections.some((rejection) => rejectsWholeHunk(rejection, target));
|
|
520
|
+
if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
|
|
521
|
+
if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
|
|
522
|
+
this.rejections = this.rejections.filter((rejection) => !sameRejection(rejection, target));
|
|
523
|
+
this.setStatus("change restored");
|
|
524
|
+
} else {
|
|
525
|
+
this.rejections.push(target);
|
|
526
|
+
this.setStatus("change rejected - dropped from the working copy");
|
|
527
|
+
}
|
|
528
|
+
this.recomputeCuration();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Recompute the curated patch and push it as the working copy (or clear it). */
|
|
532
|
+
private recomputeCuration(): void {
|
|
533
|
+
const files = this.snapshot.session?.artifact.files;
|
|
534
|
+
if (!files) return;
|
|
535
|
+
// no decisions left = the working copy reverts to the full submitted diff
|
|
536
|
+
this.setWorkingCopy(this.rejections.length ? curateDiff(files, this.rejections) : undefined);
|
|
537
|
+
// dimming reads `rejections` directly; a bare update forces the re-read
|
|
538
|
+
this.update({});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
rejectedRows(): Set<number> {
|
|
542
|
+
this.ensureDerived();
|
|
543
|
+
if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
|
|
544
|
+
const rejected = new Set<number>();
|
|
545
|
+
this.derived.rows.forEach((row, index) => {
|
|
546
|
+
const model = this.derived.models.get(row.file);
|
|
547
|
+
if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
|
|
548
|
+
});
|
|
549
|
+
return rejected;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
curationItems(): CurationItem[] {
|
|
553
|
+
this.ensureDerived();
|
|
554
|
+
const session = this.snapshot.session;
|
|
555
|
+
if (!session) return EMPTY_CURATION_ITEMS;
|
|
556
|
+
return session.artifact.type === "diff" ? this.diffCurationItems() : this.planCurationItems();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
restoreCuration(id: string): void {
|
|
560
|
+
// diff rejections drop from the reject list; a plan cut re-inserts its block
|
|
561
|
+
if (id.startsWith("diff:")) {
|
|
562
|
+
const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
|
|
563
|
+
if (kept.length === this.rejections.length) return;
|
|
564
|
+
this.rejections = kept;
|
|
565
|
+
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
566
|
+
this.recomputeCuration();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const display = this.display();
|
|
570
|
+
const displayIndex = display.findIndex(
|
|
571
|
+
(block) => block.type === "del" && block.base && planCutId(block.base) === id,
|
|
572
|
+
);
|
|
573
|
+
if (displayIndex !== -1) this.restoreDelBlock(display[displayIndex]!, displayIndex);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** The curated-out diff rejections as removal cards (newest decisions last). */
|
|
577
|
+
private diffCurationItems(): CurationItem[] {
|
|
578
|
+
if (!this.rejections.length) return EMPTY_CURATION_ITEMS;
|
|
579
|
+
const items: CurationItem[] = [];
|
|
580
|
+
for (const rejection of this.rejections) {
|
|
581
|
+
const rows = this.rowsForRejection(rejection);
|
|
582
|
+
const firstRow = rows[0];
|
|
583
|
+
items.push({
|
|
584
|
+
id: curationItemId(rejection),
|
|
585
|
+
source: "diff",
|
|
586
|
+
preview: rows.map(
|
|
587
|
+
(row) => `${row.kind === "add" ? "+" : "-"} ${row.text.replace(/\n$/, "")}`,
|
|
588
|
+
),
|
|
589
|
+
revealIndex: firstRow ? this.derived.rows.indexOf(firstRow) : 0,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
return items;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** The cut plan blocks as removal cards, in document order. */
|
|
596
|
+
private planCurationItems(): CurationItem[] {
|
|
597
|
+
const items: CurationItem[] = [];
|
|
598
|
+
this.derived.display.forEach((block, displayIndex) => {
|
|
599
|
+
if (block.type !== "del" || !block.base) return;
|
|
600
|
+
items.push({
|
|
601
|
+
id: planCutId(block.base),
|
|
602
|
+
source: "plan",
|
|
603
|
+
preview: block.base.text.split("\n"),
|
|
604
|
+
revealIndex: displayIndex,
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
return items.length ? items : EMPTY_CURATION_ITEMS;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The change/deletion rows a rejection covers, for its preview and reveal row. */
|
|
611
|
+
private rowsForRejection(rejection: HunkRejection): DiffRow[] {
|
|
612
|
+
const model = this.derived.models.get(rejection.path);
|
|
613
|
+
if (!model) return [];
|
|
614
|
+
const rows: DiffRow[] = [];
|
|
615
|
+
for (const row of this.derived.rows) {
|
|
616
|
+
if (row.file !== rejection.path || (row.kind !== "add" && row.kind !== "del")) continue;
|
|
617
|
+
if (rejection.changeIndex === undefined) {
|
|
618
|
+
const target = hunkRejectionForRow(rejection.path, model, row);
|
|
619
|
+
if (target && target.hunkIndex === rejection.hunkIndex) rows.push(row);
|
|
620
|
+
} else {
|
|
621
|
+
const target = changeRejectionForRow(rejection.path, model, row);
|
|
622
|
+
if (target && sameRejection(target, rejection)) rows.push(row);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return rows;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
edit(): void {
|
|
629
|
+
const session = this.snapshot.session;
|
|
630
|
+
if (!session || session.status === "resolved") return;
|
|
631
|
+
try {
|
|
632
|
+
const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
|
|
633
|
+
if (result.changed) {
|
|
634
|
+
this.setWorkingCopy(result.content);
|
|
635
|
+
this.reconcileAnnotations(session, result.content);
|
|
636
|
+
this.setStatus("edits tracked - one diff");
|
|
637
|
+
} else this.setStatus("no changes");
|
|
638
|
+
} catch (err) {
|
|
639
|
+
this.setStatus(err instanceof Error ? err.message : String(err));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Edit-exit reconciliation: re-resolve every annotation against the edited
|
|
645
|
+
* working copy with the quote-primary cascade. Annotations that stop
|
|
646
|
+
* resolving stay stored (the feedback serializer flags orphaned anchors);
|
|
647
|
+
* the count feeds the one-line banner above the sheet.
|
|
648
|
+
*/
|
|
649
|
+
private reconcileAnnotations(session: ReviewSession, editedContent: string): void {
|
|
650
|
+
const editedBlocks = parseBlocks(editedContent);
|
|
651
|
+
const orphanCount = session.annotations.filter(
|
|
652
|
+
(annotation) => resolveAnchor(annotation.anchor, editedBlocks) === null,
|
|
653
|
+
).length;
|
|
654
|
+
this.update({ editOrphanCount: orphanCount });
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
annotate(
|
|
658
|
+
kind: "comment",
|
|
659
|
+
displayIndex: number,
|
|
660
|
+
start: number,
|
|
661
|
+
end: number,
|
|
662
|
+
body: string,
|
|
663
|
+
): string | undefined {
|
|
664
|
+
const session = this.snapshot.session;
|
|
665
|
+
if (!session) return undefined;
|
|
666
|
+
let anchor;
|
|
667
|
+
if (session.artifact.type === "diff") {
|
|
668
|
+
anchor = { ...diffRowAnchor(this.rows(), displayIndex), blockIndex: displayIndex };
|
|
669
|
+
} else {
|
|
670
|
+
const display = this.display();
|
|
671
|
+
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
672
|
+
const workBlockIndex =
|
|
673
|
+
display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
|
|
674
|
+
anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
|
|
675
|
+
}
|
|
676
|
+
const wire = { id: newAnnotationId(), kind, anchor, body };
|
|
677
|
+
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
678
|
+
this.apply(persisted);
|
|
679
|
+
this.mirrorAnnotation(persisted, wire);
|
|
680
|
+
this.setStatus("comment added");
|
|
681
|
+
return wire.id;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
annotatePrototype(selector: string, quote: string, body: string): string | undefined {
|
|
685
|
+
const session = this.snapshot.session;
|
|
686
|
+
if (!session) return undefined;
|
|
687
|
+
const anchor = { quote, prefix: "", suffix: "", selector };
|
|
688
|
+
const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
|
|
689
|
+
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
690
|
+
this.apply(persisted);
|
|
691
|
+
this.mirrorAnnotation(persisted, wire);
|
|
692
|
+
this.setStatus("comment added");
|
|
693
|
+
return wire.id;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
updateAnnotation(id: string, body: string): void {
|
|
697
|
+
const session = this.snapshot.session;
|
|
698
|
+
if (!session) return;
|
|
699
|
+
const existing = session.annotations.find((annotation) => annotation.id === id);
|
|
700
|
+
if (!existing) return;
|
|
701
|
+
// the daemon's annotate verb upserts by id: same id + anchor, new body
|
|
702
|
+
const wire = { id: existing.id, kind: existing.kind, anchor: existing.anchor, body };
|
|
703
|
+
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
704
|
+
this.apply(persisted);
|
|
705
|
+
this.mirrorAnnotation(persisted, wire);
|
|
706
|
+
this.setStatus("annotation updated");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// push only after the local write lands, so a rejected write never leaks to the share
|
|
710
|
+
private mirrorAnnotation(
|
|
711
|
+
persisted: Promise<ReviewSession>,
|
|
712
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
713
|
+
): void {
|
|
714
|
+
const shareId = this.snapshot.session?.shareId;
|
|
715
|
+
if (!shareId) return;
|
|
716
|
+
void persisted.then(() => pushShare(shareId, [annotation])).catch(() => {});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
removeAnnotation(id: string): void {
|
|
720
|
+
const session = this.snapshot.session;
|
|
721
|
+
if (!session) return;
|
|
722
|
+
this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
|
|
723
|
+
this.setStatus("annotation deleted");
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
setWorkingCopy(content: string | undefined): void {
|
|
727
|
+
const session = this.snapshot.session;
|
|
728
|
+
if (!session) return;
|
|
729
|
+
this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ── the guided walk ─────────────────────────
|
|
733
|
+
// Marks sent but possibly not yet reflected in the session snapshot; the
|
|
734
|
+
// union keeps the walk title truthful and rapid advances from losing marks.
|
|
735
|
+
private locallyViewed = new Set<string>();
|
|
736
|
+
|
|
737
|
+
private viewedSet(): Set<string> {
|
|
738
|
+
return new Set([...(this.snapshot.session?.viewedPaths ?? []), ...this.locallyViewed]);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
walkStart(): void {
|
|
742
|
+
const session = this.snapshot.session;
|
|
743
|
+
if (!session || session.artifact.type !== "diff") return;
|
|
744
|
+
const files = this.files();
|
|
745
|
+
if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
|
|
746
|
+
// resume at the first unviewed file; a finished walk reopens on the end card
|
|
747
|
+
this.update({ walk: { index: firstUnviewedIndex(files, this.viewedSet()) } });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
walkForward(): void {
|
|
751
|
+
const walk = this.snapshot.walk;
|
|
752
|
+
const session = this.snapshot.session;
|
|
753
|
+
if (!walk || !session) return;
|
|
754
|
+
const files = this.files();
|
|
755
|
+
const current = files[walk.index];
|
|
756
|
+
if (!current) return; // already on the end card
|
|
757
|
+
// advancing IS the viewed mark: the step is complete once you move past
|
|
758
|
+
// it; the daemon verb merges, so only the new path travels
|
|
759
|
+
if (!this.viewedSet().has(current.path)) {
|
|
760
|
+
this.locallyViewed.add(current.path);
|
|
761
|
+
this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
|
|
762
|
+
}
|
|
763
|
+
this.update({ walk: { index: walk.index + 1 } });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
walkBack(): void {
|
|
767
|
+
const walk = this.snapshot.walk;
|
|
768
|
+
if (!walk) return;
|
|
769
|
+
this.update({ walk: { index: Math.max(0, walk.index - 1) } });
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
walkLeave(): void {
|
|
773
|
+
if (this.snapshot.walk) this.update({ walk: null });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
submit(verdict: VerdictKind, summary: string): void {
|
|
777
|
+
const session = this.snapshot.session;
|
|
778
|
+
if (!session) return;
|
|
779
|
+
this.walkLeave();
|
|
780
|
+
this.client!.sessionResolve(session.id, verdict, summary)
|
|
781
|
+
.then((resolved) => {
|
|
782
|
+
// The completion overlay heading already states the verdict, so the
|
|
783
|
+
// status line stays empty here - only export/error messages fill it.
|
|
784
|
+
this.update({ session: resolved, status: "" });
|
|
785
|
+
// notes-vault export: guarded by each exporter's policy (default manual = no-op)
|
|
786
|
+
for (const exporter of this.exporters) {
|
|
787
|
+
if (!exporter.runsOn(verdict)) continue;
|
|
788
|
+
void exporter.run(resolved).then((exportResult) => {
|
|
789
|
+
this.setStatus(
|
|
790
|
+
exportResult.success && exportResult.path
|
|
791
|
+
? `exported to ${exportResult.path}`
|
|
792
|
+
: `export failed: ${exportResult.error ?? "unknown"}`,
|
|
793
|
+
);
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
// Hand the reviewer back to the agent. The default is a visible
|
|
797
|
+
// countdown from DEFAULT_AUTO_CLOSE that closes on its own; esc stays,
|
|
798
|
+
// a remembers the choice. A configured delay overrides; 0 closes now.
|
|
799
|
+
const delay = this.autoClose;
|
|
800
|
+
if (delay === 0) this.finishReview();
|
|
801
|
+
else if (typeof delay === "number") this.startCounting(delay);
|
|
802
|
+
else this.startCounting(DEFAULT_AUTO_CLOSE);
|
|
803
|
+
})
|
|
804
|
+
.catch((error: unknown) =>
|
|
805
|
+
this.setStatus(String(error instanceof Error ? error.message : error)),
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
share(): void {
|
|
810
|
+
const session = this.snapshot.session;
|
|
811
|
+
if (!session) return;
|
|
812
|
+
this.setStatus("sharing…");
|
|
813
|
+
publishShare(session)
|
|
814
|
+
.then(async ({ line, copied }) => {
|
|
815
|
+
// Stamp the id back so a later pull knows which share to collect from.
|
|
816
|
+
const shareId = shareIdFromLine(line);
|
|
817
|
+
if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
|
|
818
|
+
this.setStatus("");
|
|
819
|
+
this.showToast(line, copied ? "share link copied" : "share link");
|
|
820
|
+
})
|
|
821
|
+
.catch((error: unknown) =>
|
|
822
|
+
this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Pull collaborator notes for a shared plan and union them in, awaiting the
|
|
828
|
+
* merge so a poll never overlaps rounds. The daemon's merge emits
|
|
829
|
+
* session.updated, so the re-render happens through the normal event path.
|
|
830
|
+
* Best-effort: a failed refresh is silent and the next tick retries.
|
|
831
|
+
*/
|
|
832
|
+
pullShared(): Promise<void> {
|
|
833
|
+
const session = this.snapshot.session;
|
|
834
|
+
if (!session?.shareId || !this.client) return Promise.resolve();
|
|
835
|
+
const client = this.client;
|
|
836
|
+
return pullShare(session.shareId)
|
|
837
|
+
.then((remote) =>
|
|
838
|
+
client.sessionMergeShared(session.id, {
|
|
839
|
+
annotations: collaboratorAnnotations(remote),
|
|
840
|
+
participants: remote.participants,
|
|
841
|
+
}),
|
|
842
|
+
)
|
|
843
|
+
.then(() => {})
|
|
844
|
+
.catch(() => {});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Poll the share for collaborator notes while it is open: pull now, then again
|
|
849
|
+
* every few seconds, each round waiting for the last so slow pulls never stack.
|
|
850
|
+
* Returns a stop handle the caller runs on leave.
|
|
851
|
+
*/
|
|
852
|
+
startSharePoll(): () => void {
|
|
853
|
+
this.stopSharePoll();
|
|
854
|
+
// per-run token so a stale run's in-flight pull never re-arms the poll
|
|
855
|
+
const run = {};
|
|
856
|
+
this.shareRun = run;
|
|
857
|
+
const tick = (): void => {
|
|
858
|
+
void this.pullShared().finally(() => {
|
|
859
|
+
if (this.shareRun === run && !this.closed)
|
|
860
|
+
this.sharePoll = this.clock.setTimeout(tick, SHARE_POLL_MS);
|
|
861
|
+
});
|
|
862
|
+
};
|
|
863
|
+
tick();
|
|
864
|
+
return () => {
|
|
865
|
+
if (this.shareRun === run) this.stopSharePoll();
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private stopSharePoll(): void {
|
|
870
|
+
this.shareRun = null;
|
|
871
|
+
if (this.sharePoll !== undefined) this.clock.clearTimeout(this.sharePoll);
|
|
872
|
+
this.sharePoll = undefined;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ── completion hand-back ────────────────────
|
|
876
|
+
private startCounting(remaining: number): void {
|
|
877
|
+
if (remaining <= 0) return this.finishReview();
|
|
878
|
+
this.update({ completion: { phase: "counting", remaining } });
|
|
879
|
+
this.countdown = this.clock.setTimeout(() => this.startCounting(remaining - 1), 1000);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
finishReview(): void {
|
|
883
|
+
this.clearCountdown();
|
|
884
|
+
const herdr = detectHerdr();
|
|
885
|
+
const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
|
|
886
|
+
if (herdr && pane) focusHerdrPane(herdr.binPath, pane);
|
|
887
|
+
this.options.onExit?.(0);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
dismissCompletion(): void {
|
|
891
|
+
this.clearCountdown();
|
|
892
|
+
this.update({ completion: { phase: "dismissed" } });
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
optInAutoClose(): void {
|
|
896
|
+
// Remember the default countdown for future submits, persisted to the user
|
|
897
|
+
// config. The countdown is already running; this only makes it the default.
|
|
898
|
+
try {
|
|
899
|
+
persistAutoClose(DEFAULT_AUTO_CLOSE);
|
|
900
|
+
} catch {
|
|
901
|
+
// a read-only config dir must not block closing the review
|
|
902
|
+
}
|
|
903
|
+
this.autoClose = DEFAULT_AUTO_CLOSE;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
saveReviewPanel(layout: { mode?: ReviewPanelMode; width?: number }): void {
|
|
907
|
+
try {
|
|
908
|
+
if (layout.mode !== undefined) persistReviewState(layout.mode);
|
|
909
|
+
if (layout.width !== undefined) persistReviewWidth(layout.width);
|
|
910
|
+
} catch {
|
|
911
|
+
// a read-only config dir must never block collapsing or resizing the rail
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|