@cueloop/client 0.1.0-alpha.63 → 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 (68) hide show
  1. package/package.json +7 -6
  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 +34 -22
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +134 -87
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +7 -109
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/DiffSheet.scroll.test.tsx +5 -2
  17. package/src/components/DiffSheet.tsx +1 -6
  18. package/src/components/KeybindsDialog.tsx +61 -17
  19. package/src/components/PrototypeSheet.tsx +5 -9
  20. package/src/components/ReviewPanel.stories.tsx +14 -0
  21. package/src/components/ReviewRail.stories.tsx +14 -0
  22. package/src/components/ReviewRail.tsx +15 -6
  23. package/src/components/ThreadView.selection.test.tsx +812 -0
  24. package/src/components/ThreadView.stories.tsx +26 -0
  25. package/src/components/ThreadView.tsx +1620 -0
  26. package/src/components/TreePane.stories.tsx +106 -0
  27. package/src/components/TreePane.tsx +156 -0
  28. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  29. package/src/components/primitives/Tabs.tsx +1 -1
  30. package/src/components/story-fixtures.ts +6 -2
  31. package/src/components/story.ts +16 -4
  32. package/src/components/syntax-highlight.ts +10 -2
  33. package/src/components/terminal-pane.test.tsx +1 -1
  34. package/src/components/terminal-pane.ts +7 -1
  35. package/src/config.ts +112 -99
  36. package/src/diff-hunk-curate.ts +4 -104
  37. package/src/editor.ts +5 -1
  38. package/src/ghostty-terminal.test.ts +1 -1
  39. package/src/herdr.ts +10 -2
  40. package/src/index.ts +1 -0
  41. package/src/intent-dispatch.test.ts +235 -55
  42. package/src/intent-dispatch.ts +104 -34
  43. package/src/key-bindings.test.ts +1 -1
  44. package/src/key-bindings.ts +16 -7
  45. package/src/keymap.test.ts +9 -11
  46. package/src/keymap.ts +14 -7
  47. package/src/kitty-image.test.ts +3 -3
  48. package/src/prototype-browser.ts +7 -6
  49. package/src/serve.ts +1 -1
  50. package/src/session-controller.curate.test.ts +106 -4
  51. package/src/session-controller.share.test.ts +220 -70
  52. package/src/session-controller.tree.test.ts +228 -0
  53. package/src/session-controller.ts +370 -94
  54. package/src/share.ts +117 -17
  55. package/src/test-support.test.ts +193 -1
  56. package/src/test-support.ts +69 -1
  57. package/src/theme-presets.ts +9 -2
  58. package/src/thread-chords.test.ts +154 -0
  59. package/src/thread-chords.ts +144 -0
  60. package/src/thread-selection.test.ts +210 -0
  61. package/src/thread-selection.ts +243 -0
  62. package/src/tree-view.test.ts +128 -0
  63. package/src/tree-view.ts +148 -0
  64. package/src/view-diff.ts +1 -4
  65. package/src/view-plan.ts +35 -19
  66. package/src/components/PlanSheet.stories.tsx +0 -113
  67. package/src/components/PlanSheet.tsx +0 -422
  68. package/src/components/plan-sheet-run-style.ts +0 -90
@@ -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. */
@@ -131,6 +144,26 @@ export interface ControllerSnapshot {
131
144
  walk: { index: number } | null;
132
145
  }
133
146
 
147
+ export interface ShareTransport {
148
+ publish: typeof publishShare;
149
+ pull: typeof pullShare;
150
+ push: typeof pushShare;
151
+ watch: typeof watchShare;
152
+ parseShareId: typeof shareIdFromLine;
153
+ collaboratorAnnotations: typeof collaboratorAnnotations;
154
+ mergeFromShare: typeof mergeFromShare;
155
+ }
156
+
157
+ const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
158
+ publish: publishShare,
159
+ pull: pullShare,
160
+ watch: watchShare,
161
+ push: pushShare,
162
+ parseShareId: shareIdFromLine,
163
+ collaboratorAnnotations,
164
+ mergeFromShare,
165
+ };
166
+
134
167
  export interface ReviewControllerOptions {
135
168
  home?: string;
136
169
  sessionId?: string;
@@ -145,6 +178,7 @@ export interface ReviewControllerOptions {
145
178
  * renders a decrypted share instead.
146
179
  */
147
180
  openClient?: () => Promise<SessionClient>;
181
+ shareTransport?: ShareTransport;
148
182
  }
149
183
 
150
184
  export interface ReviewController {
@@ -188,6 +222,7 @@ export interface ReviewController {
188
222
  edit(): void;
189
223
  /**
190
224
  * Anchor and store an annotation; both plan and diff anchor constructions.
225
+ * `end` is an offset within `endDisplayIndex` (the same block by default).
191
226
  * Returns the minted annotation id so the view can select the new card.
192
227
  */
193
228
  annotate(
@@ -196,7 +231,13 @@ export interface ReviewController {
196
231
  start: number,
197
232
  end: number,
198
233
  body: string,
234
+ endDisplayIndex?: number,
199
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;
200
241
  /** Anchor a prototype comment to a DOM element by its selector. */
201
242
  annotatePrototype(selector: string, quote: string, body: string): string | undefined;
202
243
  /** Rewrite a stored annotation's body in place (the rail-card edit). */
@@ -214,10 +255,25 @@ export interface ReviewController {
214
255
  submit(verdict: VerdictKind, summary: string): void;
215
256
  /** Publish the current session as a share; the ssh line lands on the clipboard. */
216
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;
217
273
  /** Pull a shared plan's collaborator notes back and union them in (planner only). */
218
274
  pullShared(): Promise<void>;
219
275
  /** Poll the share for collaborator notes while it is open; returns a stop handle. */
220
- startSharePoll(): () => void;
276
+ startShareSync(): () => void;
221
277
  /** Close the review and, inside herdr, bounce focus back to the agent. */
222
278
  finishReview(): void;
223
279
  dismissCompletion(): void;
@@ -235,6 +291,14 @@ export function createReviewController(options: ReviewControllerOptions): Review
235
291
  return new Controller(options);
236
292
  }
237
293
 
294
+ interface DerivedSessionProjection {
295
+ display: DisplayBlock[];
296
+ rows: DiffRow[];
297
+ files: WalkFile[];
298
+ models: Map<string, FileDiffMetadata>;
299
+ tree: TreeRow[];
300
+ }
301
+
238
302
  class Controller implements ReviewController {
239
303
  readonly readOnly: boolean;
240
304
  private client: SessionClient | null = null;
@@ -254,29 +318,29 @@ class Controller implements ReviewController {
254
318
  private editor: string | undefined;
255
319
  private exporters: BundledExporter[] = [];
256
320
  private readonly clock: Clock;
321
+ private readonly shareTransport: ShareTransport;
257
322
  private countdown: TimerHandle | undefined;
258
- private sharePoll: TimerHandle | undefined;
323
+ private shareReconnect: TimerHandle | undefined;
324
+ private shareStop: (() => void) | null = null;
259
325
  private shareRun: object | null = null;
260
326
  /** Projections keyed by session identity so renders reuse one computation. */
261
327
  private derivedFor: ReviewSession | null = null;
262
- private derived: {
263
- display: DisplayBlock[];
264
- rows: DiffRow[];
265
- files: WalkFile[];
266
- /** Per-path parsed diff models, for hunk curation; empty when files absent. */
267
- models: Map<string, FileDiffMetadata>;
268
- } = {
328
+ private derived: DerivedSessionProjection = {
269
329
  display: [],
270
330
  rows: [],
271
331
  files: [],
272
332
  models: new Map(),
333
+ tree: [],
273
334
  };
274
- /** The owner's per-hunk/change reject decisions for the open diff session. */
275
- 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
+ }
276
339
 
277
340
  constructor(private readonly options: ReviewControllerOptions) {
278
341
  this.readOnly = options.readOnly ?? false;
279
342
  this.clock = options.clock ?? new SystemClock();
343
+ this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
280
344
  }
281
345
 
282
346
  subscribe = (listener: () => void): (() => void) => {
@@ -324,7 +388,7 @@ class Controller implements ReviewController {
324
388
  close(): void {
325
389
  this.closed = true;
326
390
  this.clearCountdown();
327
- this.stopSharePoll();
391
+ this.stopShareSync();
328
392
  this.client?.close();
329
393
  }
330
394
 
@@ -369,9 +433,16 @@ class Controller implements ReviewController {
369
433
  rows,
370
434
  files: walkFiles(rows),
371
435
  models,
436
+ tree: session?.history ? treeRows(session.history) : [],
372
437
  };
373
438
  }
374
439
 
440
+ treeRows(): TreeRow[] {
441
+ this.ensureDerived();
442
+
443
+ return this.derived.tree;
444
+ }
445
+
375
446
  display(): DisplayBlock[] {
376
447
  this.ensureDerived();
377
448
 
@@ -402,16 +473,16 @@ class Controller implements ReviewController {
402
473
  private async refreshSession(id: string): Promise<void> {
403
474
  try {
404
475
  if (this.client) this.update({ session: await this.client.sessionGet(id) });
405
- } catch (error) {
406
- if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
476
+ } catch (cause) {
477
+ if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
407
478
  }
408
479
  }
409
480
 
410
481
  private async refreshInbox(): Promise<void> {
411
482
  try {
412
483
  if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
413
- } catch (error) {
414
- if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
484
+ } catch (cause) {
485
+ if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
415
486
  }
416
487
  }
417
488
 
@@ -419,15 +490,30 @@ class Controller implements ReviewController {
419
490
  private apply(mutation: Promise<ReviewSession>): void {
420
491
  mutation
421
492
  .then((session) => this.update({ session }))
422
- .catch((error: unknown) =>
423
- this.setStatus(String(error instanceof Error ? error.message : error)),
493
+ .catch((cause: unknown) =>
494
+ this.setStatus(String(cause instanceof Error ? cause.message : cause)),
424
495
  );
425
496
  }
426
497
 
427
- // ── 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 ───────────────────────────────────
428
515
  open(id: string): void {
429
516
  this.locallyViewed.clear();
430
- this.rejections = [];
431
517
  const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
432
518
 
433
519
  if (cached) this.update({ session: cached });
@@ -438,8 +524,8 @@ class Controller implements ReviewController {
438
524
  this.client
439
525
  ?.sessionDelete(id)
440
526
  .then(() => this.setStatus("plan deleted"))
441
- .catch((error: unknown) =>
442
- this.setStatus(`delete failed: ${error instanceof Error ? error.message : String(error)}`),
527
+ .catch((cause: unknown) =>
528
+ this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
443
529
  );
444
530
  }
445
531
 
@@ -462,7 +548,15 @@ class Controller implements ReviewController {
462
548
  if (block.type === "del") {
463
549
  this.restoreDelBlock(block, displayIndex);
464
550
  } else if (block.work) {
465
- 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
+ );
466
560
  this.setStatus("block cut - it serializes into the diff");
467
561
  }
468
562
  }
@@ -477,11 +571,19 @@ class Controller implements ReviewController {
477
571
  nextWorkBlock(this.display(), displayIndex),
478
572
  working.split("\n").length,
479
573
  );
480
- // restoreBlock returns undefined when the block structure round-trips to the
481
- // submitted revision - the working copy is back to pristine and is dropped
482
- 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
+ };
483
584
 
484
- this.setWorkingCopy(restored);
585
+ if (expected.workingCopy === undefined) delete expected.workingCopy;
586
+ this.applyOptimistic(expected, this.client!.sessionRestoreBlock(session.id, baseIndex, line));
485
587
  this.setStatus(REMOVAL_RESTORED_STATUS);
486
588
  }
487
589
 
@@ -520,18 +622,18 @@ class Controller implements ReviewController {
520
622
  const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
521
623
 
522
624
  if (this.rejections.some(wholeHunk)) {
523
- this.rejections = this.rejections.filter((rejection) => !wholeHunk(rejection));
625
+ this.curate(this.rejections.filter((rejection) => !wholeHunk(rejection)));
524
626
  this.setStatus("hunk restored");
525
627
  } else {
526
628
  // a whole-hunk reject supersedes any change-level rejects inside it
527
- this.rejections = this.rejections.filter(
629
+ const others = this.rejections.filter(
528
630
  (rejection) =>
529
631
  !(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
530
632
  );
531
- this.rejections.push(target);
633
+
634
+ this.curate([...others, target]);
532
635
  this.setStatus("hunk rejected - dropped from the working copy");
533
636
  }
534
- this.recomputeCuration();
535
637
  }
536
638
 
537
639
  toggleRejectChange(rowIndex: number): void {
@@ -547,24 +649,23 @@ class Controller implements ReviewController {
547
649
 
548
650
  if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
549
651
  if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
550
- this.rejections = this.rejections.filter((rejection) => !sameRejection(rejection, target));
652
+ this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
551
653
  this.setStatus("change restored");
552
654
  } else {
553
- this.rejections.push(target);
655
+ this.curate([...this.rejections, target]);
554
656
  this.setStatus("change rejected - dropped from the working copy");
555
657
  }
556
- this.recomputeCuration();
557
658
  }
558
659
 
559
- /** Recompute the curated patch and push it as the working copy (or clear it). */
560
- private recomputeCuration(): void {
561
- 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 };
562
666
 
563
- if (!files) return;
564
- // no decisions left = the working copy reverts to the full submitted diff
565
- this.setWorkingCopy(this.rejections.length ? curateDiff(files, this.rejections) : undefined);
566
- // dimming reads `rejections` directly; a bare update forces the re-read
567
- this.update({});
667
+ if (!rejections.length) delete expected.curation;
668
+ this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
568
669
  }
569
670
 
570
671
  rejectedRows(): Set<number> {
@@ -596,9 +697,8 @@ class Controller implements ReviewController {
596
697
  const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
597
698
 
598
699
  if (kept.length === this.rejections.length) return;
599
- this.rejections = kept;
700
+ this.curate(kept);
600
701
  this.setStatus(REMOVAL_RESTORED_STATUS);
601
- this.recomputeCuration();
602
702
 
603
703
  return;
604
704
  }
@@ -710,6 +810,7 @@ class Controller implements ReviewController {
710
810
  start: number,
711
811
  end: number,
712
812
  body: string,
813
+ endDisplayIndex: number = displayIndex,
713
814
  ): string | undefined {
714
815
  const session = this.snapshot.session;
715
816
 
@@ -721,10 +822,16 @@ class Controller implements ReviewController {
721
822
  } else {
722
823
  const display = this.display();
723
824
  const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
724
- const workBlockIndex =
725
- display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
726
-
727
- 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
+ );
728
835
  }
729
836
  const wire = { id: newAnnotationId(), kind, anchor, body };
730
837
  const persisted = this.client!.sessionAnnotate(session.id, wire);
@@ -736,6 +843,28 @@ class Controller implements ReviewController {
736
843
  return wire.id;
737
844
  }
738
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
+
739
868
  annotatePrototype(selector: string, quote: string, body: string): string | undefined {
740
869
  const session = this.snapshot.session;
741
870
 
@@ -758,8 +887,16 @@ class Controller implements ReviewController {
758
887
  const existing = session.annotations.find((annotation) => annotation.id === id);
759
888
 
760
889
  if (!existing) return;
761
- // the daemon's annotate verb upserts by id: same id + anchor, new body
762
- 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;
763
900
  const persisted = this.client!.sessionAnnotate(session.id, wire);
764
901
 
765
902
  this.apply(persisted);
@@ -772,10 +909,13 @@ class Controller implements ReviewController {
772
909
  persisted: Promise<ReviewSession>,
773
910
  annotation: Omit<Annotation, "createdAt">,
774
911
  ): void {
775
- const shareId = this.snapshot.session?.shareId;
912
+ const session = this.snapshot.session;
913
+ const shareId = session?.shareId;
776
914
 
777
915
  if (!shareId) return;
778
- void persisted.then(() => pushShare(shareId, [annotation])).catch(() => {});
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;
918
+ void persisted.then(() => this.shareTransport.push(shareId, [annotation])).catch(() => {});
779
919
  }
780
920
 
781
921
  removeAnnotation(id: string): void {
@@ -823,7 +963,7 @@ class Controller implements ReviewController {
823
963
 
824
964
  if (!current) return; // already on the end card
825
965
  // advancing IS the viewed mark: the step is complete once you move past
826
- // it; the daemon verb merges, so only the new path travels
966
+ // it; the daemon primitive merges, so only the new path travels
827
967
  if (!this.viewedSet().has(current.path)) {
828
968
  this.locallyViewed.add(current.path);
829
969
  this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
@@ -868,12 +1008,12 @@ class Controller implements ReviewController {
868
1008
  // a remembers the choice. A configured delay overrides; 0 closes now.
869
1009
  const delay = this.autoClose;
870
1010
 
871
- if (delay === 0) this.finishReview();
872
- else if (typeof delay === "number") this.startCounting(delay);
873
- else this.startCounting(DEFAULT_AUTO_CLOSE);
1011
+ if (delay === "off") this.startCounting(DEFAULT_AUTO_CLOSE);
1012
+ else if (delay === 0) this.finishReview();
1013
+ else this.startCounting(delay);
874
1014
  })
875
- .catch((error: unknown) =>
876
- this.setStatus(String(error instanceof Error ? error.message : error)),
1015
+ .catch((cause: unknown) =>
1016
+ this.setStatus(String(cause instanceof Error ? cause.message : cause)),
877
1017
  );
878
1018
  }
879
1019
 
@@ -882,72 +1022,208 @@ class Controller implements ReviewController {
882
1022
 
883
1023
  if (!session) return;
884
1024
  this.setStatus("sharing…");
885
- publishShare(session)
1025
+ this.shareTransport
1026
+ .publish(session)
886
1027
  .then(async ({ line, copied }) => {
887
1028
  // Stamp the id back so a later pull knows which share to collect from.
888
- const shareId = shareIdFromLine(line);
1029
+ const shareId = this.shareTransport.parseShareId(line);
889
1030
 
890
1031
  if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
891
1032
  this.setStatus("");
892
1033
  this.showToast(line, copied ? "share link copied" : "share link");
893
1034
  })
894
- .catch((error: unknown) =>
895
- this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`),
1035
+ .catch((cause: unknown) =>
1036
+ this.setStatus(`share failed: ${cause instanceof Error ? cause.message : String(cause)}`),
896
1037
  );
897
1038
  }
898
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
+
899
1155
  /**
900
- * Pull collaborator notes for a shared plan and union them in, awaiting the
901
- * merge so a poll never overlaps rounds. The daemon's merge emits
902
- * session.updated, so the re-render happens through the normal event path.
903
- * 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.
904
1159
  */
905
1160
  pullShared(): Promise<void> {
906
1161
  const session = this.snapshot.session;
907
1162
 
908
- if (!session?.shareId || !this.client) return Promise.resolve();
909
- const client = this.client;
1163
+ if (!session?.shareId) return Promise.resolve();
1164
+
1165
+ return this.shareTransport
1166
+ .pull(session.shareId)
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();
910
1175
 
911
- return pullShare(session.shareId)
912
- .then((remote) =>
913
- client.sessionMergeShared(session.id, {
914
- annotations: collaboratorAnnotations(remote),
915
- participants: remote.participants,
916
- }),
917
- )
1176
+ return this.client
1177
+ .sessionMergeShared(session.id, this.shareTransport.mergeFromShare(remote))
918
1178
  .then(() => {})
919
1179
  .catch(() => {});
920
1180
  }
921
1181
 
922
1182
  /**
923
- * Poll the share for collaborator notes while it is open: pull now, then again
924
- * every few seconds, each round waiting for the last so slow pulls never stack.
925
- * 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.
926
1187
  */
927
- startSharePoll(): () => void {
928
- this.stopSharePoll();
929
- // 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
930
1191
  const run = {};
1192
+ let delay = SHARE_RECONNECT_MIN_MS;
931
1193
 
932
1194
  this.shareRun = run;
933
- const tick = (): void => {
934
- void this.pullShared().finally(() => {
935
- if (this.shareRun === run && !this.closed)
936
- 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
+ },
937
1211
  });
938
1212
  };
939
1213
 
940
- tick();
1214
+ connect();
941
1215
 
942
1216
  return () => {
943
- if (this.shareRun === run) this.stopSharePoll();
1217
+ if (this.shareRun === run) this.stopShareSync();
944
1218
  };
945
1219
  }
946
1220
 
947
- private stopSharePoll(): void {
1221
+ private stopShareSync(): void {
948
1222
  this.shareRun = null;
949
- if (this.sharePoll !== undefined) this.clock.clearTimeout(this.sharePoll);
950
- this.sharePoll = undefined;
1223
+ if (this.shareReconnect !== undefined) this.clock.clearTimeout(this.shareReconnect);
1224
+ this.shareReconnect = undefined;
1225
+ this.shareStop?.();
1226
+ this.shareStop = null;
951
1227
  }
952
1228
 
953
1229
  // ── completion hand-back ────────────────────