@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.
Files changed (49) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.export.test.tsx +2 -2
  4. package/src/App.plan-review.test.tsx +76 -247
  5. package/src/App.readonly.test.tsx +80 -35
  6. package/src/App.rendering.test.tsx +5 -6
  7. package/src/App.review-panel.test.tsx +29 -32
  8. package/src/App.submit-confirm.test.tsx +27 -12
  9. package/src/App.test.tsx +73 -49
  10. package/src/App.toast.test.tsx +17 -8
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +120 -84
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +4 -96
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/KeybindsDialog.tsx +61 -17
  17. package/src/components/ReviewPanel.stories.tsx +14 -0
  18. package/src/components/ReviewRail.stories.tsx +14 -0
  19. package/src/components/ReviewRail.tsx +13 -6
  20. package/src/components/ThreadView.selection.test.tsx +812 -0
  21. package/src/components/ThreadView.stories.tsx +26 -0
  22. package/src/components/ThreadView.tsx +1620 -0
  23. package/src/components/TreePane.stories.tsx +106 -0
  24. package/src/components/TreePane.tsx +156 -0
  25. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  26. package/src/diff-hunk-curate.ts +4 -104
  27. package/src/index.ts +1 -0
  28. package/src/intent-dispatch.test.ts +136 -3
  29. package/src/intent-dispatch.ts +92 -24
  30. package/src/key-bindings.test.ts +1 -1
  31. package/src/keymap.test.ts +4 -4
  32. package/src/keymap.ts +14 -7
  33. package/src/session-controller.curate.test.ts +89 -2
  34. package/src/session-controller.share.test.ts +195 -55
  35. package/src/session-controller.tree.test.ts +228 -0
  36. package/src/session-controller.ts +322 -68
  37. package/src/share.ts +114 -16
  38. package/src/test-support.test.ts +193 -1
  39. package/src/test-support.ts +64 -0
  40. package/src/thread-chords.test.ts +154 -0
  41. package/src/thread-chords.ts +144 -0
  42. package/src/thread-selection.test.ts +210 -0
  43. package/src/thread-selection.ts +243 -0
  44. package/src/tree-view.test.ts +128 -0
  45. package/src/tree-view.ts +148 -0
  46. package/src/view-plan.ts +29 -12
  47. package/src/components/PlanSheet.stories.tsx +0 -113
  48. package/src/components/PlanSheet.tsx +0 -419
  49. 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
- * verb behind one React-free object. It owns connect/autostart/subscribe,
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
- * verbs (cut/edit/annotate/submit/...), and the post-submit completion
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
- export const SHARE_POLL_MS = 4000;
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
- startSharePoll(): () => void;
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 sharePoll: TimerHandle | undefined;
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 owner's per-hunk/change reject decisions for the open diff session. */
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.stopSharePoll();
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
- // ── verbs ───────────────────────────────────
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
- this.setWorkingCopy(cutBlock(working, block.work));
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
- // restoreBlock returns undefined when the block structure round-trips to the
501
- // submitted revision - the working copy is back to pristine and is dropped
502
- const restored = restoreBlock(session.artifact.content, working, block.base, line);
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
- this.setWorkingCopy(restored);
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.rejections = this.rejections.filter((rejection) => !wholeHunk(rejection));
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
- this.rejections = this.rejections.filter(
629
+ const others = this.rejections.filter(
548
630
  (rejection) =>
549
631
  !(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
550
632
  );
551
- this.rejections.push(target);
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.rejections = this.rejections.filter((rejection) => !sameRejection(rejection, target));
652
+ this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
571
653
  this.setStatus("change restored");
572
654
  } else {
573
- this.rejections.push(target);
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
- /** Recompute the curated patch and push it as the working copy (or clear it). */
580
- private recomputeCuration(): void {
581
- const files = this.snapshot.session?.artifact.files;
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 (!files) return;
584
- // no decisions left = the working copy reverts to the full submitted diff
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.rejections = kept;
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 workBlockIndex =
745
- display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
746
-
747
- anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
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 verb upserts by id: same id + anchor, new body
782
- const wire = { id: existing.id, kind: existing.kind, anchor: existing.anchor, body };
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 shareId = this.snapshot.session?.shareId;
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 verb merges, so only the new path travels
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, awaiting the
922
- * merge so a poll never overlaps rounds. The daemon's merge emits
923
- * session.updated, so the re-render happens through the normal event path.
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 || !this.client) return Promise.resolve();
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
- client.sessionMergeShared(session.id, {
936
- annotations: this.shareTransport.collaboratorAnnotations(remote),
937
- participants: remote.participants,
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
- * Poll the share for collaborator notes while it is open: pull now, then again
946
- * every few seconds, each round waiting for the last so slow pulls never stack.
947
- * Returns a stop handle the caller runs on leave.
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
- startSharePoll(): () => void {
950
- this.stopSharePoll();
951
- // per-run token so a stale run's in-flight pull never re-arms the poll
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 tick = (): void => {
956
- void this.pullShared().finally(() => {
957
- if (this.shareRun === run && !this.closed)
958
- this.sharePoll = this.clock.setTimeout(tick, SHARE_POLL_MS);
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
- tick();
1214
+ connect();
963
1215
 
964
1216
  return () => {
965
- if (this.shareRun === run) this.stopSharePoll();
1217
+ if (this.shareRun === run) this.stopShareSync();
966
1218
  };
967
1219
  }
968
1220
 
969
- private stopSharePoll(): void {
1221
+ private stopShareSync(): void {
970
1222
  this.shareRun = null;
971
- if (this.sharePoll !== undefined) this.clock.clearTimeout(this.sharePoll);
972
- this.sharePoll = undefined;
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 ────────────────────