@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66
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/package.json +5 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +8 -73
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +60 -412
- package/src/App.prototype.test.tsx +1 -8
- package/src/App.readonly.test.tsx +85 -62
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.submit-confirm.test.tsx +32 -70
- package/src/App.test.tsx +136 -66
- package/src/App.toast.test.tsx +17 -8
- package/src/App.tsx +243 -284
- package/src/App.walk.test.tsx +1 -1
- package/src/app-screens.tsx +67 -52
- package/src/app-view-model.ts +6 -100
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/AppShell.stories.tsx +84 -0
- package/src/components/AppShell.tsx +127 -0
- package/src/components/Breadcrumb.stories.tsx +1 -1
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +147 -0
- package/src/components/CodeBlock.stories.tsx +1 -1
- package/src/components/CompletionOverlay.stories.tsx +1 -1
- package/src/components/ConfirmCard.stories.tsx +1 -1
- package/src/components/ConfirmCard.tsx +3 -4
- package/src/components/ConfirmDialog.stories.tsx +1 -1
- package/src/components/DiffSheet.stories.tsx +1 -1
- package/src/components/InboxList.stories.tsx +23 -8
- package/src/components/InboxList.tsx +45 -19
- package/src/components/KeybindsDialog.stories.tsx +1 -1
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/MarkerPopover.stories.tsx +1 -1
- package/src/components/MenuBar.stories.tsx +3 -12
- package/src/components/MenuBar.tsx +25 -56
- package/src/components/PromptDialog.stories.tsx +1 -1
- package/src/components/PrototypeSheet.stories.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +15 -1
- package/src/components/ReviewRail.stories.tsx +15 -1
- package/src/components/ReviewRail.tsx +13 -6
- package/src/components/SettingsDialog.stories.tsx +7 -1
- package/src/components/SettingsDialog.tsx +112 -66
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.tsx +49 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +113 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1708 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +57 -0
- package/src/components/Toast.stories.tsx +1 -1
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/WalkWizard.stories.tsx +1 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/file-tree.test.ts +45 -0
- package/src/components/file-tree.ts +57 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +1 -1
- package/src/components/primitives/Card.stories.tsx +1 -1
- package/src/components/primitives/Dialog.stories.tsx +1 -1
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +43 -0
- package/src/components/primitives/StatusBar.stories.tsx +1 -1
- package/src/components/primitives/Tabs.stories.tsx +1 -1
- package/src/components/primitives/Toolbar.stories.tsx +1 -1
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +87 -0
- package/src/components/primitives/icons.ts +38 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +1 -1
- package/src/components/session-tree.test.ts +114 -0
- package/src/components/session-tree.ts +94 -0
- package/src/components/stories-app.tsx +142 -82
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +55 -0
- package/src/components/theme-context.stories.tsx +1 -1
- package/src/diff-hunk-curate.ts +4 -104
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +136 -3
- package/src/intent-dispatch.ts +92 -24
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +4 -4
- package/src/keymap.ts +14 -7
- package/src/session-controller.curate.test.ts +89 -2
- package/src/session-controller.share.test.ts +195 -55
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +331 -72
- package/src/share.ts +114 -16
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +64 -0
- package/src/theme.ts +4 -4
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-settings-dialog.tsx +9 -0
- package/src/view-plan.ts +29 -12
- package/src/App.review-panel.test.tsx +0 -143
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -419
- package/src/components/plan-sheet-run-style.ts +0 -88
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The review-session controller: every daemon round-trip and mutation
|
|
3
|
-
*
|
|
3
|
+
* primitive behind one React-free object. It owns connect/autostart/subscribe,
|
|
4
4
|
* the session/inbox/status/error snapshot, optimistic apply, the mutation
|
|
5
|
-
*
|
|
5
|
+
* primitives (cut/edit/annotate/submit/...), and the post-submit completion
|
|
6
6
|
* lifecycle including the notes-vault export and the herdr return-focus.
|
|
7
7
|
* App subscribes to snapshots and keeps only view state.
|
|
8
8
|
*/
|
|
@@ -10,32 +10,42 @@
|
|
|
10
10
|
import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
|
|
11
11
|
import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
|
|
12
12
|
import {
|
|
13
|
+
applyPathView,
|
|
14
|
+
createBranch,
|
|
13
15
|
cutBlock,
|
|
14
16
|
detectHerdr,
|
|
17
|
+
labelTip,
|
|
18
|
+
MAIN_BRANCH,
|
|
15
19
|
makeAnchor,
|
|
20
|
+
navigateTo,
|
|
16
21
|
newAnnotationId,
|
|
17
22
|
parseBlocks,
|
|
18
23
|
resolveAnchor,
|
|
19
24
|
restoreBlock,
|
|
20
25
|
restoreLine,
|
|
21
26
|
returnPaneFor,
|
|
27
|
+
switchBranch,
|
|
28
|
+
viewOfPath,
|
|
22
29
|
type Annotation,
|
|
23
30
|
type ReviewSession,
|
|
31
|
+
type SessionHistory,
|
|
24
32
|
type VerdictKind,
|
|
25
33
|
} from "@cueloop/schema";
|
|
26
34
|
import { loadBundledExporters, type BundledExporter } from "./integrations";
|
|
27
35
|
import {
|
|
28
36
|
collaboratorAnnotations,
|
|
37
|
+
mergeFromShare,
|
|
29
38
|
publishShare,
|
|
30
39
|
pullShare,
|
|
31
40
|
pushShare,
|
|
41
|
+
watchShare,
|
|
32
42
|
shareIdFromLine,
|
|
33
43
|
} from "./share";
|
|
34
44
|
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
|
|
45
|
+
import { entryTarget, treeRows, type TreeRow } from "./tree-view";
|
|
35
46
|
import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
36
47
|
import {
|
|
37
48
|
changeRejectionForRow,
|
|
38
|
-
curateDiff,
|
|
39
49
|
hunkRejectionForRow,
|
|
40
50
|
isRowRejected,
|
|
41
51
|
parseFileDiff,
|
|
@@ -71,9 +81,12 @@ export type Completion =
|
|
|
71
81
|
export const DEFAULT_AUTO_CLOSE = 5;
|
|
72
82
|
|
|
73
83
|
/** How often an open shared plan re-pulls collaborator notes (ADR 0005 stage 2). */
|
|
74
|
-
|
|
84
|
+
/** Reconnect backoff for a dropped watch stream: doubles from the floor to the cap. */
|
|
85
|
+
export const SHARE_RECONNECT_MIN_MS = 1000;
|
|
86
|
+
export const SHARE_RECONNECT_MAX_MS = 30_000;
|
|
75
87
|
|
|
76
88
|
/** Shared empty set so "nothing rejected" is a stable identity for renders. */
|
|
89
|
+
const EMPTY_REJECTIONS: HunkRejection[] = [];
|
|
77
90
|
const EMPTY_REJECTED_ROWS: Set<number> = new Set();
|
|
78
91
|
|
|
79
92
|
/** Shared empty list so "nothing curated out" is a stable identity for renders. */
|
|
@@ -135,16 +148,20 @@ export interface ShareTransport {
|
|
|
135
148
|
publish: typeof publishShare;
|
|
136
149
|
pull: typeof pullShare;
|
|
137
150
|
push: typeof pushShare;
|
|
151
|
+
watch: typeof watchShare;
|
|
138
152
|
parseShareId: typeof shareIdFromLine;
|
|
139
153
|
collaboratorAnnotations: typeof collaboratorAnnotations;
|
|
154
|
+
mergeFromShare: typeof mergeFromShare;
|
|
140
155
|
}
|
|
141
156
|
|
|
142
157
|
const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
|
|
143
158
|
publish: publishShare,
|
|
144
159
|
pull: pullShare,
|
|
160
|
+
watch: watchShare,
|
|
145
161
|
push: pushShare,
|
|
146
162
|
parseShareId: shareIdFromLine,
|
|
147
163
|
collaboratorAnnotations,
|
|
164
|
+
mergeFromShare,
|
|
148
165
|
};
|
|
149
166
|
|
|
150
167
|
export interface ReviewControllerOptions {
|
|
@@ -205,6 +222,7 @@ export interface ReviewController {
|
|
|
205
222
|
edit(): void;
|
|
206
223
|
/**
|
|
207
224
|
* Anchor and store an annotation; both plan and diff anchor constructions.
|
|
225
|
+
* `end` is an offset within `endDisplayIndex` (the same block by default).
|
|
208
226
|
* Returns the minted annotation id so the view can select the new card.
|
|
209
227
|
*/
|
|
210
228
|
annotate(
|
|
@@ -213,7 +231,13 @@ export interface ReviewController {
|
|
|
213
231
|
start: number,
|
|
214
232
|
end: number,
|
|
215
233
|
body: string,
|
|
234
|
+
endDisplayIndex?: number,
|
|
216
235
|
): string | undefined;
|
|
236
|
+
/**
|
|
237
|
+
* Reply to `rootAnnotationId`: the reply shares the root's anchor and names
|
|
238
|
+
* it in replyTo, so the discussion stays one conversation. Returns the minted id.
|
|
239
|
+
*/
|
|
240
|
+
reply(rootAnnotationId: string, body: string): string | undefined;
|
|
217
241
|
/** Anchor a prototype comment to a DOM element by its selector. */
|
|
218
242
|
annotatePrototype(selector: string, quote: string, body: string): string | undefined;
|
|
219
243
|
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
@@ -231,10 +255,25 @@ export interface ReviewController {
|
|
|
231
255
|
submit(verdict: VerdictKind, summary: string): void;
|
|
232
256
|
/** Publish the current session as a share; the ssh line lands on the clipboard. */
|
|
233
257
|
share(): void;
|
|
258
|
+
/** The session tree as rows for the rail's Tree tab, cached per session identity. */
|
|
259
|
+
treeRows(): TreeRow[];
|
|
260
|
+
/**
|
|
261
|
+
* Go to an entry: switch to the branch whose tip it is, or move the current
|
|
262
|
+
* branch's tip back to it, with an optional branch summary. Owner only.
|
|
263
|
+
*/
|
|
264
|
+
goToEntry(entryId: string, summary?: string): void;
|
|
265
|
+
/** Start a branch at the current tip and switch to it. */
|
|
266
|
+
branch(name: string): void;
|
|
267
|
+
/** Name the current tip as a checkpoint. */
|
|
268
|
+
labelTip(label: string): void;
|
|
269
|
+
/** Copy the current path into a new session and open it. */
|
|
270
|
+
fork(): void;
|
|
271
|
+
/** Fork, then share the fork; the current session stays open and unshared. */
|
|
272
|
+
forkAndShare(): void;
|
|
234
273
|
/** Pull a shared plan's collaborator notes back and union them in (planner only). */
|
|
235
274
|
pullShared(): Promise<void>;
|
|
236
275
|
/** Poll the share for collaborator notes while it is open; returns a stop handle. */
|
|
237
|
-
|
|
276
|
+
startShareSync(): () => void;
|
|
238
277
|
/** Close the review and, inside herdr, bounce focus back to the agent. */
|
|
239
278
|
finishReview(): void;
|
|
240
279
|
dismissCompletion(): void;
|
|
@@ -257,6 +296,7 @@ interface DerivedSessionProjection {
|
|
|
257
296
|
rows: DiffRow[];
|
|
258
297
|
files: WalkFile[];
|
|
259
298
|
models: Map<string, FileDiffMetadata>;
|
|
299
|
+
tree: TreeRow[];
|
|
260
300
|
}
|
|
261
301
|
|
|
262
302
|
class Controller implements ReviewController {
|
|
@@ -280,7 +320,8 @@ class Controller implements ReviewController {
|
|
|
280
320
|
private readonly clock: Clock;
|
|
281
321
|
private readonly shareTransport: ShareTransport;
|
|
282
322
|
private countdown: TimerHandle | undefined;
|
|
283
|
-
private
|
|
323
|
+
private shareReconnect: TimerHandle | undefined;
|
|
324
|
+
private shareStop: (() => void) | null = null;
|
|
284
325
|
private shareRun: object | null = null;
|
|
285
326
|
/** Projections keyed by session identity so renders reuse one computation. */
|
|
286
327
|
private derivedFor: ReviewSession | null = null;
|
|
@@ -289,9 +330,12 @@ class Controller implements ReviewController {
|
|
|
289
330
|
rows: [],
|
|
290
331
|
files: [],
|
|
291
332
|
models: new Map(),
|
|
333
|
+
tree: [],
|
|
292
334
|
};
|
|
293
|
-
/** The
|
|
294
|
-
private rejections: HunkRejection[]
|
|
335
|
+
/** The diff's reject decisions live on the session record; the daemon curates from them. */
|
|
336
|
+
private get rejections(): HunkRejection[] {
|
|
337
|
+
return this.snapshot.session?.curation ?? EMPTY_REJECTIONS;
|
|
338
|
+
}
|
|
295
339
|
|
|
296
340
|
constructor(private readonly options: ReviewControllerOptions) {
|
|
297
341
|
this.readOnly = options.readOnly ?? false;
|
|
@@ -327,13 +371,17 @@ class Controller implements ReviewController {
|
|
|
327
371
|
const session = this.snapshot.session;
|
|
328
372
|
|
|
329
373
|
if (session && event.sessionId === session.id) void this.refreshSession(session.id);
|
|
330
|
-
|
|
374
|
+
// the Threads sidebar shows the inbox even on a direct open, so keep it fresh
|
|
375
|
+
void this.refreshInbox();
|
|
331
376
|
});
|
|
332
377
|
await client.subscribe();
|
|
378
|
+
// load the inbox in both cases so the sidebar can jump between threads
|
|
379
|
+
const inbox = await client.sessionList({ status: "pending" });
|
|
380
|
+
|
|
333
381
|
if (this.options.sessionId) {
|
|
334
|
-
this.update({ session: await client.sessionGet(this.options.sessionId) });
|
|
382
|
+
this.update({ session: await client.sessionGet(this.options.sessionId), inbox });
|
|
335
383
|
} else {
|
|
336
|
-
this.update({ inbox
|
|
384
|
+
this.update({ inbox });
|
|
337
385
|
}
|
|
338
386
|
} catch (err) {
|
|
339
387
|
this.update({ error: err instanceof Error ? err.message : String(err) });
|
|
@@ -344,7 +392,7 @@ class Controller implements ReviewController {
|
|
|
344
392
|
close(): void {
|
|
345
393
|
this.closed = true;
|
|
346
394
|
this.clearCountdown();
|
|
347
|
-
this.
|
|
395
|
+
this.stopShareSync();
|
|
348
396
|
this.client?.close();
|
|
349
397
|
}
|
|
350
398
|
|
|
@@ -362,7 +410,8 @@ class Controller implements ReviewController {
|
|
|
362
410
|
}
|
|
363
411
|
|
|
364
412
|
setStatus(message: string): void {
|
|
365
|
-
|
|
413
|
+
// the persistent status bar is gone; transient feedback surfaces as a toast
|
|
414
|
+
this.update({ status: message, toast: message ? { body: message } : null });
|
|
366
415
|
}
|
|
367
416
|
|
|
368
417
|
showToast(body: string, title?: string): void {
|
|
@@ -389,9 +438,16 @@ class Controller implements ReviewController {
|
|
|
389
438
|
rows,
|
|
390
439
|
files: walkFiles(rows),
|
|
391
440
|
models,
|
|
441
|
+
tree: session?.history ? treeRows(session.history) : [],
|
|
392
442
|
};
|
|
393
443
|
}
|
|
394
444
|
|
|
445
|
+
treeRows(): TreeRow[] {
|
|
446
|
+
this.ensureDerived();
|
|
447
|
+
|
|
448
|
+
return this.derived.tree;
|
|
449
|
+
}
|
|
450
|
+
|
|
395
451
|
display(): DisplayBlock[] {
|
|
396
452
|
this.ensureDerived();
|
|
397
453
|
|
|
@@ -444,10 +500,25 @@ class Controller implements ReviewController {
|
|
|
444
500
|
);
|
|
445
501
|
}
|
|
446
502
|
|
|
447
|
-
|
|
503
|
+
/**
|
|
504
|
+
* Show the expected result before the daemon answers, so a second keypress
|
|
505
|
+
* builds on the first instead of on the stale snapshot. Answers arrive in
|
|
506
|
+
* request order over the socket and replace the guess with the daemon's copy.
|
|
507
|
+
*/
|
|
508
|
+
private applyOptimistic(expected: ReviewSession, mutation: Promise<ReviewSession>): void {
|
|
509
|
+
this.update({ session: expected });
|
|
510
|
+
mutation
|
|
511
|
+
.then((session) => this.update({ session }))
|
|
512
|
+
.catch((cause: unknown) => {
|
|
513
|
+
// a refused guess must not stay on screen: the daemon's record replaces it
|
|
514
|
+
this.setStatus(String(cause instanceof Error ? cause.message : cause));
|
|
515
|
+
void this.refreshSession(expected.id);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── primitives ───────────────────────────────────
|
|
448
520
|
open(id: string): void {
|
|
449
521
|
this.locallyViewed.clear();
|
|
450
|
-
this.rejections = [];
|
|
451
522
|
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
452
523
|
|
|
453
524
|
if (cached) this.update({ session: cached });
|
|
@@ -482,7 +553,15 @@ class Controller implements ReviewController {
|
|
|
482
553
|
if (block.type === "del") {
|
|
483
554
|
this.restoreDelBlock(block, displayIndex);
|
|
484
555
|
} else if (block.work) {
|
|
485
|
-
|
|
556
|
+
const workIndex = parseBlocks(working).findIndex(
|
|
557
|
+
(candidate) => candidate.lineStart === block.work!.lineStart,
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
if (workIndex === -1) return;
|
|
561
|
+
this.applyOptimistic(
|
|
562
|
+
{ ...session, workingCopy: cutBlock(working, block.work) },
|
|
563
|
+
this.client!.sessionCutBlock(session.id, workIndex),
|
|
564
|
+
);
|
|
486
565
|
this.setStatus("block cut - it serializes into the diff");
|
|
487
566
|
}
|
|
488
567
|
}
|
|
@@ -497,11 +576,19 @@ class Controller implements ReviewController {
|
|
|
497
576
|
nextWorkBlock(this.display(), displayIndex),
|
|
498
577
|
working.split("\n").length,
|
|
499
578
|
);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
579
|
+
const base = session.artifact.content;
|
|
580
|
+
const baseIndex = parseBlocks(base).findIndex(
|
|
581
|
+
(candidate) => candidate.lineStart === block.base!.lineStart,
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
if (baseIndex === -1) return;
|
|
585
|
+
const expected: ReviewSession = {
|
|
586
|
+
...session,
|
|
587
|
+
workingCopy: restoreBlock(base, working, block.base, line),
|
|
588
|
+
};
|
|
503
589
|
|
|
504
|
-
|
|
590
|
+
if (expected.workingCopy === undefined) delete expected.workingCopy;
|
|
591
|
+
this.applyOptimistic(expected, this.client!.sessionRestoreBlock(session.id, baseIndex, line));
|
|
505
592
|
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
506
593
|
}
|
|
507
594
|
|
|
@@ -540,18 +627,18 @@ class Controller implements ReviewController {
|
|
|
540
627
|
const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
|
|
541
628
|
|
|
542
629
|
if (this.rejections.some(wholeHunk)) {
|
|
543
|
-
this.
|
|
630
|
+
this.curate(this.rejections.filter((rejection) => !wholeHunk(rejection)));
|
|
544
631
|
this.setStatus("hunk restored");
|
|
545
632
|
} else {
|
|
546
633
|
// a whole-hunk reject supersedes any change-level rejects inside it
|
|
547
|
-
|
|
634
|
+
const others = this.rejections.filter(
|
|
548
635
|
(rejection) =>
|
|
549
636
|
!(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
|
|
550
637
|
);
|
|
551
|
-
|
|
638
|
+
|
|
639
|
+
this.curate([...others, target]);
|
|
552
640
|
this.setStatus("hunk rejected - dropped from the working copy");
|
|
553
641
|
}
|
|
554
|
-
this.recomputeCuration();
|
|
555
642
|
}
|
|
556
643
|
|
|
557
644
|
toggleRejectChange(rowIndex: number): void {
|
|
@@ -567,24 +654,23 @@ class Controller implements ReviewController {
|
|
|
567
654
|
|
|
568
655
|
if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
|
|
569
656
|
if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
|
|
570
|
-
this.
|
|
657
|
+
this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
|
|
571
658
|
this.setStatus("change restored");
|
|
572
659
|
} else {
|
|
573
|
-
this.rejections
|
|
660
|
+
this.curate([...this.rejections, target]);
|
|
574
661
|
this.setStatus("change rejected - dropped from the working copy");
|
|
575
662
|
}
|
|
576
|
-
this.recomputeCuration();
|
|
577
663
|
}
|
|
578
664
|
|
|
579
|
-
/**
|
|
580
|
-
private
|
|
581
|
-
const
|
|
665
|
+
/** Hand the daemon the full set of reject decisions; the working copy follows. */
|
|
666
|
+
private curate(rejections: HunkRejection[]): void {
|
|
667
|
+
const session = this.snapshot.session;
|
|
582
668
|
|
|
583
|
-
if (!
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
this.
|
|
669
|
+
if (!session) return;
|
|
670
|
+
const expected: ReviewSession = { ...session, curation: rejections };
|
|
671
|
+
|
|
672
|
+
if (!rejections.length) delete expected.curation;
|
|
673
|
+
this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
|
|
588
674
|
}
|
|
589
675
|
|
|
590
676
|
rejectedRows(): Set<number> {
|
|
@@ -616,9 +702,8 @@ class Controller implements ReviewController {
|
|
|
616
702
|
const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
|
|
617
703
|
|
|
618
704
|
if (kept.length === this.rejections.length) return;
|
|
619
|
-
this.
|
|
705
|
+
this.curate(kept);
|
|
620
706
|
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
621
|
-
this.recomputeCuration();
|
|
622
707
|
|
|
623
708
|
return;
|
|
624
709
|
}
|
|
@@ -730,6 +815,7 @@ class Controller implements ReviewController {
|
|
|
730
815
|
start: number,
|
|
731
816
|
end: number,
|
|
732
817
|
body: string,
|
|
818
|
+
endDisplayIndex: number = displayIndex,
|
|
733
819
|
): string | undefined {
|
|
734
820
|
const session = this.snapshot.session;
|
|
735
821
|
|
|
@@ -741,10 +827,16 @@ class Controller implements ReviewController {
|
|
|
741
827
|
} else {
|
|
742
828
|
const display = this.display();
|
|
743
829
|
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
744
|
-
const
|
|
745
|
-
display.slice(0,
|
|
746
|
-
|
|
747
|
-
anchor = makeAnchor(
|
|
830
|
+
const workIndexOf = (index: number): number =>
|
|
831
|
+
display.slice(0, index + 1).filter((entry) => entry.work).length - 1;
|
|
832
|
+
|
|
833
|
+
anchor = makeAnchor(
|
|
834
|
+
workBlocks,
|
|
835
|
+
workIndexOf(displayIndex),
|
|
836
|
+
start,
|
|
837
|
+
end,
|
|
838
|
+
workIndexOf(endDisplayIndex),
|
|
839
|
+
);
|
|
748
840
|
}
|
|
749
841
|
const wire = { id: newAnnotationId(), kind, anchor, body };
|
|
750
842
|
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
@@ -756,6 +848,28 @@ class Controller implements ReviewController {
|
|
|
756
848
|
return wire.id;
|
|
757
849
|
}
|
|
758
850
|
|
|
851
|
+
reply(rootAnnotationId: string, body: string): string | undefined {
|
|
852
|
+
const session = this.snapshot.session;
|
|
853
|
+
const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
|
|
854
|
+
|
|
855
|
+
if (!session || !root) return undefined;
|
|
856
|
+
// a reply to a reply still hangs off the discussion's root comment
|
|
857
|
+
const wire = {
|
|
858
|
+
id: newAnnotationId(),
|
|
859
|
+
kind: "comment",
|
|
860
|
+
anchor: root.anchor,
|
|
861
|
+
body,
|
|
862
|
+
replyTo: root.replyTo ?? root.id,
|
|
863
|
+
};
|
|
864
|
+
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
865
|
+
|
|
866
|
+
this.apply(persisted);
|
|
867
|
+
this.mirrorAnnotation(persisted, wire);
|
|
868
|
+
this.setStatus("reply added");
|
|
869
|
+
|
|
870
|
+
return wire.id;
|
|
871
|
+
}
|
|
872
|
+
|
|
759
873
|
annotatePrototype(selector: string, quote: string, body: string): string | undefined {
|
|
760
874
|
const session = this.snapshot.session;
|
|
761
875
|
|
|
@@ -778,8 +892,16 @@ class Controller implements ReviewController {
|
|
|
778
892
|
const existing = session.annotations.find((annotation) => annotation.id === id);
|
|
779
893
|
|
|
780
894
|
if (!existing) return;
|
|
781
|
-
// the daemon's annotate
|
|
782
|
-
|
|
895
|
+
// the daemon's annotate primitive upserts by id: same id, anchor, and
|
|
896
|
+
// reply link, new body
|
|
897
|
+
const wire: Omit<Annotation, "createdAt"> = {
|
|
898
|
+
id: existing.id,
|
|
899
|
+
kind: existing.kind,
|
|
900
|
+
anchor: existing.anchor,
|
|
901
|
+
body,
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
|
|
783
905
|
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
784
906
|
|
|
785
907
|
this.apply(persisted);
|
|
@@ -792,9 +914,12 @@ class Controller implements ReviewController {
|
|
|
792
914
|
persisted: Promise<ReviewSession>,
|
|
793
915
|
annotation: Omit<Annotation, "createdAt">,
|
|
794
916
|
): void {
|
|
795
|
-
const
|
|
917
|
+
const session = this.snapshot.session;
|
|
918
|
+
const shareId = session?.shareId;
|
|
796
919
|
|
|
797
920
|
if (!shareId) return;
|
|
921
|
+
// the share follows one branch: a note left on another stays the owner's
|
|
922
|
+
if (session.history && session.history.branch !== (session.shareBranch ?? MAIN_BRANCH)) return;
|
|
798
923
|
void persisted.then(() => this.shareTransport.push(shareId, [annotation])).catch(() => {});
|
|
799
924
|
}
|
|
800
925
|
|
|
@@ -843,7 +968,7 @@ class Controller implements ReviewController {
|
|
|
843
968
|
|
|
844
969
|
if (!current) return; // already on the end card
|
|
845
970
|
// advancing IS the viewed mark: the step is complete once you move past
|
|
846
|
-
// it; the daemon
|
|
971
|
+
// it; the daemon primitive merges, so only the new path travels
|
|
847
972
|
if (!this.viewedSet().has(current.path)) {
|
|
848
973
|
this.locallyViewed.add(current.path);
|
|
849
974
|
this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
|
|
@@ -917,59 +1042,193 @@ class Controller implements ReviewController {
|
|
|
917
1042
|
);
|
|
918
1043
|
}
|
|
919
1044
|
|
|
1045
|
+
goToEntry(entryId: string, summary?: string): void {
|
|
1046
|
+
const session = this.snapshot.session;
|
|
1047
|
+
|
|
1048
|
+
if (!session?.history || !this.client) return;
|
|
1049
|
+
const target = entryTarget(session.history, entryId);
|
|
1050
|
+
|
|
1051
|
+
if (target === null) return this.setStatus("that entry is not on any branch");
|
|
1052
|
+
if (target.kind === "here") return this.setStatus("already at the tip");
|
|
1053
|
+
if (target.kind === "switch") {
|
|
1054
|
+
this.moveTree(session, switchBranch(session.history, target.branch), () =>
|
|
1055
|
+
this.client!.sessionSwitch(session.id, target.branch),
|
|
1056
|
+
);
|
|
1057
|
+
this.setStatus(`on branch ${target.branch}`);
|
|
1058
|
+
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
// an entry another branch reaches: the daemon stands on that branch first, in the same request
|
|
1062
|
+
const onBranch = switchBranch(session.history, target.branch);
|
|
1063
|
+
const options = summary === undefined || summary === "" ? {} : { summary };
|
|
1064
|
+
const moved = navigateTo(onBranch, entryId, options);
|
|
1065
|
+
const branch = target.branch === session.history.branch ? undefined : target.branch;
|
|
1066
|
+
|
|
1067
|
+
this.moveTree(session, moved, () =>
|
|
1068
|
+
this.client!.sessionNavigate(session.id, entryId, options.summary, branch),
|
|
1069
|
+
);
|
|
1070
|
+
this.setStatus(
|
|
1071
|
+
options.summary === undefined
|
|
1072
|
+
? "moved back - later entries stay in the tree"
|
|
1073
|
+
: "moved back with a summary",
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
branch(name: string): void {
|
|
1078
|
+
const session = this.snapshot.session;
|
|
1079
|
+
const branchName = name.trim();
|
|
1080
|
+
|
|
1081
|
+
if (!session?.history || !this.client) return;
|
|
1082
|
+
if (!branchName) return this.setStatus("a branch needs a name");
|
|
1083
|
+
if (session.history.tips[branchName] !== undefined)
|
|
1084
|
+
return this.setStatus(`branch ${branchName} exists`);
|
|
1085
|
+
this.moveTree(session, createBranch(session.history, branchName), () =>
|
|
1086
|
+
this.client!.sessionBranch(session.id, branchName),
|
|
1087
|
+
);
|
|
1088
|
+
this.setStatus(`on branch ${branchName}`);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
labelTip(label: string): void {
|
|
1092
|
+
const session = this.snapshot.session;
|
|
1093
|
+
const name = label.trim();
|
|
1094
|
+
|
|
1095
|
+
if (!session?.history || !this.client) return;
|
|
1096
|
+
if (!name) return this.setStatus("a checkpoint needs a name");
|
|
1097
|
+
this.moveTree(session, labelTip(session.history, name), () =>
|
|
1098
|
+
this.client!.sessionLabel(session.id, name),
|
|
1099
|
+
);
|
|
1100
|
+
this.setStatus(`checkpoint ${name}`);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
fork(): void {
|
|
1104
|
+
const session = this.snapshot.session;
|
|
1105
|
+
|
|
1106
|
+
if (!session || !this.client) return;
|
|
1107
|
+
this.client
|
|
1108
|
+
.sessionFork(session.id)
|
|
1109
|
+
.then((fork) => {
|
|
1110
|
+
this.update({ session: fork });
|
|
1111
|
+
this.showToast(`forked ${session.id} - you are on the fork now`, "fork");
|
|
1112
|
+
})
|
|
1113
|
+
.catch((cause: unknown) =>
|
|
1114
|
+
this.setStatus(`fork failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
forkAndShare(): void {
|
|
1119
|
+
const session = this.snapshot.session;
|
|
1120
|
+
|
|
1121
|
+
if (!session || !this.client) return;
|
|
1122
|
+
const client = this.client;
|
|
1123
|
+
|
|
1124
|
+
this.setStatus("forking and sharing…");
|
|
1125
|
+
client
|
|
1126
|
+
.sessionFork(session.id)
|
|
1127
|
+
.then(async (fork) => {
|
|
1128
|
+
const { line, copied } = await this.shareTransport.publish(fork);
|
|
1129
|
+
const shareId = this.shareTransport.parseShareId(line);
|
|
1130
|
+
|
|
1131
|
+
if (shareId) await client.sessionSetShareId(fork.id, shareId);
|
|
1132
|
+
this.setStatus("");
|
|
1133
|
+
this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
|
|
1134
|
+
})
|
|
1135
|
+
.catch((cause: unknown) =>
|
|
1136
|
+
this.setStatus(
|
|
1137
|
+
`fork and share failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1138
|
+
),
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Show a moved tree at once - the record re-derived from its new path - and
|
|
1144
|
+
* let the daemon's answer replace it. A refused move surfaces as status.
|
|
1145
|
+
*/
|
|
1146
|
+
private moveTree(
|
|
1147
|
+
session: ReviewSession,
|
|
1148
|
+
history: SessionHistory,
|
|
1149
|
+
request: () => Promise<ReviewSession>,
|
|
1150
|
+
): void {
|
|
1151
|
+
const expected: ReviewSession = { ...session, history };
|
|
1152
|
+
|
|
1153
|
+
applyPathView(
|
|
1154
|
+
expected,
|
|
1155
|
+
viewOfPath(history, [...session.annotations, ...(session.shelvedAnnotations ?? [])]),
|
|
1156
|
+
);
|
|
1157
|
+
this.applyOptimistic(expected, request());
|
|
1158
|
+
}
|
|
1159
|
+
|
|
920
1160
|
/**
|
|
921
|
-
* Pull collaborator notes for a shared plan and union them in
|
|
922
|
-
* merge
|
|
923
|
-
*
|
|
924
|
-
* Best-effort: a failed refresh is silent and the next tick retries.
|
|
1161
|
+
* Pull collaborator notes for a shared plan and union them in. The daemon's
|
|
1162
|
+
* merge emits session.updated, so the re-render happens through the normal
|
|
1163
|
+
* event path. Best-effort: a failed refresh is silent.
|
|
925
1164
|
*/
|
|
926
1165
|
pullShared(): Promise<void> {
|
|
927
1166
|
const session = this.snapshot.session;
|
|
928
1167
|
|
|
929
|
-
if (!session?.shareId
|
|
930
|
-
const client = this.client;
|
|
1168
|
+
if (!session?.shareId) return Promise.resolve();
|
|
931
1169
|
|
|
932
1170
|
return this.shareTransport
|
|
933
1171
|
.pull(session.shareId)
|
|
934
|
-
.then((remote) =>
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1172
|
+
.then((remote) => this.mergeShared(remote))
|
|
1173
|
+
.catch(() => {});
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
private mergeShared(remote: ReviewSession): Promise<void> {
|
|
1177
|
+
const session = this.snapshot.session;
|
|
1178
|
+
|
|
1179
|
+
if (!session || !this.client) return Promise.resolve();
|
|
1180
|
+
|
|
1181
|
+
return this.client
|
|
1182
|
+
.sessionMergeShared(session.id, this.shareTransport.mergeFromShare(remote))
|
|
940
1183
|
.then(() => {})
|
|
941
1184
|
.catch(() => {});
|
|
942
1185
|
}
|
|
943
1186
|
|
|
944
1187
|
/**
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
1188
|
+
* Follow the share live while it is open: one watch stream delivers every
|
|
1189
|
+
* change as it lands; a pull on each (re)connect catches up on anything
|
|
1190
|
+
* missed while the link was down, and a dropped link reconnects with
|
|
1191
|
+
* backoff. Returns a stop handle the caller runs on leave.
|
|
948
1192
|
*/
|
|
949
|
-
|
|
950
|
-
this.
|
|
951
|
-
// per-run token so a stale run's
|
|
1193
|
+
startShareSync(): () => void {
|
|
1194
|
+
this.stopShareSync();
|
|
1195
|
+
// per-run token so a stale run's close never re-arms a newer run
|
|
952
1196
|
const run = {};
|
|
1197
|
+
let delay = SHARE_RECONNECT_MIN_MS;
|
|
953
1198
|
|
|
954
1199
|
this.shareRun = run;
|
|
955
|
-
const
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1200
|
+
const connect = (): void => {
|
|
1201
|
+
const shareId = this.snapshot.session?.shareId;
|
|
1202
|
+
|
|
1203
|
+
if (this.shareRun !== run || this.closed || !shareId) return;
|
|
1204
|
+
void this.pullShared();
|
|
1205
|
+
this.shareStop = this.shareTransport.watch(shareId, {
|
|
1206
|
+
onSession: (remote) => {
|
|
1207
|
+
delay = SHARE_RECONNECT_MIN_MS;
|
|
1208
|
+
void this.mergeShared(remote);
|
|
1209
|
+
},
|
|
1210
|
+
onClose: () => {
|
|
1211
|
+
if (this.shareRun !== run || this.closed) return;
|
|
1212
|
+
this.shareStop = null;
|
|
1213
|
+
this.shareReconnect = this.clock.setTimeout(connect, delay);
|
|
1214
|
+
delay = Math.min(delay * 2, SHARE_RECONNECT_MAX_MS);
|
|
1215
|
+
},
|
|
959
1216
|
});
|
|
960
1217
|
};
|
|
961
1218
|
|
|
962
|
-
|
|
1219
|
+
connect();
|
|
963
1220
|
|
|
964
1221
|
return () => {
|
|
965
|
-
if (this.shareRun === run) this.
|
|
1222
|
+
if (this.shareRun === run) this.stopShareSync();
|
|
966
1223
|
};
|
|
967
1224
|
}
|
|
968
1225
|
|
|
969
|
-
private
|
|
1226
|
+
private stopShareSync(): void {
|
|
970
1227
|
this.shareRun = null;
|
|
971
|
-
if (this.
|
|
972
|
-
this.
|
|
1228
|
+
if (this.shareReconnect !== undefined) this.clock.clearTimeout(this.shareReconnect);
|
|
1229
|
+
this.shareReconnect = undefined;
|
|
1230
|
+
this.shareStop?.();
|
|
1231
|
+
this.shareStop = null;
|
|
973
1232
|
}
|
|
974
1233
|
|
|
975
1234
|
// ── completion hand-back ────────────────────
|