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

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.101",
3
+ "version": "0.1.0-alpha.102",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.101",
31
- "@cueloop/extension-api": "0.1.0-alpha.101",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.101",
33
- "@cueloop/schema": "0.1.0-alpha.101",
30
+ "@cueloop/daemon": "0.1.0-alpha.102",
31
+ "@cueloop/extension-api": "0.1.0-alpha.102",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.102",
33
+ "@cueloop/schema": "0.1.0-alpha.102",
34
34
  "@opentui/core": "^0.5.14",
35
35
  "@opentui/keymap": "0.5.14",
36
36
  "@opentui/react": "^0.5.14",
@@ -317,12 +317,14 @@ export function CommentRow({
317
317
  tokens,
318
318
  authorLabel,
319
319
  action,
320
+ onFocus,
320
321
  }: {
321
322
  annotation: Annotation;
322
323
  tokens: Theme;
323
324
  /** The author's resolved display name, shown as a tooltip when the dot is hovered. */
324
325
  authorLabel?: string;
325
326
  action?: { label: string; run: () => void };
327
+ onFocus?: () => void;
326
328
  }): React.ReactNode {
327
329
  // own comments (no author) wear the filled dot, collaborators the outline
328
330
  const own = annotation.author === undefined;
@@ -337,7 +339,14 @@ export function CommentRow({
337
339
  : {};
338
340
 
339
341
  return (
340
- <box style={{ flexDirection: "column" }}>
342
+ <box
343
+ style={{ flexDirection: "column" }}
344
+ onMouseDown={(event) => {
345
+ if (!onFocus) return;
346
+ event.stopPropagation();
347
+ onFocus();
348
+ }}
349
+ >
341
350
  {annotation.body.split("\n").map((line, lineIndex) => (
342
351
  <box key={lineIndex} style={{ flexDirection: "row" }}>
343
352
  <box style={{ flexShrink: 0 }} {...(lineIndex === 0 ? dotHover : {})}>
@@ -47,7 +47,7 @@ import {
47
47
  slashItemsFrom,
48
48
  } from "../keyboard/slash-palette";
49
49
  import { SlashSkillsContext } from "../keyboard/skills";
50
- import { discussionsFrom, type Discussion } from "./discussions";
50
+ import { discussionsFrom, spanKey, type Discussion } from "./discussions";
51
51
  import {
52
52
  CommentRow,
53
53
  Composer,
@@ -107,13 +107,13 @@ export interface AnnotationSurfaceOptions {
107
107
  ) => boolean;
108
108
  focusedAnnotationId?: string;
109
109
  onFocusAnnotation?: (annotationId: string | undefined) => void;
110
- onAnnotate: (span: TextSpan, body: string) => void;
111
- onReply: (rootAnnotationId: string, body: string) => void;
110
+ onAnnotate: (span: TextSpan, body: string) => string | void;
111
+ onReply: (rootAnnotationId: string, body: string) => string | void;
112
112
  onUpdateAnnotation: (id: string, body: string) => void;
113
113
  isAnnotationReadOnly?: (id: string) => boolean;
114
114
  annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
115
115
  requestedBlock?: { blockIndex: number };
116
- onInvoke?: () => void;
116
+ onInvoke?: (commentId?: string) => void;
117
117
  isPromptBlock?: (blockIndex: number) => boolean;
118
118
  promptFocusRequest?: PromptFocusRequest;
119
119
  promptRestoreRequest?: PromptRestoreRequest;
@@ -277,17 +277,25 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
277
277
  // app owns it (onFocusAnnotation), the focused discussion is the one holding
278
278
  // the focused card and focusing here names the root comment; standalone, it
279
279
  // is local state
280
- const [localFocus, setLocalFocus] = useState<string | null>(null);
281
- const focusedDiscussion =
282
- onFocusAnnotation === undefined
283
- ? localFocus
284
- : (discussions.find((discussion) =>
285
- discussion.annotations.some((annotation) => annotation.id === focusedAnnotationId),
286
- )?.key ?? null);
280
+ const [localFocus, setLocalFocus] = useState<{ key: string; annotationId?: string } | null>(null);
281
+ const getFocusedDiscussion = (): string | null => {
282
+ if (onFocusAnnotation === undefined) return localFocus?.key ?? null;
283
+
284
+ return (
285
+ discussions.find((discussion) =>
286
+ discussion.annotations.some((annotation) => annotation.id === focusedAnnotationId),
287
+ )?.key ?? null
288
+ );
289
+ };
290
+ const focusedDiscussion = getFocusedDiscussion();
287
291
  const setFocusedDiscussion = (key: string | null): void => {
288
- if (onFocusAnnotation === undefined) return setLocalFocus(key);
292
+ if (onFocusAnnotation === undefined) return setLocalFocus(key === null ? null : { key });
289
293
  onFocusAnnotation(discussions.find((discussion) => discussion.key === key)?.rootId);
290
294
  };
295
+ const focusSavedComment = (annotationId: string, key: string): void => {
296
+ if (onFocusAnnotation) onFocusAnnotation(annotationId);
297
+ else setLocalFocus({ key, annotationId });
298
+ };
291
299
  const [folded, setFolded] = useState<Set<string>>(new Set());
292
300
  const [composeText, setComposeText] = useState("");
293
301
  const [composerFocusRequest, setComposerFocusRequest] = useState(0);
@@ -473,7 +481,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
473
481
  if (!target || body.trim().length === 0) return;
474
482
  if (target.editAnnotationId !== null) {
475
483
  onUpdateAnnotation(target.editAnnotationId, body);
476
- if (invoke) onInvoke?.();
484
+ if (target.discussionKey) focusSavedComment(target.editAnnotationId, target.discussionKey);
485
+ if (invoke) onInvoke?.(target.editAnnotationId);
477
486
 
478
487
  return;
479
488
  }
@@ -481,8 +490,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
481
490
  const discussion = discussions.find((candidate) => candidate.key === target.discussionKey);
482
491
 
483
492
  if (discussion) {
484
- onReply(discussion.rootId, body);
485
- if (invoke) onInvoke?.();
493
+ const commentId = onReply(discussion.rootId, body);
494
+
495
+ if (commentId) focusSavedComment(commentId, discussion.key);
496
+ if (invoke && commentId) onInvoke?.(commentId);
486
497
 
487
498
  return;
488
499
  }
@@ -490,10 +501,22 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
490
501
  const span = target.span ?? caretSpan();
491
502
 
492
503
  if (span) {
493
- onAnnotate(span, body);
494
- if (invoke) onInvoke?.();
504
+ const commentId = onAnnotate(span, body);
505
+
506
+ completeSavedComment(span, commentId, prompt, invoke);
495
507
  }
496
508
  };
509
+ const completeSavedComment = (
510
+ span: TextSpan,
511
+ commentId: string | void,
512
+ prompt: boolean,
513
+ invoke: boolean,
514
+ ): void => {
515
+ if (!prompt && commentId) {
516
+ focusSavedComment(commentId, spanKey(span));
517
+ }
518
+ if (invoke && (prompt || commentId)) onInvoke?.(commentId || undefined);
519
+ };
497
520
  /** A new discussion on the typing anchor; the card renders under the span's last block. */
498
521
  const openNewCompose = (seed: string): void => {
499
522
  const span = caretSpan();
@@ -854,6 +877,16 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
854
877
  openNewCompose(sequence);
855
878
  };
856
879
 
880
+ const invokeFocusedComment = (): void => {
881
+ const discussion = discussions.find((candidate) => candidate.key === focusedDiscussion);
882
+ const focusedCommentId = focusedAnnotationId ?? localFocus?.annotationId;
883
+ const commentId =
884
+ discussion?.annotations.find((annotation) => annotation.id === focusedCommentId)?.id ??
885
+ discussion?.annotations.at(-1)?.id;
886
+
887
+ onInvoke?.(commentId);
888
+ };
889
+
857
890
  useSharedKeyboard((key) => {
858
891
  if (suspended) return;
859
892
  if (key.ctrl && key.name === "q") return onExit();
@@ -863,7 +896,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
863
896
  if (isAgentInvokeKey(key, onInvoke)) {
864
897
  key.preventDefault();
865
898
 
866
- return onInvoke?.();
899
+ return invokeFocusedComment();
867
900
  }
868
901
  if (key.name === "escape") {
869
902
  // from type mode esc only enters nav, so a held mark survives for `c`
@@ -951,6 +984,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
951
984
  tokens={tokens}
952
985
  authorLabel={resolveAuthorLabel?.(annotation)}
953
986
  action={annotationAction?.(annotation.id)}
987
+ onFocus={() => {
988
+ blurSaveCompose();
989
+ focusSavedComment(annotation.id, discussion.key);
990
+ setCursor(discussion.blockIndex);
991
+ }}
954
992
  />
955
993
  ),
956
994
  };
@@ -1231,7 +1269,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1231
1269
 
1232
1270
  function isAgentInvokeKey(
1233
1271
  key: { name: string; ctrl?: boolean; meta?: boolean; super?: boolean },
1234
- onInvoke?: () => void,
1272
+ onInvoke?: (commentId?: string) => void,
1235
1273
  ): boolean {
1236
1274
  return Boolean(
1237
1275
  onInvoke &&
package/src/app/App.tsx CHANGED
@@ -842,7 +842,7 @@ export function App({
842
842
  const agentActions = useRef<(id: string) => { label: string; run: () => void } | undefined>(
843
843
  () => undefined,
844
844
  );
845
- const agentInvoke = useRef<(() => void) | undefined>(undefined);
845
+ const agentInvoke = useRef<((commentId?: string) => void) | undefined>(undefined);
846
846
  const [agentState, setAgentState] = useState<import("@cueloop/schema").ThreadAgentState>();
847
847
  const [prototypeComposing, setPrototypeComposing] = useState(false);
848
848
  const [welcomeComposing, setWelcomeComposing] = useState(false);
@@ -1484,7 +1484,7 @@ export function App({
1484
1484
  // a diff thread's Changes view is the artifact itself; any other thread's is the
1485
1485
  // live working-tree diff, so its notes carry a file target and anchor there
1486
1486
  onAnnotate: (span, body) =>
1487
- void controller.annotate(
1487
+ controller.annotate(
1488
1488
  "comment",
1489
1489
  span.start.blockIndex,
1490
1490
  span.start.char,
@@ -1496,11 +1496,11 @@ export function App({
1496
1496
  : { kind: "file", path: "", rev: "worktree" },
1497
1497
  ),
1498
1498
  onReply: (rootAnnotationId, body) =>
1499
- void controller.reply(rootAnnotationId, body),
1499
+ controller.reply(rootAnnotationId, body),
1500
1500
  onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1501
1501
  onInvoke: agentActive
1502
- ? () => {
1503
- agentInvoke.current?.();
1502
+ ? (commentId) => {
1503
+ agentInvoke.current?.(commentId);
1504
1504
  setFocusedPane("thread");
1505
1505
  }
1506
1506
  : undefined,
@@ -31,9 +31,9 @@ export interface AnnotatableFileViewProps {
31
31
  onCursorChange?: (rowIndex: number) => void;
32
32
  onObserverBlocked?: (reason: "observer" | "resolved") => void;
33
33
  onAddComment: (anchor: Anchor, body: string) => void;
34
- onReply: (rootAnnotationId: string, body: string) => void;
34
+ onReply: (rootAnnotationId: string, body: string) => string | void;
35
35
  onUpdateAnnotation: (id: string, body: string) => void;
36
- onInvoke?: () => void;
36
+ onInvoke?: (commentId?: string) => void;
37
37
  annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
38
38
  isAnnotationReadOnly?: (id: string) => boolean;
39
39
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
@@ -86,10 +86,10 @@ export interface DiffContentViewProps {
86
86
  onCursorChange?: (rowIndex: number) => void;
87
87
  focusedAnnotationId?: string;
88
88
  onFocusAnnotation?: (annotationId: string | undefined) => void;
89
- onAnnotate: (span: TextSpan, body: string) => void;
90
- onReply: (rootAnnotationId: string, body: string) => void;
89
+ onAnnotate: (span: TextSpan, body: string) => string | void;
90
+ onReply: (rootAnnotationId: string, body: string) => string | void;
91
91
  onUpdateAnnotation: (id: string, body: string) => void;
92
- onInvoke?: () => void;
92
+ onInvoke?: (commentId?: string) => void;
93
93
  annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
94
94
  isAnnotationReadOnly?: (id: string) => boolean;
95
95
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
@@ -159,7 +159,7 @@ export interface ThreadViewProps {
159
159
  isAnnotationReadOnly?: (id: string) => boolean;
160
160
  annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
161
161
  requestedBlock?: { blockIndex: number };
162
- onInvoke?: () => void;
162
+ onInvoke?: (commentId?: string) => void;
163
163
  isPromptBlock?: (blockIndex: number) => boolean;
164
164
  promptFocusRequest?: PromptFocusRequest;
165
165
  promptRestoreRequest?: PromptRestoreRequest;
@@ -177,8 +177,8 @@ export interface ThreadViewProps {
177
177
  onCursorChange?: (blockIndex: number) => void;
178
178
  focusedAnnotationId?: string;
179
179
  onFocusAnnotation?: (annotationId: string | undefined) => void;
180
- onAnnotate: (span: TextSpan, body: string) => void;
181
- onReply: (rootAnnotationId: string, body: string) => void;
180
+ onAnnotate: (span: TextSpan, body: string) => string | void;
181
+ onReply: (rootAnnotationId: string, body: string) => string | void;
182
182
  onUpdateAnnotation: (id: string, body: string) => void;
183
183
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
184
184
  onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
@@ -195,6 +195,8 @@ export function projectThreadConversation(
195
195
  display.push(...blocks);
196
196
  sources.push(...blocks.map(() => ({ kind: "mirror" as const, commentId })));
197
197
  annotations.push(annotation);
198
+ const mirrorAnnotations = [annotation];
199
+
198
200
  if (commentId) {
199
201
  const replies = [...thread.annotations, ...state.comments].filter(
200
202
  (entry) => entry.replyTo === commentId,
@@ -210,23 +212,22 @@ export function projectThreadConversation(
210
212
  };
211
213
 
212
214
  annotations.push(replica);
215
+ mirrorAnnotations.push(replica);
213
216
  mirrors.set(replica.id, { commentId: reply.id });
214
217
  }
215
218
  }
216
219
  const localMarks = quote.trim()
217
- ? marksByDisplay([annotation], blocks)
220
+ ? marksByDisplay(mirrorAnnotations, blocks)
218
221
  : new Map<number, Mark[]>([
219
222
  [
220
223
  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
- ],
224
+ mirrorAnnotations.map((entry) => ({
225
+ start: 0,
226
+ end: 0,
227
+ role: "mark-comment",
228
+ annotationId: entry.id,
229
+ span: { start: { blockIndex: 0, char: 0 }, end: { blockIndex: 0, char: 0 } },
230
+ })),
230
231
  ],
231
232
  ]);
232
233
 
@@ -374,6 +374,227 @@ test("typing on an accepted plain prompt cannot edit it or create an unmarked co
374
374
  }
375
375
  });
376
376
 
377
+ for (const editingReply of [false, true]) {
378
+ test(`Option+Enter preserves the exact ${editingReply ? "edited" : "new"} reply for Ctrl+Enter`, async () => {
379
+ const submitted: string[] = [];
380
+ const root = {
381
+ id: "root",
382
+ kind: "comment" as const,
383
+ body: "Submitted question",
384
+ createdAt: "2026-10-04",
385
+ anchor: { quote: "Original", prefix: "", suffix: " artifact" },
386
+ };
387
+ const display = buildDisplay(thread.artifact.content);
388
+
389
+ function ControlledDiscussion(): React.ReactNode {
390
+ const [value, setValue] = React.useState<Thread>({
391
+ ...thread,
392
+ annotations: editingReply
393
+ ? [root, { ...root, id: "reply", replyTo: "root", body: "Editable followup" }]
394
+ : [root],
395
+ });
396
+ const [focused, setFocused] = React.useState<string>();
397
+
398
+ return (
399
+ <ThreadView
400
+ session={value}
401
+ display={display}
402
+ marks={marksByDisplay(value.annotations, display)}
403
+ quickActions={[]}
404
+ observer={false}
405
+ focusedAnnotationId={focused}
406
+ onFocusAnnotation={setFocused}
407
+ isAnnotationReadOnly={(id) => id === "root"}
408
+ onAnnotate={noop}
409
+ onReply={(id, body) => {
410
+ setValue((current) => ({
411
+ ...current,
412
+ annotations: [...current.annotations, { ...root, id: "reply", replyTo: id, body }],
413
+ }));
414
+
415
+ return "reply";
416
+ }}
417
+ onUpdateAnnotation={(id, body) =>
418
+ setValue((current) => ({
419
+ ...current,
420
+ annotations: current.annotations.map((note) =>
421
+ note.id === id ? { ...note, body } : note,
422
+ ),
423
+ }))
424
+ }
425
+ onInvoke={(id) => {
426
+ if (id) submitted.push(id);
427
+ }}
428
+ onExit={noop}
429
+ />
430
+ );
431
+ }
432
+ const setup = await testRender(<ControlledDiscussion />, {
433
+ width: 100,
434
+ height: 24,
435
+ kittyKeyboard: true,
436
+ });
437
+
438
+ try {
439
+ await waitForText(setup, editingReply ? "Editable followup" : "Submitted question");
440
+ const comment = locateText(setup, editingReply ? "Editable followup" : "Submitted question");
441
+
442
+ await setup.mockMouse.click(comment.column, comment.row);
443
+ await typeText(setup, " Why?");
444
+ await pressKey(setup, "RETURN", { meta: true });
445
+ expect(submitted).toEqual([]);
446
+ const saved = locateText(setup, editingReply ? "Editable followup Why?" : "Why?");
447
+
448
+ await setup.mockMouse.click(saved.column, saved.row);
449
+ await pressKey(setup, "RETURN", { ctrl: true });
450
+ expect(submitted).toEqual(["reply"]);
451
+ } finally {
452
+ setup.renderer.destroy();
453
+ }
454
+ });
455
+ }
456
+
457
+ test("Option-saved Changes mirror replies send their stored ID with Ctrl+Enter", async () => {
458
+ const root = {
459
+ id: "changes-note",
460
+ kind: "comment" as const,
461
+ body: "Question on Changes",
462
+ createdAt: thread.createdAt,
463
+ anchor: { quote: "Changed code", prefix: "", suffix: "" },
464
+ };
465
+ const { client, prompts } = createTestAgentClient({
466
+ ...empty,
467
+ submissions: [
468
+ {
469
+ id: "accepted",
470
+ commentId: root.id,
471
+ prompt: root.body,
472
+ quote: root.anchor.quote,
473
+ status: "completed",
474
+ },
475
+ ],
476
+ });
477
+
478
+ function ChangesDiscussion(): React.ReactNode {
479
+ const [value, setValue] = React.useState<Thread>({ ...thread, annotations: [root] });
480
+ const [focused, setFocused] = React.useState<string>();
481
+ const display = buildDisplay(value.artifact.content);
482
+ const saveReply = (id: string, body: string): string => {
483
+ setValue((current) => ({
484
+ ...current,
485
+ annotations: [...current.annotations, { ...root, id: "followup", replyTo: id, body }],
486
+ }));
487
+
488
+ return "followup";
489
+ };
490
+
491
+ return (
492
+ <AgentThreadPane
493
+ thread={value}
494
+ client={client}
495
+ focused
496
+ theme={DARK}
497
+ onActiveChange={noop}
498
+ onOpenFile={noop}
499
+ >
500
+ <ThreadView
501
+ session={value}
502
+ display={display}
503
+ marks={marksByDisplay(value.annotations, display)}
504
+ quickActions={[]}
505
+ observer={false}
506
+ focusedAnnotationId={focused}
507
+ onFocusAnnotation={setFocused}
508
+ onAnnotate={noop}
509
+ onReply={saveReply}
510
+ onUpdateAnnotation={noop}
511
+ onExit={noop}
512
+ />
513
+ </AgentThreadPane>
514
+ );
515
+ }
516
+ const setup = await testRender(<ChangesDiscussion />, {
517
+ width: 100,
518
+ height: 24,
519
+ kittyKeyboard: true,
520
+ });
521
+
522
+ try {
523
+ await waitForText(setup, root.body);
524
+ const mirror = locateText(setup, root.body);
525
+
526
+ await setup.mockMouse.click(mirror.column, mirror.row);
527
+ await typeText(setup, "Follow up on this change");
528
+ await pressKey(setup, "RETURN", { meta: true });
529
+ await waitForText(setup, "Follow up on this change");
530
+ expect(prompts).toHaveLength(0);
531
+ await pressKey(setup, "RETURN", { ctrl: true });
532
+ expect(prompts.map((prompt) => prompt.commentId)).toEqual(["followup"]);
533
+ } finally {
534
+ setup.renderer.destroy();
535
+ }
536
+ });
537
+
538
+ test("Ctrl+Enter submits only the comment just saved, leaving older notes editable", async () => {
539
+ const value: Thread = {
540
+ ...thread,
541
+ annotations: [
542
+ {
543
+ id: "older",
544
+ kind: "comment",
545
+ body: "Older unsent note",
546
+ createdAt: "2026-10-04",
547
+ anchor: { quote: "Original", prefix: "", suffix: " artifact" },
548
+ },
549
+ ],
550
+ };
551
+ const { client, prompts } = createTestAgentClient(empty);
552
+ const display = buildDisplay(value.artifact.content);
553
+ const setup = await testRender(
554
+ <AgentThreadPane
555
+ thread={value}
556
+ theme={DARK}
557
+ focused
558
+ onActiveChange={noop}
559
+ onOpenFile={noop}
560
+ client={client}
561
+ >
562
+ <ThreadView
563
+ session={value}
564
+ display={display}
565
+ marks={marksByDisplay(value.annotations, display)}
566
+ quickActions={[]}
567
+ observer={false}
568
+ onAnnotate={() => "selected"}
569
+ onReply={noop}
570
+ onUpdateAnnotation={noop}
571
+ onExit={noop}
572
+ />
573
+ </AgentThreadPane>,
574
+ { width: 100, height: 24, kittyKeyboard: true },
575
+ );
576
+
577
+ try {
578
+ await waitForText(setup, "Original artifact");
579
+ const artifact = locateText(setup, "Original artifact");
580
+
581
+ await setup.mockMouse.drag(
582
+ artifact.column + 9,
583
+ artifact.row,
584
+ artifact.column + 17,
585
+ artifact.row,
586
+ );
587
+ await typeText(setup, "Only this question");
588
+ await pressKey(setup, "RETURN", { ctrl: true });
589
+ await waitForState(setup, () => prompts.length === 1, "selected comment invocation");
590
+ expect(prompts[0]?.commentId).toBe("selected");
591
+ expect(prompts[0]?.id).toBe(value.id);
592
+ expect(value.annotations[0]?.body).toBe("Older unsent note");
593
+ } finally {
594
+ setup.renderer.destroy();
595
+ }
596
+ });
597
+
377
598
  test("Option+Enter saves a comment, Command+Enter leaves its draft, and Ctrl+Enter invokes", async () => {
378
599
  const saved: string[] = [];
379
600
  let invocations = 0;
@@ -385,7 +606,11 @@ test("Option+Enter saves a comment, Command+Enter leaves its draft, and Ctrl+Ent
385
606
  marks={marksByDisplay([], display)}
386
607
  quickActions={[]}
387
608
  observer={false}
388
- onAnnotate={(_span, body) => saved.push(body)}
609
+ onAnnotate={(_span, body) => {
610
+ saved.push(body);
611
+
612
+ return `saved-${saved.length}`;
613
+ }}
389
614
  onReply={noop}
390
615
  onUpdateAnnotation={noop}
391
616
  onExit={noop}
@@ -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);
@@ -115,9 +117,16 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
115
117
  notifyControls();
116
118
  }, [state, theme]);
117
119
 
118
- const invoke = async (): Promise<void> => {
120
+ const invoke = async (commentId?: string): Promise<void> => {
119
121
  if (thread.status !== "pending") return;
120
- invocations.current.push({ text: draft.current, writes: pendingWrites.current.splice(0) });
122
+ if (!commentId && !draft.current.trim()) return;
123
+ invocations.current.push({
124
+ text: draft.current,
125
+ commentId: commentId
126
+ ? (projection.mirrors.get(commentId)?.commentId ?? commentId)
127
+ : undefined,
128
+ writes: pendingWrites.current.splice(0),
129
+ });
121
130
  draft.current = "";
122
131
  if (invoking.current) return;
123
132
  invoking.current = true;
@@ -140,6 +149,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
140
149
  client.agentPrompt({
141
150
  id: thread.id,
142
151
  text: input.text,
152
+ commentId: input.commentId,
143
153
  inputOnly: Boolean(input.text.trim()),
144
154
  operationId: newAnnotationId(),
145
155
  }),
@@ -158,32 +168,36 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
158
168
  if (text) setPromptRestoreRequest({ id: ++restoreSequence.current, text });
159
169
  }
160
170
  };
161
- const invokeRef = useRef<() => Promise<void>>(async () => {});
171
+ const invokeRef = useRef<(commentId?: string) => Promise<void>>(async () => {});
162
172
 
163
173
  useEffect(() => {
164
174
  invokeRef.current = invoke;
165
175
  });
166
- const notifyInvoke = useEffectEvent((invoke: (() => void) | undefined) =>
176
+ const notifyInvoke = useEffectEvent((invoke: ((commentId?: string) => void) | undefined) =>
167
177
  props.onInvokeChange?.(invoke),
168
178
  );
169
179
 
170
180
  useEffect(() => {
171
- notifyInvoke(() => void invokeRef.current());
181
+ notifyInvoke((commentId) => void invokeRef.current(commentId));
172
182
 
173
183
  return () => notifyInvoke(undefined);
174
184
  }, []);
175
- const reply = (id: string, body: string): void => {
185
+ const reply = (id: string, body: string): string | void => {
176
186
  const origin = projection.mirrors.get(id)?.commentId ?? id;
177
187
  const root = agentCommentRoot(state, origin);
188
+ const replyFocusId = (commentId: string | void): string | void =>
189
+ commentId && projection.mirrors.has(id) ? `${id}:${commentId}` : commentId;
190
+
191
+ if (root) {
192
+ const commentId = newAnnotationId();
178
193
 
179
- if (root)
180
194
  pendingWrites.current.push(
181
195
  agent.act((client) =>
182
196
  client.agentComment({
183
197
  id: thread.id,
184
198
  comment: {
185
199
  ...root,
186
- id: newAnnotationId(),
200
+ id: commentId,
187
201
  body,
188
202
  sent: false,
189
203
  author: undefined,
@@ -192,7 +206,11 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
192
206
  }),
193
207
  ),
194
208
  );
195
- else child?.onReply(origin, body);
209
+
210
+ return replyFocusId(commentId);
211
+ }
212
+
213
+ return replyFocusId(child?.onReply(origin, body));
196
214
  };
197
215
  const actionFor = (id: string) => {
198
216
  const mirror = projection.mirrors.get(id);
@@ -245,7 +263,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
245
263
  Boolean(state.comments.find((entry) => entry.id === id)?.sent)
246
264
  }
247
265
  annotationAction={actionFor}
248
- onInvoke={() => void invoke()}
266
+ onInvoke={(commentId) => void invoke(commentId)}
249
267
  isPromptBlock={(index) => index === projection.tailIndex}
250
268
  promptRestoreRequest={promptRestoreRequest}
251
269
  promptFocusRequest={
@@ -276,14 +294,14 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
276
294
  return;
277
295
  }
278
296
  if (source?.kind === "mirror") {
279
- if (source.commentId) reply(source.commentId, body);
297
+ const mirrorId = projection.marks.get(span.start.blockIndex)?.[0]?.annotationId;
298
+
299
+ if (mirrorId) return reply(mirrorId, body);
280
300
 
281
301
  return;
282
302
  }
283
303
  if (source?.kind === "artifact" && end?.kind === "artifact") {
284
- child?.onAnnotate(span, body);
285
-
286
- return;
304
+ return child?.onAnnotate(span, body);
287
305
  }
288
306
  if (
289
307
  source?.kind !== "message" ||
@@ -307,6 +325,8 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
307
325
  pendingWrites.current.push(
308
326
  agent.act((client) => client.agentComment({ id: thread.id, comment })),
309
327
  );
328
+
329
+ return comment.id;
310
330
  }}
311
331
  onReply={reply}
312
332
  onUpdateAnnotation={(id, body) => {