@cueloop/client 0.1.0-alpha.78 → 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 (92) hide show
  1. package/package.json +14 -13
  2. package/src/App.diff.test.tsx +9 -4
  3. package/src/App.focus.test.ts +1 -59
  4. package/src/App.identity.test.tsx +3 -3
  5. package/src/App.plan-render.test.tsx +209 -0
  6. package/src/App.plan-review.test.tsx +75 -26
  7. package/src/App.readonly.test.tsx +6 -5
  8. package/src/App.share-dialog.test.tsx +172 -0
  9. package/src/App.submit-confirm.test.tsx +6 -4
  10. package/src/App.test.tsx +31 -28
  11. package/src/App.toast.test.tsx +11 -4
  12. package/src/App.tsx +259 -274
  13. package/src/App.walk.test.tsx +9 -8
  14. package/src/App.workbench.test.tsx +86 -0
  15. package/src/annotation-palette.ts +1 -0
  16. package/src/app-screens.tsx +12 -6
  17. package/src/app-view-model.ts +0 -1
  18. package/src/components/ConfirmDialog.stories.tsx +3 -3
  19. package/src/components/DiffContentView.tsx +14 -7
  20. package/src/components/DiscussionMarkerRail.tsx +9 -4
  21. package/src/components/GridTabContent.tsx +8 -2
  22. package/src/components/HandleChipsInput.stories.tsx +24 -0
  23. package/src/components/HandleChipsInput.test.tsx +35 -0
  24. package/src/components/HandleChipsInput.tsx +145 -0
  25. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  26. package/src/components/MarkdownGridBlock.tsx +72 -0
  27. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  28. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  29. package/src/components/MarkdownThreadEditor.tsx +162 -0
  30. package/src/components/NavModeHint.stories.tsx +40 -0
  31. package/src/components/NavModeHint.tsx +31 -0
  32. package/src/components/SettingsDialog.tsx +1 -0
  33. package/src/components/ShareDialog.stories.tsx +121 -0
  34. package/src/components/ShareDialog.tsx +552 -0
  35. package/src/components/ThreadFooter.tsx +4 -1
  36. package/src/components/ThreadTree.tsx +1 -1
  37. package/src/components/ThreadView.slash.test.tsx +0 -35
  38. package/src/components/ThreadView.tsx +142 -68
  39. package/src/components/Toast.stories.tsx +7 -0
  40. package/src/components/Toast.tsx +15 -3
  41. package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
  42. package/src/components/primitives/Button.tsx +6 -2
  43. package/src/components/primitives/Card.tsx +2 -2
  44. package/src/components/primitives/Dialog.tsx +2 -2
  45. package/src/components/primitives/Switch.stories.tsx +17 -0
  46. package/src/components/primitives/Switch.tsx +32 -0
  47. package/src/components/primitives/Tree.tsx +5 -1
  48. package/src/components/primitives/frame.ts +5 -0
  49. package/src/components/share-dialog-store.ts +58 -0
  50. package/src/components/stories.test.tsx +4 -3
  51. package/src/config.test.ts +18 -0
  52. package/src/config.ts +8 -1
  53. package/src/diff-fold.ts +1 -1
  54. package/src/intent-dispatch.test.ts +12 -3
  55. package/src/intent-dispatch.ts +10 -25
  56. package/src/key-bindings.ts +0 -6
  57. package/src/keymap.ts +0 -1
  58. package/src/markdown-editor-syntax-style.ts +50 -0
  59. package/src/markdown-grid.test.ts +101 -0
  60. package/src/markdown-grid.ts +203 -0
  61. package/src/markdown-highlight.test.ts +61 -0
  62. package/src/markdown-highlight.ts +122 -0
  63. package/src/prototype-browser.test.ts +9 -1
  64. package/src/prototype-browser.ts +22 -0
  65. package/src/run.ts +8 -6
  66. package/src/share.ts +15 -1
  67. package/src/test-support.ts +14 -0
  68. package/src/thread-chords.test.ts +55 -178
  69. package/src/thread-chords.ts +78 -169
  70. package/src/thread-controller.curate.test.ts +1 -0
  71. package/src/thread-controller.live-diff.test.ts +3 -0
  72. package/src/thread-controller.share.test.ts +60 -5
  73. package/src/thread-controller.switch-cache.test.ts +30 -0
  74. package/src/thread-controller.tree.test.ts +6 -1
  75. package/src/thread-controller.ts +229 -54
  76. package/src/use-annotation-surface.tsx +45 -36
  77. package/src/use-settings-dialog.tsx +14 -3
  78. package/src/use-thread-body-editing.tsx +58 -0
  79. package/src/view-plan.test.ts +56 -6
  80. package/src/view-plan.ts +154 -5
  81. package/src/App.share-choice.test.tsx +0 -105
  82. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  83. package/src/components/ManageAccessDialog.test.tsx +0 -94
  84. package/src/components/ManageAccessDialog.tsx +0 -122
  85. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  86. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  87. package/src/components/ShareChoiceDialog.tsx +0 -80
  88. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  89. package/src/components/ThreadsSidebar.tsx +0 -71
  90. package/src/components/agent-launcher.stories.tsx +0 -26
  91. package/src/components/agent-launcher.test.tsx +0 -50
  92. package/src/components/agent-launcher.tsx +0 -149
@@ -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,
@@ -191,8 +196,10 @@ export interface ShareTransport {
191
196
  publish: typeof publishShare;
192
197
  pull: typeof pullShare;
193
198
  push: typeof pushShare;
199
+ revoke: typeof revokeShare;
194
200
  watch: typeof watchShare;
195
201
  parseShareId: typeof shareIdFromLine;
202
+ formatShareLine: typeof formatShareLine;
196
203
  collaboratorAnnotations: typeof collaboratorAnnotations;
197
204
  mergeFromShare: typeof mergeFromShare;
198
205
  }
@@ -202,11 +209,20 @@ const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
202
209
  pull: pullShare,
203
210
  watch: watchShare,
204
211
  push: pushShare,
212
+ revoke: revokeShare,
205
213
  parseShareId: shareIdFromLine,
214
+ formatShareLine,
206
215
  collaboratorAnnotations,
207
216
  mergeFromShare,
208
217
  };
209
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
+
210
226
  export interface ReviewControllerOptions {
211
227
  home?: string;
212
228
  sessionId?: string;
@@ -291,6 +307,8 @@ export interface ReviewController {
291
307
  restoreCuration(id: string): void;
292
308
  /** The $EDITOR hand-off on the working copy. */
293
309
  edit(): void;
310
+ /** Track an edited thread body as the working copy and re-resolve annotations against it. */
311
+ saveEditedBody(content: string): void;
294
312
  /**
295
313
  * Anchor and store an annotation; both plan and diff anchor constructions.
296
314
  * `end` is an offset within `endDisplayIndex` (the same block by default).
@@ -342,10 +360,22 @@ export interface ReviewController {
342
360
  walkLeave(): void;
343
361
  /** Resolve the review, run the export, start the completion hand-back. */
344
362
  submit(verdict: VerdictKind, summary: string): void;
345
- /** 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. */
346
364
  share(): void;
365
+ /** Stop sharing the current session, revoking every link at the gateway. Owner only. */
366
+ unshare(): void;
347
367
  /** Replace the private-share allowlist of GitHub logins for the current session. Owner only. */
348
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;
349
379
  /** The session tree as rows for the rail's Tree tab, cached per session identity. */
350
380
  treeRows(): TreeRow[];
351
381
  /**
@@ -406,7 +436,8 @@ class Controller implements ReviewController {
406
436
  readonly readOnly: boolean;
407
437
  private client: SessionClient | null = null;
408
438
  private closed = false;
409
- 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>(() => ({
410
441
  session: null,
411
442
  inbox: null,
412
443
  status: "",
@@ -415,8 +446,10 @@ class Controller implements ReviewController {
415
446
  completion: { phase: "idle" },
416
447
  editOrphanCount: 0,
417
448
  walk: null,
418
- };
419
- private listeners = new Set<() => void>();
449
+ }));
450
+ private get snapshot(): ControllerSnapshot {
451
+ return this.store.getState();
452
+ }
420
453
  private autoClose: AutoClose = "off";
421
454
  private quickActions: QuickAction[] = [];
422
455
  private editor: string | undefined;
@@ -442,6 +475,8 @@ class Controller implements ReviewController {
442
475
  };
443
476
  /** Recently-viewed threads' parsed projections, so returning to a thread reuses its work instead of re-parsing. */
444
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;
445
480
  /** File paths whose diff body is folded to just the file band. */
446
481
  private collapsedFiles = new Set<string>();
447
482
  /** File paths woven to their full contents (unchanged lines shown as context). */
@@ -457,17 +492,12 @@ class Controller implements ReviewController {
457
492
  this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
458
493
  }
459
494
 
460
- subscribe = (listener: () => void): (() => void) => {
461
- this.listeners.add(listener);
495
+ subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener);
462
496
 
463
- return () => this.listeners.delete(listener);
464
- };
465
-
466
- getSnapshot = (): ControllerSnapshot => this.snapshot;
497
+ getSnapshot = (): ControllerSnapshot => this.store.getState();
467
498
 
468
499
  private update(patch: Partial<ControllerSnapshot>): void {
469
- this.snapshot = { ...this.snapshot, ...this.freezeServed(patch) };
470
- for (const listener of this.listeners) listener();
500
+ this.store.setState(this.freezeServed(patch));
471
501
  }
472
502
 
473
503
  /** Serve mode pins the served thread's diff to a snapshot; its annotations and the rest stay live. */
@@ -503,6 +533,7 @@ class Controller implements ReviewController {
503
533
  const inbox = await client.sessionList({ status: "pending" });
504
534
 
505
535
  if (this.options.sessionId) {
536
+ this.viewingId = this.options.sessionId;
506
537
  this.update({ session: await client.sessionGet(this.options.sessionId), inbox });
507
538
  } else {
508
539
  this.update({ inbox });
@@ -811,7 +842,11 @@ class Controller implements ReviewController {
811
842
  // surface that as an unhandled rejection.
812
843
  private async refreshSession(id: string): Promise<void> {
813
844
  try {
814
- 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 });
815
850
  } catch (cause) {
816
851
  if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
817
852
  }
@@ -853,22 +888,42 @@ class Controller implements ReviewController {
853
888
  // ── primitives ───────────────────────────────────
854
889
  open(id: string): void {
855
890
  this.locallyViewed.clear();
891
+ this.viewingId = id;
856
892
  const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
857
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
858
897
  if (cached) this.update({ session: cached });
859
- else void this.refreshSession(id);
898
+ void this.refreshSession(id);
860
899
  }
861
900
 
862
901
  deleteSession(id: string): void {
863
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(() => {});
864
907
  this.client
865
908
  ?.sessionDelete(id)
866
- .then(() => this.setStatus("plan deleted"))
909
+ .then(() => this.setStatus("thread deleted"))
867
910
  .catch((cause: unknown) =>
868
911
  this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
869
912
  );
870
913
  }
871
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
+
872
927
  renameSession(id: string, title: string): void {
873
928
  if (this.readOnly) return this.setStatus("observer - read-only");
874
929
  this.client
@@ -1019,15 +1074,16 @@ class Controller implements ReviewController {
1019
1074
  }
1020
1075
 
1021
1076
  setShareAccess(githubLogins: string[]): void {
1022
- const session = this.snapshot.session;
1023
-
1024
- if (!session) return;
1025
- const access = { githubLogins };
1026
- const expected: Thread = { ...session, access };
1027
-
1028
- this.applyOptimistic(expected, this.client!.sessionSetAccess(session.id, githubLogins));
1029
- // an already-shared link must enforce the new access, so push it up to the gateway blob
1030
- 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
+ });
1031
1087
  }
1032
1088
 
1033
1089
  rejectedRows(): Set<number> {
@@ -1142,8 +1198,7 @@ class Controller implements ReviewController {
1142
1198
  const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
1143
1199
 
1144
1200
  if (result.changed) {
1145
- this.setWorkingCopy(result.content);
1146
- this.reconcileAnnotations(session, result.content);
1201
+ this.saveEditedBody(result.content);
1147
1202
  this.setStatus("edits tracked - one diff");
1148
1203
  } else this.setStatus("no changes");
1149
1204
  } catch (err) {
@@ -1151,6 +1206,16 @@ class Controller implements ReviewController {
1151
1206
  }
1152
1207
  }
1153
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
+
1154
1219
  /**
1155
1220
  * Edit-exit reconciliation: re-resolve every annotation against the edited
1156
1221
  * working copy with the quote-primary cascade. Annotations that stop
@@ -1212,12 +1277,15 @@ class Controller implements ReviewController {
1212
1277
  const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
1213
1278
  const workIndexOf = (index: number): number =>
1214
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);
1215
1283
 
1216
1284
  anchor = makeAnchor(
1217
1285
  workBlocks,
1218
1286
  workIndexOf(displayIndex),
1219
- start,
1220
- end,
1287
+ work.start,
1288
+ work.end,
1221
1289
  workIndexOf(endDisplayIndex),
1222
1290
  );
1223
1291
  }
@@ -1359,12 +1427,16 @@ class Controller implements ReviewController {
1359
1427
  annotation: Omit<Annotation, "createdAt">,
1360
1428
  ): void {
1361
1429
  const session = this.snapshot.session;
1362
- 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
+ );
1363
1435
 
1364
- if (!shareId) return;
1365
- // the share follows one branch: a note left on another stays the owner's
1366
- if (session.history && session.history.branch !== (session.shareBranch ?? MAIN_BRANCH)) return;
1367
- 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(() => {});
1368
1440
  }
1369
1441
 
1370
1442
  removeAnnotation(id: string): void {
@@ -1479,25 +1551,118 @@ class Controller implements ReviewController {
1479
1551
  }
1480
1552
 
1481
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");
1482
1563
  const session = this.snapshot.session;
1483
1564
 
1484
- if (!session) return;
1565
+ if (!session || !this.client) return;
1566
+ const client = this.client;
1567
+ const shareBranch = session.history?.branch ?? MAIN_BRANCH;
1568
+
1485
1569
  this.setStatus("sharing…");
1486
1570
  this.freezeForShare(session)
1487
- .then((shared) => this.shareTransport.publish(shared))
1488
- .then(async ({ line, copied }) => {
1489
- // Stamp the id back so a later pull knows which share to collect from.
1490
- 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;
1491
1574
 
1492
- 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]);
1493
1590
  this.setStatus("");
1494
- this.showToast(line, copied ? "share link copied" : "share link");
1591
+ this.showToast(line, copied ? "link copied" : "link created");
1495
1592
  })
1496
1593
  .catch((cause: unknown) =>
1497
1594
  this.setStatus(`share failed: ${cause instanceof Error ? cause.message : String(cause)}`),
1498
1595
  );
1499
1596
  }
1500
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
+
1501
1666
  goToEntry(entryId: string, summary?: string): void {
1502
1667
  const session = this.snapshot.session;
1503
1668
 
@@ -1581,11 +1746,15 @@ class Controller implements ReviewController {
1581
1746
  client
1582
1747
  .sessionFork(session.id)
1583
1748
  .then(async (fork) => {
1749
+ const shareBranch = fork.history?.branch ?? MAIN_BRANCH;
1584
1750
  const shared = await this.freezeForShare(fork);
1585
- const { line, copied } = await this.shareTransport.publish(shared);
1586
- const shareId = this.shareTransport.parseShareId(line);
1751
+ const { line, copied } = await this.shareTransport.publish({ ...shared, shareBranch });
1752
+ const id = this.shareTransport.parseShareId(line);
1587
1753
 
1588
- if (shareId) await client.sessionSetShareId(fork.id, shareId);
1754
+ if (id)
1755
+ await client.sessionSetShares(fork.id, [
1756
+ { id, requireAuth: false, allowlist: [], shareBranch },
1757
+ ]);
1589
1758
  this.setStatus("");
1590
1759
  this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
1591
1760
  })
@@ -1616,14 +1785,19 @@ class Controller implements ReviewController {
1616
1785
  * event path. Best-effort: a failed refresh is silent.
1617
1786
  */
1618
1787
  pullShared(): Promise<void> {
1619
- const session = this.snapshot.session;
1788
+ const links = this.linksFor(this.snapshot.session);
1620
1789
 
1621
- if (!session?.shareId) return Promise.resolve();
1790
+ if (links.length === 0) return Promise.resolve();
1622
1791
 
1623
- return this.shareTransport
1624
- .pull(session.shareId)
1625
- .then((remote) => this.mergeShared(remote))
1626
- .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(() => {});
1627
1801
  }
1628
1802
 
1629
1803
  private mergeShared(remote: Thread): Promise<void> {
@@ -1638,9 +1812,10 @@ class Controller implements ReviewController {
1638
1812
  }
1639
1813
 
1640
1814
  /**
1641
- * Follow the share live while it is open: one watch stream delivers every
1642
- * change as it lands; a pull on each (re)connect catches up on anything
1643
- * 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
1644
1819
  * backoff. Returns a stop handle the caller runs on leave.
1645
1820
  */
1646
1821
  startShareSync(): () => void {
@@ -1651,7 +1826,7 @@ class Controller implements ReviewController {
1651
1826
 
1652
1827
  this.shareRun = run;
1653
1828
  const connect = (): void => {
1654
- const shareId = this.snapshot.session?.shareId;
1829
+ const shareId = this.linksFor(this.snapshot.session)[0]?.id;
1655
1830
 
1656
1831
  if (this.shareRun !== run || this.closed || !shareId) return;
1657
1832
  void this.pullShared();
@@ -37,7 +37,6 @@ import {
37
37
  } from "./thread-selection";
38
38
  import { annotationPaletteFor, type AnnotationPalette } from "./annotation-palette";
39
39
  import { printableSequence, type MarkRange, type VisualLine } from "./mark-runs";
40
- import { matchesLeader } from "./thread-chords";
41
40
  import {
42
41
  activeSlashToken,
43
42
  insertSlashItem,
@@ -107,8 +106,8 @@ export interface AnnotationSurfaceOptions {
107
106
  onUpdateAnnotation: (id: string, body: string) => void;
108
107
  /** The author's display name for a comment's hover tooltip; the rail resolves it against the participant registry. */
109
108
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
110
- leaderCombos?: readonly string[];
111
- onLeaderCommand?: (key: KeyEvent) => void;
109
+ /** Resolve a nav-mode key to a session/curation/tree/diff command; true when it acted. */
110
+ onNavCommand?: (key: KeyEvent) => boolean;
112
111
  onExit: () => void;
113
112
  }
114
113
 
@@ -119,6 +118,8 @@ export interface AnnotationSurface {
119
118
  cursor: number;
120
119
  head: TextPosition;
121
120
  compose: ComposeState | null;
121
+ /** True while the surface is in nav mode: bare keys act as commands, typing does not compose. */
122
+ navMode: boolean;
122
123
  focusedDiscussion: string | null;
123
124
  /** The block to keep in view: an opening card, a focused discussion, else the caret. */
124
125
  revealBlockIndex: number;
@@ -183,8 +184,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
183
184
  onReply,
184
185
  onUpdateAnnotation,
185
186
  resolveAuthorLabel,
186
- leaderCombos,
187
- onLeaderCommand,
187
+ onNavCommand,
188
188
  onExit,
189
189
  } = options;
190
190
  const palette = annotationPaletteFor(tokens);
@@ -199,7 +199,12 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
199
199
  return { head: start, anchor: start };
200
200
  });
201
201
  const [compose, setCompose] = useState<ComposeState | null>(null);
202
- const leaderPending = useRef(false);
202
+ const [navMode, setNavModeState] = useState(false);
203
+ const navModeRef = useRef(false);
204
+ const setNavMode = (value: boolean): void => {
205
+ navModeRef.current = value;
206
+ setNavModeState(value);
207
+ };
203
208
 
204
209
  useEffect(() => {
205
210
  onComposingChange?.(compose !== null);
@@ -514,7 +519,22 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
514
519
  if (!composerReady.current) handlePremountKey(key, activeCompose);
515
520
  };
516
521
 
517
- /** m / cmd+[ / cmd+] / tab / return - true when the key was a discussion primitive. */
522
+ /** Fold or unfold every discussion sitting on the caret's block (nav-mode z). */
523
+ const toggleFoldAtCursor = (): void => {
524
+ setFolded((current) => {
525
+ const next = new Set(current);
526
+
527
+ for (const discussion of discussions) {
528
+ if (discussion.blockIndex !== cursor) continue;
529
+ if (next.has(discussion.key)) next.delete(discussion.key);
530
+ else next.add(discussion.key);
531
+ }
532
+
533
+ return next;
534
+ });
535
+ };
536
+
537
+ /** m / cmd+[ / cmd+] / return - true when the key was a discussion primitive. */
518
538
  const handleDiscussionVerb = (key: KeyEvent): boolean => {
519
539
  // comment on selection: cmd+option+m (alt+m where cmd arrives ESC-prefixed)
520
540
  if (key.name === "m" && (key.super || key.meta || key.option)) {
@@ -538,21 +558,6 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
538
558
 
539
559
  return true;
540
560
  }
541
- if (key.name === "tab") {
542
- setFolded((current) => {
543
- const next = new Set(current);
544
-
545
- for (const discussion of discussions) {
546
- if (discussion.blockIndex !== cursor) continue;
547
- if (next.has(discussion.key)) next.delete(discussion.key);
548
- else next.add(discussion.key);
549
- }
550
-
551
- return next;
552
- });
553
-
554
- return true;
555
- }
556
561
  // enter replies into the focused discussion, else the cursor block's discussion
557
562
  if (key.name === "return") {
558
563
  const replyTarget =
@@ -668,23 +673,25 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
668
673
 
669
674
  if (activeCompose) return handleComposeKey(key, activeCompose);
670
675
  if (key.name === "escape") {
671
- if (leaderPending.current) {
672
- leaderPending.current = false;
673
-
674
- return;
675
- }
676
- if (focusedDiscussion !== null) return setFocusedDiscussion(null);
676
+ // from type mode esc only enters nav, so a held mark survives for `c`
677
+ if (!navModeRef.current) return setNavMode(true);
678
+ if (focusedDiscussion !== null) setFocusedDiscussion(null);
677
679
 
678
680
  return collapseCaret();
679
681
  }
680
- if (leaderPending.current) {
681
- leaderPending.current = false;
682
- onLeaderCommand?.(key);
683
-
684
- return;
685
- }
686
- if (leaderCombos && matchesLeader(key, leaderCombos)) {
687
- leaderPending.current = true;
682
+ if (navModeRef.current) {
683
+ if (handleCaretKey(key)) return;
684
+ if (key.name === "z") return toggleFoldAtCursor();
685
+ if (onNavCommand?.(key)) return;
686
+ if (key.name === "c") {
687
+ setNavMode(false);
688
+
689
+ return openNewCompose("");
690
+ }
691
+ if (printableSequence(key)) {
692
+ setNavMode(false);
693
+ startTyping(key);
694
+ }
688
695
 
689
696
  return;
690
697
  }
@@ -865,6 +872,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
865
872
  const pressed = positionAt(allGeometry(), event.x, event.y);
866
873
 
867
874
  if (!pressed || !source.annotatable(pressed.blockIndex)) return;
875
+ setNavMode(false);
868
876
  const stamp = { time: Date.now(), x: event.x, y: event.y };
869
877
  const wordMode = isDoubleClick(lastClick.current, stamp);
870
878
 
@@ -892,6 +900,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
892
900
  cursor,
893
901
  head,
894
902
  compose,
903
+ navMode,
895
904
  focusedDiscussion,
896
905
  revealBlockIndex,
897
906
  spanQuote,