@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +8 -73
  4. package/src/App.export.test.tsx +2 -2
  5. package/src/App.plan-review.test.tsx +60 -412
  6. package/src/App.prototype.test.tsx +1 -8
  7. package/src/App.readonly.test.tsx +85 -62
  8. package/src/App.rendering.test.tsx +5 -6
  9. package/src/App.submit-confirm.test.tsx +32 -70
  10. package/src/App.test.tsx +136 -66
  11. package/src/App.toast.test.tsx +17 -8
  12. package/src/App.tsx +243 -284
  13. package/src/App.walk.test.tsx +1 -1
  14. package/src/app-screens.tsx +67 -52
  15. package/src/app-view-model.ts +6 -100
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +2 -2
  18. package/src/components/AppShell.stories.tsx +84 -0
  19. package/src/components/AppShell.tsx +127 -0
  20. package/src/components/Breadcrumb.stories.tsx +1 -1
  21. package/src/components/ChangesColumn.stories.tsx +39 -0
  22. package/src/components/ChangesColumn.tsx +147 -0
  23. package/src/components/CodeBlock.stories.tsx +1 -1
  24. package/src/components/CompletionOverlay.stories.tsx +1 -1
  25. package/src/components/ConfirmCard.stories.tsx +1 -1
  26. package/src/components/ConfirmCard.tsx +3 -4
  27. package/src/components/ConfirmDialog.stories.tsx +1 -1
  28. package/src/components/DiffSheet.stories.tsx +1 -1
  29. package/src/components/InboxList.stories.tsx +23 -8
  30. package/src/components/InboxList.tsx +45 -19
  31. package/src/components/KeybindsDialog.stories.tsx +1 -1
  32. package/src/components/KeybindsDialog.tsx +61 -17
  33. package/src/components/MarkerPopover.stories.tsx +1 -1
  34. package/src/components/MenuBar.stories.tsx +3 -12
  35. package/src/components/MenuBar.tsx +25 -56
  36. package/src/components/PromptDialog.stories.tsx +1 -1
  37. package/src/components/PrototypeSheet.stories.tsx +1 -1
  38. package/src/components/ReviewPanel.stories.tsx +15 -1
  39. package/src/components/ReviewRail.stories.tsx +15 -1
  40. package/src/components/ReviewRail.tsx +13 -6
  41. package/src/components/SettingsDialog.stories.tsx +7 -1
  42. package/src/components/SettingsDialog.tsx +112 -66
  43. package/src/components/SettingsRows.stories.tsx +1 -1
  44. package/src/components/ShellHeader.stories.tsx +50 -0
  45. package/src/components/ShellHeader.tsx +137 -0
  46. package/src/components/ThreadFooter.stories.tsx +30 -0
  47. package/src/components/ThreadFooter.tsx +49 -0
  48. package/src/components/ThreadView.selection.test.tsx +812 -0
  49. package/src/components/ThreadView.slash.test.tsx +113 -0
  50. package/src/components/ThreadView.stories.tsx +26 -0
  51. package/src/components/ThreadView.tsx +1708 -0
  52. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  53. package/src/components/ThreadsSidebar.tsx +57 -0
  54. package/src/components/Toast.stories.tsx +1 -1
  55. package/src/components/TreePane.stories.tsx +106 -0
  56. package/src/components/TreePane.tsx +156 -0
  57. package/src/components/WalkWizard.stories.tsx +1 -1
  58. package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
  59. package/src/components/agent-launcher.stories.tsx +1 -1
  60. package/src/components/file-tree.test.ts +45 -0
  61. package/src/components/file-tree.ts +57 -0
  62. package/src/components/pages/Workspace.stories.tsx +181 -0
  63. package/src/components/primitives/Button.stories.tsx +1 -1
  64. package/src/components/primitives/Card.stories.tsx +1 -1
  65. package/src/components/primitives/Dialog.stories.tsx +1 -1
  66. package/src/components/primitives/IconButton.stories.tsx +36 -0
  67. package/src/components/primitives/IconButton.tsx +43 -0
  68. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  69. package/src/components/primitives/Tabs.stories.tsx +1 -1
  70. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  71. package/src/components/primitives/Tree.stories.tsx +124 -0
  72. package/src/components/primitives/Tree.tsx +87 -0
  73. package/src/components/primitives/icons.ts +38 -0
  74. package/src/components/primitives/tree-model.test.ts +99 -0
  75. package/src/components/primitives/tree-model.ts +137 -0
  76. package/src/components/quick-actions-editor.stories.tsx +1 -1
  77. package/src/components/session-tree.test.ts +114 -0
  78. package/src/components/session-tree.ts +94 -0
  79. package/src/components/stories-app.tsx +142 -82
  80. package/src/components/story-tree.test.ts +100 -0
  81. package/src/components/story.ts +55 -0
  82. package/src/components/theme-context.stories.tsx +1 -1
  83. package/src/diff-hunk-curate.ts +4 -104
  84. package/src/index.ts +1 -0
  85. package/src/intent-dispatch.test.ts +136 -3
  86. package/src/intent-dispatch.ts +92 -24
  87. package/src/key-bindings.test.ts +1 -1
  88. package/src/keymap.test.ts +4 -4
  89. package/src/keymap.ts +14 -7
  90. package/src/session-controller.curate.test.ts +89 -2
  91. package/src/session-controller.share.test.ts +195 -55
  92. package/src/session-controller.tree.test.ts +228 -0
  93. package/src/session-controller.ts +331 -72
  94. package/src/share.ts +114 -16
  95. package/src/test-support.test.ts +193 -1
  96. package/src/test-support.ts +64 -0
  97. package/src/theme.ts +4 -4
  98. package/src/thread-chords.test.ts +154 -0
  99. package/src/thread-chords.ts +144 -0
  100. package/src/thread-selection.test.ts +210 -0
  101. package/src/thread-selection.ts +243 -0
  102. package/src/tree-view.test.ts +128 -0
  103. package/src/tree-view.ts +148 -0
  104. package/src/use-settings-dialog.tsx +9 -0
  105. package/src/view-plan.ts +29 -12
  106. package/src/App.review-panel.test.tsx +0 -143
  107. package/src/components/PlanSheet.stories.tsx +0 -113
  108. package/src/components/PlanSheet.tsx +0 -419
  109. 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;
@@ -327,13 +371,17 @@ class Controller implements ReviewController {
327
371
  const session = this.snapshot.session;
328
372
 
329
373
  if (session && event.sessionId === session.id) void this.refreshSession(session.id);
330
- if (!this.options.sessionId) void this.refreshInbox();
374
+ // the Threads sidebar shows the inbox even on a direct open, so keep it fresh
375
+ void this.refreshInbox();
331
376
  });
332
377
  await client.subscribe();
378
+ // load the inbox in both cases so the sidebar can jump between threads
379
+ const inbox = await client.sessionList({ status: "pending" });
380
+
333
381
  if (this.options.sessionId) {
334
- this.update({ session: await client.sessionGet(this.options.sessionId) });
382
+ this.update({ session: await client.sessionGet(this.options.sessionId), inbox });
335
383
  } else {
336
- this.update({ inbox: await client.sessionList({ status: "pending" }) });
384
+ this.update({ inbox });
337
385
  }
338
386
  } catch (err) {
339
387
  this.update({ error: err instanceof Error ? err.message : String(err) });
@@ -344,7 +392,7 @@ class Controller implements ReviewController {
344
392
  close(): void {
345
393
  this.closed = true;
346
394
  this.clearCountdown();
347
- this.stopSharePoll();
395
+ this.stopShareSync();
348
396
  this.client?.close();
349
397
  }
350
398
 
@@ -362,7 +410,8 @@ class Controller implements ReviewController {
362
410
  }
363
411
 
364
412
  setStatus(message: string): void {
365
- this.update({ status: message });
413
+ // the persistent status bar is gone; transient feedback surfaces as a toast
414
+ this.update({ status: message, toast: message ? { body: message } : null });
366
415
  }
367
416
 
368
417
  showToast(body: string, title?: string): void {
@@ -389,9 +438,16 @@ class Controller implements ReviewController {
389
438
  rows,
390
439
  files: walkFiles(rows),
391
440
  models,
441
+ tree: session?.history ? treeRows(session.history) : [],
392
442
  };
393
443
  }
394
444
 
445
+ treeRows(): TreeRow[] {
446
+ this.ensureDerived();
447
+
448
+ return this.derived.tree;
449
+ }
450
+
395
451
  display(): DisplayBlock[] {
396
452
  this.ensureDerived();
397
453
 
@@ -444,10 +500,25 @@ class Controller implements ReviewController {
444
500
  );
445
501
  }
446
502
 
447
- // ── verbs ───────────────────────────────────
503
+ /**
504
+ * Show the expected result before the daemon answers, so a second keypress
505
+ * builds on the first instead of on the stale snapshot. Answers arrive in
506
+ * request order over the socket and replace the guess with the daemon's copy.
507
+ */
508
+ private applyOptimistic(expected: ReviewSession, mutation: Promise<ReviewSession>): void {
509
+ this.update({ session: expected });
510
+ mutation
511
+ .then((session) => this.update({ session }))
512
+ .catch((cause: unknown) => {
513
+ // a refused guess must not stay on screen: the daemon's record replaces it
514
+ this.setStatus(String(cause instanceof Error ? cause.message : cause));
515
+ void this.refreshSession(expected.id);
516
+ });
517
+ }
518
+
519
+ // ── primitives ───────────────────────────────────
448
520
  open(id: string): void {
449
521
  this.locallyViewed.clear();
450
- this.rejections = [];
451
522
  const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
452
523
 
453
524
  if (cached) this.update({ session: cached });
@@ -482,7 +553,15 @@ class Controller implements ReviewController {
482
553
  if (block.type === "del") {
483
554
  this.restoreDelBlock(block, displayIndex);
484
555
  } else if (block.work) {
485
- this.setWorkingCopy(cutBlock(working, block.work));
556
+ const workIndex = parseBlocks(working).findIndex(
557
+ (candidate) => candidate.lineStart === block.work!.lineStart,
558
+ );
559
+
560
+ if (workIndex === -1) return;
561
+ this.applyOptimistic(
562
+ { ...session, workingCopy: cutBlock(working, block.work) },
563
+ this.client!.sessionCutBlock(session.id, workIndex),
564
+ );
486
565
  this.setStatus("block cut - it serializes into the diff");
487
566
  }
488
567
  }
@@ -497,11 +576,19 @@ class Controller implements ReviewController {
497
576
  nextWorkBlock(this.display(), displayIndex),
498
577
  working.split("\n").length,
499
578
  );
500
- // 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);
579
+ const base = session.artifact.content;
580
+ const baseIndex = parseBlocks(base).findIndex(
581
+ (candidate) => candidate.lineStart === block.base!.lineStart,
582
+ );
583
+
584
+ if (baseIndex === -1) return;
585
+ const expected: ReviewSession = {
586
+ ...session,
587
+ workingCopy: restoreBlock(base, working, block.base, line),
588
+ };
503
589
 
504
- this.setWorkingCopy(restored);
590
+ if (expected.workingCopy === undefined) delete expected.workingCopy;
591
+ this.applyOptimistic(expected, this.client!.sessionRestoreBlock(session.id, baseIndex, line));
505
592
  this.setStatus(REMOVAL_RESTORED_STATUS);
506
593
  }
507
594
 
@@ -540,18 +627,18 @@ class Controller implements ReviewController {
540
627
  const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
541
628
 
542
629
  if (this.rejections.some(wholeHunk)) {
543
- this.rejections = this.rejections.filter((rejection) => !wholeHunk(rejection));
630
+ this.curate(this.rejections.filter((rejection) => !wholeHunk(rejection)));
544
631
  this.setStatus("hunk restored");
545
632
  } else {
546
633
  // a whole-hunk reject supersedes any change-level rejects inside it
547
- this.rejections = this.rejections.filter(
634
+ const others = this.rejections.filter(
548
635
  (rejection) =>
549
636
  !(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
550
637
  );
551
- this.rejections.push(target);
638
+
639
+ this.curate([...others, target]);
552
640
  this.setStatus("hunk rejected - dropped from the working copy");
553
641
  }
554
- this.recomputeCuration();
555
642
  }
556
643
 
557
644
  toggleRejectChange(rowIndex: number): void {
@@ -567,24 +654,23 @@ class Controller implements ReviewController {
567
654
 
568
655
  if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
569
656
  if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
570
- this.rejections = this.rejections.filter((rejection) => !sameRejection(rejection, target));
657
+ this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
571
658
  this.setStatus("change restored");
572
659
  } else {
573
- this.rejections.push(target);
660
+ this.curate([...this.rejections, target]);
574
661
  this.setStatus("change rejected - dropped from the working copy");
575
662
  }
576
- this.recomputeCuration();
577
663
  }
578
664
 
579
- /** 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;
665
+ /** Hand the daemon the full set of reject decisions; the working copy follows. */
666
+ private curate(rejections: HunkRejection[]): void {
667
+ const session = this.snapshot.session;
582
668
 
583
- if (!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({});
669
+ if (!session) return;
670
+ const expected: ReviewSession = { ...session, curation: rejections };
671
+
672
+ if (!rejections.length) delete expected.curation;
673
+ this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
588
674
  }
589
675
 
590
676
  rejectedRows(): Set<number> {
@@ -616,9 +702,8 @@ class Controller implements ReviewController {
616
702
  const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
617
703
 
618
704
  if (kept.length === this.rejections.length) return;
619
- this.rejections = kept;
705
+ this.curate(kept);
620
706
  this.setStatus(REMOVAL_RESTORED_STATUS);
621
- this.recomputeCuration();
622
707
 
623
708
  return;
624
709
  }
@@ -730,6 +815,7 @@ class Controller implements ReviewController {
730
815
  start: number,
731
816
  end: number,
732
817
  body: string,
818
+ endDisplayIndex: number = displayIndex,
733
819
  ): string | undefined {
734
820
  const session = this.snapshot.session;
735
821
 
@@ -741,10 +827,16 @@ class Controller implements ReviewController {
741
827
  } else {
742
828
  const display = this.display();
743
829
  const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
744
- const workBlockIndex =
745
- display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
746
-
747
- anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
830
+ const workIndexOf = (index: number): number =>
831
+ display.slice(0, index + 1).filter((entry) => entry.work).length - 1;
832
+
833
+ anchor = makeAnchor(
834
+ workBlocks,
835
+ workIndexOf(displayIndex),
836
+ start,
837
+ end,
838
+ workIndexOf(endDisplayIndex),
839
+ );
748
840
  }
749
841
  const wire = { id: newAnnotationId(), kind, anchor, body };
750
842
  const persisted = this.client!.sessionAnnotate(session.id, wire);
@@ -756,6 +848,28 @@ class Controller implements ReviewController {
756
848
  return wire.id;
757
849
  }
758
850
 
851
+ reply(rootAnnotationId: string, body: string): string | undefined {
852
+ const session = this.snapshot.session;
853
+ const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
854
+
855
+ if (!session || !root) return undefined;
856
+ // a reply to a reply still hangs off the discussion's root comment
857
+ const wire = {
858
+ id: newAnnotationId(),
859
+ kind: "comment",
860
+ anchor: root.anchor,
861
+ body,
862
+ replyTo: root.replyTo ?? root.id,
863
+ };
864
+ const persisted = this.client!.sessionAnnotate(session.id, wire);
865
+
866
+ this.apply(persisted);
867
+ this.mirrorAnnotation(persisted, wire);
868
+ this.setStatus("reply added");
869
+
870
+ return wire.id;
871
+ }
872
+
759
873
  annotatePrototype(selector: string, quote: string, body: string): string | undefined {
760
874
  const session = this.snapshot.session;
761
875
 
@@ -778,8 +892,16 @@ class Controller implements ReviewController {
778
892
  const existing = session.annotations.find((annotation) => annotation.id === id);
779
893
 
780
894
  if (!existing) return;
781
- // the daemon's annotate verb upserts by id: same id + anchor, new body
782
- const wire = { id: existing.id, kind: existing.kind, anchor: existing.anchor, body };
895
+ // the daemon's annotate primitive upserts by id: same id, anchor, and
896
+ // reply link, new body
897
+ const wire: Omit<Annotation, "createdAt"> = {
898
+ id: existing.id,
899
+ kind: existing.kind,
900
+ anchor: existing.anchor,
901
+ body,
902
+ };
903
+
904
+ if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
783
905
  const persisted = this.client!.sessionAnnotate(session.id, wire);
784
906
 
785
907
  this.apply(persisted);
@@ -792,9 +914,12 @@ class Controller implements ReviewController {
792
914
  persisted: Promise<ReviewSession>,
793
915
  annotation: Omit<Annotation, "createdAt">,
794
916
  ): void {
795
- const shareId = this.snapshot.session?.shareId;
917
+ const session = this.snapshot.session;
918
+ const shareId = session?.shareId;
796
919
 
797
920
  if (!shareId) return;
921
+ // the share follows one branch: a note left on another stays the owner's
922
+ if (session.history && session.history.branch !== (session.shareBranch ?? MAIN_BRANCH)) return;
798
923
  void persisted.then(() => this.shareTransport.push(shareId, [annotation])).catch(() => {});
799
924
  }
800
925
 
@@ -843,7 +968,7 @@ class Controller implements ReviewController {
843
968
 
844
969
  if (!current) return; // already on the end card
845
970
  // advancing IS the viewed mark: the step is complete once you move past
846
- // it; the daemon verb merges, so only the new path travels
971
+ // it; the daemon primitive merges, so only the new path travels
847
972
  if (!this.viewedSet().has(current.path)) {
848
973
  this.locallyViewed.add(current.path);
849
974
  this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
@@ -917,59 +1042,193 @@ class Controller implements ReviewController {
917
1042
  );
918
1043
  }
919
1044
 
1045
+ goToEntry(entryId: string, summary?: string): void {
1046
+ const session = this.snapshot.session;
1047
+
1048
+ if (!session?.history || !this.client) return;
1049
+ const target = entryTarget(session.history, entryId);
1050
+
1051
+ if (target === null) return this.setStatus("that entry is not on any branch");
1052
+ if (target.kind === "here") return this.setStatus("already at the tip");
1053
+ if (target.kind === "switch") {
1054
+ this.moveTree(session, switchBranch(session.history, target.branch), () =>
1055
+ this.client!.sessionSwitch(session.id, target.branch),
1056
+ );
1057
+ this.setStatus(`on branch ${target.branch}`);
1058
+
1059
+ return;
1060
+ }
1061
+ // an entry another branch reaches: the daemon stands on that branch first, in the same request
1062
+ const onBranch = switchBranch(session.history, target.branch);
1063
+ const options = summary === undefined || summary === "" ? {} : { summary };
1064
+ const moved = navigateTo(onBranch, entryId, options);
1065
+ const branch = target.branch === session.history.branch ? undefined : target.branch;
1066
+
1067
+ this.moveTree(session, moved, () =>
1068
+ this.client!.sessionNavigate(session.id, entryId, options.summary, branch),
1069
+ );
1070
+ this.setStatus(
1071
+ options.summary === undefined
1072
+ ? "moved back - later entries stay in the tree"
1073
+ : "moved back with a summary",
1074
+ );
1075
+ }
1076
+
1077
+ branch(name: string): void {
1078
+ const session = this.snapshot.session;
1079
+ const branchName = name.trim();
1080
+
1081
+ if (!session?.history || !this.client) return;
1082
+ if (!branchName) return this.setStatus("a branch needs a name");
1083
+ if (session.history.tips[branchName] !== undefined)
1084
+ return this.setStatus(`branch ${branchName} exists`);
1085
+ this.moveTree(session, createBranch(session.history, branchName), () =>
1086
+ this.client!.sessionBranch(session.id, branchName),
1087
+ );
1088
+ this.setStatus(`on branch ${branchName}`);
1089
+ }
1090
+
1091
+ labelTip(label: string): void {
1092
+ const session = this.snapshot.session;
1093
+ const name = label.trim();
1094
+
1095
+ if (!session?.history || !this.client) return;
1096
+ if (!name) return this.setStatus("a checkpoint needs a name");
1097
+ this.moveTree(session, labelTip(session.history, name), () =>
1098
+ this.client!.sessionLabel(session.id, name),
1099
+ );
1100
+ this.setStatus(`checkpoint ${name}`);
1101
+ }
1102
+
1103
+ fork(): void {
1104
+ const session = this.snapshot.session;
1105
+
1106
+ if (!session || !this.client) return;
1107
+ this.client
1108
+ .sessionFork(session.id)
1109
+ .then((fork) => {
1110
+ this.update({ session: fork });
1111
+ this.showToast(`forked ${session.id} - you are on the fork now`, "fork");
1112
+ })
1113
+ .catch((cause: unknown) =>
1114
+ this.setStatus(`fork failed: ${cause instanceof Error ? cause.message : String(cause)}`),
1115
+ );
1116
+ }
1117
+
1118
+ forkAndShare(): void {
1119
+ const session = this.snapshot.session;
1120
+
1121
+ if (!session || !this.client) return;
1122
+ const client = this.client;
1123
+
1124
+ this.setStatus("forking and sharing…");
1125
+ client
1126
+ .sessionFork(session.id)
1127
+ .then(async (fork) => {
1128
+ const { line, copied } = await this.shareTransport.publish(fork);
1129
+ const shareId = this.shareTransport.parseShareId(line);
1130
+
1131
+ if (shareId) await client.sessionSetShareId(fork.id, shareId);
1132
+ this.setStatus("");
1133
+ this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
1134
+ })
1135
+ .catch((cause: unknown) =>
1136
+ this.setStatus(
1137
+ `fork and share failed: ${cause instanceof Error ? cause.message : String(cause)}`,
1138
+ ),
1139
+ );
1140
+ }
1141
+
1142
+ /**
1143
+ * Show a moved tree at once - the record re-derived from its new path - and
1144
+ * let the daemon's answer replace it. A refused move surfaces as status.
1145
+ */
1146
+ private moveTree(
1147
+ session: ReviewSession,
1148
+ history: SessionHistory,
1149
+ request: () => Promise<ReviewSession>,
1150
+ ): void {
1151
+ const expected: ReviewSession = { ...session, history };
1152
+
1153
+ applyPathView(
1154
+ expected,
1155
+ viewOfPath(history, [...session.annotations, ...(session.shelvedAnnotations ?? [])]),
1156
+ );
1157
+ this.applyOptimistic(expected, request());
1158
+ }
1159
+
920
1160
  /**
921
- * Pull collaborator notes for a shared plan and union them in, 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.
1161
+ * Pull collaborator notes for a shared plan and union them in. The daemon's
1162
+ * merge emits session.updated, so the re-render happens through the normal
1163
+ * event path. Best-effort: a failed refresh is silent.
925
1164
  */
926
1165
  pullShared(): Promise<void> {
927
1166
  const session = this.snapshot.session;
928
1167
 
929
- if (!session?.shareId || !this.client) return Promise.resolve();
930
- const client = this.client;
1168
+ if (!session?.shareId) return Promise.resolve();
931
1169
 
932
1170
  return this.shareTransport
933
1171
  .pull(session.shareId)
934
- .then((remote) =>
935
- client.sessionMergeShared(session.id, {
936
- annotations: this.shareTransport.collaboratorAnnotations(remote),
937
- participants: remote.participants,
938
- }),
939
- )
1172
+ .then((remote) => this.mergeShared(remote))
1173
+ .catch(() => {});
1174
+ }
1175
+
1176
+ private mergeShared(remote: ReviewSession): Promise<void> {
1177
+ const session = this.snapshot.session;
1178
+
1179
+ if (!session || !this.client) return Promise.resolve();
1180
+
1181
+ return this.client
1182
+ .sessionMergeShared(session.id, this.shareTransport.mergeFromShare(remote))
940
1183
  .then(() => {})
941
1184
  .catch(() => {});
942
1185
  }
943
1186
 
944
1187
  /**
945
- * 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.
1188
+ * Follow the share live while it is open: one watch stream delivers every
1189
+ * change as it lands; a pull on each (re)connect catches up on anything
1190
+ * missed while the link was down, and a dropped link reconnects with
1191
+ * backoff. Returns a stop handle the caller runs on leave.
948
1192
  */
949
- startSharePoll(): () => void {
950
- this.stopSharePoll();
951
- // per-run token so a stale run's in-flight pull never re-arms the poll
1193
+ startShareSync(): () => void {
1194
+ this.stopShareSync();
1195
+ // per-run token so a stale run's close never re-arms a newer run
952
1196
  const run = {};
1197
+ let delay = SHARE_RECONNECT_MIN_MS;
953
1198
 
954
1199
  this.shareRun = run;
955
- const tick = (): void => {
956
- void this.pullShared().finally(() => {
957
- if (this.shareRun === run && !this.closed)
958
- this.sharePoll = this.clock.setTimeout(tick, SHARE_POLL_MS);
1200
+ const connect = (): void => {
1201
+ const shareId = this.snapshot.session?.shareId;
1202
+
1203
+ if (this.shareRun !== run || this.closed || !shareId) return;
1204
+ void this.pullShared();
1205
+ this.shareStop = this.shareTransport.watch(shareId, {
1206
+ onSession: (remote) => {
1207
+ delay = SHARE_RECONNECT_MIN_MS;
1208
+ void this.mergeShared(remote);
1209
+ },
1210
+ onClose: () => {
1211
+ if (this.shareRun !== run || this.closed) return;
1212
+ this.shareStop = null;
1213
+ this.shareReconnect = this.clock.setTimeout(connect, delay);
1214
+ delay = Math.min(delay * 2, SHARE_RECONNECT_MAX_MS);
1215
+ },
959
1216
  });
960
1217
  };
961
1218
 
962
- tick();
1219
+ connect();
963
1220
 
964
1221
  return () => {
965
- if (this.shareRun === run) this.stopSharePoll();
1222
+ if (this.shareRun === run) this.stopShareSync();
966
1223
  };
967
1224
  }
968
1225
 
969
- private stopSharePoll(): void {
1226
+ private stopShareSync(): void {
970
1227
  this.shareRun = null;
971
- if (this.sharePoll !== undefined) this.clock.clearTimeout(this.sharePoll);
972
- this.sharePoll = undefined;
1228
+ if (this.shareReconnect !== undefined) this.clock.clearTimeout(this.shareReconnect);
1229
+ this.shareReconnect = undefined;
1230
+ this.shareStop?.();
1231
+ this.shareStop = null;
973
1232
  }
974
1233
 
975
1234
  // ── completion hand-back ────────────────────