@cueloop/client 0.1.0-alpha.100 → 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.100",
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.100",
31
- "@cueloop/extension-api": "0.1.0-alpha.100",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.100",
33
- "@cueloop/schema": "0.1.0-alpha.100",
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",
@@ -57,6 +57,7 @@ export function Composer({
57
57
  onInput,
58
58
  placeholder,
59
59
  agentEnabled = false,
60
+ focusRequest,
60
61
  }: {
61
62
  seed: string;
62
63
  glyph: string | null;
@@ -66,6 +67,7 @@ export function Composer({
66
67
  onInput: (text: string, caret: number) => void;
67
68
  placeholder?: string;
68
69
  agentEnabled?: boolean;
70
+ focusRequest?: number;
69
71
  }): React.ReactNode {
70
72
  const editorRef = useRef<TextareaRenderable | null>(null);
71
73
  const pastedImageCount = useRef(0);
@@ -105,6 +107,9 @@ export function Composer({
105
107
  onReady(() => editorRef.current?.plainText ?? "");
106
108
  // eslint-disable-next-line react-hooks/exhaustive-deps
107
109
  }, []);
110
+ useLayoutEffect(() => {
111
+ editorRef.current?.focus();
112
+ }, [focusRequest]);
108
113
 
109
114
  return (
110
115
  <box style={{ flexDirection: "row" }}>
@@ -312,12 +317,14 @@ export function CommentRow({
312
317
  tokens,
313
318
  authorLabel,
314
319
  action,
320
+ onFocus,
315
321
  }: {
316
322
  annotation: Annotation;
317
323
  tokens: Theme;
318
324
  /** The author's resolved display name, shown as a tooltip when the dot is hovered. */
319
325
  authorLabel?: string;
320
326
  action?: { label: string; run: () => void };
327
+ onFocus?: () => void;
321
328
  }): React.ReactNode {
322
329
  // own comments (no author) wear the filled dot, collaborators the outline
323
330
  const own = annotation.author === undefined;
@@ -332,7 +339,14 @@ export function CommentRow({
332
339
  : {};
333
340
 
334
341
  return (
335
- <box style={{ flexDirection: "column" }}>
342
+ <box
343
+ style={{ flexDirection: "column" }}
344
+ onMouseDown={(event) => {
345
+ if (!onFocus) return;
346
+ event.stopPropagation();
347
+ onFocus();
348
+ }}
349
+ >
336
350
  {annotation.body.split("\n").map((line, lineIndex) => (
337
351
  <box key={lineIndex} style={{ flexDirection: "row" }}>
338
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,19 +277,28 @@ 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("");
301
+ const [composerFocusRequest, setComposerFocusRequest] = useState(0);
293
302
  const composeTextRef = useRef("");
294
303
  const setDraft = (text: string): void => {
295
304
  composeTextRef.current = text;
@@ -436,6 +445,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
436
445
  readComposerText.current = null;
437
446
  composeRef.current = state;
438
447
  setCompose(state);
448
+ setComposerFocusRequest((request) => request + 1);
439
449
  setDraft(state.seed);
440
450
  setCaretOffset(state.seed.length);
441
451
  setSlashIndex(0);
@@ -471,7 +481,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
471
481
  if (!target || body.trim().length === 0) return;
472
482
  if (target.editAnnotationId !== null) {
473
483
  onUpdateAnnotation(target.editAnnotationId, body);
474
- if (invoke) onInvoke?.();
484
+ if (target.discussionKey) focusSavedComment(target.editAnnotationId, target.discussionKey);
485
+ if (invoke) onInvoke?.(target.editAnnotationId);
475
486
 
476
487
  return;
477
488
  }
@@ -479,8 +490,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
479
490
  const discussion = discussions.find((candidate) => candidate.key === target.discussionKey);
480
491
 
481
492
  if (discussion) {
482
- onReply(discussion.rootId, body);
483
- if (invoke) onInvoke?.();
493
+ const commentId = onReply(discussion.rootId, body);
494
+
495
+ if (commentId) focusSavedComment(commentId, discussion.key);
496
+ if (invoke && commentId) onInvoke?.(commentId);
484
497
 
485
498
  return;
486
499
  }
@@ -488,10 +501,22 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
488
501
  const span = target.span ?? caretSpan();
489
502
 
490
503
  if (span) {
491
- onAnnotate(span, body);
492
- if (invoke) onInvoke?.();
504
+ const commentId = onAnnotate(span, body);
505
+
506
+ completeSavedComment(span, commentId, prompt, invoke);
493
507
  }
494
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
+ };
495
520
  /** A new discussion on the typing anchor; the card renders under the span's last block. */
496
521
  const openNewCompose = (seed: string): void => {
497
522
  const span = caretSpan();
@@ -852,6 +877,16 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
852
877
  openNewCompose(sequence);
853
878
  };
854
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
+
855
890
  useSharedKeyboard((key) => {
856
891
  if (suspended) return;
857
892
  if (key.ctrl && key.name === "q") return onExit();
@@ -861,7 +896,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
861
896
  if (isAgentInvokeKey(key, onInvoke)) {
862
897
  key.preventDefault();
863
898
 
864
- return onInvoke?.();
899
+ return invokeFocusedComment();
865
900
  }
866
901
  if (key.name === "escape") {
867
902
  // from type mode esc only enters nav, so a held mark survives for `c`
@@ -903,6 +938,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
903
938
  tokens={tokens}
904
939
  onSave={saveComment}
905
940
  agentEnabled={Boolean(onInvoke)}
941
+ focusRequest={composerFocusRequest}
906
942
  onReady={(readText) => {
907
943
  readComposerText.current = readText;
908
944
  composerReady.current = true;
@@ -948,6 +984,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
948
984
  tokens={tokens}
949
985
  authorLabel={resolveAuthorLabel?.(annotation)}
950
986
  action={annotationAction?.(annotation.id)}
987
+ onFocus={() => {
988
+ blurSaveCompose();
989
+ focusSavedComment(annotation.id, discussion.key);
990
+ setCursor(discussion.blockIndex);
991
+ }}
951
992
  />
952
993
  ),
953
994
  };
@@ -996,6 +1037,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
996
1037
  return ranges;
997
1038
  };
998
1039
 
1040
+ const composeBlockIndex = compose?.kind === "prompt" ? source.count - 1 : compose?.blockIndex;
999
1041
  const cardsAfterLine = (
1000
1042
  blockIndex: number,
1001
1043
  line: VisualLine,
@@ -1004,7 +1046,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1004
1046
  const endsInLine = (end: number): boolean =>
1005
1047
  (end === 0 && line.start === 0) || (end - 1 >= line.start && end - 1 < line.end);
1006
1048
  const nodes: React.ReactNode[] = [];
1007
- const composeHere = compose && compose.blockIndex === blockIndex;
1049
+ const composeHere = compose && composeBlockIndex === blockIndex;
1008
1050
  const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
1009
1051
  const composeAnchoredHere =
1010
1052
  newComposeHere && (compose!.span ? endsInLine(compose!.span.end.char) : isLastLine);
@@ -1151,7 +1193,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1151
1193
  const geometry = allGeometry();
1152
1194
  const promptLine = geometry.find((entry) => isPromptBlock?.(entry.blockIndex));
1153
1195
  const lastReplyLine = geometry
1154
- .filter((entry) => entry.blockIndex === blockIndex - 1)
1196
+ // Activity blocks render outside the text geometry, so use the last measured line.
1197
+ .filter((entry) => entry.blockIndex < blockIndex)
1155
1198
  .sort((left, right) => right.y - left.y)[0];
1156
1199
  const promptStart = promptLine ? promptLine.y - 1 : lastReplyLine && lastReplyLine.y + 1;
1157
1200
  const viewport = dragViewport?.();
@@ -1226,7 +1269,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1226
1269
 
1227
1270
  function isAgentInvokeKey(
1228
1271
  key: { name: string; ctrl?: boolean; meta?: boolean; super?: boolean },
1229
- onInvoke?: () => void,
1272
+ onInvoke?: (commentId?: string) => void,
1230
1273
  ): boolean {
1231
1274
  return Boolean(
1232
1275
  onInvoke &&
@@ -10,3 +10,11 @@ test("experimental agent UI requires a local owner and an enabled flag", () => {
10
10
  expect(canRunThreadAgent({ ...local, editing: true })).toBe(false);
11
11
  expect(canRunThreadAgent({ ...local, shared: true })).toBe(false);
12
12
  });
13
+
14
+ test("a per-share agent capability enables collaborator chat without a gateway flag", () => {
15
+ const shared = { enabled: false, owner: false, editing: false, shared: true };
16
+
17
+ expect(canRunThreadAgent(shared)).toBe(false);
18
+ expect(canRunThreadAgent({ ...shared, sharedAgent: true })).toBe(true);
19
+ expect(canRunThreadAgent({ ...shared, sharedAgent: true, editing: true })).toBe(false);
20
+ });
package/src/app/App.tsx CHANGED
@@ -119,6 +119,7 @@ export interface AppProps {
119
119
  sessionId?: string;
120
120
  initialReview?: { session: Thread; diff: { patch: string; files: DiffFileContents[] } };
121
121
  initialClient?: ThreadClient;
122
+ agentClient?: import("../thread/use-thread-agent").ThreadAgentClient;
122
123
  cwd?: string;
123
124
  readOnly?: boolean;
124
125
  onExit?: (code: number) => void;
@@ -531,6 +532,7 @@ export function App({
531
532
  sessionId,
532
533
  initialReview,
533
534
  initialClient,
535
+ agentClient,
534
536
  cwd,
535
537
  readOnly = false,
536
538
  onExit,
@@ -840,7 +842,7 @@ export function App({
840
842
  const agentActions = useRef<(id: string) => { label: string; run: () => void } | undefined>(
841
843
  () => undefined,
842
844
  );
843
- const agentInvoke = useRef<(() => void) | undefined>(undefined);
845
+ const agentInvoke = useRef<((commentId?: string) => void) | undefined>(undefined);
844
846
  const [agentState, setAgentState] = useState<import("@cueloop/schema").ThreadAgentState>();
845
847
  const [prototypeComposing, setPrototypeComposing] = useState(false);
846
848
  const [welcomeComposing, setWelcomeComposing] = useState(false);
@@ -1315,11 +1317,13 @@ export function App({
1315
1317
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
1316
1318
  <AgentThreadPrototype
1317
1319
  key={activeSession.id}
1320
+ client={agentClient}
1318
1321
  enabled={canRunThreadAgent({
1319
1322
  enabled: threadAgentEnabled,
1320
1323
  owner: isOwner,
1321
1324
  editing: bodyEditing.editing,
1322
1325
  shared: isSharedThreadConnection(openClient, shareTransport),
1326
+ sharedAgent: sharedAgentAvailable(agentClient, activeSession),
1323
1327
  })}
1324
1328
  observer={observer}
1325
1329
  pixelPrototype={isPixelPrototype}
@@ -1480,7 +1484,7 @@ export function App({
1480
1484
  // a diff thread's Changes view is the artifact itself; any other thread's is the
1481
1485
  // live working-tree diff, so its notes carry a file target and anchor there
1482
1486
  onAnnotate: (span, body) =>
1483
- void controller.annotate(
1487
+ controller.annotate(
1484
1488
  "comment",
1485
1489
  span.start.blockIndex,
1486
1490
  span.start.char,
@@ -1492,11 +1496,11 @@ export function App({
1492
1496
  : { kind: "file", path: "", rev: "worktree" },
1493
1497
  ),
1494
1498
  onReply: (rootAnnotationId, body) =>
1495
- void controller.reply(rootAnnotationId, body),
1499
+ controller.reply(rootAnnotationId, body),
1496
1500
  onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1497
1501
  onInvoke: agentActive
1498
- ? () => {
1499
- agentInvoke.current?.();
1502
+ ? (commentId) => {
1503
+ agentInvoke.current?.(commentId);
1500
1504
  setFocusedPane("thread");
1501
1505
  }
1502
1506
  : undefined,
@@ -1592,6 +1596,12 @@ export function App({
1592
1596
  threadName={shareThreadName(session)}
1593
1597
  links={shareLinksFor(session)}
1594
1598
  isOwner={isOwner}
1599
+ agentAvailable={canRunThreadAgent({
1600
+ enabled: threadAgentEnabled,
1601
+ owner: isOwner,
1602
+ editing: false,
1603
+ shared: isSharedThreadConnection(openClient, shareTransport),
1604
+ })}
1595
1605
  onCreateLink={(input) => controller.createShareLink(input)}
1596
1606
  onUpdateLink={(id, input) => controller.updateShareLink(id, input)}
1597
1607
  onDeleteLink={(id) => controller.deleteShareLink(id)}
@@ -1618,8 +1628,15 @@ export function canRunThreadAgent(options: {
1618
1628
  owner: boolean;
1619
1629
  editing: boolean;
1620
1630
  shared: boolean;
1631
+ sharedAgent?: boolean;
1621
1632
  }): boolean {
1622
- return options.enabled && options.owner && !options.editing && !options.shared;
1633
+ if (options.editing) return false;
1634
+
1635
+ return Boolean(options.sharedAgent || (options.enabled && options.owner && !options.shared));
1636
+ }
1637
+
1638
+ function sharedAgentAvailable(client: AppProps["agentClient"], thread: Thread): boolean {
1639
+ return Boolean(client && thread.shares?.[0]?.agentEnabled);
1623
1640
  }
1624
1641
 
1625
1642
  function isSubmittedComment(
@@ -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;
@@ -0,0 +1,50 @@
1
+ import { expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { DARK } from "../../appearance/theme";
5
+ import { press, waitForText } from "../../testing/test-support";
6
+ import { ShareDialog } from "./ShareDialog";
7
+ import { newWizardDraft, shareDialogStore } from "./share-dialog-store";
8
+ import type { NewShareLink } from "../../thread/thread-controller";
9
+
10
+ for (const enabled of [false, true])
11
+ test(`share agent toggle is ${enabled ? "opt-in" : "hidden"}`, async () => {
12
+ const created: NewShareLink[] = [];
13
+
14
+ shareDialogStore.getState().reset();
15
+ shareDialogStore.getState().openWizard(newWizardDraft("Review"));
16
+ const setup = await testRender(
17
+ <ShareDialog
18
+ isOpen
19
+ isOwner
20
+ agentAvailable={enabled}
21
+ links={[]}
22
+ threadName="Review"
23
+ theme={DARK}
24
+ onCreateLink={(input) => created.push(input)}
25
+ onUpdateLink={() => {}}
26
+ onDeleteLink={() => {}}
27
+ onCopyLink={() => {}}
28
+ onClose={() => {}}
29
+ />,
30
+ { width: 100, height: 26 },
31
+ );
32
+
33
+ try {
34
+ await waitForText(setup, "link name");
35
+ expect(shareDialogStore.getState().wizard?.agentEnabled ?? false).toBe(false);
36
+ expect(setup.captureCharFrame().includes("Allow agent messages")).toBe(enabled);
37
+ await press(setup, "down");
38
+ await press(setup, "down");
39
+ if (enabled) {
40
+ await press(setup, " ");
41
+ await press(setup, "down");
42
+ }
43
+ await press(setup, "enter");
44
+ expect(created).toHaveLength(1);
45
+ expect(created[0]?.agentEnabled).toBe(enabled ? true : undefined);
46
+ } finally {
47
+ setup.renderer.destroy();
48
+ shareDialogStore.getState().reset();
49
+ }
50
+ });
@@ -68,6 +68,7 @@ function draftForLink(link: ShareLink, threadName: string): ShareWizardDraft {
68
68
  editingId: link.id,
69
69
  name: link.name ?? threadName,
70
70
  requireAuth: link.requireAuth,
71
+ agentEnabled: link.agentEnabled,
71
72
  allowlist: link.allowlist,
72
73
  };
73
74
  }
@@ -259,7 +260,7 @@ function LinksList({
259
260
  }
260
261
 
261
262
  /** The focus ring positions inside the wizard, in vertical order. */
262
- type WizardFocus = "name" | "auth" | "allowlist" | "actions";
263
+ type WizardFocus = "name" | "auth" | "allowlist" | "agent" | "actions";
263
264
 
264
265
  function Wizard({
265
266
  draft,
@@ -267,6 +268,8 @@ function Wizard({
267
268
  saveDisabled,
268
269
  onName,
269
270
  onToggleAuth,
271
+ agentAvailable,
272
+ onToggleAgent,
270
273
  onAllowlist,
271
274
  onSave,
272
275
  onCancel,
@@ -278,6 +281,8 @@ function Wizard({
278
281
  saveDisabled: boolean;
279
282
  onName: (name: string) => void;
280
283
  onToggleAuth: () => void;
284
+ agentAvailable: boolean;
285
+ onToggleAgent: () => void;
281
286
  onAllowlist: (logins: string[]) => void;
282
287
  onSave: () => void;
283
288
  onCancel: () => void;
@@ -329,6 +334,13 @@ function Wizard({
329
334
  />
330
335
  </box>
331
336
  ) : null}
337
+ {agentAvailable ? (
338
+ <box style={{ flexDirection: "row", alignItems: "center", marginTop: 1 }}>
339
+ <text fg={focus === "agent" ? tokens.accent : tokens.textDim}>Allow agent messages</text>
340
+ <box style={{ flexGrow: 1 }} />
341
+ <Switch on={draft.agentEnabled ?? false} onToggle={onToggleAgent} theme={theme} />
342
+ </box>
343
+ ) : null}
332
344
  <box style={{ flexGrow: 1 }} />
333
345
  <DialogActions
334
346
  confirmLabel={draft.editingId ? "save" : "create"}
@@ -346,6 +358,7 @@ export interface ShareDialogProps {
346
358
  threadName: string;
347
359
  links: ShareLink[];
348
360
  isOwner: boolean;
361
+ agentAvailable?: boolean;
349
362
  onCreateLink: (input: NewShareLink) => void;
350
363
  onUpdateLink: (id: string, input: NewShareLink) => void;
351
364
  onDeleteLink: (id: string) => void;
@@ -359,6 +372,7 @@ export function ShareDialog({
359
372
  threadName,
360
373
  links,
361
374
  isOwner,
375
+ agentAvailable = false,
362
376
  onCreateLink,
363
377
  onUpdateLink,
364
378
  onDeleteLink,
@@ -384,8 +398,13 @@ export function ShareDialog({
384
398
  shareDialogStore.getState().openWizard(draft);
385
399
  };
386
400
 
387
- const wizardRing = (): WizardFocus[] =>
388
- wizard?.requireAuth ? ["name", "auth", "allowlist", "actions"] : ["name", "auth", "actions"];
401
+ const wizardRing = (): WizardFocus[] => [
402
+ "name",
403
+ "auth",
404
+ ...(wizard?.requireAuth ? ["allowlist" as const] : []),
405
+ ...(agentAvailable ? ["agent" as const] : []),
406
+ "actions",
407
+ ];
389
408
 
390
409
  const moveWizardFocus = (delta: number): void => {
391
410
  const ring = wizardRing();
@@ -405,6 +424,7 @@ export function ShareDialog({
405
424
  allowlist: wizard.allowlist,
406
425
  };
407
426
 
427
+ if (agentAvailable) input.agentEnabled = wizard.agentEnabled ?? false;
408
428
  if (wizard.editingId) onUpdateLink(wizard.editingId, input);
409
429
  else onCreateLink(input);
410
430
  backToList();
@@ -436,6 +456,8 @@ export function ShareDialog({
436
456
  const activated = name === "return" || name === "enter";
437
457
 
438
458
  if (wizardFocus === "auth" && (activated || name === "space")) return toggleAuth();
459
+ if (wizardFocus === "agent" && (activated || name === "space"))
460
+ return shareDialogStore.getState().setAgentEnabled(!wizard.agentEnabled);
439
461
  if (wizardFocus === "actions" && activated) saveWizard();
440
462
  };
441
463
 
@@ -494,6 +516,8 @@ export function ShareDialog({
494
516
  >
495
517
  {wizard ? (
496
518
  <Wizard
519
+ agentAvailable={agentAvailable}
520
+ onToggleAgent={() => shareDialogStore.getState().setAgentEnabled(!wizard.agentEnabled)}
497
521
  draft={wizard}
498
522
  focus={wizardFocus}
499
523
  saveDisabled={saveDisabled}
@@ -16,6 +16,7 @@ export interface ShareWizardDraft {
16
16
  name: string;
17
17
  requireAuth: boolean;
18
18
  allowlist: string[];
19
+ agentEnabled?: boolean;
19
20
  }
20
21
 
21
22
  export interface ShareDialogState {
@@ -27,6 +28,7 @@ export interface ShareDialogState {
27
28
  setName: (name: string) => void;
28
29
  setRequireAuth: (requireAuth: boolean) => void;
29
30
  setAllowlist: (allowlist: string[]) => void;
31
+ setAgentEnabled: (agentEnabled: boolean) => void;
30
32
  reset: () => void;
31
33
  }
32
34
 
@@ -46,6 +48,8 @@ export const shareDialogStore = createStore<ShareDialogState>((set) => ({
46
48
  set((state) => (state.wizard ? { wizard: { ...state.wizard, requireAuth } } : {})),
47
49
  setAllowlist: (allowlist) =>
48
50
  set((state) => (state.wizard ? { wizard: { ...state.wizard, allowlist } } : {})),
51
+ setAgentEnabled: (agentEnabled) =>
52
+ set((state) => (state.wizard ? { wizard: { ...state.wizard, agentEnabled } } : {})),
49
53
  reset: () => set({ category: "external", wizard: null }),
50
54
  }));
51
55
 
@@ -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;
@@ -412,7 +412,7 @@ export function ThreadView({
412
412
  blockIndex === 0 || tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
413
413
  const measuredBox = (children: React.ReactNode): React.ReactNode => (
414
414
  <box
415
- key={`discussion-block-${blockIndex}`}
415
+ key={isPromptBlock?.(blockIndex) ? "prompt-block" : `discussion-block-${blockIndex}`}
416
416
  id={`discussion-block-${blockIndex}`}
417
417
  ref={virtual.measureRef(blockIndex)}
418
418
  style={{ flexDirection: "column" }}
@@ -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