@cueloop/client 0.1.0-alpha.102 → 0.1.0-alpha.104

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 (126) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/annotation-palette.ts +1 -0
  3. package/src/annotations/components/AnnotationCards.tsx +4 -0
  4. package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
  5. package/src/annotations/discussions.ts +1 -0
  6. package/src/annotations/thread-selection.ts +6 -0
  7. package/src/annotations/use-annotation-surface.tsx +97 -0
  8. package/src/app/App.deleted-scroll.test.tsx +3 -0
  9. package/src/app/App.harness-settings.test.tsx +77 -0
  10. package/src/app/App.plan-render.test.tsx +2 -0
  11. package/src/app/App.plan-review.test.tsx +1 -0
  12. package/src/app/App.readonly.test.tsx +63 -0
  13. package/src/app/App.test.tsx +1 -0
  14. package/src/app/App.tsx +94 -12
  15. package/src/app/App.visual.test.tsx +1 -0
  16. package/src/app/App.walk.test.tsx +1 -0
  17. package/src/app/App.workbench.test.tsx +1 -0
  18. package/src/app/app-screens.tsx +3 -2
  19. package/src/app/app-view-model.ts +11 -1
  20. package/src/app/components/WorkspacePanels.tsx +1 -0
  21. package/src/app/intent-dispatch.test.ts +1 -0
  22. package/src/app/intent-dispatch.ts +26 -0
  23. package/src/app/ready-signal.ts +2 -0
  24. package/src/app/run.ts +2 -0
  25. package/src/app/serve.test.ts +4 -0
  26. package/src/diff/components/AnnotatableFileView.tsx +1 -0
  27. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  28. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  29. package/src/diff/components/DiffContentView.tsx +25 -0
  30. package/src/diff/components/diff-content-view-layout.ts +3 -0
  31. package/src/diff/diff-fold.ts +4 -0
  32. package/src/diff/diff-hunk-curate.ts +3 -0
  33. package/src/diff/diff-intraline.ts +6 -0
  34. package/src/diff/diff-syntax.ts +8 -0
  35. package/src/diff/split-diff.ts +6 -0
  36. package/src/diff/view-diff.ts +18 -0
  37. package/src/diff/walk.ts +3 -0
  38. package/src/index.ts +1 -0
  39. package/src/integrations/attribution.ts +2 -0
  40. package/src/integrations/client-extension-registry.ts +4 -0
  41. package/src/integrations/clipboard.ts +1 -0
  42. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  43. package/src/integrations/components/ShareDialog.tsx +28 -0
  44. package/src/integrations/editor.ts +3 -0
  45. package/src/integrations/github-identity.test.ts +1 -0
  46. package/src/integrations/github-identity.ts +1 -0
  47. package/src/integrations/herdr.ts +2 -0
  48. package/src/integrations/pasted-image.ts +2 -0
  49. package/src/integrations/share.ts +46 -1
  50. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  51. package/src/keyboard/components/SlashComposer.tsx +3 -0
  52. package/src/keyboard/key-bindings.ts +3 -0
  53. package/src/keyboard/keymap.ts +51 -0
  54. package/src/keyboard/skills.ts +2 -0
  55. package/src/keyboard/slash-palette.ts +8 -0
  56. package/src/keyboard/thread-chords.ts +5 -0
  57. package/src/keyboard/use-shared-keyboard.ts +1 -0
  58. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  59. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  60. package/src/markdown/components/ThreadView.tsx +13 -0
  61. package/src/markdown/mark-runs.ts +4 -0
  62. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  63. package/src/markdown/markdown-grid.ts +6 -0
  64. package/src/markdown/markdown-highlight.ts +1 -0
  65. package/src/markdown/thread-code-syntax.ts +3 -0
  66. package/src/markdown/view-plan.ts +52 -0
  67. package/src/performance/perf-report.ts +1 -0
  68. package/src/performance/perf-timings.ts +1 -0
  69. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  70. package/src/prototype/prototype-browser.ts +11 -0
  71. package/src/settings/components/SettingsDialog.tsx +3 -0
  72. package/src/settings/config.test.ts +79 -1
  73. package/src/settings/config.ts +91 -1
  74. package/src/settings/use-settings-dialog.test.tsx +172 -0
  75. package/src/settings/use-settings-dialog.tsx +115 -1
  76. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  77. package/src/settings/use-thread-harness-setting.ts +86 -0
  78. package/src/stories/stories.test.tsx +4 -0
  79. package/src/stories/story.ts +6 -0
  80. package/src/terminal/ansi-screen.ts +5 -0
  81. package/src/terminal/components/terminal-pane.ts +12 -0
  82. package/src/terminal/ghostty-terminal.ts +10 -0
  83. package/src/terminal/kitty-image.ts +3 -0
  84. package/src/terminal/pty.ts +9 -0
  85. package/src/testing/test-support.test.ts +2 -0
  86. package/src/testing/test-support.ts +16 -0
  87. package/src/thread/agent-continuation.test.ts +2 -0
  88. package/src/thread/agent-transcript.ts +7 -0
  89. package/src/thread/components/AgentConfigControls.test.tsx +41 -0
  90. package/src/thread/components/AgentConfigControls.tsx +8 -1
  91. package/src/thread/components/AgentThreadPane.test.tsx +91 -3
  92. package/src/thread/components/AgentThreadPane.tsx +61 -0
  93. package/src/thread/components/ThreadTree.tsx +1 -0
  94. package/src/thread/components/thread-tree.test.ts +1 -0
  95. package/src/thread/thread-controller.curate.test.ts +2 -0
  96. package/src/thread/thread-controller.share.test.ts +47 -2
  97. package/src/thread/thread-controller.ts +129 -4
  98. package/src/thread/tree-view.ts +4 -0
  99. package/src/thread/use-thread-agent.test.tsx +1 -0
  100. package/src/thread/use-thread-agent.ts +5 -0
  101. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  102. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  103. package/src/ui/components/PromptDialog.tsx +2 -0
  104. package/src/ui/components/RootOverlay.tsx +1 -0
  105. package/src/ui/components/ScrollArea.tsx +2 -0
  106. package/src/ui/components/primitives/Button.tsx +1 -0
  107. package/src/ui/components/primitives/Tabs.tsx +1 -0
  108. package/src/ui/components/primitives/Tree.tsx +4 -0
  109. package/src/ui/components/primitives/tree-model.ts +7 -0
  110. package/src/ui/components/syntax-highlight.ts +5 -0
  111. package/src/ui/components/truncate-title.ts +1 -0
  112. package/src/ui/use-frame-measure.ts +3 -0
  113. package/src/ui/use-terminal-virtualizer.ts +6 -0
  114. package/src/workbench/components/ChangesColumn.tsx +7 -0
  115. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  116. package/src/workbench/components/EditorGrid.tsx +8 -0
  117. package/src/workbench/components/FileContentsView.tsx +2 -0
  118. package/src/workbench/components/GridTabContent.tsx +2 -0
  119. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  120. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  121. package/src/workbench/components/TreePane.tsx +2 -0
  122. package/src/workbench/components/editor-grid.ts +3 -0
  123. package/src/workbench/components/file-tree.ts +2 -0
  124. package/src/workbench/use-changes-workbench.ts +5 -0
  125. package/src/workbench/use-remember-layout.ts +2 -0
  126. package/src/workbench/use-repo-changes.ts +2 -0
@@ -49,6 +49,7 @@ import {
49
49
  collaboratorAnnotations,
50
50
  mergeFromShare,
51
51
  publishShare,
52
+ snapshotThreadConversation,
52
53
  pullShare,
53
54
  pushShare,
54
55
  revokeShare,
@@ -435,11 +436,13 @@ class Controller implements ReviewController {
435
436
 
436
437
  constructor(private readonly options: ReviewControllerOptions) {
437
438
  this.client = options.initialClient ?? null;
439
+
438
440
  if (options.initialSession?.id === options.sessionId && options.initialDiff) {
439
441
  this.liveDiff = options.initialDiff;
440
442
  this.initialDiffAvailable = true;
441
443
  this.store.setState({ session: options.initialSession });
442
444
  }
445
+
443
446
  this.readOnly = options.readOnly ?? false;
444
447
  this.clock = options.clock ?? new SystemClock();
445
448
  this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
@@ -473,12 +476,14 @@ class Controller implements ReviewController {
473
476
  const client = await openClient();
474
477
 
475
478
  if (this.closed) return void client.close();
479
+
476
480
  this.client = client;
477
481
  client.onEvent((event) => {
478
482
  // another controller/observer changed state: re-fetch the active session's content
479
483
  const session = this.snapshot.session;
480
484
 
481
485
  if (session && event.sessionId === session.id) void this.refreshSession(session.id);
486
+
482
487
  // only the list-changing events touch the Threads sidebar; refreshing on every
483
488
  // content edit (session.updated) would amplify an active review into a request storm
484
489
  if (event.event !== "session.updated") void this.refreshInbox();
@@ -508,6 +513,7 @@ class Controller implements ReviewController {
508
513
 
509
514
  private clearCountdown(): void {
510
515
  if (this.countdown !== undefined) this.clock.clearTimeout(this.countdown);
516
+
511
517
  this.countdown = undefined;
512
518
  }
513
519
 
@@ -577,9 +583,11 @@ class Controller implements ReviewController {
577
583
  const cached = this.derived.models.get(path);
578
584
 
579
585
  if (cached) return cached;
586
+
580
587
  const contents = this.derived.fileContents.get(path);
581
588
 
582
589
  if (!contents) return undefined;
590
+
583
591
  const model = parseFileDiff(contents);
584
592
 
585
593
  this.derived.models.set(path, model);
@@ -594,6 +602,7 @@ class Controller implements ReviewController {
594
602
  const liveDiff = frozenDiff ? null : this.liveDiff;
595
603
 
596
604
  if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
605
+
597
606
  // reuse the parsed projection across an update that left the inputs unchanged; only the cheap tree can differ
598
607
  if (session !== null && this.reusesDerived(session, liveDiff)) {
599
608
  this.derivedFor = session;
@@ -601,11 +610,13 @@ class Controller implements ReviewController {
601
610
 
602
611
  return;
603
612
  }
613
+
604
614
  // a different thread resets fold state; a same-thread re-derivation (content edit) keeps it
605
615
  if (this.derivedFor?.id !== session?.id) {
606
616
  this.collapsedFiles.clear();
607
617
  this.expandedFiles.clear();
608
618
  }
619
+
609
620
  // returning to a recently-viewed thread whose inputs are unchanged reuses its parsed projection
610
621
  const cached = session !== null ? this.derivedCache.get(session.id) : undefined;
611
622
 
@@ -624,9 +635,11 @@ class Controller implements ReviewController {
624
635
 
625
636
  return;
626
637
  }
638
+
627
639
  this.derivedFor = session;
628
640
  this.derivedForLiveDiff = liveDiff;
629
641
  this.derived = this.buildDerived(session, frozenDiff, liveDiff);
642
+
630
643
  if (session !== null) this.rememberDerived(session, liveDiff, this.derived);
631
644
  }
632
645
 
@@ -646,6 +659,7 @@ class Controller implements ReviewController {
646
659
  const oldest = this.derivedCache.keys().next().value;
647
660
 
648
661
  if (oldest === undefined) break;
662
+
649
663
  this.derivedCache.delete(oldest);
650
664
  }
651
665
  }
@@ -671,6 +685,7 @@ class Controller implements ReviewController {
671
685
 
672
686
  rows(): DiffRow[] {
673
687
  this.ensureDerived();
688
+
674
689
  if (this.collapsedFiles.size === 0 && this.expandedFiles.size === 0) return this.derived.rows;
675
690
 
676
691
  return applyFold(this.derived.rows, this.collapsedFiles, this.expandedFiles, this.foldFiles());
@@ -683,6 +698,7 @@ class Controller implements ReviewController {
683
698
  } else {
684
699
  this.collapsedFiles.delete(file);
685
700
  }
701
+
686
702
  this.update({});
687
703
  }
688
704
 
@@ -697,6 +713,7 @@ class Controller implements ReviewController {
697
713
  } else {
698
714
  this.expandedFiles.delete(file);
699
715
  }
716
+
700
717
  this.update({});
701
718
  }
702
719
 
@@ -770,9 +787,11 @@ class Controller implements ReviewController {
770
787
  const session = this.snapshot.session;
771
788
 
772
789
  if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
790
+
773
791
  const initialFiles = this.consumeInitialDiffFiles(session);
774
792
 
775
793
  if (initialFiles !== null) return initialFiles;
794
+
776
795
  // eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
777
796
  if (this.client?.repoDiff !== undefined) {
778
797
  const diff = await this.client.repoDiff(
@@ -784,6 +803,7 @@ class Controller implements ReviewController {
784
803
  // showing changes from the wrong repo; compare the thread id, not the object, so an unrelated
785
804
  // re-render that replaced the snapshot for the SAME thread does not drop its diff to "No changes"
786
805
  if (this.snapshot.session?.id !== session?.id) return diff.files;
806
+
787
807
  const changed =
788
808
  this.liveDiff?.patch !== diff.patch ||
789
809
  this.liveDiff.files.length !== diff.files.length ||
@@ -803,8 +823,10 @@ class Controller implements ReviewController {
803
823
 
804
824
  return diff.files;
805
825
  }
826
+
806
827
  // a peer without the live-diff capability still lists changes, just without a diff body
807
828
  if (this.client?.repoChanges === undefined) return [];
829
+
808
830
  const changes = await this.client.repoChanges(this.sidebarRepoRoot());
809
831
 
810
832
  return changes.map((change) => ({
@@ -817,6 +839,7 @@ class Controller implements ReviewController {
817
839
 
818
840
  private consumeInitialDiffFiles(session: Thread | null): readonly DiffFileContents[] | null {
819
841
  if (!this.initialDiffAvailable) return null;
842
+
820
843
  this.initialDiffAvailable = false;
821
844
 
822
845
  return session?.id === this.options.sessionId ? (this.liveDiff?.files ?? []) : null;
@@ -826,6 +849,7 @@ class Controller implements ReviewController {
826
849
  const session = this.snapshot.session;
827
850
 
828
851
  if (session?.artifact.type !== "diff" || !this.client?.sessionRefreshDiff) return;
852
+
829
853
  try {
830
854
  await this.client.sessionRefreshDiff(session.id);
831
855
  } catch (cause) {
@@ -839,6 +863,7 @@ class Controller implements ReviewController {
839
863
  private async refreshSession(id: string): Promise<void> {
840
864
  try {
841
865
  if (!this.client) return;
866
+
842
867
  const session = await this.client.sessionGet(id);
843
868
 
844
869
  // a rapid switch may have moved on during the fetch; never clobber the now-viewed thread
@@ -898,11 +923,13 @@ class Controller implements ReviewController {
898
923
  // copy trails the daemon for a live diff, whose session.updated refreshes the open
899
924
  // thread but not the list, so a returned-to diff would otherwise show stale content
900
925
  if (cached) this.update({ session: cached });
926
+
901
927
  void this.refreshSession(id);
902
928
  }
903
929
 
904
930
  async createEmptyThread(): Promise<void> {
905
931
  if (this.readOnly || !this.client?.sessionCreate) return;
932
+
906
933
  const workspace = this.snapshot.session?.workspace ?? {
907
934
  repoRoot: this.options.cwd ?? process.cwd(),
908
935
  branch: "detached",
@@ -927,6 +954,7 @@ class Controller implements ReviewController {
927
954
 
928
955
  deleteSession(id: string): void {
929
956
  if (this.readOnly) return this.setStatus("observer - read-only");
957
+
930
958
  // a deleted thread's links must not outlive it; revoke every one first, best-effort, so an
931
959
  // unreachable gateway never blocks the local delete (the blob's 30-day TTL is the backstop)
932
960
  for (const link of this.linksFor(this.sessionRecord(id)))
@@ -952,6 +980,7 @@ class Controller implements ReviewController {
952
980
 
953
981
  renameSession(id: string, title: string): void {
954
982
  if (this.readOnly) return this.setStatus("observer - read-only");
983
+
955
984
  void this.client
956
985
  ?.sessionSetTitle(id, title)
957
986
  .catch((cause: unknown) =>
@@ -963,6 +992,7 @@ class Controller implements ReviewController {
963
992
  const session = this.snapshot.session;
964
993
 
965
994
  if (!session) return;
995
+
966
996
  this.apply(this.client!.sessionSetSelfName(session.id, name));
967
997
  }
968
998
 
@@ -970,9 +1000,11 @@ class Controller implements ReviewController {
970
1000
  const session = this.snapshot.session;
971
1001
 
972
1002
  if (!session || session.status === "resolved") return;
1003
+
973
1004
  const block = this.display()[displayIndex];
974
1005
 
975
1006
  if (!block) return;
1007
+
976
1008
  const working = this.working();
977
1009
 
978
1010
  if (start !== undefined && end !== undefined && block.work) {
@@ -980,6 +1012,7 @@ class Controller implements ReviewController {
980
1012
  const endBlock = display[endDisplayIndex];
981
1013
 
982
1014
  if (!endBlock?.work) return;
1015
+
983
1016
  const exactSourceRange = this.exactCutSourceRange(
984
1017
  session,
985
1018
  display,
@@ -1007,6 +1040,7 @@ class Controller implements ReviewController {
1007
1040
  const content = applyTextCuts(session.artifact.content, textCuts);
1008
1041
 
1009
1042
  if (content === working) return;
1043
+
1010
1044
  const expected: Thread = { ...session, workingCopy: content, textCuts };
1011
1045
 
1012
1046
  if (textCuts.length === 0) {
@@ -1021,10 +1055,12 @@ class Controller implements ReviewController {
1021
1055
 
1022
1056
  return;
1023
1057
  }
1058
+
1024
1059
  const range = renderedSpanToWork(display, displayIndex, endDisplayIndex, start, end);
1025
1060
  const content = cutTextRange(working, block.work, range.start, endBlock.work, range.end);
1026
1061
 
1027
1062
  if (content === working) return;
1063
+
1028
1064
  this.setWorkingCopy(content);
1029
1065
  } else if (block.type === "del") {
1030
1066
  this.restoreDelBlock(block, displayIndex);
@@ -1035,6 +1071,7 @@ class Controller implements ReviewController {
1035
1071
 
1036
1072
  private cutWholeBlock(session: Thread, block: DisplayBlock, working: string): void {
1037
1073
  if (!block.work) return;
1074
+
1038
1075
  const base = session.artifact.content;
1039
1076
  const existingCuts = session.textCuts ?? [];
1040
1077
 
@@ -1044,6 +1081,7 @@ class Controller implements ReviewController {
1044
1081
  const content = applyTextCuts(base, textCuts);
1045
1082
 
1046
1083
  if (content === working) return;
1084
+
1047
1085
  this.applyOptimistic(
1048
1086
  { ...session, workingCopy: content, textCuts },
1049
1087
  this.client!.sessionSetWorkingCopy(session.id, content, textCuts),
@@ -1051,11 +1089,13 @@ class Controller implements ReviewController {
1051
1089
 
1052
1090
  return;
1053
1091
  }
1092
+
1054
1093
  const workIndex = parseBlocks(working).findIndex(
1055
1094
  (candidate) => candidate.lineStart === block.work!.lineStart,
1056
1095
  );
1057
1096
 
1058
1097
  if (workIndex === -1) return;
1098
+
1059
1099
  this.applyOptimistic(
1060
1100
  { ...session, workingCopy: cutBlock(working, block.work) },
1061
1101
  this.client!.sessionCutBlock(session.id, workIndex),
@@ -1074,10 +1114,12 @@ class Controller implements ReviewController {
1074
1114
  const existingCuts = session.textCuts ?? [];
1075
1115
 
1076
1116
  if ((session.workingCopy ?? base) !== applyTextCuts(base, existingCuts)) return null;
1117
+
1077
1118
  const startBlock = display[displayIndex];
1078
1119
  const endBlock = display[endDisplayIndex];
1079
1120
 
1080
1121
  if (!startBlock?.base || !endBlock?.base) return null;
1122
+
1081
1123
  if (existingCuts.length === 0) {
1082
1124
  const workRange = renderedSpanToWork(
1083
1125
  display,
@@ -1093,6 +1135,7 @@ class Controller implements ReviewController {
1093
1135
  ? null
1094
1136
  : { start: sourceStart, end: sourceEnd };
1095
1137
  }
1138
+
1096
1139
  const startRange = baseRangeForRendered(
1097
1140
  blockRuns(startBlock, true),
1098
1141
  renderedStart,
@@ -1118,6 +1161,7 @@ class Controller implements ReviewController {
1118
1161
  const session = this.snapshot.session;
1119
1162
 
1120
1163
  if (!session || !block.base) return;
1164
+
1121
1165
  const working = this.working();
1122
1166
  const line = restoreLine(
1123
1167
  nextWorkBlock(this.display(), displayIndex),
@@ -1129,12 +1173,14 @@ class Controller implements ReviewController {
1129
1173
  );
1130
1174
 
1131
1175
  if (baseIndex === -1) return;
1176
+
1132
1177
  const expected: Thread = {
1133
1178
  ...session,
1134
1179
  workingCopy: restoreBlock(base, working, block.base, line),
1135
1180
  };
1136
1181
 
1137
1182
  if (expected.workingCopy === undefined) delete expected.workingCopy;
1183
+
1138
1184
  this.applyOptimistic(expected, this.client!.sessionRestoreBlock(session.id, baseIndex, line));
1139
1185
  }
1140
1186
 
@@ -1144,11 +1190,13 @@ class Controller implements ReviewController {
1144
1190
  const session = this.snapshot.session;
1145
1191
 
1146
1192
  if (!session || session.status === "resolved") return null;
1193
+
1147
1194
  if (!session.artifact.files?.length) {
1148
1195
  this.setStatus("hunk curation needs full file contents (PR diffs cannot be curated)");
1149
1196
 
1150
1197
  return null;
1151
1198
  }
1199
+
1152
1200
  const row = this.rows()[rowIndex];
1153
1201
 
1154
1202
  if (!row || row.kind === "file" || row.kind === "hunk") {
@@ -1156,6 +1204,7 @@ class Controller implements ReviewController {
1156
1204
 
1157
1205
  return null;
1158
1206
  }
1207
+
1159
1208
  const model = this.fileModel(row.file);
1160
1209
 
1161
1210
  if (!model) return null;
@@ -1167,9 +1216,11 @@ class Controller implements ReviewController {
1167
1216
  const located = this.curationRow(rowIndex);
1168
1217
 
1169
1218
  if (!located) return;
1219
+
1170
1220
  const target = hunkRejectionForRow(located.row.file, located.model, located.row);
1171
1221
 
1172
1222
  if (!target) return this.setStatus("no hunk under the cursor");
1223
+
1173
1224
  const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
1174
1225
 
1175
1226
  if (this.rejections.some(wholeHunk)) {
@@ -1189,14 +1240,18 @@ class Controller implements ReviewController {
1189
1240
  const located = this.curationRow(rowIndex);
1190
1241
 
1191
1242
  if (!located) return;
1243
+
1192
1244
  if (located.row.kind !== "add" && located.row.kind !== "del")
1193
1245
  return this.setStatus("move to a changed line to reject a change");
1246
+
1194
1247
  const target = changeRejectionForRow(located.row.file, located.model, located.row);
1195
1248
 
1196
1249
  if (!target) return this.setStatus("no change under the cursor");
1250
+
1197
1251
  const wholeCovers = this.rejections.some((rejection) => rejectsWholeHunk(rejection, target));
1198
1252
 
1199
1253
  if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
1254
+
1200
1255
  if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
1201
1256
  this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
1202
1257
  } else {
@@ -1209,15 +1264,19 @@ class Controller implements ReviewController {
1209
1264
  const session = this.snapshot.session;
1210
1265
 
1211
1266
  if (!session) return;
1267
+
1212
1268
  const expected: Thread = { ...session, curation: rejections };
1213
1269
 
1214
1270
  if (!rejections.length) delete expected.curation;
1271
+
1215
1272
  this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
1216
1273
  }
1217
1274
 
1218
1275
  rejectedRows(): Set<number> {
1219
1276
  this.ensureDerived();
1277
+
1220
1278
  if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
1279
+
1221
1280
  const rejected = new Set<number>();
1222
1281
 
1223
1282
  this.derived.rows.forEach((row, index) => {
@@ -1244,10 +1303,12 @@ class Controller implements ReviewController {
1244
1303
  const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
1245
1304
 
1246
1305
  if (kept.length === this.rejections.length) return;
1306
+
1247
1307
  this.curate(kept);
1248
1308
 
1249
1309
  return;
1250
1310
  }
1311
+
1251
1312
  const display = this.display();
1252
1313
  const displayIndex = display.findIndex(
1253
1314
  (block) => block.type === "del" && block.base && planCutId(block.base) === id,
@@ -1259,6 +1320,7 @@ class Controller implements ReviewController {
1259
1320
  /** The curated-out diff rejections as removal cards (newest decisions last). */
1260
1321
  private diffCurationItems(): CurationItem[] {
1261
1322
  if (!this.rejections.length) return EMPTY_CURATION_ITEMS;
1323
+
1262
1324
  const items: CurationItem[] = [];
1263
1325
 
1264
1326
  for (const rejection of this.rejections) {
@@ -1284,6 +1346,7 @@ class Controller implements ReviewController {
1284
1346
 
1285
1347
  this.derived.display.forEach((block, displayIndex) => {
1286
1348
  if (block.type !== "del" || !block.base) return;
1349
+
1287
1350
  items.push({
1288
1351
  id: planCutId(block.base),
1289
1352
  source: "plan",
@@ -1300,10 +1363,12 @@ class Controller implements ReviewController {
1300
1363
  const model = this.fileModel(rejection.path);
1301
1364
 
1302
1365
  if (!model) return [];
1366
+
1303
1367
  const rows: DiffRow[] = [];
1304
1368
 
1305
1369
  for (const row of this.derived.rows) {
1306
1370
  if (row.file !== rejection.path || (row.kind !== "add" && row.kind !== "del")) continue;
1371
+
1307
1372
  if (rejection.changeIndex === undefined) {
1308
1373
  const target = hunkRejectionForRow(rejection.path, model, row);
1309
1374
 
@@ -1322,6 +1387,7 @@ class Controller implements ReviewController {
1322
1387
  const session = this.snapshot.session;
1323
1388
 
1324
1389
  if (!session || session.status === "resolved") return;
1390
+
1325
1391
  try {
1326
1392
  const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
1327
1393
 
@@ -1336,7 +1402,9 @@ class Controller implements ReviewController {
1336
1402
  const session = this.snapshot.session;
1337
1403
 
1338
1404
  if (!session || session.status === "resolved") return;
1405
+
1339
1406
  if (content === this.working()) return;
1407
+
1340
1408
  this.setWorkingCopy(content);
1341
1409
  this.reconcileAnnotations(session, content);
1342
1410
  }
@@ -1368,6 +1436,7 @@ class Controller implements ReviewController {
1368
1436
  const session = this.snapshot.session;
1369
1437
 
1370
1438
  if (!session) return undefined;
1439
+
1371
1440
  let anchor;
1372
1441
  let resolvedTarget = target;
1373
1442
  // a file target (a Changes-panel diff note) and a diff artifact both anchor into the diff rows
@@ -1414,6 +1483,7 @@ class Controller implements ReviewController {
1414
1483
  workIndexOf(endDisplayIndex),
1415
1484
  );
1416
1485
  }
1486
+
1417
1487
  // absent target means the reviewed artifact, so keep artifact notes free of the field
1418
1488
  const wire =
1419
1489
  resolvedTarget.kind === "artifact"
@@ -1431,6 +1501,7 @@ class Controller implements ReviewController {
1431
1501
  const session = this.snapshot.session;
1432
1502
 
1433
1503
  if (!session) return undefined;
1504
+
1434
1505
  const wire =
1435
1506
  target.kind === "artifact"
1436
1507
  ? { id: newAnnotationId(), kind: "comment", anchor, body }
@@ -1446,7 +1517,9 @@ class Controller implements ReviewController {
1446
1517
  /** Find-or-create the per-repo workbench thread and adopt it as active; true once a session exists. */
1447
1518
  private async ensureWorkbenchSession(): Promise<boolean> {
1448
1519
  if (this.snapshot.session) return true;
1520
+
1449
1521
  if (this.client?.sessionWorkbench === undefined) return false;
1522
+
1450
1523
  let workbench;
1451
1524
 
1452
1525
  try {
@@ -1466,6 +1539,7 @@ class Controller implements ReviewController {
1466
1539
 
1467
1540
  async commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void> {
1468
1541
  if (!(await this.ensureWorkbenchSession())) return;
1542
+
1469
1543
  this.addComment(anchor, target, body);
1470
1544
  }
1471
1545
 
@@ -1478,6 +1552,7 @@ class Controller implements ReviewController {
1478
1552
  body: string,
1479
1553
  ): Promise<void> {
1480
1554
  if (!(await this.ensureWorkbenchSession())) return;
1555
+
1481
1556
  // annotate re-derives the file path and rev from the diff row it lands on
1482
1557
  this.annotate("comment", displayIndex, start, end, body, endDisplayIndex, {
1483
1558
  kind: "file",
@@ -1491,6 +1566,7 @@ class Controller implements ReviewController {
1491
1566
  const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
1492
1567
 
1493
1568
  if (!session || !root) return undefined;
1569
+
1494
1570
  // a reply to a reply still hangs off the discussion's root comment, and shares its target
1495
1571
  // so it renders and resolves on the same surface
1496
1572
  const base = {
@@ -1513,6 +1589,7 @@ class Controller implements ReviewController {
1513
1589
  const session = this.snapshot.session;
1514
1590
 
1515
1591
  if (!session) return undefined;
1592
+
1516
1593
  const anchor = { quote, prefix: "", suffix: "", selector };
1517
1594
  const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
1518
1595
  const persisted = this.client!.sessionComment(session.id, wire);
@@ -1527,9 +1604,11 @@ class Controller implements ReviewController {
1527
1604
  const session = this.snapshot.session;
1528
1605
 
1529
1606
  if (!session) return;
1607
+
1530
1608
  const existing = session.annotations.find((annotation) => annotation.id === id);
1531
1609
 
1532
1610
  if (!existing) return;
1611
+
1533
1612
  // the daemon's annotate primitive upserts by id: same id, anchor, and
1534
1613
  // reply link, new body
1535
1614
  const wire: Omit<Annotation, "createdAt"> = {
@@ -1540,6 +1619,7 @@ class Controller implements ReviewController {
1540
1619
  };
1541
1620
 
1542
1621
  if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
1622
+
1543
1623
  const persisted = this.client!.sessionComment(session.id, wire);
1544
1624
 
1545
1625
  this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
@@ -1559,6 +1639,7 @@ class Controller implements ReviewController {
1559
1639
  );
1560
1640
 
1561
1641
  if (links.length === 0) return;
1642
+
1562
1643
  void persisted
1563
1644
  .then(() => Promise.all(links.map((link) => this.shareTransport.push(link.id, [annotation]))))
1564
1645
  .catch(() => {});
@@ -1568,6 +1649,7 @@ class Controller implements ReviewController {
1568
1649
  const session = this.snapshot.session;
1569
1650
 
1570
1651
  if (!session) return;
1652
+
1571
1653
  this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
1572
1654
  }
1573
1655
 
@@ -1575,6 +1657,7 @@ class Controller implements ReviewController {
1575
1657
  const session = this.snapshot.session;
1576
1658
 
1577
1659
  if (!session) return;
1660
+
1578
1661
  this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
1579
1662
  }
1580
1663
 
@@ -1591,9 +1674,11 @@ class Controller implements ReviewController {
1591
1674
  const session = this.snapshot.session;
1592
1675
 
1593
1676
  if (!session || session.artifact.type !== "diff") return;
1677
+
1594
1678
  const files = this.files();
1595
1679
 
1596
1680
  if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
1681
+
1597
1682
  // resume at the first unviewed file; a finished walk reopens on the end card
1598
1683
  this.update({ walk: { index: firstUnviewedIndex(files, this.viewedSet()) } });
1599
1684
  }
@@ -1603,16 +1688,19 @@ class Controller implements ReviewController {
1603
1688
  const session = this.snapshot.session;
1604
1689
 
1605
1690
  if (!walk || !session) return;
1691
+
1606
1692
  const files = this.files();
1607
1693
  const current = files[walk.index];
1608
1694
 
1609
1695
  if (!current) return; // already on the end card
1696
+
1610
1697
  // advancing IS the viewed mark: the step is complete once you move past
1611
1698
  // it; the daemon primitive merges, so only the new path travels
1612
1699
  if (!this.viewedSet().has(current.path)) {
1613
1700
  this.locallyViewed.add(current.path);
1614
1701
  this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
1615
1702
  }
1703
+
1616
1704
  this.update({ walk: { index: walk.index + 1 } });
1617
1705
  }
1618
1706
 
@@ -1620,6 +1708,7 @@ class Controller implements ReviewController {
1620
1708
  const walk = this.snapshot.walk;
1621
1709
 
1622
1710
  if (!walk) return;
1711
+
1623
1712
  this.update({ walk: { index: Math.max(0, walk.index - 1) } });
1624
1713
  }
1625
1714
 
@@ -1631,6 +1720,7 @@ class Controller implements ReviewController {
1631
1720
  const session = this.snapshot.session;
1632
1721
 
1633
1722
  if (!session) return;
1723
+
1634
1724
  this.walkLeave();
1635
1725
  const actionBodies = Object.fromEntries(
1636
1726
  slashItemsFrom(this.quickActions).map((item) => [item.name, item.body]),
@@ -1641,14 +1731,17 @@ class Controller implements ReviewController {
1641
1731
  // The completion overlay heading already states the message, so the
1642
1732
  // status line stays empty here - only export/error messages fill it.
1643
1733
  this.update({ session: resolved, status: "" });
1734
+
1644
1735
  if (message === "comment") {
1645
1736
  this.setStatus("comment sent - thread stays open");
1646
1737
 
1647
1738
  return;
1648
1739
  }
1740
+
1649
1741
  // notes-vault export: guarded by each exporter's policy (default manual = no-op)
1650
1742
  for (const exporter of this.exporters) {
1651
1743
  if (!exporter.runsOn(message)) continue;
1744
+
1652
1745
  void exporter.run(resolved).then((exportResult) => {
1653
1746
  this.setStatus(
1654
1747
  exportResult.success && exportResult.path
@@ -1672,12 +1765,13 @@ class Controller implements ReviewController {
1672
1765
  }
1673
1766
 
1674
1767
  /** A workbench thread renders live locally; freeze its diff so a remote reviewer gets a stable snapshot. */
1675
- private freezeForShare(session: Thread): Promise<Thread> {
1768
+ private async freezeForShare(session: Thread): Promise<Thread> {
1676
1769
  const repoDiff = this.client?.repoDiff;
1770
+ const shared = repoDiff
1771
+ ? await snapshotWorkbench(session, (root, vcs) => repoDiff.call(this.client, root, vcs))
1772
+ : session;
1677
1773
 
1678
- return repoDiff
1679
- ? snapshotWorkbench(session, (root, vcs) => repoDiff.call(this.client, root, vcs))
1680
- : Promise.resolve(session);
1774
+ return this.client ? snapshotThreadConversation(shared, this.client) : shared;
1681
1775
  }
1682
1776
 
1683
1777
  share(): void {
@@ -1690,9 +1784,11 @@ class Controller implements ReviewController {
1690
1784
 
1691
1785
  createShareLink(input: NewShareLink): void {
1692
1786
  if (this.readOnly) return this.setStatus("observer - read-only");
1787
+
1693
1788
  const session = this.snapshot.session;
1694
1789
 
1695
1790
  if (!session || !this.client) return;
1791
+
1696
1792
  const client = this.client;
1697
1793
  const shareBranch = session.history?.branch ?? MAIN_BRANCH;
1698
1794
 
@@ -1714,6 +1810,7 @@ class Controller implements ReviewController {
1714
1810
  const id = this.shareTransport.parseShareId(line);
1715
1811
 
1716
1812
  if (!id) throw new Error("gateway returned no share id");
1813
+
1717
1814
  const link: ShareLink = {
1718
1815
  id,
1719
1816
  name: input.name?.trim() || undefined,
@@ -1723,6 +1820,7 @@ class Controller implements ReviewController {
1723
1820
  };
1724
1821
 
1725
1822
  if (input.agentEnabled !== undefined) link.agentEnabled = input.agentEnabled;
1823
+
1726
1824
  await client.sessionSetShares(session.id, [...this.linksFor(this.snapshot.session), link]);
1727
1825
  this.setStatus("");
1728
1826
  this.showToast(line, copied ? "link copied" : "link created");
@@ -1734,21 +1832,26 @@ class Controller implements ReviewController {
1734
1832
 
1735
1833
  updateShareLink(id: string, input: NewShareLink): void {
1736
1834
  if (this.readOnly) return this.setStatus("observer - read-only");
1835
+
1737
1836
  const session = this.snapshot.session;
1738
1837
 
1739
1838
  if (!session || !this.client) return;
1839
+
1740
1840
  const links = this.linksFor(session);
1741
1841
 
1742
1842
  if (!links.some((link) => link.id === id)) return;
1843
+
1743
1844
  const allowlist = input.requireAuth ? input.allowlist : [];
1744
1845
 
1745
1846
  const policy: SharePolicy = { requireAuth: input.requireAuth, allowlist };
1746
1847
 
1747
1848
  if (input.agentEnabled !== undefined) policy.agentEnabled = input.agentEnabled;
1849
+
1748
1850
  // Publish the same policy stored on the local link.
1749
1851
  void this.shareTransport.push(id, [], policy).catch(() => {});
1750
1852
  const next = links.map((link) => {
1751
1853
  if (link.id !== id) return link;
1854
+
1752
1855
  const updated = {
1753
1856
  ...link,
1754
1857
  name: input.name?.trim() || undefined,
@@ -1769,9 +1872,11 @@ class Controller implements ReviewController {
1769
1872
 
1770
1873
  deleteShareLink(id: string): void {
1771
1874
  if (this.readOnly) return this.setStatus("observer - read-only");
1875
+
1772
1876
  const session = this.snapshot.session;
1773
1877
 
1774
1878
  if (!session || !this.client) return;
1879
+
1775
1880
  const remaining = this.linksFor(session).filter((link) => link.id !== id);
1776
1881
 
1777
1882
  void this.shareTransport.revoke(id).catch(() => {});
@@ -1792,10 +1897,12 @@ class Controller implements ReviewController {
1792
1897
  /** Stop sharing the open thread: revoke every link so none of them resolve. */
1793
1898
  unshare(): void {
1794
1899
  if (this.readOnly) return this.setStatus("observer - read-only");
1900
+
1795
1901
  const session = this.snapshot.session;
1796
1902
  const links = this.linksFor(session);
1797
1903
 
1798
1904
  if (!session || links.length === 0) return this.setStatus("this thread is not shared");
1905
+
1799
1906
  this.stopShareSync();
1800
1907
  void Promise.all(links.map((link) => this.shareTransport.revoke(link.id).catch(() => {})));
1801
1908
  void this.client?.sessionSetShares(session.id, []).catch(() => {});
@@ -1805,10 +1912,13 @@ class Controller implements ReviewController {
1805
1912
  const session = this.snapshot.session;
1806
1913
 
1807
1914
  if (!session?.history || !this.client) return;
1915
+
1808
1916
  const target = entryTarget(session.history, entryId);
1809
1917
 
1810
1918
  if (target === null) return this.setStatus("that entry is not on any branch");
1919
+
1811
1920
  if (target.kind === "here") return this.setStatus("already at the tip");
1921
+
1812
1922
  if (target.kind === "switch") {
1813
1923
  this.moveTree(session, switchBranch(session.history, target.branch), () =>
1814
1924
  this.client!.sessionSwitch(session.id, target.branch),
@@ -1816,6 +1926,7 @@ class Controller implements ReviewController {
1816
1926
 
1817
1927
  return;
1818
1928
  }
1929
+
1819
1930
  // an entry another branch reaches: the daemon stands on that branch first, in the same request
1820
1931
  const onBranch = switchBranch(session.history, target.branch);
1821
1932
  const options = summary === undefined || summary === "" ? {} : { summary };
@@ -1832,9 +1943,12 @@ class Controller implements ReviewController {
1832
1943
  const branchName = name.trim();
1833
1944
 
1834
1945
  if (!session?.history || !this.client) return;
1946
+
1835
1947
  if (!branchName) return this.setStatus("a branch needs a name");
1948
+
1836
1949
  if (session.history.tips[branchName] !== undefined)
1837
1950
  return this.setStatus(`branch ${branchName} exists`);
1951
+
1838
1952
  this.moveTree(session, createBranch(session.history, branchName), () =>
1839
1953
  this.client!.sessionBranch(session.id, branchName),
1840
1954
  );
@@ -1845,7 +1959,9 @@ class Controller implements ReviewController {
1845
1959
  const name = label.trim();
1846
1960
 
1847
1961
  if (!session?.history || !this.client) return;
1962
+
1848
1963
  if (!name) return this.setStatus("a checkpoint needs a name");
1964
+
1849
1965
  this.moveTree(session, labelTip(session.history, name), () =>
1850
1966
  this.client!.sessionLabel(session.id, name),
1851
1967
  );
@@ -1855,6 +1971,7 @@ class Controller implements ReviewController {
1855
1971
  const session = this.snapshot.session;
1856
1972
 
1857
1973
  if (!session || !this.client) return;
1974
+
1858
1975
  this.client
1859
1976
  .sessionFork(session.id)
1860
1977
  .then((fork) => {
@@ -1869,6 +1986,7 @@ class Controller implements ReviewController {
1869
1986
  const session = this.snapshot.session;
1870
1987
 
1871
1988
  if (!session || !this.client) return;
1989
+
1872
1990
  const client = this.client;
1873
1991
 
1874
1992
  this.setStatus("forking and sharing…");
@@ -1888,6 +2006,7 @@ class Controller implements ReviewController {
1888
2006
  await client.sessionSetShares(fork.id, [
1889
2007
  { id, requireAuth: false, allowlist: [], shareBranch },
1890
2008
  ]);
2009
+
1891
2010
  this.setStatus("");
1892
2011
  this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
1893
2012
  })
@@ -1962,6 +2081,7 @@ class Controller implements ReviewController {
1962
2081
  const shareId = this.linksFor(this.snapshot.session)[0]?.id;
1963
2082
 
1964
2083
  if (this.shareRun !== run || this.closed || !shareId) return;
2084
+
1965
2085
  void this.pullShared();
1966
2086
  this.shareStop = this.shareTransport.watch(shareId, {
1967
2087
  onSession: (remote) => {
@@ -1970,6 +2090,7 @@ class Controller implements ReviewController {
1970
2090
  },
1971
2091
  onClose: () => {
1972
2092
  if (this.shareRun !== run || this.closed) return;
2093
+
1973
2094
  this.shareStop = null;
1974
2095
  this.shareReconnect = this.clock.setTimeout(connect, delay);
1975
2096
  delay = Math.min(delay * 2, SHARE_RECONNECT_MAX_MS);
@@ -1986,7 +2107,9 @@ class Controller implements ReviewController {
1986
2107
 
1987
2108
  private stopShareSync(): void {
1988
2109
  this.shareRun = null;
2110
+
1989
2111
  if (this.shareReconnect !== undefined) this.clock.clearTimeout(this.shareReconnect);
2112
+
1990
2113
  this.shareReconnect = undefined;
1991
2114
  this.shareStop?.();
1992
2115
  this.shareStop = null;
@@ -1995,6 +2118,7 @@ class Controller implements ReviewController {
1995
2118
  // ── completion hand-back ────────────────────
1996
2119
  private startCounting(remaining: number): void {
1997
2120
  if (remaining <= 0) return this.finishReview();
2121
+
1998
2122
  this.update({ completion: { phase: "counting", remaining } });
1999
2123
  this.countdown = this.clock.setTimeout(() => this.startCounting(remaining - 1), 1000);
2000
2124
  }
@@ -2005,6 +2129,7 @@ class Controller implements ReviewController {
2005
2129
  const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
2006
2130
 
2007
2131
  if (herdr && pane) focusHerdrPane(herdr.binPath, pane);
2132
+
2008
2133
  this.options.onExit?.(0);
2009
2134
  }
2010
2135