@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
@@ -36,6 +36,7 @@ export function projectAgentTranscript(thread: Thread, state: ThreadAgentState)
36
36
 
37
37
  display.push(...blocks);
38
38
  sources.push(...blocks.map((_, blockIndex) => ({ messageId: message.id, blockIndex })));
39
+
39
40
  if (message.role === "user") {
40
41
  const tools = state.tools.filter((tool) => tool.turnId === message.id);
41
42
 
@@ -176,6 +177,7 @@ export function projectThreadConversation(
176
177
  kind: "p",
177
178
  work: { kind: "p", text: "", lineStart: 0, lineEnd: 0 },
178
179
  });
180
+
179
181
  const offset = display.length;
180
182
  const annotation = {
181
183
  id: `mirror:${id}`,
@@ -195,6 +197,8 @@ export function projectThreadConversation(
195
197
  display.push(...blocks);
196
198
  sources.push(...blocks.map(() => ({ kind: "mirror" as const, commentId })));
197
199
  annotations.push(annotation);
200
+ const mirrorAnnotations = [annotation];
201
+
198
202
  if (commentId) {
199
203
  const replies = [...thread.annotations, ...state.comments].filter(
200
204
  (entry) => entry.replyTo === commentId,
@@ -210,23 +214,23 @@ export function projectThreadConversation(
210
214
  };
211
215
 
212
216
  annotations.push(replica);
217
+ mirrorAnnotations.push(replica);
213
218
  mirrors.set(replica.id, { commentId: reply.id });
214
219
  }
215
220
  }
221
+
216
222
  const localMarks = quote.trim()
217
- ? marksByDisplay([annotation], blocks)
223
+ ? marksByDisplay(mirrorAnnotations, blocks)
218
224
  : new Map<number, Mark[]>([
219
225
  [
220
226
  0,
221
- [
222
- {
223
- start: 0,
224
- end: 0,
225
- role: "mark-comment",
226
- annotationId: annotation.id,
227
- span: { start: { blockIndex: 0, char: 0 }, end: { blockIndex: 0, char: 0 } },
228
- },
229
- ],
227
+ mirrorAnnotations.map((entry) => ({
228
+ start: 0,
229
+ end: 0,
230
+ role: "mark-comment",
231
+ annotationId: entry.id,
232
+ span: { start: { blockIndex: 0, char: 0 }, end: { blockIndex: 0, char: 0 } },
233
+ })),
230
234
  ],
231
235
  ]);
232
236
 
@@ -245,6 +249,7 @@ export function projectThreadConversation(
245
249
  );
246
250
  }
247
251
  mirrors.set(annotation.id, { commentId, submissionId });
252
+
248
253
  if (submissionId) destinations.set(submissionId, offset);
249
254
  };
250
255
  const message = (entry: ThreadAgentState["messages"][number]): void => {
@@ -298,12 +303,14 @@ export function projectThreadConversation(
298
303
  revision: 1,
299
304
  });
300
305
  }
306
+
301
307
  for (const entry of state.messages.filter(
302
308
  (entry) => entry.role === "agent" && entry.submissionId === submission.id,
303
309
  ))
304
310
  message(entry);
305
311
  }
306
312
  } else for (const entry of state.messages) message(entry);
313
+
307
314
  const activityIndex = showActivity ? display.length : -1;
308
315
 
309
316
  if (showActivity) {
@@ -314,6 +321,7 @@ export function projectThreadConversation(
314
321
  });
315
322
  sources.push({ kind: "activity" });
316
323
  }
324
+
317
325
  const tailIndex = display.length;
318
326
 
319
327
  display.push({
@@ -62,6 +62,7 @@ function AgentConfigMenu({
62
62
 
63
63
  useEffect(() => {
64
64
  if (!open) return;
65
+
65
66
  overlay.setOverlay(
66
67
  "agent-config",
67
68
  <box
@@ -43,6 +43,33 @@ const empty: ThreadAgentState = {
43
43
  comments: [],
44
44
  };
45
45
 
46
+ test("a failed harness turn displays its error below the conversation", async () => {
47
+ const failure = "OAuth auth derivation failed: missing provider module";
48
+ const { client } = createTestAgentClient({ ...empty, phase: { kind: "failed", error: failure } });
49
+ const setup = await testRender(
50
+ <AgentThreadPane
51
+ thread={thread}
52
+ client={client}
53
+ focused
54
+ theme={DARK}
55
+ onActiveChange={noop}
56
+ onOpenFile={noop}
57
+ >
58
+ {artifactView(thread)}
59
+ </AgentThreadPane>,
60
+ { width: 100, height: 24 },
61
+ );
62
+
63
+ try {
64
+ await waitForText(setup, "Original artifact");
65
+ await settle(setup);
66
+ expect(setup.captureCharFrame()).toContain(failure);
67
+ expect(setup.captureCharFrame()).not.toContain("Thinking");
68
+ } finally {
69
+ setup.renderer.destroy();
70
+ }
71
+ });
72
+
46
73
  function createTestAgentClient(initial: ThreadAgentState) {
47
74
  let state = initial;
48
75
  const prompts: AgentPromptRequest[] = [];
@@ -56,6 +83,7 @@ function createTestAgentClient(initial: ThreadAgentState) {
56
83
  agentGet: async () => state,
57
84
  agentPrompt: async (params) => {
58
85
  prompts.push(params);
86
+
59
87
  if (params.text)
60
88
  state = {
61
89
  ...state,
@@ -287,6 +315,7 @@ test("three bottom prompts survive delayed acceptance and remain separate submis
287
315
 
288
316
  client.agentPrompt = async (params) => {
289
317
  prompts.push(params.text);
318
+
290
319
  if (prompts.length === 1)
291
320
  return new Promise((resolve) => {
292
321
  release = resolve;
@@ -374,6 +403,227 @@ test("typing on an accepted plain prompt cannot edit it or create an unmarked co
374
403
  }
375
404
  });
376
405
 
406
+ for (const editingReply of [false, true]) {
407
+ test(`Option+Enter preserves the exact ${editingReply ? "edited" : "new"} reply for Ctrl+Enter`, async () => {
408
+ const submitted: string[] = [];
409
+ const root = {
410
+ id: "root",
411
+ kind: "comment" as const,
412
+ body: "Submitted question",
413
+ createdAt: "2026-10-04",
414
+ anchor: { quote: "Original", prefix: "", suffix: " artifact" },
415
+ };
416
+ const display = buildDisplay(thread.artifact.content);
417
+
418
+ function ControlledDiscussion(): React.ReactNode {
419
+ const [value, setValue] = React.useState<Thread>({
420
+ ...thread,
421
+ annotations: editingReply
422
+ ? [root, { ...root, id: "reply", replyTo: "root", body: "Editable followup" }]
423
+ : [root],
424
+ });
425
+ const [focused, setFocused] = React.useState<string>();
426
+
427
+ return (
428
+ <ThreadView
429
+ session={value}
430
+ display={display}
431
+ marks={marksByDisplay(value.annotations, display)}
432
+ quickActions={[]}
433
+ observer={false}
434
+ focusedAnnotationId={focused}
435
+ onFocusAnnotation={setFocused}
436
+ isAnnotationReadOnly={(id) => id === "root"}
437
+ onAnnotate={noop}
438
+ onReply={(id, body) => {
439
+ setValue((current) => ({
440
+ ...current,
441
+ annotations: [...current.annotations, { ...root, id: "reply", replyTo: id, body }],
442
+ }));
443
+
444
+ return "reply";
445
+ }}
446
+ onUpdateAnnotation={(id, body) =>
447
+ setValue((current) => ({
448
+ ...current,
449
+ annotations: current.annotations.map((note) =>
450
+ note.id === id ? { ...note, body } : note,
451
+ ),
452
+ }))
453
+ }
454
+ onInvoke={(id) => {
455
+ if (id) submitted.push(id);
456
+ }}
457
+ onExit={noop}
458
+ />
459
+ );
460
+ }
461
+ const setup = await testRender(<ControlledDiscussion />, {
462
+ width: 100,
463
+ height: 24,
464
+ kittyKeyboard: true,
465
+ });
466
+
467
+ try {
468
+ await waitForText(setup, editingReply ? "Editable followup" : "Submitted question");
469
+ const comment = locateText(setup, editingReply ? "Editable followup" : "Submitted question");
470
+
471
+ await setup.mockMouse.click(comment.column, comment.row);
472
+ await typeText(setup, " Why?");
473
+ await pressKey(setup, "RETURN", { meta: true });
474
+ expect(submitted).toEqual([]);
475
+ const saved = locateText(setup, editingReply ? "Editable followup Why?" : "Why?");
476
+
477
+ await setup.mockMouse.click(saved.column, saved.row);
478
+ await pressKey(setup, "RETURN", { ctrl: true });
479
+ expect(submitted).toEqual(["reply"]);
480
+ } finally {
481
+ setup.renderer.destroy();
482
+ }
483
+ });
484
+ }
485
+
486
+ test("Option-saved Changes mirror replies send their stored ID with Ctrl+Enter", async () => {
487
+ const root = {
488
+ id: "changes-note",
489
+ kind: "comment" as const,
490
+ body: "Question on Changes",
491
+ createdAt: thread.createdAt,
492
+ anchor: { quote: "Changed code", prefix: "", suffix: "" },
493
+ };
494
+ const { client, prompts } = createTestAgentClient({
495
+ ...empty,
496
+ submissions: [
497
+ {
498
+ id: "accepted",
499
+ commentId: root.id,
500
+ prompt: root.body,
501
+ quote: root.anchor.quote,
502
+ status: "completed",
503
+ },
504
+ ],
505
+ });
506
+
507
+ function ChangesDiscussion(): React.ReactNode {
508
+ const [value, setValue] = React.useState<Thread>({ ...thread, annotations: [root] });
509
+ const [focused, setFocused] = React.useState<string>();
510
+ const display = buildDisplay(value.artifact.content);
511
+ const saveReply = (id: string, body: string): string => {
512
+ setValue((current) => ({
513
+ ...current,
514
+ annotations: [...current.annotations, { ...root, id: "followup", replyTo: id, body }],
515
+ }));
516
+
517
+ return "followup";
518
+ };
519
+
520
+ return (
521
+ <AgentThreadPane
522
+ thread={value}
523
+ client={client}
524
+ focused
525
+ theme={DARK}
526
+ onActiveChange={noop}
527
+ onOpenFile={noop}
528
+ >
529
+ <ThreadView
530
+ session={value}
531
+ display={display}
532
+ marks={marksByDisplay(value.annotations, display)}
533
+ quickActions={[]}
534
+ observer={false}
535
+ focusedAnnotationId={focused}
536
+ onFocusAnnotation={setFocused}
537
+ onAnnotate={noop}
538
+ onReply={saveReply}
539
+ onUpdateAnnotation={noop}
540
+ onExit={noop}
541
+ />
542
+ </AgentThreadPane>
543
+ );
544
+ }
545
+ const setup = await testRender(<ChangesDiscussion />, {
546
+ width: 100,
547
+ height: 24,
548
+ kittyKeyboard: true,
549
+ });
550
+
551
+ try {
552
+ await waitForText(setup, root.body);
553
+ const mirror = locateText(setup, root.body);
554
+
555
+ await setup.mockMouse.click(mirror.column, mirror.row);
556
+ await typeText(setup, "Follow up on this change");
557
+ await pressKey(setup, "RETURN", { meta: true });
558
+ await waitForText(setup, "Follow up on this change");
559
+ expect(prompts).toHaveLength(0);
560
+ await pressKey(setup, "RETURN", { ctrl: true });
561
+ expect(prompts.map((prompt) => prompt.commentId)).toEqual(["followup"]);
562
+ } finally {
563
+ setup.renderer.destroy();
564
+ }
565
+ });
566
+
567
+ test("Ctrl+Enter submits only the comment just saved, leaving older notes editable", async () => {
568
+ const value: Thread = {
569
+ ...thread,
570
+ annotations: [
571
+ {
572
+ id: "older",
573
+ kind: "comment",
574
+ body: "Older unsent note",
575
+ createdAt: "2026-10-04",
576
+ anchor: { quote: "Original", prefix: "", suffix: " artifact" },
577
+ },
578
+ ],
579
+ };
580
+ const { client, prompts } = createTestAgentClient(empty);
581
+ const display = buildDisplay(value.artifact.content);
582
+ const setup = await testRender(
583
+ <AgentThreadPane
584
+ thread={value}
585
+ theme={DARK}
586
+ focused
587
+ onActiveChange={noop}
588
+ onOpenFile={noop}
589
+ client={client}
590
+ >
591
+ <ThreadView
592
+ session={value}
593
+ display={display}
594
+ marks={marksByDisplay(value.annotations, display)}
595
+ quickActions={[]}
596
+ observer={false}
597
+ onAnnotate={() => "selected"}
598
+ onReply={noop}
599
+ onUpdateAnnotation={noop}
600
+ onExit={noop}
601
+ />
602
+ </AgentThreadPane>,
603
+ { width: 100, height: 24, kittyKeyboard: true },
604
+ );
605
+
606
+ try {
607
+ await waitForText(setup, "Original artifact");
608
+ const artifact = locateText(setup, "Original artifact");
609
+
610
+ await setup.mockMouse.drag(
611
+ artifact.column + 9,
612
+ artifact.row,
613
+ artifact.column + 17,
614
+ artifact.row,
615
+ );
616
+ await typeText(setup, "Only this question");
617
+ await pressKey(setup, "RETURN", { ctrl: true });
618
+ await waitForState(setup, () => prompts.length === 1, "selected comment invocation");
619
+ expect(prompts[0]?.commentId).toBe("selected");
620
+ expect(prompts[0]?.id).toBe(value.id);
621
+ expect(value.annotations[0]?.body).toBe("Older unsent note");
622
+ } finally {
623
+ setup.renderer.destroy();
624
+ }
625
+ });
626
+
377
627
  test("Option+Enter saves a comment, Command+Enter leaves its draft, and Ctrl+Enter invokes", async () => {
378
628
  const saved: string[] = [];
379
629
  let invocations = 0;
@@ -385,7 +635,11 @@ test("Option+Enter saves a comment, Command+Enter leaves its draft, and Ctrl+Ent
385
635
  marks={marksByDisplay([], display)}
386
636
  quickActions={[]}
387
637
  observer={false}
388
- onAnnotate={(_span, body) => saved.push(body)}
638
+ onAnnotate={(_span, body) => {
639
+ saved.push(body);
640
+
641
+ return `saved-${saved.length}`;
642
+ }}
389
643
  onReply={noop}
390
644
  onUpdateAnnotation={noop}
391
645
  onExit={noop}
@@ -643,6 +897,7 @@ test("an arriving reply preserves a bottom draft and submits it as the next prom
643
897
 
644
898
  client.agentPrompt = async (params) => {
645
899
  prompts.push(params);
900
+
646
901
  if (prompts.length === 1)
647
902
  return new Promise<ThreadAgentState>((resolve) => {
648
903
  finish = resolve;
@@ -813,6 +1068,7 @@ test("a failed mutation flush restores the prompt alongside a newer visible draf
813
1068
  onOpenFile={noop}
814
1069
  flushMutations={() => {
815
1070
  if (flushStarted) return Promise.resolve();
1071
+
816
1072
  flushStarted = true;
817
1073
 
818
1074
  return failedFlush;
@@ -29,7 +29,7 @@ export interface AgentThreadPaneProps {
29
29
  action: (id: string) => { label: string; run: () => void } | undefined,
30
30
  ) => void;
31
31
  onRevealReply?: () => void;
32
- onInvokeChange?: (invoke: (() => void) | undefined) => void;
32
+ onInvokeChange?: (invoke: ((commentId?: string) => void) | undefined) => void;
33
33
  onStateChange?: (state: ThreadAgentState) => void;
34
34
  flushMutations?: () => Promise<void>;
35
35
  client?: ThreadAgentClient;
@@ -68,7 +68,9 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
68
68
  useState<ThreadViewProps["promptRestoreRequest"]>();
69
69
  const restoreSequence = useRef(0);
70
70
  const invoking = useRef(false);
71
- const invocations = useRef<{ text: string; writes: Promise<boolean>[] }[]>([]);
71
+ const invocations = useRef<{ text: string; commentId?: string; writes: Promise<boolean>[] }[]>(
72
+ [],
73
+ );
72
74
  const pendingWrites = useRef<Promise<boolean>[]>([]);
73
75
  const state = agent.state;
74
76
  const continuation = agentContinuation(state);
@@ -78,6 +80,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
78
80
 
79
81
  useEffect(() => {
80
82
  if (!busy) return;
83
+
81
84
  const timer = setInterval(() => setPulse((value) => !value), 600);
82
85
 
83
86
  return () => clearInterval(timer);
@@ -115,11 +118,22 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
115
118
  notifyControls();
116
119
  }, [state, theme]);
117
120
 
118
- const invoke = async (): Promise<void> => {
121
+ const invoke = async (commentId?: string): Promise<void> => {
119
122
  if (thread.status !== "pending") return;
120
- invocations.current.push({ text: draft.current, writes: pendingWrites.current.splice(0) });
123
+
124
+ if (!commentId && !draft.current.trim()) return;
125
+
126
+ invocations.current.push({
127
+ text: draft.current,
128
+ commentId: commentId
129
+ ? (projection.mirrors.get(commentId)?.commentId ?? commentId)
130
+ : undefined,
131
+ writes: pendingWrites.current.splice(0),
132
+ });
121
133
  draft.current = "";
134
+
122
135
  if (invoking.current) return;
136
+
123
137
  invoking.current = true;
124
138
 
125
139
  const rejected: string[] = [];
@@ -135,11 +149,13 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
135
149
  rejected.push(input.text);
136
150
  continue;
137
151
  }
152
+
138
153
  await props.flushMutations?.();
139
154
  const accepted = await agent.act((client) =>
140
155
  client.agentPrompt({
141
156
  id: thread.id,
142
157
  text: input.text,
158
+ commentId: input.commentId,
143
159
  inputOnly: Boolean(input.text.trim()),
144
160
  operationId: newAnnotationId(),
145
161
  }),
@@ -158,32 +174,36 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
158
174
  if (text) setPromptRestoreRequest({ id: ++restoreSequence.current, text });
159
175
  }
160
176
  };
161
- const invokeRef = useRef<() => Promise<void>>(async () => {});
177
+ const invokeRef = useRef<(commentId?: string) => Promise<void>>(async () => {});
162
178
 
163
179
  useEffect(() => {
164
180
  invokeRef.current = invoke;
165
181
  });
166
- const notifyInvoke = useEffectEvent((invoke: (() => void) | undefined) =>
182
+ const notifyInvoke = useEffectEvent((invoke: ((commentId?: string) => void) | undefined) =>
167
183
  props.onInvokeChange?.(invoke),
168
184
  );
169
185
 
170
186
  useEffect(() => {
171
- notifyInvoke(() => void invokeRef.current());
187
+ notifyInvoke((commentId) => void invokeRef.current(commentId));
172
188
 
173
189
  return () => notifyInvoke(undefined);
174
190
  }, []);
175
- const reply = (id: string, body: string): void => {
191
+ const reply = (id: string, body: string): string | void => {
176
192
  const origin = projection.mirrors.get(id)?.commentId ?? id;
177
193
  const root = agentCommentRoot(state, origin);
194
+ const replyFocusId = (commentId: string | void): string | void =>
195
+ commentId && projection.mirrors.has(id) ? `${id}:${commentId}` : commentId;
196
+
197
+ if (root) {
198
+ const commentId = newAnnotationId();
178
199
 
179
- if (root)
180
200
  pendingWrites.current.push(
181
201
  agent.act((client) =>
182
202
  client.agentComment({
183
203
  id: thread.id,
184
204
  comment: {
185
205
  ...root,
186
- id: newAnnotationId(),
206
+ id: commentId,
187
207
  body,
188
208
  sent: false,
189
209
  author: undefined,
@@ -192,7 +212,11 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
192
212
  }),
193
213
  ),
194
214
  );
195
- else child?.onReply(origin, body);
215
+
216
+ return replyFocusId(commentId);
217
+ }
218
+
219
+ return replyFocusId(child?.onReply(origin, body));
196
220
  };
197
221
  const actionFor = (id: string) => {
198
222
  const mirror = projection.mirrors.get(id);
@@ -201,6 +225,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
201
225
  );
202
226
 
203
227
  if (!submission) return undefined;
228
+
204
229
  if (submission.status === "failed" && props.client?.canControlAgent !== false)
205
230
  return {
206
231
  label: "Retry",
@@ -245,7 +270,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
245
270
  Boolean(state.comments.find((entry) => entry.id === id)?.sent)
246
271
  }
247
272
  annotationAction={actionFor}
248
- onInvoke={() => void invoke()}
273
+ onInvoke={(commentId) => void invoke(commentId)}
249
274
  isPromptBlock={(index) => index === projection.tailIndex}
250
275
  promptRestoreRequest={promptRestoreRequest}
251
276
  promptFocusRequest={
@@ -275,22 +300,26 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
275
300
 
276
301
  return;
277
302
  }
303
+
278
304
  if (source?.kind === "mirror") {
279
- if (source.commentId) reply(source.commentId, body);
305
+ const mirrorId = projection.marks.get(span.start.blockIndex)?.[0]?.annotationId;
306
+
307
+ if (mirrorId) return reply(mirrorId, body);
280
308
 
281
309
  return;
282
310
  }
283
- if (source?.kind === "artifact" && end?.kind === "artifact") {
284
- child?.onAnnotate(span, body);
285
311
 
286
- return;
312
+ if (source?.kind === "artifact" && end?.kind === "artifact") {
313
+ return child?.onAnnotate(span, body);
287
314
  }
315
+
288
316
  if (
289
317
  source?.kind !== "message" ||
290
318
  end?.kind !== "message" ||
291
319
  source.messageId !== end.messageId
292
320
  )
293
321
  return;
322
+
294
323
  const message = state.messages.find((entry) => entry.id === source.messageId)!;
295
324
  const localState = { ...state, messages: [message] };
296
325
  const comment = commentOnAgentSpan(
@@ -307,6 +336,8 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
307
336
  pendingWrites.current.push(
308
337
  agent.act((client) => client.agentComment({ id: thread.id, comment })),
309
338
  );
339
+
340
+ return comment.id;
310
341
  }}
311
342
  onReply={reply}
312
343
  onUpdateAnnotation={(id, body) => {
@@ -327,6 +358,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
327
358
  {state.phase.kind === "offline" ? (
328
359
  <text fg={theme.textMuted}>Owner offline</text>
329
360
  ) : null}
361
+ {state.phase.kind === "failed" ? <text fg={theme.red}>{state.phase.error}</text> : null}
330
362
  {busy ? <text fg={pulse ? theme.textDim : theme.textMuted}>Thinking…</text> : null}
331
363
  {state.phase.kind === "permission" && props.client?.canControlAgent !== false ? (
332
364
  <>
@@ -116,6 +116,7 @@ function ThreadRow(props: ThreadRowProps): React.ReactNode {
116
116
 
117
117
  useEffect(() => {
118
118
  if (!menuOpen) return;
119
+
119
120
  // the full-screen box closes the menu on an outside click; the menu drops from under the kebab
120
121
  setOverlay(
121
122
  "thread-menu",
@@ -6,6 +6,7 @@ function session(id: string, title: string, rootCommit?: string, remote?: string
6
6
  const workspace: WorkspaceKey = { repoRoot: `/home/dev/${id}-checkout`, branch: "main" };
7
7
 
8
8
  if (rootCommit !== undefined) workspace.rootCommit = rootCommit;
9
+
9
10
  if (remote !== undefined) workspace.remote = remote;
10
11
 
11
12
  return {
@@ -99,6 +99,7 @@ function fakeClient(initial: Thread, sink: WorkingCopySink): ThreadClient {
99
99
 
100
100
  sink.workingCopy = content;
101
101
  session = { ...session, workingCopy: content };
102
+
102
103
  if (content === undefined) delete session.workingCopy;
103
104
 
104
105
  return session;
@@ -111,6 +112,7 @@ function fakeClient(initial: Thread, sink: WorkingCopySink): ThreadClient {
111
112
 
112
113
  sink.workingCopy = content;
113
114
  session = { ...session, workingCopy: content };
115
+
114
116
  if (rejections.length) session.curation = rejections;
115
117
  else delete session.curation;
116
118