@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 +5 -5
- package/src/annotations/components/AnnotationCards.tsx +15 -1
- package/src/annotations/use-annotation-surface.tsx +64 -21
- package/src/app/App.thread-agent.test.ts +8 -0
- package/src/app/App.tsx +23 -6
- package/src/diff/components/AnnotatableFileView.tsx +2 -2
- package/src/diff/components/DiffContentView.tsx +3 -3
- package/src/integrations/components/ShareDialog.agent.test.tsx +50 -0
- package/src/integrations/components/ShareDialog.tsx +27 -3
- package/src/integrations/components/share-dialog-store.ts +4 -0
- package/src/markdown/components/ThreadView.tsx +4 -4
- package/src/thread/agent-transcript.ts +11 -10
- package/src/thread/components/AgentThreadPane.test.tsx +335 -4
- package/src/thread/components/AgentThreadPane.tsx +61 -31
- package/src/thread/thread-controller.ts +30 -22
- package/src/thread/use-thread-agent.ts +7 -1
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
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);
|
|
@@ -356,6 +374,227 @@ test("typing on an accepted plain prompt cannot edit it or create an unmarked co
|
|
|
356
374
|
}
|
|
357
375
|
});
|
|
358
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
|
+
|
|
359
598
|
test("Option+Enter saves a comment, Command+Enter leaves its draft, and Ctrl+Enter invokes", async () => {
|
|
360
599
|
const saved: string[] = [];
|
|
361
600
|
let invocations = 0;
|
|
@@ -367,7 +606,11 @@ test("Option+Enter saves a comment, Command+Enter leaves its draft, and Ctrl+Ent
|
|
|
367
606
|
marks={marksByDisplay([], display)}
|
|
368
607
|
quickActions={[]}
|
|
369
608
|
observer={false}
|
|
370
|
-
onAnnotate={(_span, body) =>
|
|
609
|
+
onAnnotate={(_span, body) => {
|
|
610
|
+
saved.push(body);
|
|
611
|
+
|
|
612
|
+
return `saved-${saved.length}`;
|
|
613
|
+
}}
|
|
371
614
|
onReply={noop}
|
|
372
615
|
onUpdateAnnotation={noop}
|
|
373
616
|
onExit={noop}
|
|
@@ -827,3 +1070,91 @@ test("a failed mutation flush restores the prompt alongside a newer visible draf
|
|
|
827
1070
|
setup.renderer.destroy();
|
|
828
1071
|
}
|
|
829
1072
|
});
|
|
1073
|
+
|
|
1074
|
+
test("an offline shared agent accepts continuation below its status without model controls", async () => {
|
|
1075
|
+
const state: ThreadAgentState = {
|
|
1076
|
+
...empty,
|
|
1077
|
+
messages: [
|
|
1078
|
+
{ id: "online-answer", role: "agent", text: "An online answer", complete: true, revision: 1 },
|
|
1079
|
+
],
|
|
1080
|
+
};
|
|
1081
|
+
const { client, prompts, publishTestAgentState } = createTestAgentClient(state);
|
|
1082
|
+
const setup = await testRender(
|
|
1083
|
+
<AgentThreadPane
|
|
1084
|
+
thread={thread}
|
|
1085
|
+
client={client}
|
|
1086
|
+
focused
|
|
1087
|
+
theme={DARK}
|
|
1088
|
+
onActiveChange={noop}
|
|
1089
|
+
onOpenFile={noop}
|
|
1090
|
+
>
|
|
1091
|
+
{artifactView(thread)}
|
|
1092
|
+
</AgentThreadPane>,
|
|
1093
|
+
{ width: 80, height: 15, kittyKeyboard: true },
|
|
1094
|
+
);
|
|
1095
|
+
|
|
1096
|
+
try {
|
|
1097
|
+
await waitForText(setup, "An online answer");
|
|
1098
|
+
await waitForState(
|
|
1099
|
+
setup,
|
|
1100
|
+
() => setup.renderer.currentFocusedEditor !== null,
|
|
1101
|
+
"online continuation editor",
|
|
1102
|
+
);
|
|
1103
|
+
publishTestAgentState({ ...state, phase: { kind: "offline" } });
|
|
1104
|
+
await waitForText(setup, "Owner offline");
|
|
1105
|
+
expect(setup.captureCharFrame()).toContain("Original artifact");
|
|
1106
|
+
expect(setup.captureCharFrame()).not.toContain("Thinking");
|
|
1107
|
+
const status = locateText(setup, "Owner offline");
|
|
1108
|
+
|
|
1109
|
+
await setup.mockMouse.click(status.column + 5, status.row + 3);
|
|
1110
|
+
await waitForState(
|
|
1111
|
+
setup,
|
|
1112
|
+
() => setup.renderer.currentFocusedEditor !== null,
|
|
1113
|
+
"offline continuation editor",
|
|
1114
|
+
);
|
|
1115
|
+
await typeText(setup, "Queue until the owner reconnects");
|
|
1116
|
+
await waitForText(setup, "Queue until the owner reconnects");
|
|
1117
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
1118
|
+
expect(prompts[0]?.text).toBe("Queue until the owner reconnects");
|
|
1119
|
+
expect(prompts[0]?.inputOnly).toBe(true);
|
|
1120
|
+
} finally {
|
|
1121
|
+
setup.renderer.destroy();
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
test("shared viewers see pending activity without owner permission controls", async () => {
|
|
1126
|
+
const { client } = createTestAgentClient({
|
|
1127
|
+
...empty,
|
|
1128
|
+
phase: {
|
|
1129
|
+
kind: "permission",
|
|
1130
|
+
permission: {
|
|
1131
|
+
id: "owner-permission",
|
|
1132
|
+
title: "Owner approval required",
|
|
1133
|
+
options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }],
|
|
1134
|
+
},
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
client.canControlAgent = false;
|
|
1139
|
+
const setup = await testRender(
|
|
1140
|
+
<AgentThreadPane
|
|
1141
|
+
thread={thread}
|
|
1142
|
+
client={client}
|
|
1143
|
+
focused
|
|
1144
|
+
theme={DARK}
|
|
1145
|
+
onActiveChange={noop}
|
|
1146
|
+
onOpenFile={noop}
|
|
1147
|
+
>
|
|
1148
|
+
{artifactView(thread)}
|
|
1149
|
+
</AgentThreadPane>,
|
|
1150
|
+
{ width: 80, height: 15 },
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
try {
|
|
1154
|
+
await waitForText(setup, "Thinking");
|
|
1155
|
+
expect(setup.captureCharFrame()).not.toContain("Allow once");
|
|
1156
|
+
expect(setup.captureCharFrame()).not.toContain("Owner approval required");
|
|
1157
|
+
} finally {
|
|
1158
|
+
setup.renderer.destroy();
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
@@ -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);
|
|
@@ -86,18 +88,20 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
86
88
|
const notifyState = useEffectEvent(() => props.onStateChange?.(state));
|
|
87
89
|
const notifyControls = useEffectEvent(() =>
|
|
88
90
|
props.onControlsChange?.(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
props.client && !props.client.agentConfigure ? null : (
|
|
92
|
+
<AgentConfigControls
|
|
93
|
+
state={state}
|
|
94
|
+
theme={theme}
|
|
95
|
+
onConfigure={(configId, value) =>
|
|
96
|
+
void agent.act((client) => {
|
|
97
|
+
if (!client.agentConfigure)
|
|
98
|
+
return Promise.reject(new Error("Agent configuration is unavailable"));
|
|
99
|
+
|
|
100
|
+
return client.agentConfigure({ id: thread.id, configId, value });
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
),
|
|
101
105
|
),
|
|
102
106
|
);
|
|
103
107
|
|
|
@@ -113,9 +117,16 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
113
117
|
notifyControls();
|
|
114
118
|
}, [state, theme]);
|
|
115
119
|
|
|
116
|
-
const invoke = async (): Promise<void> => {
|
|
120
|
+
const invoke = async (commentId?: string): Promise<void> => {
|
|
117
121
|
if (thread.status !== "pending") return;
|
|
118
|
-
|
|
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
|
+
});
|
|
119
130
|
draft.current = "";
|
|
120
131
|
if (invoking.current) return;
|
|
121
132
|
invoking.current = true;
|
|
@@ -135,7 +146,13 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
135
146
|
}
|
|
136
147
|
await props.flushMutations?.();
|
|
137
148
|
const accepted = await agent.act((client) =>
|
|
138
|
-
client.agentPrompt({
|
|
149
|
+
client.agentPrompt({
|
|
150
|
+
id: thread.id,
|
|
151
|
+
text: input.text,
|
|
152
|
+
commentId: input.commentId,
|
|
153
|
+
inputOnly: Boolean(input.text.trim()),
|
|
154
|
+
operationId: newAnnotationId(),
|
|
155
|
+
}),
|
|
139
156
|
);
|
|
140
157
|
|
|
141
158
|
if (!accepted) rejected.push(input.text);
|
|
@@ -151,32 +168,36 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
151
168
|
if (text) setPromptRestoreRequest({ id: ++restoreSequence.current, text });
|
|
152
169
|
}
|
|
153
170
|
};
|
|
154
|
-
const invokeRef = useRef<() => Promise<void>>(async () => {});
|
|
171
|
+
const invokeRef = useRef<(commentId?: string) => Promise<void>>(async () => {});
|
|
155
172
|
|
|
156
173
|
useEffect(() => {
|
|
157
174
|
invokeRef.current = invoke;
|
|
158
175
|
});
|
|
159
|
-
const notifyInvoke = useEffectEvent((invoke: (() => void) | undefined) =>
|
|
176
|
+
const notifyInvoke = useEffectEvent((invoke: ((commentId?: string) => void) | undefined) =>
|
|
160
177
|
props.onInvokeChange?.(invoke),
|
|
161
178
|
);
|
|
162
179
|
|
|
163
180
|
useEffect(() => {
|
|
164
|
-
notifyInvoke(() => void invokeRef.current());
|
|
181
|
+
notifyInvoke((commentId) => void invokeRef.current(commentId));
|
|
165
182
|
|
|
166
183
|
return () => notifyInvoke(undefined);
|
|
167
184
|
}, []);
|
|
168
|
-
const reply = (id: string, body: string): void => {
|
|
185
|
+
const reply = (id: string, body: string): string | void => {
|
|
169
186
|
const origin = projection.mirrors.get(id)?.commentId ?? id;
|
|
170
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();
|
|
171
193
|
|
|
172
|
-
if (root)
|
|
173
194
|
pendingWrites.current.push(
|
|
174
195
|
agent.act((client) =>
|
|
175
196
|
client.agentComment({
|
|
176
197
|
id: thread.id,
|
|
177
198
|
comment: {
|
|
178
199
|
...root,
|
|
179
|
-
id:
|
|
200
|
+
id: commentId,
|
|
180
201
|
body,
|
|
181
202
|
sent: false,
|
|
182
203
|
author: undefined,
|
|
@@ -185,7 +206,11 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
185
206
|
}),
|
|
186
207
|
),
|
|
187
208
|
);
|
|
188
|
-
|
|
209
|
+
|
|
210
|
+
return replyFocusId(commentId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return replyFocusId(child?.onReply(origin, body));
|
|
189
214
|
};
|
|
190
215
|
const actionFor = (id: string) => {
|
|
191
216
|
const mirror = projection.mirrors.get(id);
|
|
@@ -194,7 +219,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
194
219
|
);
|
|
195
220
|
|
|
196
221
|
if (!submission) return undefined;
|
|
197
|
-
if (submission.status === "failed")
|
|
222
|
+
if (submission.status === "failed" && props.client?.canControlAgent !== false)
|
|
198
223
|
return {
|
|
199
224
|
label: "Retry",
|
|
200
225
|
run: () =>
|
|
@@ -238,7 +263,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
238
263
|
Boolean(state.comments.find((entry) => entry.id === id)?.sent)
|
|
239
264
|
}
|
|
240
265
|
annotationAction={actionFor}
|
|
241
|
-
onInvoke={() => void invoke()}
|
|
266
|
+
onInvoke={(commentId) => void invoke(commentId)}
|
|
242
267
|
isPromptBlock={(index) => index === projection.tailIndex}
|
|
243
268
|
promptRestoreRequest={promptRestoreRequest}
|
|
244
269
|
promptFocusRequest={
|
|
@@ -269,14 +294,14 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
269
294
|
return;
|
|
270
295
|
}
|
|
271
296
|
if (source?.kind === "mirror") {
|
|
272
|
-
|
|
297
|
+
const mirrorId = projection.marks.get(span.start.blockIndex)?.[0]?.annotationId;
|
|
298
|
+
|
|
299
|
+
if (mirrorId) return reply(mirrorId, body);
|
|
273
300
|
|
|
274
301
|
return;
|
|
275
302
|
}
|
|
276
303
|
if (source?.kind === "artifact" && end?.kind === "artifact") {
|
|
277
|
-
child?.onAnnotate(span, body);
|
|
278
|
-
|
|
279
|
-
return;
|
|
304
|
+
return child?.onAnnotate(span, body);
|
|
280
305
|
}
|
|
281
306
|
if (
|
|
282
307
|
source?.kind !== "message" ||
|
|
@@ -300,6 +325,8 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
300
325
|
pendingWrites.current.push(
|
|
301
326
|
agent.act((client) => client.agentComment({ id: thread.id, comment })),
|
|
302
327
|
);
|
|
328
|
+
|
|
329
|
+
return comment.id;
|
|
303
330
|
}}
|
|
304
331
|
onReply={reply}
|
|
305
332
|
onUpdateAnnotation={(id, body) => {
|
|
@@ -317,8 +344,11 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
317
344
|
renderBlock={(index) =>
|
|
318
345
|
index === projection.activityIndex ? (
|
|
319
346
|
<box style={{ flexDirection: "column", paddingLeft: 2 }}>
|
|
347
|
+
{state.phase.kind === "offline" ? (
|
|
348
|
+
<text fg={theme.textMuted}>Owner offline</text>
|
|
349
|
+
) : null}
|
|
320
350
|
{busy ? <text fg={pulse ? theme.textDim : theme.textMuted}>Thinking…</text> : null}
|
|
321
|
-
{state.phase.kind === "permission" ? (
|
|
351
|
+
{state.phase.kind === "permission" && props.client?.canControlAgent !== false ? (
|
|
322
352
|
<>
|
|
323
353
|
<text fg={theme.text}>{state.phase.permission.title}</text>
|
|
324
354
|
<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
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
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
|
-
|
|
1744
|
-
.
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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 },
|