@cueloop/client 0.1.0-alpha.101 → 0.1.0-alpha.103

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 (122) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/annotation-palette.ts +1 -0
  3. package/src/annotations/components/AnnotationCards.tsx +14 -1
  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 +154 -19
  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.test.tsx +1 -0
  13. package/src/app/App.tsx +72 -6
  14. package/src/app/App.visual.test.tsx +1 -0
  15. package/src/app/App.walk.test.tsx +1 -0
  16. package/src/app/App.workbench.test.tsx +1 -0
  17. package/src/app/app-screens.tsx +1 -0
  18. package/src/app/app-view-model.ts +7 -0
  19. package/src/app/components/WorkspacePanels.tsx +1 -0
  20. package/src/app/intent-dispatch.test.ts +1 -0
  21. package/src/app/intent-dispatch.ts +26 -0
  22. package/src/app/ready-signal.ts +2 -0
  23. package/src/app/run.ts +2 -0
  24. package/src/app/serve.test.ts +4 -0
  25. package/src/diff/components/AnnotatableFileView.tsx +3 -2
  26. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  27. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  28. package/src/diff/components/DiffContentView.tsx +28 -3
  29. package/src/diff/components/diff-content-view-layout.ts +3 -0
  30. package/src/diff/diff-fold.ts +4 -0
  31. package/src/diff/diff-hunk-curate.ts +3 -0
  32. package/src/diff/diff-intraline.ts +6 -0
  33. package/src/diff/diff-syntax.ts +8 -0
  34. package/src/diff/split-diff.ts +6 -0
  35. package/src/diff/view-diff.ts +18 -0
  36. package/src/diff/walk.ts +3 -0
  37. package/src/integrations/attribution.ts +2 -0
  38. package/src/integrations/client-extension-registry.ts +4 -0
  39. package/src/integrations/clipboard.ts +1 -0
  40. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  41. package/src/integrations/components/ShareDialog.tsx +28 -0
  42. package/src/integrations/editor.ts +3 -0
  43. package/src/integrations/github-identity.test.ts +1 -0
  44. package/src/integrations/github-identity.ts +1 -0
  45. package/src/integrations/herdr.ts +2 -0
  46. package/src/integrations/pasted-image.ts +2 -0
  47. package/src/integrations/share.ts +4 -0
  48. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  49. package/src/keyboard/components/SlashComposer.tsx +3 -0
  50. package/src/keyboard/key-bindings.ts +3 -0
  51. package/src/keyboard/keymap.ts +51 -0
  52. package/src/keyboard/skills.ts +2 -0
  53. package/src/keyboard/slash-palette.ts +8 -0
  54. package/src/keyboard/thread-chords.ts +5 -0
  55. package/src/keyboard/use-shared-keyboard.ts +1 -0
  56. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  57. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  58. package/src/markdown/components/ThreadView.tsx +16 -3
  59. package/src/markdown/mark-runs.ts +4 -0
  60. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  61. package/src/markdown/markdown-grid.ts +6 -0
  62. package/src/markdown/markdown-highlight.ts +1 -0
  63. package/src/markdown/thread-code-syntax.ts +3 -0
  64. package/src/markdown/view-plan.ts +52 -0
  65. package/src/performance/perf-report.ts +1 -0
  66. package/src/performance/perf-timings.ts +1 -0
  67. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  68. package/src/prototype/prototype-browser.ts +11 -0
  69. package/src/settings/components/SettingsDialog.tsx +3 -0
  70. package/src/settings/config.test.ts +41 -0
  71. package/src/settings/config.ts +44 -0
  72. package/src/settings/use-settings-dialog.test.tsx +98 -0
  73. package/src/settings/use-settings-dialog.tsx +38 -1
  74. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  75. package/src/settings/use-thread-harness-setting.ts +86 -0
  76. package/src/stories/stories.test.tsx +4 -0
  77. package/src/stories/story.ts +6 -0
  78. package/src/terminal/ansi-screen.ts +5 -0
  79. package/src/terminal/components/terminal-pane.ts +12 -0
  80. package/src/terminal/ghostty-terminal.ts +10 -0
  81. package/src/terminal/kitty-image.ts +3 -0
  82. package/src/terminal/pty.ts +9 -0
  83. package/src/testing/test-support.test.ts +2 -0
  84. package/src/testing/test-support.ts +16 -0
  85. package/src/thread/agent-continuation.test.ts +2 -0
  86. package/src/thread/agent-transcript.ts +18 -10
  87. package/src/thread/components/AgentConfigControls.tsx +1 -0
  88. package/src/thread/components/AgentThreadPane.test.tsx +257 -1
  89. package/src/thread/components/AgentThreadPane.tsx +48 -16
  90. package/src/thread/components/ThreadTree.tsx +1 -0
  91. package/src/thread/components/thread-tree.test.ts +1 -0
  92. package/src/thread/thread-controller.curate.test.ts +2 -0
  93. package/src/thread/thread-controller.ts +123 -0
  94. package/src/thread/tree-view.ts +4 -0
  95. package/src/thread/use-thread-agent.test.tsx +1 -0
  96. package/src/thread/use-thread-agent.ts +5 -0
  97. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  98. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  99. package/src/ui/components/PromptDialog.tsx +2 -0
  100. package/src/ui/components/RootOverlay.tsx +1 -0
  101. package/src/ui/components/ScrollArea.tsx +2 -0
  102. package/src/ui/components/primitives/Button.tsx +1 -0
  103. package/src/ui/components/primitives/Tabs.tsx +1 -0
  104. package/src/ui/components/primitives/Tree.tsx +4 -0
  105. package/src/ui/components/primitives/tree-model.ts +7 -0
  106. package/src/ui/components/syntax-highlight.ts +5 -0
  107. package/src/ui/components/truncate-title.ts +1 -0
  108. package/src/ui/use-frame-measure.ts +3 -0
  109. package/src/ui/use-terminal-virtualizer.ts +6 -0
  110. package/src/workbench/components/ChangesColumn.tsx +7 -0
  111. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  112. package/src/workbench/components/EditorGrid.tsx +8 -0
  113. package/src/workbench/components/FileContentsView.tsx +2 -0
  114. package/src/workbench/components/GridTabContent.tsx +2 -0
  115. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  116. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  117. package/src/workbench/components/TreePane.tsx +2 -0
  118. package/src/workbench/components/editor-grid.ts +3 -0
  119. package/src/workbench/components/file-tree.ts +2 -0
  120. package/src/workbench/use-changes-workbench.ts +5 -0
  121. package/src/workbench/use-remember-layout.ts +2 -0
  122. package/src/workbench/use-repo-changes.ts +2 -0
@@ -0,0 +1,77 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import React from "react";
6
+ import { DaemonServer } from "@cueloop/daemon";
7
+ import type { ThreadAgentState } from "@cueloop/schema";
8
+ import { App } from "./App";
9
+ import {
10
+ clickText,
11
+ isolateUserConfig,
12
+ isolatedUserConfigPath,
13
+ renderReadyApp,
14
+ waitForText,
15
+ waitForTextGone,
16
+ } from "../testing/test-support";
17
+
18
+ test("repeated harness toggle failures show feedback without losing the controller binding", async () => {
19
+ const home = mkdtempSync(join(tmpdir(), "cueloop-harness-settings-"));
20
+ const restore = isolateUserConfig(home);
21
+ const server = new DaemonServer({ home, idleExitMs: 0 });
22
+
23
+ writeFileSync(isolatedUserConfigPath(home), "[experimental]\nthread_agent = true\n");
24
+ server.start();
25
+ const thread = server.core.sessionCreate({
26
+ workspace: { repoRoot: home, branch: "main" },
27
+ artifact: { type: "plan", content: "# Switching review", meta: { title: "Switching review" } },
28
+ });
29
+ const state: ThreadAgentState = {
30
+ threadId: thread.id,
31
+ phase: { kind: "idle" },
32
+ messages: [],
33
+ comments: [],
34
+ tools: [],
35
+ };
36
+ let switches = 0;
37
+ const setup = await renderReadyApp(
38
+ <App
39
+ home={home}
40
+ sessionId={thread.id}
41
+ agentClient={{
42
+ agentGet: async () => state,
43
+ agentPrompt: async () => state,
44
+ agentCancel: async () => state,
45
+ agentComment: async () => state,
46
+ agentPermission: async () => state,
47
+ agentConfigure: async (params) => {
48
+ if (!params.configId) return state;
49
+
50
+ switches++;
51
+ throw new Error(`Switch failure ${switches}`);
52
+ },
53
+ }}
54
+ />,
55
+ { width: 120, height: 32 },
56
+ );
57
+
58
+ try {
59
+ await setup.mockMouse.click(1, 0);
60
+ await waitForText(setup, "settings");
61
+ await clickText(setup, "settings");
62
+ await waitForText(setup, "General");
63
+ await clickText(setup, "Thread ");
64
+ await waitForText(setup, "Harness");
65
+ await clickText(setup, "Harness");
66
+ await waitForText(setup, "Switch failure 1");
67
+ await waitForTextGone(setup, "Switch failure 1");
68
+ await clickText(setup, "Harness");
69
+ await waitForText(setup, "Switch failure 2");
70
+ expect(switches).toBe(2);
71
+ } finally {
72
+ setup.renderer.destroy();
73
+ server.stop();
74
+ restore();
75
+ rmSync(home, { recursive: true, force: true });
76
+ }
77
+ });
@@ -91,6 +91,7 @@ function foregroundsOf(setup: Setup, needle: string): string[] {
91
91
  for (const line of setup.captureSpans().lines) {
92
92
  for (const span of line.spans) {
93
93
  if (span.fg === undefined || !span.text.includes(needle)) continue;
94
+
94
95
  const [red, green, blue] = span.fg.toInts();
95
96
 
96
97
  foregrounds.push(
@@ -109,6 +110,7 @@ function backgroundsOf(setup: Setup, needle: string): string[] {
109
110
  for (const line of setup.captureSpans().lines) {
110
111
  for (const span of line.spans) {
111
112
  if (!span.text.includes(needle)) continue;
113
+
112
114
  const [red, green, blue] = span.bg.toInts();
113
115
 
114
116
  backgrounds.push(
@@ -84,6 +84,7 @@ function backgroundsOf(setup: Setup, needle: string): string[] {
84
84
  for (const line of setup.captureSpans().lines) {
85
85
  for (const span of line.spans) {
86
86
  if (!span.text.includes(needle)) continue;
87
+
87
88
  const [red, green, blue] = span.bg.toInts();
88
89
 
89
90
  backgrounds.push(
@@ -78,6 +78,7 @@ function foregroundsOf(setup: Setup, needle: string): string[] {
78
78
  for (const line of setup.captureSpans().lines) {
79
79
  for (const span of line.spans) {
80
80
  if (!span.text.includes(needle)) continue;
81
+
81
82
  const [red, green, blue] = span.fg.toInts();
82
83
 
83
84
  foregrounds.push(
package/src/app/App.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { useThreadHarnessSetting } from "../settings/use-thread-harness-setting";
1
2
  import React, {
2
3
  useCallback,
3
4
  useEffect,
@@ -203,6 +204,7 @@ export function quitKeyHandled(
203
204
  onExit?: (code: number) => void,
204
205
  ): boolean {
205
206
  if (!key.ctrl || key.name !== "q") return false;
207
+
206
208
  onExit?.(0);
207
209
 
208
210
  return true;
@@ -214,6 +216,7 @@ function menuModalHandled(
214
216
  key: { name: string },
215
217
  ): boolean {
216
218
  if (menuControl.openMenuId === null) return false;
219
+
217
220
  if (key.name === "escape") menuControl.closeMenu();
218
221
 
219
222
  return true;
@@ -300,7 +303,9 @@ function chooseThreadBody(choice: {
300
303
  threadView: React.ReactNode;
301
304
  }): React.ReactNode {
302
305
  if (choice.isPixelPrototype) return choice.prototype;
306
+
303
307
  if (choice.isDiff) return choice.diffPlaceholder;
308
+
304
309
  if (choice.editingBody) return choice.editor;
305
310
 
306
311
  return choice.threadView;
@@ -453,16 +458,19 @@ export function threadsNavHandled(params: {
453
458
  const { focusedPane, quiet, key, count } = params;
454
459
 
455
460
  if (focusedPane !== "threads" || !quiet || count === 0) return false;
461
+
456
462
  if (key.name === "j" || key.name === "down") {
457
463
  params.setInboxCursor((cursor) => Math.min(cursor + 1, count - 1));
458
464
 
459
465
  return true;
460
466
  }
467
+
461
468
  if (key.name === "k" || key.name === "up") {
462
469
  params.setInboxCursor((cursor) => Math.max(cursor - 1, 0));
463
470
 
464
471
  return true;
465
472
  }
473
+
466
474
  if (key.name === "return" || key.name === "enter") {
467
475
  params.openSession();
468
476
 
@@ -490,8 +498,11 @@ export function visiblePanes(
490
498
  const panes: FocusPane[] = [];
491
499
 
492
500
  if (sidebarOpen) panes.push("threads");
501
+
493
502
  if (threadShown) panes.push("thread");
503
+
494
504
  if (changesOpen) panes.push("changes");
505
+
495
506
  if (projectOpen) panes.push("project");
496
507
 
497
508
  return panes;
@@ -503,6 +514,7 @@ export function nextFocusPane(
503
514
  backward: boolean,
504
515
  ): FocusPane {
505
516
  if (panes.length === 0) return current;
517
+
506
518
  const index = panes.indexOf(current);
507
519
  const step = backward ? -1 : 1;
508
520
 
@@ -617,6 +629,7 @@ export function App({
617
629
 
618
630
  if (next.has(id)) next.delete(id);
619
631
  else next.add(id);
632
+
620
633
  persistPins([...next]);
621
634
 
622
635
  return next;
@@ -688,6 +701,7 @@ export function App({
688
701
 
689
702
  setPrototypePixels(config.experimental.prototypePixels);
690
703
  setThreadAgentEnabled(config.experimental.threadAgent);
704
+ setThreadHarness(config.thread.harness);
691
705
  keysRef.current = config.keys;
692
706
  keyBindings.setKeys(config.keys);
693
707
  setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
@@ -714,6 +728,7 @@ export function App({
714
728
 
715
729
  useEffect(() => {
716
730
  if (!toast) return;
731
+
717
732
  const timer = setTimeout(() => controller.dismissToast(), TOAST_DISMISS_MS);
718
733
 
719
734
  return () => clearTimeout(timer);
@@ -725,6 +740,7 @@ export function App({
725
740
 
726
741
  useEffect(() => {
727
742
  if (promptedSelfRef.current || role !== "collaborator" || !selfAuthor || !session) return;
743
+
728
744
  promptedSelfRef.current = true;
729
745
  const known = session.participants?.find((participant) => participant.id === selfAuthor)?.name;
730
746
 
@@ -747,13 +763,31 @@ export function App({
747
763
 
748
764
  void resolveGithubIdentity().then((github) => {
749
765
  if (identityGenerationRef.current !== generation) return;
766
+
750
767
  if (!github) return controller.setStatus("GitHub not connected - run gh auth login");
768
+
751
769
  const name = github.name?.trim() || github.login;
752
770
 
753
771
  applyIdentity({ name, provider: "github" });
754
772
  });
755
773
  };
756
774
 
775
+ const [agentState, setAgentState] = useState<import("@cueloop/schema").ThreadAgentState>();
776
+ const [threadHarness, setThreadHarness] = useState<"pi" | "fx">("pi");
777
+
778
+ const harnessSetting = useThreadHarnessSetting({
779
+ home,
780
+ thread: session,
781
+ enabled: threadAgentEnabled,
782
+ owner: isOwner,
783
+ state: agentState,
784
+ preferred: threadHarness,
785
+ client: agentClient,
786
+ onState: setAgentState,
787
+ onPreferred: setThreadHarness,
788
+ onError: (message) => controller.setStatus(message),
789
+ });
790
+
757
791
  // ── settings dialog: config-backed model, navigation, persistence ──
758
792
  const {
759
793
  settingsNav,
@@ -787,6 +821,9 @@ export function App({
787
821
  reviewSkill,
788
822
  reviewWorkspace,
789
823
  setReviewWorkspace,
824
+ canSwitchHarness: harnessSetting.enabled,
825
+ threadHarness: harnessSetting.harness,
826
+ onSwitchHarness: harnessSetting.switchHarness,
790
827
  });
791
828
 
792
829
  // ── derived view model ──────────────────────
@@ -842,8 +879,7 @@ export function App({
842
879
  const agentActions = useRef<(id: string) => { label: string; run: () => void } | undefined>(
843
880
  () => undefined,
844
881
  );
845
- const agentInvoke = useRef<(() => void) | undefined>(undefined);
846
- const [agentState, setAgentState] = useState<import("@cueloop/schema").ThreadAgentState>();
882
+ const agentInvoke = useRef<((commentId?: string) => void) | undefined>(undefined);
847
883
  const [prototypeComposing, setPrototypeComposing] = useState(false);
848
884
  const [welcomeComposing, setWelcomeComposing] = useState(false);
849
885
  // inline body edit: the markdown editor owns the thread pane and all keys while open
@@ -868,9 +904,11 @@ export function App({
868
904
 
869
905
  const openCardEdit = (annotationId: string): void => {
870
906
  if (observer) return controller.setStatus("observer - read-only");
907
+
871
908
  const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
872
909
 
873
910
  if (!annotation) return;
911
+
874
912
  // a collaborator's note is theirs to word: activating it (click or e) renames
875
913
  // the author rather than editing the body the planner does not own
876
914
  if (annotation.author) {
@@ -882,7 +920,9 @@ export function App({
882
920
  text: authorNames[annotation.author] ?? "",
883
921
  });
884
922
  }
923
+
885
924
  if (resolved) return controller.setStatus("review submitted - read-only");
925
+
886
926
  liveInput.current = annotation.body;
887
927
  setMode({ type: "railEdit", id: annotation.id, text: annotation.body });
888
928
  };
@@ -894,6 +934,7 @@ export function App({
894
934
 
895
935
  return;
896
936
  }
937
+
897
938
  renderer?.suspend();
898
939
  try {
899
940
  controller.edit();
@@ -949,6 +990,7 @@ export function App({
949
990
  },
950
991
  openShareDialog: () => {
951
992
  if (!isOwner) return controller.setStatus("only the plan owner can share");
993
+
952
994
  shareDialogStore.getState().reset();
953
995
  setShareDialogOpen(true);
954
996
  },
@@ -974,6 +1016,7 @@ export function App({
974
1016
  useEffect(() => {
975
1017
  // the bare shell drives its panes from its own workbench, so only reconcile against this one
976
1018
  if (!session) return;
1019
+
977
1020
  const next = reconciledFocus(navigablePanes, focusedPane);
978
1021
 
979
1022
  if (next) setFocusedPane(next);
@@ -995,6 +1038,7 @@ export function App({
995
1038
  });
996
1039
 
997
1040
  if (!intent) return false;
1041
+
998
1042
  if (intent.type === "cut" && selection) {
999
1043
  controller.cut(
1000
1044
  selection.start.blockIndex,
@@ -1010,9 +1054,11 @@ export function App({
1010
1054
  const openThread = (id: string): void => {
1011
1055
  // save the open editor into the leaving thread's working copy before the switch, so a click away never drops edits
1012
1056
  if (bodyEditing.editing) bodyEditing.requestExit();
1057
+
1013
1058
  const index = grouped.ordered.findIndex((thread) => thread.id === id);
1014
1059
 
1015
1060
  if (index >= 0) setInboxCursor(() => index);
1061
+
1016
1062
  controller.open(id);
1017
1063
  };
1018
1064
 
@@ -1038,7 +1084,9 @@ export function App({
1038
1084
  !key.super
1039
1085
  )
1040
1086
  return true;
1087
+
1041
1088
  if (!threadViewActive || threadViewSuspended) return false;
1089
+
1042
1090
  if (!threadComposing) {
1043
1091
  const chord = resolveSessionChord(key, { isOwner, resolved });
1044
1092
 
@@ -1050,14 +1098,19 @@ export function App({
1050
1098
 
1051
1099
  useKeyboard((key) => {
1052
1100
  if (quitKeyHandled(key, onExit)) return;
1101
+
1053
1102
  // the inline body editor owns the pane and every key while open
1054
1103
  if (bodyEditing.editing) return;
1104
+
1055
1105
  // the share dialog owns its own keys while open; the shell grammar stands down
1056
1106
  if (shareDialogOpen) return;
1107
+
1057
1108
  if (menuModalHandled(menuControl, key)) return;
1109
+
1058
1110
  if (paneCycleRequested(key, overlay, menuOwnsKeyboard, threadComposing)) {
1059
1111
  return cyclePanes(Boolean(key.shift));
1060
1112
  }
1113
+
1061
1114
  if (
1062
1115
  threadsNavHandled({
1063
1116
  focusedPane,
@@ -1069,17 +1122,24 @@ export function App({
1069
1122
  })
1070
1123
  )
1071
1124
  return;
1125
+
1072
1126
  if (threadSurfaceHandledKey(key)) return;
1127
+
1073
1128
  // A compose textarea owns the keyboard while open: let it receive the typed note instead of the
1074
1129
  // global keymap acting on each letter (the prototype, and the bare-shell welcome playground).
1075
1130
  if (prototypeComposing || welcomeComposing) return;
1131
+
1076
1132
  if (menuDialog === "settings") return void handleSettingsKey(key.name);
1133
+
1077
1134
  if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
1135
+
1078
1136
  // the toast is non-modal: escape only dismisses it when nothing else owns
1079
1137
  // escape, so an open overlay (compose, submit, prompt, walk) still cancels
1080
1138
  if (toastDismissRequested(toast !== null, key, overlay, mode.type === "span"))
1081
1139
  return controller.dismissToast();
1140
+
1082
1141
  if (bareShellDefersKeys(session === null, overlay === "none", focusedPane)) return;
1142
+
1083
1143
  const state = buildKeyState({
1084
1144
  keys: keysRef.current,
1085
1145
  observer,
@@ -1136,6 +1196,7 @@ export function App({
1136
1196
 
1137
1197
  // ── render ──────────────────────────────────
1138
1198
  if (error) return <ErrorScreen error={error} theme={theme} />;
1199
+
1139
1200
  if (!session)
1140
1201
  return inbox ? (
1141
1202
  <SlashSkillsContext.Provider value={skills}>
@@ -1229,15 +1290,20 @@ export function App({
1229
1290
  // A share viewer/observer has no Edit affordance (the button is hidden), so
1230
1291
  // this is owner-only; stay silent rather than nag if it is ever reached.
1231
1292
  if (!isOwner) return;
1293
+
1232
1294
  if (resolved) return controller.setStatus("review submitted - read-only");
1295
+
1233
1296
  openBodyEditor();
1234
1297
  };
1235
1298
 
1236
1299
  // clicking the rail Submit button: same read-only answer as the submit key
1237
1300
  const onSubmitRequest = (): void => {
1238
1301
  if (observer) return controller.setStatus("observer - read-only");
1302
+
1239
1303
  if (!isOwner) return controller.setStatus("shared view - your notes save as you go");
1304
+
1240
1305
  if (resolved) return;
1306
+
1241
1307
  dispatch({ type: "openSubmit" });
1242
1308
  };
1243
1309
 
@@ -1484,7 +1550,7 @@ export function App({
1484
1550
  // a diff thread's Changes view is the artifact itself; any other thread's is the
1485
1551
  // live working-tree diff, so its notes carry a file target and anchor there
1486
1552
  onAnnotate: (span, body) =>
1487
- void controller.annotate(
1553
+ controller.annotate(
1488
1554
  "comment",
1489
1555
  span.start.blockIndex,
1490
1556
  span.start.char,
@@ -1496,11 +1562,11 @@ export function App({
1496
1562
  : { kind: "file", path: "", rev: "worktree" },
1497
1563
  ),
1498
1564
  onReply: (rootAnnotationId, body) =>
1499
- void controller.reply(rootAnnotationId, body),
1565
+ controller.reply(rootAnnotationId, body),
1500
1566
  onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1501
1567
  onInvoke: agentActive
1502
- ? () => {
1503
- agentInvoke.current?.();
1568
+ ? (commentId) => {
1569
+ agentInvoke.current?.(commentId);
1504
1570
  setFocusedPane("thread");
1505
1571
  }
1506
1572
  : undefined,
@@ -32,6 +32,7 @@ function hasSpanColor(
32
32
  for (const line of setup.captureSpans().lines) {
33
33
  for (const span of line.spans) {
34
34
  if (!span.text.includes(needle) || !span.fg) continue;
35
+
35
36
  const [red, green, blue] = span.fg.toInts();
36
37
 
37
38
  if (red === color[0] && green === color[1] && blue === color[2]) return true;
@@ -88,6 +88,7 @@ function foregroundsOf(setup: Setup, needle: string): string[] {
88
88
  for (const line of setup.captureSpans().lines) {
89
89
  for (const span of line.spans) {
90
90
  if (!span.text.includes(needle)) continue;
91
+
91
92
  const [red, green, blue] = span.fg.toInts();
92
93
 
93
94
  foregrounds.push(
@@ -244,6 +244,7 @@ describe("the four-pane workbench", () => {
244
244
  for (const [index, direction] of Array.from({ length: 500 }, () => "down" as const).entries()) {
245
245
  // eslint-disable-next-line no-await-in-loop
246
246
  await setup.mockMouse.scroll(75, 10, direction);
247
+
247
248
  if (index % 25 === 0) {
248
249
  // eslint-disable-next-line no-await-in-loop
249
250
  await setup.waitForVisualIdle();
@@ -395,6 +395,7 @@ export function NoThreadShell(props: {
395
395
  message={confirming ? `Delete "${confirming.title}"? This removes the thread.` : ""}
396
396
  onConfirm={() => {
397
397
  if (confirming) controller.deleteSession(confirming.sessionId);
398
+
398
399
  setMode({ type: "normal" });
399
400
  }}
400
401
  onCancel={() => setMode({ type: "normal" })}
@@ -49,8 +49,11 @@ export function resolveOverlay(
49
49
  walking: boolean,
50
50
  ): KeyState["overlay"] {
51
51
  if (mode.type === "compose" || mode.type === "railEdit") return "compose";
52
+
52
53
  if (mode.type === "submit") return "submit";
54
+
53
55
  if (mode.type === "confirmDelete") return "confirm";
56
+
54
57
  if (
55
58
  mode.type === "rename" ||
56
59
  mode.type === "renameThread" ||
@@ -58,9 +61,13 @@ export function resolveOverlay(
58
61
  mode.type === "treePrompt"
59
62
  )
60
63
  return "prompt";
64
+
61
65
  if (mode.type === "spanActions") return "spanActions";
66
+
62
67
  if (completionPhase === "prompt") return "completion-prompt";
68
+
63
69
  if (completionPhase === "counting") return "completion-counting";
70
+
64
71
  if (walking) return "walk";
65
72
 
66
73
  return "none";
@@ -53,6 +53,7 @@ export function WorkspacePanels(props: {
53
53
 
54
54
  useKeyboard((key) => {
55
55
  if (!props.focused || (key.name !== "[" && key.name !== "]")) return;
56
+
56
57
  const choices = [null, ...views];
57
58
  const current = selected ? views.indexOf(selected) + 1 : 0;
58
59
  const step = key.name === "]" ? 1 : -1;
@@ -194,6 +194,7 @@ describe("move", () => {
194
194
  const advance = setCursor.mock.calls[0]![0];
195
195
 
196
196
  if (!isUpdater(advance)) throw new Error("expected setCursor to receive an updater");
197
+
197
198
  expect(advance(2)).toBe(2); // already at the end, stays
198
199
  expect(advance(0)).toBe(1);
199
200
  });
@@ -125,7 +125,9 @@ function isLandableRow(rows: DiffRow[], index: number): boolean {
125
125
  const row = rows[index];
126
126
 
127
127
  if (isCodeRow(row)) return true;
128
+
128
129
  if (row?.kind !== "file") return false;
130
+
129
131
  const next = rows[index + 1];
130
132
 
131
133
  return next === undefined || next.kind === "file";
@@ -138,6 +140,7 @@ function nextCodeRowIndex(rows: DiffRow[], from: number, to: IntentOfType<"move"
138
140
 
139
141
  return from;
140
142
  }
143
+
141
144
  if (to === "bottom") {
142
145
  for (let index = rows.length - 1; index >= 0; index--) {
143
146
  if (isLandableRow(rows, index)) return index;
@@ -145,6 +148,7 @@ function nextCodeRowIndex(rows: DiffRow[], from: number, to: IntentOfType<"move"
145
148
 
146
149
  return from;
147
150
  }
151
+
148
152
  const step = to === "down" ? 1 : -1;
149
153
 
150
154
  for (let index = from + step; index >= 0 && index < rows.length; index += step) {
@@ -160,6 +164,7 @@ function handleMove(intent: IntentOfType<"move">, deps: IntentDispatchDeps): voi
160
164
 
161
165
  return;
162
166
  }
167
+
163
168
  const navigableCount = deps.display.length;
164
169
 
165
170
  if (intent.to === "down") deps.setCursor((current) => Math.min(navigableCount - 1, current + 1));
@@ -206,6 +211,7 @@ function handleOpenRename(_intent: IntentOfType<"openRename">, deps: IntentDispa
206
211
 
207
212
  return;
208
213
  }
214
+
209
215
  deps.setMode({
210
216
  type: "rename",
211
217
  authorId: focused.author,
@@ -225,6 +231,7 @@ function handleConfirmDialog(
225
231
  else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
226
232
  else if (mode.type === "renameSelf") deps.setLocalIdentityName(mode.text.trim());
227
233
  else if (mode.type === "treePrompt") confirmTreePrompt(mode, deps);
234
+
228
235
  deps.setMode({ type: "normal" });
229
236
  }
230
237
 
@@ -232,6 +239,7 @@ function handleStartSpan(_intent: IntentOfType<"startSpan">, deps: IntentDispatc
232
239
  const block = deps.display[deps.cursor];
233
240
 
234
241
  if (!block?.work) return;
242
+
235
243
  const span = startSpan(deps.cursor, displayText(block));
236
244
 
237
245
  if (span) deps.setMode({ type: "span", span });
@@ -301,6 +309,7 @@ function handlePickSpanAction(
301
309
  const { mode, session, controller } = deps;
302
310
 
303
311
  if (mode.type !== "spanActions") return;
312
+
304
313
  const action = deps.quickActions[intent.index ?? mode.index];
305
314
 
306
315
  if (session && action) {
@@ -315,6 +324,7 @@ function handlePickSpanAction(
315
324
 
316
325
  if (annotationId) deps.setFocusedAnnotationId(annotationId);
317
326
  }
327
+
318
328
  deps.setMode({ type: "normal" });
319
329
  }
320
330
 
@@ -322,6 +332,7 @@ function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDisp
322
332
  const { mode } = deps;
323
333
 
324
334
  deps.liveInput.current = "";
335
+
325
336
  if (intent.from === "span" && mode.type === "span") {
326
337
  deps.setMode({
327
338
  type: "compose",
@@ -362,6 +373,7 @@ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispa
362
373
  const { session } = deps;
363
374
 
364
375
  if (!session) return;
376
+
365
377
  deps.liveInput.current = "";
366
378
  deps.setMode({ type: "submit", message: deps.defaultMessage, summary: "" });
367
379
  }
@@ -387,6 +399,7 @@ function handleFoldFile(_intent: IntentOfType<"foldFile">, deps: IntentDispatchD
387
399
  const file = deps.rows[deps.cursor]?.file;
388
400
 
389
401
  if (!file || deps.controller.isFileCollapsed(file)) return;
402
+
390
403
  deps.controller.setFileCollapsed(file, true);
391
404
  // the file band is unchanged in index by its own collapse, so land the cursor on it
392
405
  const headerIndex = deps.controller
@@ -400,6 +413,7 @@ function handleUnfoldFile(_intent: IntentOfType<"unfoldFile">, deps: IntentDispa
400
413
  const file = deps.rows[deps.cursor]?.file;
401
414
 
402
415
  if (!file || !deps.controller.isFileCollapsed(file)) return;
416
+
403
417
  deps.controller.setFileCollapsed(file, false);
404
418
  // drop the cursor onto the file's first code line now that its body is back
405
419
  const firstCode = deps.controller.rows().findIndex((row) => row.file === file && isCodeRow(row));
@@ -423,6 +437,7 @@ function handleRestoreCuration(
423
437
  const items = deps.controller.curationItems();
424
438
 
425
439
  if (!items.length) return;
440
+
426
441
  const targetId = deps.selectedCurationId ?? items[items.length - 1]!.id;
427
442
 
428
443
  deps.controller.restoreCuration(targetId);
@@ -447,6 +462,7 @@ function handleAnnotationCycle(
447
462
  );
448
463
 
449
464
  if (!annotations.length) return;
465
+
450
466
  const focusedIndex = annotations.findIndex(
451
467
  (annotation) => annotation.id === deps.focusedAnnotationId,
452
468
  );
@@ -500,11 +516,14 @@ function handleSaveCompose(_intent: IntentOfType<"saveCompose">, deps: IntentDis
500
516
 
501
517
  if (mode.type === "railEdit") {
502
518
  if (session && body) controller.updateAnnotation(mode.id, body);
519
+
503
520
  deps.setMode({ type: "normal" });
504
521
 
505
522
  return;
506
523
  }
524
+
507
525
  if (mode.type !== "compose") return;
526
+
508
527
  if (session && body) {
509
528
  const annotationId = controller.annotate(
510
529
  mode.kind,
@@ -516,6 +535,7 @@ function handleSaveCompose(_intent: IntentOfType<"saveCompose">, deps: IntentDis
516
535
 
517
536
  if (annotationId) deps.setFocusedAnnotationId(annotationId);
518
537
  }
538
+
519
539
  deps.setMode({ type: "normal" });
520
540
  }
521
541
 
@@ -526,6 +546,7 @@ function handleSubmitMessage(
526
546
  const { mode } = deps;
527
547
 
528
548
  if (mode.type === "submit") deps.controller.submit(mode.message, deps.liveInput.current);
549
+
529
550
  deps.setMode({ type: "normal" });
530
551
  }
531
552
 
@@ -533,6 +554,7 @@ function handleCycleMessage(intent: IntentOfType<"cycleMessage">, deps: IntentDi
533
554
  const { mode } = deps;
534
555
 
535
556
  if (mode.type !== "submit") return;
557
+
536
558
  const messageIndex =
537
559
  (MESSAGE_OUTCOMES.indexOf(mode.message) + intent.direction + MESSAGE_OUTCOMES.length) %
538
560
  MESSAGE_OUTCOMES.length;
@@ -582,6 +604,7 @@ function handleTreeMove(intent: IntentOfType<"treeMove">, deps: IntentDispatchDe
582
604
  const rows = deps.controller.treeRows();
583
605
 
584
606
  if (rows.length === 0) return;
607
+
585
608
  const current = selectedTreeIndex(deps, rows);
586
609
  const next = Math.min(rows.length - 1, Math.max(0, current + intent.direction));
587
610
 
@@ -593,9 +616,12 @@ function handleTreeGo(_intent: IntentOfType<"treeGo">, deps: IntentDispatchDeps)
593
616
  const row = rows[selectedTreeIndex(deps, rows)];
594
617
 
595
618
  if (!row) return deps.controller.setStatus("select an entry in the tree first");
619
+
596
620
  if (row.isCurrentTip) return deps.controller.setStatus("already at the tip");
621
+
597
622
  // a switch needs no summary; a move back may leave one, so it asks
598
623
  if (row.tips.length > 0) return deps.controller.goToEntry(row.entryId);
624
+
599
625
  deps.setMode({ type: "treePrompt", ask: "navigate", entryId: row.entryId, text: "" });
600
626
  }
601
627