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

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.101",
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.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",
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" }}>
@@ -290,6 +290,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
290
290
  };
291
291
  const [folded, setFolded] = useState<Set<string>>(new Set());
292
292
  const [composeText, setComposeText] = useState("");
293
+ const [composerFocusRequest, setComposerFocusRequest] = useState(0);
293
294
  const composeTextRef = useRef("");
294
295
  const setDraft = (text: string): void => {
295
296
  composeTextRef.current = text;
@@ -436,6 +437,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
436
437
  readComposerText.current = null;
437
438
  composeRef.current = state;
438
439
  setCompose(state);
440
+ setComposerFocusRequest((request) => request + 1);
439
441
  setDraft(state.seed);
440
442
  setCaretOffset(state.seed.length);
441
443
  setSlashIndex(0);
@@ -903,6 +905,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
903
905
  tokens={tokens}
904
906
  onSave={saveComment}
905
907
  agentEnabled={Boolean(onInvoke)}
908
+ focusRequest={composerFocusRequest}
906
909
  onReady={(readText) => {
907
910
  readComposerText.current = readText;
908
911
  composerReady.current = true;
@@ -996,6 +999,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
996
999
  return ranges;
997
1000
  };
998
1001
 
1002
+ const composeBlockIndex = compose?.kind === "prompt" ? source.count - 1 : compose?.blockIndex;
999
1003
  const cardsAfterLine = (
1000
1004
  blockIndex: number,
1001
1005
  line: VisualLine,
@@ -1004,7 +1008,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1004
1008
  const endsInLine = (end: number): boolean =>
1005
1009
  (end === 0 && line.start === 0) || (end - 1 >= line.start && end - 1 < line.end);
1006
1010
  const nodes: React.ReactNode[] = [];
1007
- const composeHere = compose && compose.blockIndex === blockIndex;
1011
+ const composeHere = compose && composeBlockIndex === blockIndex;
1008
1012
  const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
1009
1013
  const composeAnchoredHere =
1010
1014
  newComposeHere && (compose!.span ? endsInLine(compose!.span.end.char) : isLastLine);
@@ -1151,7 +1155,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1151
1155
  const geometry = allGeometry();
1152
1156
  const promptLine = geometry.find((entry) => isPromptBlock?.(entry.blockIndex));
1153
1157
  const lastReplyLine = geometry
1154
- .filter((entry) => entry.blockIndex === blockIndex - 1)
1158
+ // Activity blocks render outside the text geometry, so use the last measured line.
1159
+ .filter((entry) => entry.blockIndex < blockIndex)
1155
1160
  .sort((left, right) => right.y - left.y)[0];
1156
1161
  const promptStart = promptLine ? promptLine.y - 1 : lastReplyLine && lastReplyLine.y + 1;
1157
1162
  const viewport = dragViewport?.();
@@ -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,
@@ -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}
@@ -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(
@@ -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
 
@@ -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" }}
@@ -1,7 +1,12 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import React from "react";
3
3
  import { testRender } from "@opentui/react/test-utils";
4
- import { SCHEMA_VERSION, type Thread, type ThreadAgentState } from "@cueloop/schema";
4
+ import {
5
+ SCHEMA_VERSION,
6
+ type Thread,
7
+ type ThreadAgentState,
8
+ type AgentPromptRequest,
9
+ } from "@cueloop/schema";
5
10
  import { AgentThreadPane, AgentThreadPrototype } from "./AgentThreadPane";
6
11
  import { ThreadFooter } from "./ThreadFooter";
7
12
  import { ThreadView } from "../../markdown/components/ThreadView";
@@ -16,6 +21,7 @@ import {
16
21
  waitForState,
17
22
  } from "../../testing/test-support";
18
23
  import type { ThreadAgentClient } from "../use-thread-agent";
24
+ import type { EventFrame } from "@cueloop/daemon/client";
19
25
 
20
26
  const thread: Thread = {
21
27
  schemaVersion: SCHEMA_VERSION,
@@ -39,8 +45,14 @@ const empty: ThreadAgentState = {
39
45
 
40
46
  function createTestAgentClient(initial: ThreadAgentState) {
41
47
  let state = initial;
42
- const prompts: { id: string; text: string; retry?: string }[] = [];
48
+ const prompts: AgentPromptRequest[] = [];
49
+ const listeners = new Set<(event: EventFrame) => void>();
43
50
  const client: ThreadAgentClient = {
51
+ onEvent: (listener) => {
52
+ listeners.add(listener);
53
+
54
+ return () => listeners.delete(listener);
55
+ },
44
56
  agentGet: async () => state,
45
57
  agentPrompt: async (params) => {
46
58
  prompts.push(params);
@@ -66,7 +78,12 @@ function createTestAgentClient(initial: ThreadAgentState) {
66
78
  agentPermission: async () => ({ ...state, phase: { kind: "idle" } }),
67
79
  };
68
80
 
69
- return { client, prompts };
81
+ const publishTestAgentState = (next: ThreadAgentState): void => {
82
+ state = next;
83
+ for (const listener of listeners) listener({ event: "agent.updated", sessionId: thread.id });
84
+ };
85
+
86
+ return { client, prompts, publishTestAgentState };
70
87
  }
71
88
 
72
89
  function artifactView(
@@ -132,6 +149,7 @@ test("typing on the final blank line invokes the agent while the existing footer
132
149
  setup.mockInput.pressKey("RETURN", { ctrl: true });
133
150
  await waitForText(setup, "The timer survives cancellation.");
134
151
  expect(prompts[0]?.text).toBe("Explain retries");
152
+ expect(prompts[0]?.inputOnly).toBe(true);
135
153
  const send = locateText(setup, "Send message (0)");
136
154
 
137
155
  await setup.mockMouse.click(send.column, send.row);
@@ -827,3 +845,91 @@ test("a failed mutation flush restores the prompt alongside a newer visible draf
827
845
  setup.renderer.destroy();
828
846
  }
829
847
  });
848
+
849
+ test("an offline shared agent accepts continuation below its status without model controls", async () => {
850
+ const state: ThreadAgentState = {
851
+ ...empty,
852
+ messages: [
853
+ { id: "online-answer", role: "agent", text: "An online answer", complete: true, revision: 1 },
854
+ ],
855
+ };
856
+ const { client, prompts, publishTestAgentState } = createTestAgentClient(state);
857
+ const setup = await testRender(
858
+ <AgentThreadPane
859
+ thread={thread}
860
+ client={client}
861
+ focused
862
+ theme={DARK}
863
+ onActiveChange={noop}
864
+ onOpenFile={noop}
865
+ >
866
+ {artifactView(thread)}
867
+ </AgentThreadPane>,
868
+ { width: 80, height: 15, kittyKeyboard: true },
869
+ );
870
+
871
+ try {
872
+ await waitForText(setup, "An online answer");
873
+ await waitForState(
874
+ setup,
875
+ () => setup.renderer.currentFocusedEditor !== null,
876
+ "online continuation editor",
877
+ );
878
+ publishTestAgentState({ ...state, phase: { kind: "offline" } });
879
+ await waitForText(setup, "Owner offline");
880
+ expect(setup.captureCharFrame()).toContain("Original artifact");
881
+ expect(setup.captureCharFrame()).not.toContain("Thinking");
882
+ const status = locateText(setup, "Owner offline");
883
+
884
+ await setup.mockMouse.click(status.column + 5, status.row + 3);
885
+ await waitForState(
886
+ setup,
887
+ () => setup.renderer.currentFocusedEditor !== null,
888
+ "offline continuation editor",
889
+ );
890
+ await typeText(setup, "Queue until the owner reconnects");
891
+ await waitForText(setup, "Queue until the owner reconnects");
892
+ await pressKey(setup, "RETURN", { ctrl: true });
893
+ expect(prompts[0]?.text).toBe("Queue until the owner reconnects");
894
+ expect(prompts[0]?.inputOnly).toBe(true);
895
+ } finally {
896
+ setup.renderer.destroy();
897
+ }
898
+ });
899
+
900
+ test("shared viewers see pending activity without owner permission controls", async () => {
901
+ const { client } = createTestAgentClient({
902
+ ...empty,
903
+ phase: {
904
+ kind: "permission",
905
+ permission: {
906
+ id: "owner-permission",
907
+ title: "Owner approval required",
908
+ options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }],
909
+ },
910
+ },
911
+ });
912
+
913
+ client.canControlAgent = false;
914
+ const setup = await testRender(
915
+ <AgentThreadPane
916
+ thread={thread}
917
+ client={client}
918
+ focused
919
+ theme={DARK}
920
+ onActiveChange={noop}
921
+ onOpenFile={noop}
922
+ >
923
+ {artifactView(thread)}
924
+ </AgentThreadPane>,
925
+ { width: 80, height: 15 },
926
+ );
927
+
928
+ try {
929
+ await waitForText(setup, "Thinking");
930
+ expect(setup.captureCharFrame()).not.toContain("Allow once");
931
+ expect(setup.captureCharFrame()).not.toContain("Owner approval required");
932
+ } finally {
933
+ setup.renderer.destroy();
934
+ }
935
+ });
@@ -86,18 +86,20 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
86
86
  const notifyState = useEffectEvent(() => props.onStateChange?.(state));
87
87
  const notifyControls = useEffectEvent(() =>
88
88
  props.onControlsChange?.(
89
- <AgentConfigControls
90
- state={state}
91
- theme={theme}
92
- onConfigure={(configId, value) =>
93
- void agent.act((client) => {
94
- if (!client.agentConfigure)
95
- return Promise.reject(new Error("Agent configuration is unavailable"));
96
-
97
- return client.agentConfigure({ id: thread.id, configId, value });
98
- })
99
- }
100
- />,
89
+ props.client && !props.client.agentConfigure ? null : (
90
+ <AgentConfigControls
91
+ state={state}
92
+ theme={theme}
93
+ onConfigure={(configId, value) =>
94
+ void agent.act((client) => {
95
+ if (!client.agentConfigure)
96
+ return Promise.reject(new Error("Agent configuration is unavailable"));
97
+
98
+ return client.agentConfigure({ id: thread.id, configId, value });
99
+ })
100
+ }
101
+ />
102
+ ),
101
103
  ),
102
104
  );
103
105
 
@@ -135,7 +137,12 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
135
137
  }
136
138
  await props.flushMutations?.();
137
139
  const accepted = await agent.act((client) =>
138
- client.agentPrompt({ id: thread.id, text: input.text }),
140
+ client.agentPrompt({
141
+ id: thread.id,
142
+ text: input.text,
143
+ inputOnly: Boolean(input.text.trim()),
144
+ operationId: newAnnotationId(),
145
+ }),
139
146
  );
140
147
 
141
148
  if (!accepted) rejected.push(input.text);
@@ -194,7 +201,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
194
201
  );
195
202
 
196
203
  if (!submission) return undefined;
197
- if (submission.status === "failed")
204
+ if (submission.status === "failed" && props.client?.canControlAgent !== false)
198
205
  return {
199
206
  label: "Retry",
200
207
  run: () =>
@@ -317,8 +324,11 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
317
324
  renderBlock={(index) =>
318
325
  index === projection.activityIndex ? (
319
326
  <box style={{ flexDirection: "column", paddingLeft: 2 }}>
327
+ {state.phase.kind === "offline" ? (
328
+ <text fg={theme.textMuted}>Owner offline</text>
329
+ ) : null}
320
330
  {busy ? <text fg={pulse ? theme.textDim : theme.textMuted}>Thinking…</text> : null}
321
- {state.phase.kind === "permission" ? (
331
+ {state.phase.kind === "permission" && props.client?.canControlAgent !== false ? (
322
332
  <>
323
333
  <text fg={theme.text}>{state.phase.permission.title}</text>
324
334
  <box style={{ flexDirection: "row", gap: 2 }}>
@@ -39,6 +39,7 @@ import {
39
39
  type Artifact,
40
40
  type DiffFileContents,
41
41
  type ShareLink,
42
+ type SharePolicy,
42
43
  type Thread,
43
44
  type SessionHistory,
44
45
  type MessageOutcome,
@@ -241,6 +242,7 @@ export interface NewShareLink {
241
242
  name?: string;
242
243
  requireAuth: boolean;
243
244
  allowlist: string[];
245
+ agentEnabled?: boolean;
244
246
  }
245
247
 
246
248
  export interface ReviewControllerOptions {
@@ -1697,15 +1699,16 @@ class Controller implements ReviewController {
1697
1699
  this.setStatus("sharing…");
1698
1700
  this.freezeForShare(session)
1699
1701
  .then((shared) => {
1700
- return this.shareTransport.publish(
1701
- shared,
1702
- {},
1703
- {
1704
- requireAuth: input.requireAuth,
1705
- allowlist: input.allowlist,
1706
- shareBranch,
1707
- },
1708
- );
1702
+ const policy: ShareLink = {
1703
+ id: session.id,
1704
+ requireAuth: input.requireAuth,
1705
+ allowlist: input.allowlist,
1706
+ shareBranch,
1707
+ };
1708
+
1709
+ if (input.agentEnabled !== undefined) policy.agentEnabled = input.agentEnabled;
1710
+
1711
+ return this.shareTransport.publish(shared, {}, policy);
1709
1712
  })
1710
1713
  .then(async ({ line, copied }) => {
1711
1714
  const id = this.shareTransport.parseShareId(line);
@@ -1719,6 +1722,7 @@ class Controller implements ReviewController {
1719
1722
  shareBranch,
1720
1723
  };
1721
1724
 
1725
+ if (input.agentEnabled !== undefined) link.agentEnabled = input.agentEnabled;
1722
1726
  await client.sessionSetShares(session.id, [...this.linksFor(this.snapshot.session), link]);
1723
1727
  this.setStatus("");
1724
1728
  this.showToast(line, copied ? "link copied" : "link created");
@@ -1738,20 +1742,24 @@ class Controller implements ReviewController {
1738
1742
  if (!links.some((link) => link.id === id)) return;
1739
1743
  const allowlist = input.requireAuth ? input.allowlist : [];
1740
1744
 
1745
+ const policy: SharePolicy = { requireAuth: input.requireAuth, allowlist };
1746
+
1747
+ if (input.agentEnabled !== undefined) policy.agentEnabled = input.agentEnabled;
1741
1748
  // Publish the same policy stored on the local link.
1742
- void this.shareTransport
1743
- .push(id, [], { requireAuth: input.requireAuth, allowlist })
1744
- .catch(() => {});
1745
- const next = links.map((link) =>
1746
- link.id === id
1747
- ? {
1748
- ...link,
1749
- name: input.name?.trim() || undefined,
1750
- requireAuth: input.requireAuth,
1751
- allowlist,
1752
- }
1753
- : link,
1754
- );
1749
+ void this.shareTransport.push(id, [], policy).catch(() => {});
1750
+ const next = links.map((link) => {
1751
+ if (link.id !== id) return link;
1752
+ const updated = {
1753
+ ...link,
1754
+ name: input.name?.trim() || undefined,
1755
+ requireAuth: input.requireAuth,
1756
+ allowlist,
1757
+ };
1758
+
1759
+ if (input.agentEnabled !== undefined) updated.agentEnabled = input.agentEnabled;
1760
+
1761
+ return updated;
1762
+ });
1755
1763
 
1756
1764
  this.applyOptimistic(
1757
1765
  { ...session, shares: next },
@@ -8,7 +8,7 @@ export type ThreadAgentClient = Pick<
8
8
  DaemonClient,
9
9
  "agentGet" | "agentPrompt" | "agentCancel" | "agentComment" | "agentPermission"
10
10
  > &
11
- Partial<Pick<DaemonClient, "agentConfigure">>;
11
+ Partial<Pick<DaemonClient, "agentConfigure" | "onEvent">> & { canControlAgent?: boolean };
12
12
 
13
13
  /** Subscribe to durable agent state; unmount closes only this client connection. */
14
14
  export function useThreadAgent(id: string, home?: string, injected?: ThreadAgentClient) {
@@ -48,6 +48,12 @@ export function useThreadAgent(id: string, home?: string, injected?: ThreadAgent
48
48
  void (async () => {
49
49
  try {
50
50
  if (injected) {
51
+ unsubscribe = injected.onEvent?.((event) => {
52
+ if (event.event === "agent.updated" && event.sessionId === id)
53
+ void refresh(injected).catch((error) => {
54
+ if (!cancelled) setError(String(error));
55
+ });
56
+ });
51
57
  await refresh(injected);
52
58
  await configure(injected);
53
59