@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.65
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.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +76 -247
- package/src/App.readonly.test.tsx +80 -35
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.review-panel.test.tsx +29 -32
- package/src/App.submit-confirm.test.tsx +27 -12
- package/src/App.test.tsx +73 -49
- package/src/App.toast.test.tsx +17 -8
- package/src/App.tree.test.tsx +224 -0
- package/src/App.tsx +120 -84
- package/src/app-screens.tsx +27 -1
- package/src/app-view-model.ts +4 -96
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/ReviewPanel.stories.tsx +14 -0
- package/src/components/ReviewRail.stories.tsx +14 -0
- package/src/components/ReviewRail.tsx +13 -6
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1620 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
- 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 +322 -68
- package/src/share.ts +114 -16
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +64 -0
- 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/view-plan.ts +29 -12
- 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;
|
|
@@ -344,7 +388,7 @@ class Controller implements ReviewController {
|
|
|
344
388
|
close(): void {
|
|
345
389
|
this.closed = true;
|
|
346
390
|
this.clearCountdown();
|
|
347
|
-
this.
|
|
391
|
+
this.stopShareSync();
|
|
348
392
|
this.client?.close();
|
|
349
393
|
}
|
|
350
394
|
|
|
@@ -389,9 +433,16 @@ class Controller implements ReviewController {
|
|
|
389
433
|
rows,
|
|
390
434
|
files: walkFiles(rows),
|
|
391
435
|
models,
|
|
436
|
+
tree: session?.history ? treeRows(session.history) : [],
|
|
392
437
|
};
|
|
393
438
|
}
|
|
394
439
|
|
|
440
|
+
treeRows(): TreeRow[] {
|
|
441
|
+
this.ensureDerived();
|
|
442
|
+
|
|
443
|
+
return this.derived.tree;
|
|
444
|
+
}
|
|
445
|
+
|
|
395
446
|
display(): DisplayBlock[] {
|
|
396
447
|
this.ensureDerived();
|
|
397
448
|
|
|
@@ -444,10 +495,25 @@ class Controller implements ReviewController {
|
|
|
444
495
|
);
|
|
445
496
|
}
|
|
446
497
|
|
|
447
|
-
|
|
498
|
+
/**
|
|
499
|
+
* Show the expected result before the daemon answers, so a second keypress
|
|
500
|
+
* builds on the first instead of on the stale snapshot. Answers arrive in
|
|
501
|
+
* request order over the socket and replace the guess with the daemon's copy.
|
|
502
|
+
*/
|
|
503
|
+
private applyOptimistic(expected: ReviewSession, mutation: Promise<ReviewSession>): void {
|
|
504
|
+
this.update({ session: expected });
|
|
505
|
+
mutation
|
|
506
|
+
.then((session) => this.update({ session }))
|
|
507
|
+
.catch((cause: unknown) => {
|
|
508
|
+
// a refused guess must not stay on screen: the daemon's record replaces it
|
|
509
|
+
this.setStatus(String(cause instanceof Error ? cause.message : cause));
|
|
510
|
+
void this.refreshSession(expected.id);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── primitives ───────────────────────────────────
|
|
448
515
|
open(id: string): void {
|
|
449
516
|
this.locallyViewed.clear();
|
|
450
|
-
this.rejections = [];
|
|
451
517
|
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
452
518
|
|
|
453
519
|
if (cached) this.update({ session: cached });
|
|
@@ -482,7 +548,15 @@ class Controller implements ReviewController {
|
|
|
482
548
|
if (block.type === "del") {
|
|
483
549
|
this.restoreDelBlock(block, displayIndex);
|
|
484
550
|
} else if (block.work) {
|
|
485
|
-
|
|
551
|
+
const workIndex = parseBlocks(working).findIndex(
|
|
552
|
+
(candidate) => candidate.lineStart === block.work!.lineStart,
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
if (workIndex === -1) return;
|
|
556
|
+
this.applyOptimistic(
|
|
557
|
+
{ ...session, workingCopy: cutBlock(working, block.work) },
|
|
558
|
+
this.client!.sessionCutBlock(session.id, workIndex),
|
|
559
|
+
);
|
|
486
560
|
this.setStatus("block cut - it serializes into the diff");
|
|
487
561
|
}
|
|
488
562
|
}
|
|
@@ -497,11 +571,19 @@ class Controller implements ReviewController {
|
|
|
497
571
|
nextWorkBlock(this.display(), displayIndex),
|
|
498
572
|
working.split("\n").length,
|
|
499
573
|
);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
574
|
+
const base = session.artifact.content;
|
|
575
|
+
const baseIndex = parseBlocks(base).findIndex(
|
|
576
|
+
(candidate) => candidate.lineStart === block.base!.lineStart,
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
if (baseIndex === -1) return;
|
|
580
|
+
const expected: ReviewSession = {
|
|
581
|
+
...session,
|
|
582
|
+
workingCopy: restoreBlock(base, working, block.base, line),
|
|
583
|
+
};
|
|
503
584
|
|
|
504
|
-
|
|
585
|
+
if (expected.workingCopy === undefined) delete expected.workingCopy;
|
|
586
|
+
this.applyOptimistic(expected, this.client!.sessionRestoreBlock(session.id, baseIndex, line));
|
|
505
587
|
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
506
588
|
}
|
|
507
589
|
|
|
@@ -540,18 +622,18 @@ class Controller implements ReviewController {
|
|
|
540
622
|
const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
|
|
541
623
|
|
|
542
624
|
if (this.rejections.some(wholeHunk)) {
|
|
543
|
-
this.
|
|
625
|
+
this.curate(this.rejections.filter((rejection) => !wholeHunk(rejection)));
|
|
544
626
|
this.setStatus("hunk restored");
|
|
545
627
|
} else {
|
|
546
628
|
// a whole-hunk reject supersedes any change-level rejects inside it
|
|
547
|
-
|
|
629
|
+
const others = this.rejections.filter(
|
|
548
630
|
(rejection) =>
|
|
549
631
|
!(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
|
|
550
632
|
);
|
|
551
|
-
|
|
633
|
+
|
|
634
|
+
this.curate([...others, target]);
|
|
552
635
|
this.setStatus("hunk rejected - dropped from the working copy");
|
|
553
636
|
}
|
|
554
|
-
this.recomputeCuration();
|
|
555
637
|
}
|
|
556
638
|
|
|
557
639
|
toggleRejectChange(rowIndex: number): void {
|
|
@@ -567,24 +649,23 @@ class Controller implements ReviewController {
|
|
|
567
649
|
|
|
568
650
|
if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
|
|
569
651
|
if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
|
|
570
|
-
this.
|
|
652
|
+
this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
|
|
571
653
|
this.setStatus("change restored");
|
|
572
654
|
} else {
|
|
573
|
-
this.rejections
|
|
655
|
+
this.curate([...this.rejections, target]);
|
|
574
656
|
this.setStatus("change rejected - dropped from the working copy");
|
|
575
657
|
}
|
|
576
|
-
this.recomputeCuration();
|
|
577
658
|
}
|
|
578
659
|
|
|
579
|
-
/**
|
|
580
|
-
private
|
|
581
|
-
const
|
|
660
|
+
/** Hand the daemon the full set of reject decisions; the working copy follows. */
|
|
661
|
+
private curate(rejections: HunkRejection[]): void {
|
|
662
|
+
const session = this.snapshot.session;
|
|
663
|
+
|
|
664
|
+
if (!session) return;
|
|
665
|
+
const expected: ReviewSession = { ...session, curation: rejections };
|
|
582
666
|
|
|
583
|
-
if (!
|
|
584
|
-
|
|
585
|
-
this.setWorkingCopy(this.rejections.length ? curateDiff(files, this.rejections) : undefined);
|
|
586
|
-
// dimming reads `rejections` directly; a bare update forces the re-read
|
|
587
|
-
this.update({});
|
|
667
|
+
if (!rejections.length) delete expected.curation;
|
|
668
|
+
this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
|
|
588
669
|
}
|
|
589
670
|
|
|
590
671
|
rejectedRows(): Set<number> {
|
|
@@ -616,9 +697,8 @@ class Controller implements ReviewController {
|
|
|
616
697
|
const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
|
|
617
698
|
|
|
618
699
|
if (kept.length === this.rejections.length) return;
|
|
619
|
-
this.
|
|
700
|
+
this.curate(kept);
|
|
620
701
|
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
621
|
-
this.recomputeCuration();
|
|
622
702
|
|
|
623
703
|
return;
|
|
624
704
|
}
|
|
@@ -730,6 +810,7 @@ class Controller implements ReviewController {
|
|
|
730
810
|
start: number,
|
|
731
811
|
end: number,
|
|
732
812
|
body: string,
|
|
813
|
+
endDisplayIndex: number = displayIndex,
|
|
733
814
|
): string | undefined {
|
|
734
815
|
const session = this.snapshot.session;
|
|
735
816
|
|
|
@@ -741,10 +822,16 @@ class Controller implements ReviewController {
|
|
|
741
822
|
} else {
|
|
742
823
|
const display = this.display();
|
|
743
824
|
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
744
|
-
const
|
|
745
|
-
display.slice(0,
|
|
746
|
-
|
|
747
|
-
anchor = makeAnchor(
|
|
825
|
+
const workIndexOf = (index: number): number =>
|
|
826
|
+
display.slice(0, index + 1).filter((entry) => entry.work).length - 1;
|
|
827
|
+
|
|
828
|
+
anchor = makeAnchor(
|
|
829
|
+
workBlocks,
|
|
830
|
+
workIndexOf(displayIndex),
|
|
831
|
+
start,
|
|
832
|
+
end,
|
|
833
|
+
workIndexOf(endDisplayIndex),
|
|
834
|
+
);
|
|
748
835
|
}
|
|
749
836
|
const wire = { id: newAnnotationId(), kind, anchor, body };
|
|
750
837
|
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
@@ -756,6 +843,28 @@ class Controller implements ReviewController {
|
|
|
756
843
|
return wire.id;
|
|
757
844
|
}
|
|
758
845
|
|
|
846
|
+
reply(rootAnnotationId: string, body: string): string | undefined {
|
|
847
|
+
const session = this.snapshot.session;
|
|
848
|
+
const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
|
|
849
|
+
|
|
850
|
+
if (!session || !root) return undefined;
|
|
851
|
+
// a reply to a reply still hangs off the discussion's root comment
|
|
852
|
+
const wire = {
|
|
853
|
+
id: newAnnotationId(),
|
|
854
|
+
kind: "comment",
|
|
855
|
+
anchor: root.anchor,
|
|
856
|
+
body,
|
|
857
|
+
replyTo: root.replyTo ?? root.id,
|
|
858
|
+
};
|
|
859
|
+
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
860
|
+
|
|
861
|
+
this.apply(persisted);
|
|
862
|
+
this.mirrorAnnotation(persisted, wire);
|
|
863
|
+
this.setStatus("reply added");
|
|
864
|
+
|
|
865
|
+
return wire.id;
|
|
866
|
+
}
|
|
867
|
+
|
|
759
868
|
annotatePrototype(selector: string, quote: string, body: string): string | undefined {
|
|
760
869
|
const session = this.snapshot.session;
|
|
761
870
|
|
|
@@ -778,8 +887,16 @@ class Controller implements ReviewController {
|
|
|
778
887
|
const existing = session.annotations.find((annotation) => annotation.id === id);
|
|
779
888
|
|
|
780
889
|
if (!existing) return;
|
|
781
|
-
// the daemon's annotate
|
|
782
|
-
|
|
890
|
+
// the daemon's annotate primitive upserts by id: same id, anchor, and
|
|
891
|
+
// reply link, new body
|
|
892
|
+
const wire: Omit<Annotation, "createdAt"> = {
|
|
893
|
+
id: existing.id,
|
|
894
|
+
kind: existing.kind,
|
|
895
|
+
anchor: existing.anchor,
|
|
896
|
+
body,
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
|
|
783
900
|
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
784
901
|
|
|
785
902
|
this.apply(persisted);
|
|
@@ -792,9 +909,12 @@ class Controller implements ReviewController {
|
|
|
792
909
|
persisted: Promise<ReviewSession>,
|
|
793
910
|
annotation: Omit<Annotation, "createdAt">,
|
|
794
911
|
): void {
|
|
795
|
-
const
|
|
912
|
+
const session = this.snapshot.session;
|
|
913
|
+
const shareId = session?.shareId;
|
|
796
914
|
|
|
797
915
|
if (!shareId) return;
|
|
916
|
+
// the share follows one branch: a note left on another stays the owner's
|
|
917
|
+
if (session.history && session.history.branch !== (session.shareBranch ?? MAIN_BRANCH)) return;
|
|
798
918
|
void persisted.then(() => this.shareTransport.push(shareId, [annotation])).catch(() => {});
|
|
799
919
|
}
|
|
800
920
|
|
|
@@ -843,7 +963,7 @@ class Controller implements ReviewController {
|
|
|
843
963
|
|
|
844
964
|
if (!current) return; // already on the end card
|
|
845
965
|
// advancing IS the viewed mark: the step is complete once you move past
|
|
846
|
-
// it; the daemon
|
|
966
|
+
// it; the daemon primitive merges, so only the new path travels
|
|
847
967
|
if (!this.viewedSet().has(current.path)) {
|
|
848
968
|
this.locallyViewed.add(current.path);
|
|
849
969
|
this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
|
|
@@ -917,59 +1037,193 @@ class Controller implements ReviewController {
|
|
|
917
1037
|
);
|
|
918
1038
|
}
|
|
919
1039
|
|
|
1040
|
+
goToEntry(entryId: string, summary?: string): void {
|
|
1041
|
+
const session = this.snapshot.session;
|
|
1042
|
+
|
|
1043
|
+
if (!session?.history || !this.client) return;
|
|
1044
|
+
const target = entryTarget(session.history, entryId);
|
|
1045
|
+
|
|
1046
|
+
if (target === null) return this.setStatus("that entry is not on any branch");
|
|
1047
|
+
if (target.kind === "here") return this.setStatus("already at the tip");
|
|
1048
|
+
if (target.kind === "switch") {
|
|
1049
|
+
this.moveTree(session, switchBranch(session.history, target.branch), () =>
|
|
1050
|
+
this.client!.sessionSwitch(session.id, target.branch),
|
|
1051
|
+
);
|
|
1052
|
+
this.setStatus(`on branch ${target.branch}`);
|
|
1053
|
+
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
// an entry another branch reaches: the daemon stands on that branch first, in the same request
|
|
1057
|
+
const onBranch = switchBranch(session.history, target.branch);
|
|
1058
|
+
const options = summary === undefined || summary === "" ? {} : { summary };
|
|
1059
|
+
const moved = navigateTo(onBranch, entryId, options);
|
|
1060
|
+
const branch = target.branch === session.history.branch ? undefined : target.branch;
|
|
1061
|
+
|
|
1062
|
+
this.moveTree(session, moved, () =>
|
|
1063
|
+
this.client!.sessionNavigate(session.id, entryId, options.summary, branch),
|
|
1064
|
+
);
|
|
1065
|
+
this.setStatus(
|
|
1066
|
+
options.summary === undefined
|
|
1067
|
+
? "moved back - later entries stay in the tree"
|
|
1068
|
+
: "moved back with a summary",
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
branch(name: string): void {
|
|
1073
|
+
const session = this.snapshot.session;
|
|
1074
|
+
const branchName = name.trim();
|
|
1075
|
+
|
|
1076
|
+
if (!session?.history || !this.client) return;
|
|
1077
|
+
if (!branchName) return this.setStatus("a branch needs a name");
|
|
1078
|
+
if (session.history.tips[branchName] !== undefined)
|
|
1079
|
+
return this.setStatus(`branch ${branchName} exists`);
|
|
1080
|
+
this.moveTree(session, createBranch(session.history, branchName), () =>
|
|
1081
|
+
this.client!.sessionBranch(session.id, branchName),
|
|
1082
|
+
);
|
|
1083
|
+
this.setStatus(`on branch ${branchName}`);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
labelTip(label: string): void {
|
|
1087
|
+
const session = this.snapshot.session;
|
|
1088
|
+
const name = label.trim();
|
|
1089
|
+
|
|
1090
|
+
if (!session?.history || !this.client) return;
|
|
1091
|
+
if (!name) return this.setStatus("a checkpoint needs a name");
|
|
1092
|
+
this.moveTree(session, labelTip(session.history, name), () =>
|
|
1093
|
+
this.client!.sessionLabel(session.id, name),
|
|
1094
|
+
);
|
|
1095
|
+
this.setStatus(`checkpoint ${name}`);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
fork(): void {
|
|
1099
|
+
const session = this.snapshot.session;
|
|
1100
|
+
|
|
1101
|
+
if (!session || !this.client) return;
|
|
1102
|
+
this.client
|
|
1103
|
+
.sessionFork(session.id)
|
|
1104
|
+
.then((fork) => {
|
|
1105
|
+
this.update({ session: fork });
|
|
1106
|
+
this.showToast(`forked ${session.id} - you are on the fork now`, "fork");
|
|
1107
|
+
})
|
|
1108
|
+
.catch((cause: unknown) =>
|
|
1109
|
+
this.setStatus(`fork failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
forkAndShare(): void {
|
|
1114
|
+
const session = this.snapshot.session;
|
|
1115
|
+
|
|
1116
|
+
if (!session || !this.client) return;
|
|
1117
|
+
const client = this.client;
|
|
1118
|
+
|
|
1119
|
+
this.setStatus("forking and sharing…");
|
|
1120
|
+
client
|
|
1121
|
+
.sessionFork(session.id)
|
|
1122
|
+
.then(async (fork) => {
|
|
1123
|
+
const { line, copied } = await this.shareTransport.publish(fork);
|
|
1124
|
+
const shareId = this.shareTransport.parseShareId(line);
|
|
1125
|
+
|
|
1126
|
+
if (shareId) await client.sessionSetShareId(fork.id, shareId);
|
|
1127
|
+
this.setStatus("");
|
|
1128
|
+
this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
|
|
1129
|
+
})
|
|
1130
|
+
.catch((cause: unknown) =>
|
|
1131
|
+
this.setStatus(
|
|
1132
|
+
`fork and share failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1133
|
+
),
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Show a moved tree at once - the record re-derived from its new path - and
|
|
1139
|
+
* let the daemon's answer replace it. A refused move surfaces as status.
|
|
1140
|
+
*/
|
|
1141
|
+
private moveTree(
|
|
1142
|
+
session: ReviewSession,
|
|
1143
|
+
history: SessionHistory,
|
|
1144
|
+
request: () => Promise<ReviewSession>,
|
|
1145
|
+
): void {
|
|
1146
|
+
const expected: ReviewSession = { ...session, history };
|
|
1147
|
+
|
|
1148
|
+
applyPathView(
|
|
1149
|
+
expected,
|
|
1150
|
+
viewOfPath(history, [...session.annotations, ...(session.shelvedAnnotations ?? [])]),
|
|
1151
|
+
);
|
|
1152
|
+
this.applyOptimistic(expected, request());
|
|
1153
|
+
}
|
|
1154
|
+
|
|
920
1155
|
/**
|
|
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.
|
|
1156
|
+
* Pull collaborator notes for a shared plan and union them in. The daemon's
|
|
1157
|
+
* merge emits session.updated, so the re-render happens through the normal
|
|
1158
|
+
* event path. Best-effort: a failed refresh is silent.
|
|
925
1159
|
*/
|
|
926
1160
|
pullShared(): Promise<void> {
|
|
927
1161
|
const session = this.snapshot.session;
|
|
928
1162
|
|
|
929
|
-
if (!session?.shareId
|
|
930
|
-
const client = this.client;
|
|
1163
|
+
if (!session?.shareId) return Promise.resolve();
|
|
931
1164
|
|
|
932
1165
|
return this.shareTransport
|
|
933
1166
|
.pull(session.shareId)
|
|
934
|
-
.then((remote) =>
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1167
|
+
.then((remote) => this.mergeShared(remote))
|
|
1168
|
+
.catch(() => {});
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
private mergeShared(remote: ReviewSession): Promise<void> {
|
|
1172
|
+
const session = this.snapshot.session;
|
|
1173
|
+
|
|
1174
|
+
if (!session || !this.client) return Promise.resolve();
|
|
1175
|
+
|
|
1176
|
+
return this.client
|
|
1177
|
+
.sessionMergeShared(session.id, this.shareTransport.mergeFromShare(remote))
|
|
940
1178
|
.then(() => {})
|
|
941
1179
|
.catch(() => {});
|
|
942
1180
|
}
|
|
943
1181
|
|
|
944
1182
|
/**
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
1183
|
+
* Follow the share live while it is open: one watch stream delivers every
|
|
1184
|
+
* change as it lands; a pull on each (re)connect catches up on anything
|
|
1185
|
+
* missed while the link was down, and a dropped link reconnects with
|
|
1186
|
+
* backoff. Returns a stop handle the caller runs on leave.
|
|
948
1187
|
*/
|
|
949
|
-
|
|
950
|
-
this.
|
|
951
|
-
// per-run token so a stale run's
|
|
1188
|
+
startShareSync(): () => void {
|
|
1189
|
+
this.stopShareSync();
|
|
1190
|
+
// per-run token so a stale run's close never re-arms a newer run
|
|
952
1191
|
const run = {};
|
|
1192
|
+
let delay = SHARE_RECONNECT_MIN_MS;
|
|
953
1193
|
|
|
954
1194
|
this.shareRun = run;
|
|
955
|
-
const
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1195
|
+
const connect = (): void => {
|
|
1196
|
+
const shareId = this.snapshot.session?.shareId;
|
|
1197
|
+
|
|
1198
|
+
if (this.shareRun !== run || this.closed || !shareId) return;
|
|
1199
|
+
void this.pullShared();
|
|
1200
|
+
this.shareStop = this.shareTransport.watch(shareId, {
|
|
1201
|
+
onSession: (remote) => {
|
|
1202
|
+
delay = SHARE_RECONNECT_MIN_MS;
|
|
1203
|
+
void this.mergeShared(remote);
|
|
1204
|
+
},
|
|
1205
|
+
onClose: () => {
|
|
1206
|
+
if (this.shareRun !== run || this.closed) return;
|
|
1207
|
+
this.shareStop = null;
|
|
1208
|
+
this.shareReconnect = this.clock.setTimeout(connect, delay);
|
|
1209
|
+
delay = Math.min(delay * 2, SHARE_RECONNECT_MAX_MS);
|
|
1210
|
+
},
|
|
959
1211
|
});
|
|
960
1212
|
};
|
|
961
1213
|
|
|
962
|
-
|
|
1214
|
+
connect();
|
|
963
1215
|
|
|
964
1216
|
return () => {
|
|
965
|
-
if (this.shareRun === run) this.
|
|
1217
|
+
if (this.shareRun === run) this.stopShareSync();
|
|
966
1218
|
};
|
|
967
1219
|
}
|
|
968
1220
|
|
|
969
|
-
private
|
|
1221
|
+
private stopShareSync(): void {
|
|
970
1222
|
this.shareRun = null;
|
|
971
|
-
if (this.
|
|
972
|
-
this.
|
|
1223
|
+
if (this.shareReconnect !== undefined) this.clock.clearTimeout(this.shareReconnect);
|
|
1224
|
+
this.shareReconnect = undefined;
|
|
1225
|
+
this.shareStop?.();
|
|
1226
|
+
this.shareStop = null;
|
|
973
1227
|
}
|
|
974
1228
|
|
|
975
1229
|
// ── completion hand-back ────────────────────
|