@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.79

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 (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The review-session controller: every daemon round-trip and mutation
2
+ * The thread controller: every daemon round-trip and mutation
3
3
  * primitive behind one React-free object. It owns connect/autostart/subscribe,
4
4
  * the session/inbox/status/error snapshot, optimistic apply, the mutation
5
5
  * primitives (cut/edit/annotate/submit/...), and the post-submit completion
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
11
+ import { createStore, type StoreApi } from "zustand/vanilla";
11
12
  import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
12
13
  import {
13
14
  applyPathView,
@@ -25,12 +26,14 @@ import {
25
26
  restoreLine,
26
27
  returnPaneFor,
27
28
  switchBranch,
29
+ threadShareLinks,
28
30
  viewOfPath,
29
31
  type Anchor,
30
32
  type Annotation,
31
33
  type AnnotationTarget,
32
34
  type Artifact,
33
35
  type DiffFileContents,
36
+ type ShareLink,
34
37
  type Thread,
35
38
  type SessionHistory,
36
39
  type VerdictKind,
@@ -42,10 +45,12 @@ import {
42
45
  publishShare,
43
46
  pullShare,
44
47
  pushShare,
48
+ revokeShare,
45
49
  watchShare,
46
50
  shareIdFromLine,
51
+ formatShareLine,
47
52
  } from "./share";
48
- import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
53
+ import { buildDisplay, nextWorkBlock, renderedSpanToWork, type DisplayBlock } from "./view-plan";
49
54
  import { entryTarget, treeRows, type TreeRow } from "./tree-view";
50
55
  import {
51
56
  diffRowBlocks,
@@ -132,6 +137,41 @@ function planCutId(base: { lineStart: number; lineEnd: number }): string {
132
137
  return `plan:${base.lineStart}-${base.lineEnd}`;
133
138
  }
134
139
 
140
+ /**
141
+ * Whether two records of the same thread yield the same document projection (display, rows, files,
142
+ * models). Those derive only from the artifact content and the working copy, so an update that
143
+ * touched only annotations, status, or the verdict leaves them identical - the projection can be
144
+ * reused rather than re-parsed. Content and working copy are strings, compared here by value.
145
+ */
146
+ export function sameDerivationInputs(previous: Thread, next: Thread): boolean {
147
+ return (
148
+ previous.id === next.id &&
149
+ previous.artifact.content === next.artifact.content &&
150
+ previous.workingCopy === next.workingCopy
151
+ );
152
+ }
153
+
154
+ /**
155
+ * The session with `annotation` upserted, for an optimistic paint the instant the composer closes
156
+ * rather than after the daemon round-trip - otherwise a saved comment blinks out until the write
157
+ * lands. An edit keeps the note in place and its timestamp; a new note appends. The daemon's record
158
+ * replaces this guess when the write returns.
159
+ */
160
+ export function withAnnotationUpserted(
161
+ session: Thread,
162
+ annotation: Omit<Annotation, "createdAt">,
163
+ ): Thread {
164
+ const index = session.annotations.findIndex((entry) => entry.id === annotation.id);
165
+ const createdAt = index >= 0 ? session.annotations[index]!.createdAt : new Date().toISOString();
166
+ const optimistic: Annotation = { ...annotation, createdAt };
167
+ const annotations =
168
+ index >= 0
169
+ ? session.annotations.map((entry) => (entry.id === annotation.id ? optimistic : entry))
170
+ : [...session.annotations, optimistic];
171
+
172
+ return { ...session, annotations };
173
+ }
174
+
135
175
  export interface ControllerSnapshot {
136
176
  session: Thread | null;
137
177
  inbox: Thread[] | null;
@@ -156,8 +196,10 @@ export interface ShareTransport {
156
196
  publish: typeof publishShare;
157
197
  pull: typeof pullShare;
158
198
  push: typeof pushShare;
199
+ revoke: typeof revokeShare;
159
200
  watch: typeof watchShare;
160
201
  parseShareId: typeof shareIdFromLine;
202
+ formatShareLine: typeof formatShareLine;
161
203
  collaboratorAnnotations: typeof collaboratorAnnotations;
162
204
  mergeFromShare: typeof mergeFromShare;
163
205
  }
@@ -167,11 +209,20 @@ const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
167
209
  pull: pullShare,
168
210
  watch: watchShare,
169
211
  push: pushShare,
212
+ revoke: revokeShare,
170
213
  parseShareId: shareIdFromLine,
214
+ formatShareLine,
171
215
  collaboratorAnnotations,
172
216
  mergeFromShare,
173
217
  };
174
218
 
219
+ /** The editable fields of a share link, as the share wizard collects them. */
220
+ export interface NewShareLink {
221
+ name?: string;
222
+ requireAuth: boolean;
223
+ allowlist: string[];
224
+ }
225
+
175
226
  export interface ReviewControllerOptions {
176
227
  home?: string;
177
228
  sessionId?: string;
@@ -256,6 +307,8 @@ export interface ReviewController {
256
307
  restoreCuration(id: string): void;
257
308
  /** The $EDITOR hand-off on the working copy. */
258
309
  edit(): void;
310
+ /** Track an edited thread body as the working copy and re-resolve annotations against it. */
311
+ saveEditedBody(content: string): void;
259
312
  /**
260
313
  * Anchor and store an annotation; both plan and diff anchor constructions.
261
314
  * `end` is an offset within `endDisplayIndex` (the same block by default).
@@ -307,10 +360,22 @@ export interface ReviewController {
307
360
  walkLeave(): void;
308
361
  /** Resolve the review, run the export, start the completion hand-back. */
309
362
  submit(verdict: VerdictKind, summary: string): void;
310
- /** Publish the current session as a share; the ssh line lands on the clipboard. */
363
+ /** Publish the current session as a public share link; the ssh line lands on the clipboard. */
311
364
  share(): void;
365
+ /** Stop sharing the current session, revoking every link at the gateway. Owner only. */
366
+ unshare(): void;
312
367
  /** Replace the private-share allowlist of GitHub logins for the current session. Owner only. */
313
368
  setShareAccess(githubLogins: string[]): void;
369
+ /** The current thread's share links (a legacy single share migrates to a one-element list). */
370
+ shareLinks(): ShareLink[];
371
+ /** Publish a new share link with the given name and auth; the ssh line lands on the clipboard. Owner only. */
372
+ createShareLink(input: NewShareLink): void;
373
+ /** Update a link's name and auth in place, re-pushing its access to the gateway. Owner only. */
374
+ updateShareLink(id: string, input: NewShareLink): void;
375
+ /** Revoke a link and remove it from the thread. Owner only. */
376
+ deleteShareLink(id: string): void;
377
+ /** Copy a link's ssh line to the clipboard. */
378
+ copyShareLink(id: string): void;
314
379
  /** The session tree as rows for the rail's Tree tab, cached per session identity. */
315
380
  treeRows(): TreeRow[];
316
381
  /**
@@ -345,6 +410,9 @@ interface DerivedSessionProjection {
345
410
  display: DisplayBlock[];
346
411
  rows: DiffRow[];
347
412
  files: WalkFile[];
413
+ /** Raw per-file contents; parsed into a model lazily on first curation touch, not all upfront. */
414
+ fileContents: Map<string, DiffFileContents>;
415
+ /** Parsed file models, filled on demand from fileContents and memoized here. */
348
416
  models: Map<string, FileDiffMetadata>;
349
417
  tree: TreeRow[];
350
418
  }
@@ -355,11 +423,21 @@ interface LiveWorkingDiff {
355
423
  files: readonly DiffFileContents[];
356
424
  }
357
425
 
426
+ interface DerivedCacheEntry {
427
+ derivedFor: Thread;
428
+ derivedForLiveDiff: LiveWorkingDiff | null;
429
+ derived: DerivedSessionProjection;
430
+ }
431
+
432
+ /** Recently-viewed threads whose parsed projection is kept for an instant return; oldest evicted first. */
433
+ const DERIVED_CACHE_LIMIT = 8;
434
+
358
435
  class Controller implements ReviewController {
359
436
  readonly readOnly: boolean;
360
437
  private client: SessionClient | null = null;
361
438
  private closed = false;
362
- private snapshot: ControllerSnapshot = {
439
+ /** The controller's state lives in a zustand store; internal reads go through the snapshot getter. */
440
+ private readonly store: StoreApi<ControllerSnapshot> = createStore<ControllerSnapshot>(() => ({
363
441
  session: null,
364
442
  inbox: null,
365
443
  status: "",
@@ -368,8 +446,10 @@ class Controller implements ReviewController {
368
446
  completion: { phase: "idle" },
369
447
  editOrphanCount: 0,
370
448
  walk: null,
371
- };
372
- private listeners = new Set<() => void>();
449
+ }));
450
+ private get snapshot(): ControllerSnapshot {
451
+ return this.store.getState();
452
+ }
373
453
  private autoClose: AutoClose = "off";
374
454
  private quickActions: QuickAction[] = [];
375
455
  private editor: string | undefined;
@@ -389,9 +469,14 @@ class Controller implements ReviewController {
389
469
  display: [],
390
470
  rows: [],
391
471
  files: [],
472
+ fileContents: new Map(),
392
473
  models: new Map(),
393
474
  tree: [],
394
475
  };
476
+ /** Recently-viewed threads' parsed projections, so returning to a thread reuses its work instead of re-parsing. */
477
+ private derivedCache = new Map<string, DerivedCacheEntry>();
478
+ /** The thread the view is on now; a revalidation for a since-abandoned thread is discarded. */
479
+ private viewingId: string | undefined;
395
480
  /** File paths whose diff body is folded to just the file band. */
396
481
  private collapsedFiles = new Set<string>();
397
482
  /** File paths woven to their full contents (unchanged lines shown as context). */
@@ -407,17 +492,12 @@ class Controller implements ReviewController {
407
492
  this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
408
493
  }
409
494
 
410
- subscribe = (listener: () => void): (() => void) => {
411
- this.listeners.add(listener);
495
+ subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener);
412
496
 
413
- return () => this.listeners.delete(listener);
414
- };
415
-
416
- getSnapshot = (): ControllerSnapshot => this.snapshot;
497
+ getSnapshot = (): ControllerSnapshot => this.store.getState();
417
498
 
418
499
  private update(patch: Partial<ControllerSnapshot>): void {
419
- this.snapshot = { ...this.snapshot, ...this.freezeServed(patch) };
420
- for (const listener of this.listeners) listener();
500
+ this.store.setState(this.freezeServed(patch));
421
501
  }
422
502
 
423
503
  /** Serve mode pins the served thread's diff to a snapshot; its annotations and the rest stay live. */
@@ -453,6 +533,7 @@ class Controller implements ReviewController {
453
533
  const inbox = await client.sessionList({ status: "pending" });
454
534
 
455
535
  if (this.options.sessionId) {
536
+ this.viewingId = this.options.sessionId;
456
537
  this.update({ session: await client.sessionGet(this.options.sessionId), inbox });
457
538
  } else {
458
539
  this.update({ inbox });
@@ -498,6 +579,57 @@ class Controller implements ReviewController {
498
579
  }
499
580
 
500
581
  // ── derived projections ─────────────────────
582
+ /**
583
+ * The projection reuses when the same thread's derivation inputs are unchanged. display, rows,
584
+ * files, and models derive only from the artifact and working copy - never from annotations,
585
+ * status, or the verdict - so an update that touched only those produces an all-new session
586
+ * record but an identical projection.
587
+ */
588
+ private reusesDerived(session: Thread, liveDiff: LiveWorkingDiff | null): boolean {
589
+ return (
590
+ this.derivedFor !== null &&
591
+ this.derivedForLiveDiff === liveDiff &&
592
+ sameDerivationInputs(this.derivedFor, session)
593
+ );
594
+ }
595
+
596
+ private buildDerived(
597
+ session: Thread | null,
598
+ frozenDiff: boolean,
599
+ liveDiff: LiveWorkingDiff | null,
600
+ ): DerivedSessionProjection {
601
+ const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
602
+ const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
603
+ const rows = content ? diffRows(content) : [];
604
+ const fileContents = new Map<string, DiffFileContents>();
605
+
606
+ for (const file of files) fileContents.set(file.path, file);
607
+
608
+ return {
609
+ display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
610
+ rows,
611
+ files: walkFiles(rows),
612
+ fileContents,
613
+ models: new Map<string, FileDiffMetadata>(),
614
+ tree: session?.history ? treeRows(session.history) : [],
615
+ };
616
+ }
617
+
618
+ /** The parsed model for a changed file, computed on first touch and memoized in the projection. */
619
+ private fileModel(path: string): FileDiffMetadata | undefined {
620
+ const cached = this.derived.models.get(path);
621
+
622
+ if (cached) return cached;
623
+ const contents = this.derived.fileContents.get(path);
624
+
625
+ if (!contents) return undefined;
626
+ const model = parseFileDiff(contents);
627
+
628
+ this.derived.models.set(path, model);
629
+
630
+ return model;
631
+ }
632
+
501
633
  private ensureDerived(): void {
502
634
  const session = this.snapshot.session;
503
635
  const frozenDiff = readsFrozenDiff(session);
@@ -505,27 +637,60 @@ class Controller implements ReviewController {
505
637
  const liveDiff = frozenDiff ? null : this.liveDiff;
506
638
 
507
639
  if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
508
- // fold state belongs to the session, so a live-tree refresh keeps it; only a new session resets it
509
- if (this.derivedFor !== session) {
640
+ // reuse the parsed projection across an update that left the inputs unchanged; only the cheap tree can differ
641
+ if (session !== null && this.reusesDerived(session, liveDiff)) {
642
+ this.derivedFor = session;
643
+ this.derived = { ...this.derived, tree: session.history ? treeRows(session.history) : [] };
644
+
645
+ return;
646
+ }
647
+ // a different thread resets fold state; a same-thread re-derivation (content edit) keeps it
648
+ if (this.derivedFor?.id !== session?.id) {
510
649
  this.collapsedFiles.clear();
511
650
  this.expandedFiles.clear();
512
651
  }
652
+ // returning to a recently-viewed thread whose inputs are unchanged reuses its parsed projection
653
+ const cached = session !== null ? this.derivedCache.get(session.id) : undefined;
654
+
655
+ if (
656
+ cached &&
657
+ cached.derivedForLiveDiff === liveDiff &&
658
+ sameDerivationInputs(cached.derivedFor, session!)
659
+ ) {
660
+ this.derivedFor = session;
661
+ this.derivedForLiveDiff = liveDiff;
662
+ this.derived = {
663
+ ...cached.derived,
664
+ tree: session!.history ? treeRows(session!.history) : [],
665
+ };
666
+ this.rememberDerived(session!, liveDiff, this.derived);
667
+
668
+ return;
669
+ }
513
670
  this.derivedFor = session;
514
671
  this.derivedForLiveDiff = liveDiff;
672
+ this.derived = this.buildDerived(session, frozenDiff, liveDiff);
673
+ if (session !== null) this.rememberDerived(session, liveDiff, this.derived);
674
+ }
515
675
 
516
- const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
517
- const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
518
- const rows = content ? diffRows(content) : [];
519
- const models = new Map<string, FileDiffMetadata>();
676
+ /** Keep the freshest projections per thread, evicting the least-recently-used past the cap. */
677
+ private rememberDerived(
678
+ session: Thread,
679
+ liveDiff: LiveWorkingDiff | null,
680
+ derived: DerivedSessionProjection,
681
+ ): void {
682
+ this.derivedCache.delete(session.id);
683
+ this.derivedCache.set(session.id, {
684
+ derivedFor: session,
685
+ derivedForLiveDiff: liveDiff,
686
+ derived,
687
+ });
688
+ while (this.derivedCache.size > DERIVED_CACHE_LIMIT) {
689
+ const oldest = this.derivedCache.keys().next().value;
520
690
 
521
- for (const file of files) models.set(file.path, parseFileDiff(file));
522
- this.derived = {
523
- display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
524
- rows,
525
- files: walkFiles(rows),
526
- models,
527
- tree: session?.history ? treeRows(session.history) : [],
528
- };
691
+ if (oldest === undefined) break;
692
+ this.derivedCache.delete(oldest);
693
+ }
529
694
  }
530
695
 
531
696
  /** The per-file contents that back the current rows: a diff thread's capture, else the live working tree. */
@@ -677,7 +842,11 @@ class Controller implements ReviewController {
677
842
  // surface that as an unhandled rejection.
678
843
  private async refreshSession(id: string): Promise<void> {
679
844
  try {
680
- if (this.client) this.update({ session: await this.client.sessionGet(id) });
845
+ if (!this.client) return;
846
+ const session = await this.client.sessionGet(id);
847
+
848
+ // a rapid switch may have moved on during the fetch; never clobber the now-viewed thread
849
+ if (this.viewingId === session.id) this.update({ session });
681
850
  } catch (cause) {
682
851
  if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
683
852
  }
@@ -719,22 +888,42 @@ class Controller implements ReviewController {
719
888
  // ── primitives ───────────────────────────────────
720
889
  open(id: string): void {
721
890
  this.locallyViewed.clear();
891
+ this.viewingId = id;
722
892
  const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
723
893
 
894
+ // paint the sidebar copy at once for a snappy switch, then revalidate: the inbox
895
+ // copy trails the daemon for a live diff, whose session.updated refreshes the open
896
+ // thread but not the list, so a returned-to diff would otherwise show stale content
724
897
  if (cached) this.update({ session: cached });
725
- else void this.refreshSession(id);
898
+ void this.refreshSession(id);
726
899
  }
727
900
 
728
901
  deleteSession(id: string): void {
729
902
  if (this.readOnly) return this.setStatus("observer - read-only");
903
+ // a deleted thread's links must not outlive it; revoke every one first, best-effort, so an
904
+ // unreachable gateway never blocks the local delete (the blob's 30-day TTL is the backstop)
905
+ for (const link of this.linksFor(this.sessionRecord(id)))
906
+ void this.shareTransport.revoke(link.id).catch(() => {});
730
907
  this.client
731
908
  ?.sessionDelete(id)
732
- .then(() => this.setStatus("plan deleted"))
909
+ .then(() => this.setStatus("thread deleted"))
733
910
  .catch((cause: unknown) =>
734
911
  this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
735
912
  );
736
913
  }
737
914
 
915
+ /** The record for a thread id, whether it is the open thread or a sidebar row. */
916
+ private sessionRecord(id: string): Thread | undefined {
917
+ if (this.snapshot.session?.id === id) return this.snapshot.session;
918
+
919
+ return this.snapshot.inbox?.find((candidate) => candidate.id === id);
920
+ }
921
+
922
+ /** A thread's share links, migrating a legacy single share; empty when never shared. */
923
+ private linksFor(session: Thread | null | undefined): ShareLink[] {
924
+ return session ? (threadShareLinks(session) ?? []) : [];
925
+ }
926
+
738
927
  renameSession(id: string, title: string): void {
739
928
  if (this.readOnly) return this.setStatus("observer - read-only");
740
929
  this.client
@@ -821,7 +1010,7 @@ class Controller implements ReviewController {
821
1010
 
822
1011
  return null;
823
1012
  }
824
- const model = this.derived.models.get(row.file);
1013
+ const model = this.fileModel(row.file);
825
1014
 
826
1015
  if (!model) return null;
827
1016
 
@@ -885,15 +1074,16 @@ class Controller implements ReviewController {
885
1074
  }
886
1075
 
887
1076
  setShareAccess(githubLogins: string[]): void {
888
- const session = this.snapshot.session;
889
-
890
- if (!session) return;
891
- const access = { githubLogins };
892
- const expected: Thread = { ...session, access };
893
-
894
- this.applyOptimistic(expected, this.client!.sessionSetAccess(session.id, githubLogins));
895
- // an already-shared link must enforce the new access, so push it up to the gateway blob
896
- if (session.shareId) void this.shareTransport.push(session.shareId, [], access).catch(() => {});
1077
+ if (this.readOnly) return this.setStatus("observer - read-only");
1078
+ // legacy single-allowlist entry point: apply it to the thread's primary link
1079
+ const primary = this.linksFor(this.snapshot.session)[0];
1080
+
1081
+ if (!primary) return;
1082
+ this.updateShareLink(primary.id, {
1083
+ name: primary.name,
1084
+ requireAuth: true,
1085
+ allowlist: githubLogins,
1086
+ });
897
1087
  }
898
1088
 
899
1089
  rejectedRows(): Set<number> {
@@ -902,7 +1092,7 @@ class Controller implements ReviewController {
902
1092
  const rejected = new Set<number>();
903
1093
 
904
1094
  this.derived.rows.forEach((row, index) => {
905
- const model = this.derived.models.get(row.file);
1095
+ const model = this.fileModel(row.file);
906
1096
 
907
1097
  if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
908
1098
  });
@@ -979,7 +1169,7 @@ class Controller implements ReviewController {
979
1169
 
980
1170
  /** The change/deletion rows a rejection covers, for its preview and reveal row. */
981
1171
  private rowsForRejection(rejection: HunkRejection): DiffRow[] {
982
- const model = this.derived.models.get(rejection.path);
1172
+ const model = this.fileModel(rejection.path);
983
1173
 
984
1174
  if (!model) return [];
985
1175
  const rows: DiffRow[] = [];
@@ -1008,8 +1198,7 @@ class Controller implements ReviewController {
1008
1198
  const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
1009
1199
 
1010
1200
  if (result.changed) {
1011
- this.setWorkingCopy(result.content);
1012
- this.reconcileAnnotations(session, result.content);
1201
+ this.saveEditedBody(result.content);
1013
1202
  this.setStatus("edits tracked - one diff");
1014
1203
  } else this.setStatus("no changes");
1015
1204
  } catch (err) {
@@ -1017,6 +1206,16 @@ class Controller implements ReviewController {
1017
1206
  }
1018
1207
  }
1019
1208
 
1209
+ // no toast: leaving the inline editor is a quiet return to the read-only view
1210
+ saveEditedBody(content: string): void {
1211
+ const session = this.snapshot.session;
1212
+
1213
+ if (!session || session.status === "resolved") return;
1214
+ if (content === this.working()) return;
1215
+ this.setWorkingCopy(content);
1216
+ this.reconcileAnnotations(session, content);
1217
+ }
1218
+
1020
1219
  /**
1021
1220
  * Edit-exit reconciliation: re-resolve every annotation against the edited
1022
1221
  * working copy with the quote-primary cascade. Annotations that stop
@@ -1078,12 +1277,15 @@ class Controller implements ReviewController {
1078
1277
  const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
1079
1278
  const workIndexOf = (index: number): number =>
1080
1279
  display.slice(0, index + 1).filter((entry) => entry.work).length - 1;
1280
+ // the plan surface hands back rendered offsets (inline markers concealed); the quote is cut
1281
+ // from work text, so map the selection onto the work characters it covers first
1282
+ const work = renderedSpanToWork(display, displayIndex, endDisplayIndex, start, end);
1081
1283
 
1082
1284
  anchor = makeAnchor(
1083
1285
  workBlocks,
1084
1286
  workIndexOf(displayIndex),
1085
- start,
1086
- end,
1287
+ work.start,
1288
+ work.end,
1087
1289
  workIndexOf(endDisplayIndex),
1088
1290
  );
1089
1291
  }
@@ -1094,7 +1296,7 @@ class Controller implements ReviewController {
1094
1296
  : { id: newAnnotationId(), kind, anchor, body, target: resolvedTarget };
1095
1297
  const persisted = this.client!.sessionComment(session.id, wire);
1096
1298
 
1097
- this.apply(persisted);
1299
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1098
1300
  this.mirrorAnnotation(persisted, wire);
1099
1301
 
1100
1302
  return wire.id;
@@ -1110,7 +1312,7 @@ class Controller implements ReviewController {
1110
1312
  : { id: newAnnotationId(), kind: "comment", anchor, body, target };
1111
1313
  const persisted = this.client!.sessionComment(session.id, wire);
1112
1314
 
1113
- this.apply(persisted);
1315
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1114
1316
  this.mirrorAnnotation(persisted, wire);
1115
1317
 
1116
1318
  return wire.id;
@@ -1176,7 +1378,7 @@ class Controller implements ReviewController {
1176
1378
  const wire = root.target ? { ...base, target: root.target } : base;
1177
1379
  const persisted = this.client!.sessionComment(session.id, wire);
1178
1380
 
1179
- this.apply(persisted);
1381
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1180
1382
  this.mirrorAnnotation(persisted, wire);
1181
1383
 
1182
1384
  return wire.id;
@@ -1190,7 +1392,7 @@ class Controller implements ReviewController {
1190
1392
  const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
1191
1393
  const persisted = this.client!.sessionComment(session.id, wire);
1192
1394
 
1193
- this.apply(persisted);
1395
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1194
1396
  this.mirrorAnnotation(persisted, wire);
1195
1397
 
1196
1398
  return wire.id;
@@ -1215,9 +1417,8 @@ class Controller implements ReviewController {
1215
1417
  if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
1216
1418
  const persisted = this.client!.sessionComment(session.id, wire);
1217
1419
 
1218
- this.apply(persisted);
1420
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1219
1421
  this.mirrorAnnotation(persisted, wire);
1220
- this.setStatus("annotation updated");
1221
1422
  }
1222
1423
 
1223
1424
  // push only after the local write lands, so a rejected write never leaks to the share
@@ -1226,12 +1427,16 @@ class Controller implements ReviewController {
1226
1427
  annotation: Omit<Annotation, "createdAt">,
1227
1428
  ): void {
1228
1429
  const session = this.snapshot.session;
1229
- const shareId = session?.shareId;
1430
+ const branch = session?.history?.branch;
1431
+ // each link follows one branch: a note lands on the links whose branch is the one being edited
1432
+ const links = this.linksFor(session).filter(
1433
+ (link) => branch === undefined || branch === (link.shareBranch ?? MAIN_BRANCH),
1434
+ );
1230
1435
 
1231
- if (!shareId) return;
1232
- // the share follows one branch: a note left on another stays the owner's
1233
- if (session.history && session.history.branch !== (session.shareBranch ?? MAIN_BRANCH)) return;
1234
- void persisted.then(() => this.shareTransport.push(shareId, [annotation])).catch(() => {});
1436
+ if (links.length === 0) return;
1437
+ void persisted
1438
+ .then(() => Promise.all(links.map((link) => this.shareTransport.push(link.id, [annotation]))))
1439
+ .catch(() => {});
1235
1440
  }
1236
1441
 
1237
1442
  removeAnnotation(id: string): void {
@@ -1346,25 +1551,118 @@ class Controller implements ReviewController {
1346
1551
  }
1347
1552
 
1348
1553
  share(): void {
1554
+ this.createShareLink({ requireAuth: false, allowlist: [] });
1555
+ }
1556
+
1557
+ shareLinks(): ShareLink[] {
1558
+ return this.linksFor(this.snapshot.session);
1559
+ }
1560
+
1561
+ createShareLink(input: NewShareLink): void {
1562
+ if (this.readOnly) return this.setStatus("observer - read-only");
1349
1563
  const session = this.snapshot.session;
1350
1564
 
1351
- if (!session) return;
1565
+ if (!session || !this.client) return;
1566
+ const client = this.client;
1567
+ const shareBranch = session.history?.branch ?? MAIN_BRANCH;
1568
+
1352
1569
  this.setStatus("sharing…");
1353
1570
  this.freezeForShare(session)
1354
- .then((shared) => this.shareTransport.publish(shared))
1355
- .then(async ({ line, copied }) => {
1356
- // Stamp the id back so a later pull knows which share to collect from.
1357
- const shareId = this.shareTransport.parseShareId(line);
1571
+ .then((shared) => {
1572
+ // bake this link's access into its own blob: an allowlist gates it, absent = public
1573
+ const access = input.requireAuth ? { githubLogins: input.allowlist } : undefined;
1358
1574
 
1359
- if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
1575
+ return this.shareTransport.publish({ ...shared, access, shareBranch });
1576
+ })
1577
+ .then(async ({ line, copied }) => {
1578
+ const id = this.shareTransport.parseShareId(line);
1579
+
1580
+ if (!id) throw new Error("gateway returned no share id");
1581
+ const link: ShareLink = {
1582
+ id,
1583
+ name: input.name?.trim() || undefined,
1584
+ requireAuth: input.requireAuth,
1585
+ allowlist: input.requireAuth ? input.allowlist : [],
1586
+ shareBranch,
1587
+ };
1588
+
1589
+ await client.sessionSetShares(session.id, [...this.linksFor(this.snapshot.session), link]);
1360
1590
  this.setStatus("");
1361
- this.showToast(line, copied ? "share link copied" : "share link");
1591
+ this.showToast(line, copied ? "link copied" : "link created");
1362
1592
  })
1363
1593
  .catch((cause: unknown) =>
1364
1594
  this.setStatus(`share failed: ${cause instanceof Error ? cause.message : String(cause)}`),
1365
1595
  );
1366
1596
  }
1367
1597
 
1598
+ updateShareLink(id: string, input: NewShareLink): void {
1599
+ if (this.readOnly) return this.setStatus("observer - read-only");
1600
+ const session = this.snapshot.session;
1601
+
1602
+ if (!session || !this.client) return;
1603
+ const links = this.linksFor(session);
1604
+
1605
+ if (!links.some((link) => link.id === id)) return;
1606
+ const allowlist = input.requireAuth ? input.allowlist : [];
1607
+ // re-push the link's access to its blob: an allowlist for private, "public" clears it
1608
+ void this.shareTransport
1609
+ .push(id, [], input.requireAuth ? { githubLogins: allowlist } : "public")
1610
+ .catch(() => {});
1611
+ const next = links.map((link) =>
1612
+ link.id === id
1613
+ ? {
1614
+ ...link,
1615
+ name: input.name?.trim() || undefined,
1616
+ requireAuth: input.requireAuth,
1617
+ allowlist,
1618
+ }
1619
+ : link,
1620
+ );
1621
+
1622
+ this.applyOptimistic(
1623
+ { ...session, shares: next },
1624
+ this.client.sessionSetShares(session.id, next),
1625
+ );
1626
+ this.setStatus("link updated");
1627
+ }
1628
+
1629
+ deleteShareLink(id: string): void {
1630
+ if (this.readOnly) return this.setStatus("observer - read-only");
1631
+ const session = this.snapshot.session;
1632
+
1633
+ if (!session || !this.client) return;
1634
+ const remaining = this.linksFor(session).filter((link) => link.id !== id);
1635
+
1636
+ void this.shareTransport.revoke(id).catch(() => {});
1637
+ this.applyOptimistic(
1638
+ { ...session, shares: remaining },
1639
+ this.client.sessionSetShares(session.id, remaining),
1640
+ );
1641
+ this.setStatus("link revoked");
1642
+ }
1643
+
1644
+ copyShareLink(id: string): void {
1645
+ const line = this.shareTransport.formatShareLine(id);
1646
+
1647
+ void copyToClipboard(line).then((copied) =>
1648
+ this.showToast(line, copied ? "link copied" : "link"),
1649
+ );
1650
+ }
1651
+
1652
+ /** Stop sharing the open thread: revoke every link so none of them resolve. */
1653
+ unshare(): void {
1654
+ if (this.readOnly) return this.setStatus("observer - read-only");
1655
+ const session = this.snapshot.session;
1656
+ const links = this.linksFor(session);
1657
+
1658
+ if (!session || links.length === 0) return this.setStatus("this thread is not shared");
1659
+ this.stopShareSync();
1660
+ void Promise.all(links.map((link) => this.shareTransport.revoke(link.id).catch(() => {})))
1661
+ .then(() => this.setStatus("sharing stopped"))
1662
+ .catch(() => {});
1663
+ void this.client?.sessionSetShares(session.id, []).catch(() => {});
1664
+ }
1665
+
1368
1666
  goToEntry(entryId: string, summary?: string): void {
1369
1667
  const session = this.snapshot.session;
1370
1668
 
@@ -1448,11 +1746,15 @@ class Controller implements ReviewController {
1448
1746
  client
1449
1747
  .sessionFork(session.id)
1450
1748
  .then(async (fork) => {
1749
+ const shareBranch = fork.history?.branch ?? MAIN_BRANCH;
1451
1750
  const shared = await this.freezeForShare(fork);
1452
- const { line, copied } = await this.shareTransport.publish(shared);
1453
- const shareId = this.shareTransport.parseShareId(line);
1751
+ const { line, copied } = await this.shareTransport.publish({ ...shared, shareBranch });
1752
+ const id = this.shareTransport.parseShareId(line);
1454
1753
 
1455
- if (shareId) await client.sessionSetShareId(fork.id, shareId);
1754
+ if (id)
1755
+ await client.sessionSetShares(fork.id, [
1756
+ { id, requireAuth: false, allowlist: [], shareBranch },
1757
+ ]);
1456
1758
  this.setStatus("");
1457
1759
  this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
1458
1760
  })
@@ -1483,14 +1785,19 @@ class Controller implements ReviewController {
1483
1785
  * event path. Best-effort: a failed refresh is silent.
1484
1786
  */
1485
1787
  pullShared(): Promise<void> {
1486
- const session = this.snapshot.session;
1788
+ const links = this.linksFor(this.snapshot.session);
1487
1789
 
1488
- if (!session?.shareId) return Promise.resolve();
1790
+ if (links.length === 0) return Promise.resolve();
1489
1791
 
1490
- return this.shareTransport
1491
- .pull(session.shareId)
1492
- .then((remote) => this.mergeShared(remote))
1493
- .catch(() => {});
1792
+ // pull every link's blob and union its collaborator notes back in
1793
+ return Promise.all(
1794
+ links.map((link) =>
1795
+ this.shareTransport
1796
+ .pull(link.id)
1797
+ .then((remote) => this.mergeShared(remote))
1798
+ .catch(() => {}),
1799
+ ),
1800
+ ).then(() => {});
1494
1801
  }
1495
1802
 
1496
1803
  private mergeShared(remote: Thread): Promise<void> {
@@ -1505,9 +1812,10 @@ class Controller implements ReviewController {
1505
1812
  }
1506
1813
 
1507
1814
  /**
1508
- * Follow the share live while it is open: one watch stream delivers every
1509
- * change as it lands; a pull on each (re)connect catches up on anything
1510
- * missed while the link was down, and a dropped link reconnects with
1815
+ * Follow the thread's shares live while it is open. To bound idle cost at one
1816
+ * connection per open thread (not one per link), a single watch stream follows
1817
+ * the primary link for instant updates, while a pull on each (re)connect
1818
+ * catches up every link's collaborator notes. A dropped link reconnects with
1511
1819
  * backoff. Returns a stop handle the caller runs on leave.
1512
1820
  */
1513
1821
  startShareSync(): () => void {
@@ -1518,7 +1826,7 @@ class Controller implements ReviewController {
1518
1826
 
1519
1827
  this.shareRun = run;
1520
1828
  const connect = (): void => {
1521
- const shareId = this.snapshot.session?.shareId;
1829
+ const shareId = this.linksFor(this.snapshot.session)[0]?.id;
1522
1830
 
1523
1831
  if (this.shareRun !== run || this.closed || !shareId) return;
1524
1832
  void this.pullShared();