@cueloop/client 0.1.0-alpha.98 → 0.1.0-alpha.99
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 +11 -11
- package/src/annotations/components/AnnotationCards.tsx +2 -2
- package/src/annotations/use-annotation-surface.tsx +13 -2
- package/src/app/App.deleted-scroll.test.tsx +1 -0
- package/src/app/App.diff.test.tsx +1 -0
- package/src/app/App.identity.test.tsx +1 -0
- package/src/app/App.regression.test.tsx +5 -0
- package/src/app/App.share-dialog.test.tsx +5 -0
- package/src/app/App.submit-confirm.test.tsx +3 -0
- package/src/app/App.test.tsx +1 -0
- package/src/app/App.tsx +6 -0
- package/src/app/App.visual.test.tsx +2 -0
- package/src/app/App.workbench.test.tsx +53 -0
- package/src/app/app-screens.tsx +1 -0
- package/src/app/components/AppShell.tsx +1 -0
- package/src/app/run.ts +1 -0
- package/src/diff/components/AnnotatableFileView.test.tsx +2 -0
- package/src/diff/components/AnnotatableFileView.tsx +2 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +1 -0
- package/src/diff/components/DiffContentView.tsx +1 -0
- package/src/diff/diff-intraline.test.ts +1 -0
- package/src/diff/diff-intraline.ts +2 -0
- package/src/diff/split-diff.ts +2 -0
- package/src/integrations/components/ShareDialog.tsx +1 -0
- package/src/markdown/components/ThreadView.tsx +2 -0
- package/src/markdown/mark-runs.ts +1 -0
- package/src/markdown/markdown-highlight.ts +2 -0
- package/src/performance/otlp-trace.test.ts +3 -0
- package/src/performance/otlp-trace.ts +4 -0
- package/src/performance/perf-report.ts +1 -0
- package/src/performance/perf-timings.ts +1 -0
- package/src/settings/components/quick-actions-editor.test.tsx +3 -0
- package/src/stories/stories-app.tsx +3 -0
- package/src/stories/story.ts +2 -0
- package/src/terminal/pty.ts +1 -0
- package/src/thread/agent-transcript.ts +4 -0
- package/src/thread/components/AgentConfigControls.test.tsx +2 -0
- package/src/thread/components/AgentThreadPane.test.tsx +54 -5
- package/src/thread/components/AgentThreadPane.tsx +11 -0
- package/src/thread/components/ThreadFooter.test.tsx +2 -0
- package/src/thread/components/thread-tree.test.ts +3 -0
- package/src/thread/components/thread-tree.ts +4 -0
- package/src/thread/thread-controller.curate.test.ts +1 -0
- package/src/thread/thread-controller.live-diff.test.ts +7 -0
- package/src/thread/thread-controller.share.test.ts +1 -0
- package/src/thread/thread-controller.ts +8 -0
- package/src/thread/use-thread-agent.test.tsx +143 -0
- package/src/thread/use-thread-agent.ts +22 -12
- package/src/ui/components/PromptDialog.test.tsx +4 -0
- package/src/ui/components/RootOverlay.tsx +1 -0
- package/src/ui/components/primitives/Dialog.test.tsx +1 -0
- package/src/workbench/components/ChangesColumn.tsx +1 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +2 -0
- package/src/workbench/components/FileContentsView.tsx +2 -0
- package/src/workbench/components/GridTabContent.tsx +1 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
- package/src/workbench/components/ProjectTreeView.tsx +1 -0
- package/src/workbench/components/editor-grid.test.ts +11 -0
- package/src/workbench/components/editor-grid.ts +10 -0
- package/src/workbench/use-changes-workbench.ts +5 -0
|
@@ -169,6 +169,7 @@ export function projectThreadConversation(
|
|
|
169
169
|
submissionId?: string,
|
|
170
170
|
): void => {
|
|
171
171
|
const blocks: DisplayBlock[] = buildDisplay(quote);
|
|
172
|
+
|
|
172
173
|
if (!blocks.length)
|
|
173
174
|
blocks.push({
|
|
174
175
|
type: "same",
|
|
@@ -198,6 +199,7 @@ export function projectThreadConversation(
|
|
|
198
199
|
const replies = [...thread.annotations, ...state.comments].filter(
|
|
199
200
|
(entry) => entry.replyTo === commentId,
|
|
200
201
|
);
|
|
202
|
+
|
|
201
203
|
for (const reply of replies) {
|
|
202
204
|
const replica = {
|
|
203
205
|
...annotation,
|
|
@@ -227,6 +229,7 @@ export function projectThreadConversation(
|
|
|
227
229
|
],
|
|
228
230
|
],
|
|
229
231
|
]);
|
|
232
|
+
|
|
230
233
|
for (const [index, values] of localMarks) {
|
|
231
234
|
marks.set(
|
|
232
235
|
index + offset,
|
|
@@ -273,6 +276,7 @@ export function projectThreadConversation(
|
|
|
273
276
|
);
|
|
274
277
|
}
|
|
275
278
|
};
|
|
279
|
+
|
|
276
280
|
if (state.submissions?.length) {
|
|
277
281
|
for (const submission of state.submissions) {
|
|
278
282
|
// A prompt may use its submission ID as a discussion root; that is not an inline comment.
|
|
@@ -43,6 +43,7 @@ const initial: ThreadAgentState = {
|
|
|
43
43
|
|
|
44
44
|
test("model and reasoning choices share the existing overlay and close after selection", async () => {
|
|
45
45
|
const choices: string[] = [];
|
|
46
|
+
|
|
46
47
|
function TestMenus(): React.ReactNode {
|
|
47
48
|
const menu = useMenuControlState();
|
|
48
49
|
const [state, setState] = useState(initial);
|
|
@@ -95,6 +96,7 @@ test("model and reasoning choices share the existing overlay and close after sel
|
|
|
95
96
|
await click("Second model");
|
|
96
97
|
expect(setup.captureCharFrame()).not.toContain("Extra model 17");
|
|
97
98
|
const model = locateText(setup, "First model");
|
|
99
|
+
|
|
98
100
|
for (let count = 0; count < 12; count++)
|
|
99
101
|
await setup.mockMouse.scroll(model.column, model.row, "down");
|
|
100
102
|
await settle(setup);
|
|
@@ -127,8 +127,9 @@ test("typing on the final blank line invokes the agent while the existing footer
|
|
|
127
127
|
expect(prompts).toHaveLength(0);
|
|
128
128
|
await setup.mockMouse.click(artifact.column, artifact.row);
|
|
129
129
|
await setup.mockMouse.click(artifact.column, artifact.row + 2);
|
|
130
|
-
|
|
131
|
-
await
|
|
130
|
+
// Submit the typing burst before waiting for content-change repaint.
|
|
131
|
+
await setup.mockInput.typeText(" retries");
|
|
132
|
+
setup.mockInput.pressKey("RETURN", { ctrl: true });
|
|
132
133
|
await waitForText(setup, "The timer survives cancellation.");
|
|
133
134
|
expect(prompts[0]?.text).toBe("Explain retries");
|
|
134
135
|
const send = locateText(setup, "Send message (0)");
|
|
@@ -265,6 +266,7 @@ test("three bottom prompts survive delayed acceptance and remain separate submis
|
|
|
265
266
|
const { client } = createTestAgentClient(empty);
|
|
266
267
|
let release: ((state: ThreadAgentState) => void) | undefined;
|
|
267
268
|
const prompts: string[] = [];
|
|
269
|
+
|
|
268
270
|
client.agentPrompt = async (params) => {
|
|
269
271
|
prompts.push(params.text);
|
|
270
272
|
if (prompts.length === 1)
|
|
@@ -319,8 +321,10 @@ test("typing on an accepted plain prompt cannot edit it or create an unmarked co
|
|
|
319
321
|
};
|
|
320
322
|
const { client } = createTestAgentClient(state);
|
|
321
323
|
const replies: string[] = [];
|
|
324
|
+
|
|
322
325
|
client.agentComment = async (params) => {
|
|
323
326
|
replies.push(params.comment.replyTo ?? "");
|
|
327
|
+
|
|
324
328
|
return state;
|
|
325
329
|
};
|
|
326
330
|
const setup = await testRender(
|
|
@@ -411,17 +415,21 @@ test("comments on a later paragraph of agent output save editable and submit ind
|
|
|
411
415
|
],
|
|
412
416
|
};
|
|
413
417
|
const { client, prompts } = createTestAgentClient(state);
|
|
418
|
+
|
|
414
419
|
client.agentComment = async ({ comment }) => {
|
|
415
420
|
state = {
|
|
416
421
|
...state,
|
|
417
422
|
comments: [...state.comments.filter((entry) => entry.id !== comment.id), comment],
|
|
418
423
|
};
|
|
424
|
+
|
|
419
425
|
return state;
|
|
420
426
|
};
|
|
421
427
|
const prompt = client.agentPrompt;
|
|
428
|
+
|
|
422
429
|
client.agentPrompt = async (params) => {
|
|
423
430
|
await prompt(params);
|
|
424
431
|
state = { ...state, comments: state.comments.map((comment) => ({ ...comment, sent: true })) };
|
|
432
|
+
|
|
425
433
|
return state;
|
|
426
434
|
};
|
|
427
435
|
const setup = await testRender(
|
|
@@ -472,8 +480,10 @@ for (const policy of [
|
|
|
472
480
|
test(`disabled or shared agent views retain the original document without opening a harness (${JSON.stringify(policy)})`, async () => {
|
|
473
481
|
let reads = 0;
|
|
474
482
|
const { client } = createTestAgentClient(empty);
|
|
483
|
+
|
|
475
484
|
client.agentGet = async () => {
|
|
476
485
|
reads++;
|
|
486
|
+
|
|
477
487
|
return empty;
|
|
478
488
|
};
|
|
479
489
|
const setup = await testRender(
|
|
@@ -505,6 +515,7 @@ test("empty Thread typing and accepted prompts use normal paragraph padding with
|
|
|
505
515
|
const value = { ...thread, artifact: { ...thread.artifact, content: "" } };
|
|
506
516
|
let accepted = empty;
|
|
507
517
|
const { client, prompts } = createTestAgentClient(empty);
|
|
518
|
+
|
|
508
519
|
client.agentPrompt = async (params) => {
|
|
509
520
|
prompts.push(params);
|
|
510
521
|
accepted = {
|
|
@@ -567,7 +578,15 @@ test("clicking whitespace below the conversation focuses a blinking prompt befor
|
|
|
567
578
|
const text = locateText(setup, "Original artifact");
|
|
568
579
|
|
|
569
580
|
await setup.mockMouse.click(text.column + 20, text.row + 5);
|
|
570
|
-
await
|
|
581
|
+
await waitForState(
|
|
582
|
+
setup,
|
|
583
|
+
() => {
|
|
584
|
+
const cursor = setup.renderer.getCursorState();
|
|
585
|
+
|
|
586
|
+
return cursor.visible && cursor.blinking;
|
|
587
|
+
},
|
|
588
|
+
"continuation cursor focused",
|
|
589
|
+
);
|
|
571
590
|
expect(setup.renderer.getCursorState()).toMatchObject({ visible: true, blinking: true });
|
|
572
591
|
expect(prompts).toHaveLength(0);
|
|
573
592
|
await typeText(setup, "A prompt from whitespace");
|
|
@@ -603,6 +622,7 @@ test("an arriving reply preserves a bottom draft and submits it as the next prom
|
|
|
603
622
|
},
|
|
604
623
|
],
|
|
605
624
|
};
|
|
625
|
+
|
|
606
626
|
client.agentPrompt = async (params) => {
|
|
607
627
|
prompts.push(params);
|
|
608
628
|
if (prompts.length === 1)
|
|
@@ -670,15 +690,41 @@ test("clicking the first blank row after a reply opens continuation without losi
|
|
|
670
690
|
|
|
671
691
|
try {
|
|
672
692
|
await waitForText(setup, "The final reply.");
|
|
693
|
+
await waitForState(
|
|
694
|
+
setup,
|
|
695
|
+
() => setup.renderer.currentFocusedEditor !== null,
|
|
696
|
+
"initial continuation focus ready",
|
|
697
|
+
);
|
|
673
698
|
await pressKey(setup, "ESCAPE");
|
|
699
|
+
await waitForState(
|
|
700
|
+
setup,
|
|
701
|
+
() => setup.renderer.currentFocusedEditor === null,
|
|
702
|
+
"continuation dismissed",
|
|
703
|
+
);
|
|
674
704
|
const reply = locateText(setup, "The final reply.");
|
|
675
705
|
|
|
676
706
|
await setup.mockMouse.click(reply.column + 5, reply.row + 1);
|
|
677
|
-
await
|
|
707
|
+
await waitForState(
|
|
708
|
+
setup,
|
|
709
|
+
() => {
|
|
710
|
+
const cursor = setup.renderer.getCursorState();
|
|
711
|
+
|
|
712
|
+
return setup.renderer.currentFocusedEditor !== null && cursor.visible && cursor.blinking;
|
|
713
|
+
},
|
|
714
|
+
"continuation cursor focused",
|
|
715
|
+
);
|
|
678
716
|
expect(setup.renderer.getCursorState()).toMatchObject({ visible: true, blinking: true });
|
|
679
717
|
await typeText(setup, "Continue here");
|
|
680
718
|
await setup.mockMouse.click(reply.column + 5, reply.row + 1);
|
|
681
|
-
await
|
|
719
|
+
await waitForState(
|
|
720
|
+
setup,
|
|
721
|
+
() => {
|
|
722
|
+
const cursor = setup.renderer.getCursorState();
|
|
723
|
+
|
|
724
|
+
return setup.renderer.currentFocusedEditor !== null && cursor.visible && cursor.blinking;
|
|
725
|
+
},
|
|
726
|
+
"continuation cursor focused",
|
|
727
|
+
);
|
|
682
728
|
expect(setup.renderer.getCursorState()).toMatchObject({ visible: true, blinking: true });
|
|
683
729
|
await pressKey(setup, "RETURN", { ctrl: true });
|
|
684
730
|
expect(prompts[0]?.text).toBe("Continue here");
|
|
@@ -691,6 +737,7 @@ test("a rejected prompt returns visibly to the composer and can be edited before
|
|
|
691
737
|
const { client, prompts } = createTestAgentClient(empty);
|
|
692
738
|
let reject = true;
|
|
693
739
|
const acceptPrompt = client.agentPrompt;
|
|
740
|
+
|
|
694
741
|
client.agentPrompt = async (params) => {
|
|
695
742
|
if (reject) {
|
|
696
743
|
reject = false;
|
|
@@ -716,6 +763,7 @@ test("a rejected prompt returns visibly to the composer and can be edited before
|
|
|
716
763
|
try {
|
|
717
764
|
await waitForText(setup, "Original artifact");
|
|
718
765
|
const artifact = locateText(setup, "Original artifact");
|
|
766
|
+
|
|
719
767
|
await setup.mockMouse.click(artifact.column, artifact.row + 2);
|
|
720
768
|
await typeText(setup, "Explain retries");
|
|
721
769
|
await pressKey(setup, "RETURN", { ctrl: true });
|
|
@@ -760,6 +808,7 @@ test("a failed mutation flush restores the prompt alongside a newer visible draf
|
|
|
760
808
|
try {
|
|
761
809
|
await waitForText(setup, "Original artifact");
|
|
762
810
|
const artifact = locateText(setup, "Original artifact");
|
|
811
|
+
|
|
763
812
|
await setup.mockMouse.click(artifact.column, artifact.row + 2);
|
|
764
813
|
await typeText(setup, "First question");
|
|
765
814
|
await pressKey(setup, "RETURN", { ctrl: true });
|
|
@@ -75,6 +75,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
75
75
|
const busy = state.phase.kind === "running" || state.phase.kind === "permission";
|
|
76
76
|
|
|
77
77
|
const [pulse, setPulse] = useState(false);
|
|
78
|
+
|
|
78
79
|
useEffect(() => {
|
|
79
80
|
if (!busy) return;
|
|
80
81
|
const timer = setInterval(() => setPulse((value) => !value), 600);
|
|
@@ -99,8 +100,10 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
99
100
|
/>,
|
|
100
101
|
),
|
|
101
102
|
);
|
|
103
|
+
|
|
102
104
|
useEffect(() => {
|
|
103
105
|
notifyActive(true);
|
|
106
|
+
|
|
104
107
|
return () => notifyActive(false);
|
|
105
108
|
}, []);
|
|
106
109
|
useEffect(() => {
|
|
@@ -122,8 +125,10 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
122
125
|
try {
|
|
123
126
|
while (invocations.current.length) {
|
|
124
127
|
const input = invocations.current.shift()!;
|
|
128
|
+
|
|
125
129
|
try {
|
|
126
130
|
const writesSaved = (await Promise.all(input.writes)).every(Boolean);
|
|
131
|
+
|
|
127
132
|
if (!writesSaved) {
|
|
128
133
|
rejected.push(input.text);
|
|
129
134
|
continue;
|
|
@@ -147,12 +152,14 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
147
152
|
}
|
|
148
153
|
};
|
|
149
154
|
const invokeRef = useRef<() => Promise<void>>(async () => {});
|
|
155
|
+
|
|
150
156
|
useEffect(() => {
|
|
151
157
|
invokeRef.current = invoke;
|
|
152
158
|
});
|
|
153
159
|
const notifyInvoke = useEffectEvent((invoke: (() => void) | undefined) =>
|
|
154
160
|
props.onInvokeChange?.(invoke),
|
|
155
161
|
);
|
|
162
|
+
|
|
156
163
|
useEffect(() => {
|
|
157
164
|
notifyInvoke(() => void invokeRef.current());
|
|
158
165
|
|
|
@@ -208,6 +215,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
208
215
|
};
|
|
209
216
|
|
|
210
217
|
const notifyActions = useEffectEvent(() => props.onActionsChange?.(actionFor));
|
|
218
|
+
|
|
211
219
|
useEffect(() => {
|
|
212
220
|
notifyActions();
|
|
213
221
|
}, [state, projection]);
|
|
@@ -257,14 +265,17 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
257
265
|
|
|
258
266
|
if (source?.kind === "tail") {
|
|
259
267
|
draft.current = body;
|
|
268
|
+
|
|
260
269
|
return;
|
|
261
270
|
}
|
|
262
271
|
if (source?.kind === "mirror") {
|
|
263
272
|
if (source.commentId) reply(source.commentId, body);
|
|
273
|
+
|
|
264
274
|
return;
|
|
265
275
|
}
|
|
266
276
|
if (source?.kind === "artifact" && end?.kind === "artifact") {
|
|
267
277
|
child?.onAnnotate(span, body);
|
|
278
|
+
|
|
268
279
|
return;
|
|
269
280
|
}
|
|
270
281
|
if (
|
|
@@ -21,11 +21,13 @@ test("a long branch truncates on one line and keeps the send control in place",
|
|
|
21
21
|
</box>,
|
|
22
22
|
{ width: 60, height: 3 },
|
|
23
23
|
);
|
|
24
|
+
|
|
24
25
|
await settle(setup);
|
|
25
26
|
await settle(setup);
|
|
26
27
|
|
|
27
28
|
const frame = setup.captureCharFrame();
|
|
28
29
|
const footerRow = frame.split("\n").find((line) => line.includes("cueloop"))!;
|
|
30
|
+
|
|
29
31
|
// the repo/branch context and the send control share the single footer row
|
|
30
32
|
expect(footerRow).toContain("Send message (0)");
|
|
31
33
|
// the branch is clipped to an ellipsis, not wrapped onto a second row
|
|
@@ -4,6 +4,7 @@ import { groupInbox, projectName } from "./thread-tree";
|
|
|
4
4
|
|
|
5
5
|
function session(id: string, title: string, rootCommit?: string, remote?: string): Thread {
|
|
6
6
|
const workspace: WorkspaceKey = { repoRoot: `/home/dev/${id}-checkout`, branch: "main" };
|
|
7
|
+
|
|
7
8
|
if (rootCommit !== undefined) workspace.rootCommit = rootCommit;
|
|
8
9
|
if (remote !== undefined) workspace.remote = remote;
|
|
9
10
|
|
|
@@ -61,6 +62,7 @@ describe("groupInbox", () => {
|
|
|
61
62
|
|
|
62
63
|
// Assert
|
|
63
64
|
const labels = rows.map((row) => `${row.kind}:${"label" in row ? row.label : row.id}`);
|
|
65
|
+
|
|
64
66
|
expect(labels).toEqual([
|
|
65
67
|
"section:Projects",
|
|
66
68
|
"project:widget",
|
|
@@ -83,6 +85,7 @@ describe("groupInbox", () => {
|
|
|
83
85
|
|
|
84
86
|
// Assert
|
|
85
87
|
const projectRows = rows.filter((row) => row.kind === "project");
|
|
88
|
+
|
|
86
89
|
expect(projectRows).toHaveLength(1);
|
|
87
90
|
expect(ordered).toHaveLength(2);
|
|
88
91
|
});
|
|
@@ -18,6 +18,7 @@ export function projectName(workspace: WorkspaceKey): string {
|
|
|
18
18
|
.split(/[/:]/)
|
|
19
19
|
.filter((part) => part.length > 0)
|
|
20
20
|
.at(-1);
|
|
21
|
+
|
|
21
22
|
if (base !== undefined) return base;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -66,12 +67,14 @@ export function groupInbox(
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
const key = session.workspace.rootCommit;
|
|
70
|
+
|
|
69
71
|
if (key === undefined) {
|
|
70
72
|
standalone.push(session);
|
|
71
73
|
continue;
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
const existing = projects.get(key);
|
|
77
|
+
|
|
75
78
|
if (existing !== undefined) existing.sessions.push(session);
|
|
76
79
|
else projects.set(key, { name: projectName(session.workspace), sessions: [session] });
|
|
77
80
|
}
|
|
@@ -89,6 +92,7 @@ export function groupInbox(
|
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
|
|
95
|
+
|
|
92
96
|
if (projectEntries.length > 0) {
|
|
93
97
|
rows.push({ kind: "section", id: "section:projects", label: "Projects" });
|
|
94
98
|
for (const [key, project] of projectEntries) {
|
|
@@ -450,6 +450,7 @@ describe("diff file fold", () => {
|
|
|
450
450
|
{ path: "src/gone.ts", oldContents: "old line\n", newContents: "", status: "deleted" },
|
|
451
451
|
];
|
|
452
452
|
const session = diffSession(files);
|
|
453
|
+
|
|
453
454
|
session.artifact.content = patch;
|
|
454
455
|
session.revisions[0]!.content = patch;
|
|
455
456
|
const { controller } = await connected(session);
|
|
@@ -134,6 +134,7 @@ describe("live working-tree diff for a non-diff thread", () => {
|
|
|
134
134
|
openClient: async () => fakeClient(session),
|
|
135
135
|
shareTransport,
|
|
136
136
|
});
|
|
137
|
+
|
|
137
138
|
controller.connect();
|
|
138
139
|
await tick();
|
|
139
140
|
|
|
@@ -169,6 +170,7 @@ describe("live working-tree diff for a non-diff thread", () => {
|
|
|
169
170
|
openClient: async () => client,
|
|
170
171
|
shareTransport,
|
|
171
172
|
});
|
|
173
|
+
|
|
172
174
|
controller.connect();
|
|
173
175
|
await tick();
|
|
174
176
|
await controller.repoChanges();
|
|
@@ -213,11 +215,13 @@ describe("live working-tree diff for a non-diff thread", () => {
|
|
|
213
215
|
openClient: async () => client,
|
|
214
216
|
shareTransport,
|
|
215
217
|
});
|
|
218
|
+
|
|
216
219
|
controller.connect();
|
|
217
220
|
await tick();
|
|
218
221
|
|
|
219
222
|
// thread A's diff is still in flight when the user switches to thread B
|
|
220
223
|
const inFlightA = controller.repoChanges();
|
|
224
|
+
|
|
221
225
|
controller.open("ses_B");
|
|
222
226
|
await controller.repoChanges();
|
|
223
227
|
// B's diff is showing; now A's stale response lands
|
|
@@ -272,6 +276,7 @@ describe("frozen vs live diff by thread kind", () => {
|
|
|
272
276
|
openClient: async () => fakeClient(session),
|
|
273
277
|
shareTransport,
|
|
274
278
|
});
|
|
279
|
+
|
|
275
280
|
controller.connect();
|
|
276
281
|
await tick();
|
|
277
282
|
|
|
@@ -294,6 +299,7 @@ describe("frozen vs live diff by thread kind", () => {
|
|
|
294
299
|
openClient: async () => client,
|
|
295
300
|
shareTransport,
|
|
296
301
|
});
|
|
302
|
+
|
|
297
303
|
controller.connect();
|
|
298
304
|
await tick();
|
|
299
305
|
|
|
@@ -338,6 +344,7 @@ describe("serve mode pins the served thread to a frozen snapshot", () => {
|
|
|
338
344
|
meta: { workbench: true, snapshot: true },
|
|
339
345
|
},
|
|
340
346
|
});
|
|
347
|
+
|
|
341
348
|
controller.connect();
|
|
342
349
|
await tick();
|
|
343
350
|
|
|
@@ -829,6 +829,7 @@ class Controller implements ReviewController {
|
|
|
829
829
|
|
|
830
830
|
async projectFiles(): Promise<string[]> {
|
|
831
831
|
const session = this.snapshot.session;
|
|
832
|
+
|
|
832
833
|
if (this.client?.projectFiles === undefined || !session) return [];
|
|
833
834
|
|
|
834
835
|
return this.client.projectFiles(session.id);
|
|
@@ -836,6 +837,7 @@ class Controller implements ReviewController {
|
|
|
836
837
|
|
|
837
838
|
async readFile(path: string): Promise<string | null> {
|
|
838
839
|
const session = this.snapshot.session;
|
|
840
|
+
|
|
839
841
|
if (this.client?.fileContents === undefined || !session) return null;
|
|
840
842
|
|
|
841
843
|
return this.client.fileContents(session.id, path);
|
|
@@ -861,6 +863,7 @@ class Controller implements ReviewController {
|
|
|
861
863
|
async repoChanges(): Promise<readonly DiffFileContents[]> {
|
|
862
864
|
// a plain diff review pins its captured snapshot; a workbench thread and every other thread reflect the live working tree
|
|
863
865
|
const session = this.snapshot.session;
|
|
866
|
+
|
|
864
867
|
if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
|
|
865
868
|
const initialFiles = this.consumeInitialDiffFiles(session);
|
|
866
869
|
|
|
@@ -871,6 +874,7 @@ class Controller implements ReviewController {
|
|
|
871
874
|
this.sidebarRepoRoot(),
|
|
872
875
|
session?.artifact.meta.workbench ? (session.artifact.meta.vcs ?? "git") : undefined,
|
|
873
876
|
);
|
|
877
|
+
|
|
874
878
|
// a thread switch during the request would let this response overwrite the new thread's diff,
|
|
875
879
|
// showing changes from the wrong repo; compare the thread id, not the object, so an unrelated
|
|
876
880
|
// re-render that replaced the snapshot for the SAME thread does not drop its diff to "No changes"
|
|
@@ -885,6 +889,7 @@ class Controller implements ReviewController {
|
|
|
885
889
|
file.oldContents !== diff.files[index]?.oldContents ||
|
|
886
890
|
file.newContents !== diff.files[index]?.newContents,
|
|
887
891
|
);
|
|
892
|
+
|
|
888
893
|
if (changed) {
|
|
889
894
|
this.liveDiff = diff;
|
|
890
895
|
// rows() derive from the fresh patch; re-render so an open diff tab repaints
|
|
@@ -1004,6 +1009,7 @@ class Controller implements ReviewController {
|
|
|
1004
1009
|
content: "",
|
|
1005
1010
|
meta: { title: "New Thread" },
|
|
1006
1011
|
});
|
|
1012
|
+
|
|
1007
1013
|
this.locallyViewed.clear();
|
|
1008
1014
|
this.viewingId = thread.id;
|
|
1009
1015
|
this.update({ session: thread });
|
|
@@ -1737,6 +1743,7 @@ class Controller implements ReviewController {
|
|
|
1737
1743
|
const actionBodies = Object.fromEntries(
|
|
1738
1744
|
slashItemsFrom(this.quickActions).map((item) => [item.name, item.body]),
|
|
1739
1745
|
);
|
|
1746
|
+
|
|
1740
1747
|
this.client!.sessionSendMessage(session.id, message, summary, actionBodies)
|
|
1741
1748
|
.then((resolved) => {
|
|
1742
1749
|
// The completion overlay heading already states the message, so the
|
|
@@ -1835,6 +1842,7 @@ class Controller implements ReviewController {
|
|
|
1835
1842
|
|
|
1836
1843
|
if (!links.some((link) => link.id === id)) return;
|
|
1837
1844
|
const allowlist = input.requireAuth ? input.allowlist : [];
|
|
1845
|
+
|
|
1838
1846
|
// re-push the link's access to its blob: an allowlist for private, "public" clears it
|
|
1839
1847
|
void this.shareTransport
|
|
1840
1848
|
.push(id, [], input.requireAuth ? { githubLogins: allowlist } : "public")
|
|
@@ -38,8 +38,10 @@ test("late configuration and action responses cannot replace a newer accepted st
|
|
|
38
38
|
agentComment: async () => state,
|
|
39
39
|
agentPermission: async () => state,
|
|
40
40
|
};
|
|
41
|
+
|
|
41
42
|
function TestAgent(): React.ReactNode {
|
|
42
43
|
const agent = useThreadAgent("test", undefined, client);
|
|
44
|
+
|
|
43
45
|
useEffect(() => {
|
|
44
46
|
request = () => agent.act((api) => api.agentPrompt({ id: "test", text: "Prompt" }));
|
|
45
47
|
}, [agent]);
|
|
@@ -51,6 +53,7 @@ test("late configuration and action responses cannot replace a newer accepted st
|
|
|
51
53
|
try {
|
|
52
54
|
await waitForText(setup, "idle");
|
|
53
55
|
const pending = request();
|
|
56
|
+
|
|
54
57
|
await request();
|
|
55
58
|
await waitForText(setup, "running");
|
|
56
59
|
first!(initial);
|
|
@@ -63,3 +66,143 @@ test("late configuration and action responses cannot replace a newer accepted st
|
|
|
63
66
|
setup.renderer.destroy();
|
|
64
67
|
}
|
|
65
68
|
});
|
|
69
|
+
|
|
70
|
+
test("production subscription shows daemon updates and reconnects without an injected client", async () => {
|
|
71
|
+
const { mkdtempSync, rmSync } = await import("node:fs");
|
|
72
|
+
const { tmpdir } = await import("node:os");
|
|
73
|
+
const { join } = await import("node:path");
|
|
74
|
+
const { DaemonServer } = await import("@cueloop/daemon");
|
|
75
|
+
const { DaemonClient } = await import("@cueloop/daemon/client");
|
|
76
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-agent-ui-subscription-"));
|
|
77
|
+
const adapter = {
|
|
78
|
+
id: "ui-subscription",
|
|
79
|
+
label: "UI subscription",
|
|
80
|
+
connect({ onEvent }: Parameters<import("@cueloop/schema").AgentHarnessAdapter["connect"]>[0]) {
|
|
81
|
+
return {
|
|
82
|
+
start: async () => "ui-session",
|
|
83
|
+
prompt: async () => {
|
|
84
|
+
onEvent({ kind: "message" as const, id: "answer", text: "Updated answer" });
|
|
85
|
+
|
|
86
|
+
return { outcome: "completed" as const };
|
|
87
|
+
},
|
|
88
|
+
cancel() {},
|
|
89
|
+
permission() {},
|
|
90
|
+
close() {},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const options = { home, idleExitMs: 0, threadAgent: { enabled: true, adapter } };
|
|
95
|
+
let server = new DaemonServer(options);
|
|
96
|
+
|
|
97
|
+
server.start();
|
|
98
|
+
const client = await DaemonClient.connect({ home });
|
|
99
|
+
const thread = await client.sessionCreate(
|
|
100
|
+
{ repoRoot: home, branch: "main" },
|
|
101
|
+
{ type: "plan", content: "Watch this", meta: {} },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
function TestAgentSubscription(): React.ReactNode {
|
|
105
|
+
const agent = useThreadAgent(thread.id, home);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<text>
|
|
109
|
+
{agent.client ? "Connected " : "Connecting "}
|
|
110
|
+
{agent.state.messages.map((message) => message.text).join(" ")}
|
|
111
|
+
{agent.error}
|
|
112
|
+
</text>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const setup = await testRender(<TestAgentSubscription />, { width: 80, height: 4 });
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await waitForText(setup, "Connected");
|
|
119
|
+
await client.agentPrompt({ id: thread.id, text: "Question" });
|
|
120
|
+
await waitForText(setup, "Updated answer");
|
|
121
|
+
server.stop();
|
|
122
|
+
server = new DaemonServer(options);
|
|
123
|
+
server.start();
|
|
124
|
+
const resumed = await DaemonClient.connect({ home });
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await resumed.agentPrompt({ id: thread.id, text: "Continuation" });
|
|
128
|
+
} finally {
|
|
129
|
+
resumed.close();
|
|
130
|
+
}
|
|
131
|
+
await waitForText(setup, "Continuation");
|
|
132
|
+
expect(setup.captureCharFrame()).toContain("Updated answer");
|
|
133
|
+
} finally {
|
|
134
|
+
setup.renderer.destroy();
|
|
135
|
+
client.close();
|
|
136
|
+
server.stop();
|
|
137
|
+
rmSync(home, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("reconnecting during a running turn preserves state without a configuration error", async () => {
|
|
142
|
+
const { mkdtempSync, rmSync } = await import("node:fs");
|
|
143
|
+
const { tmpdir } = await import("node:os");
|
|
144
|
+
const { join } = await import("node:path");
|
|
145
|
+
const { DaemonServer } = await import("@cueloop/daemon");
|
|
146
|
+
const { DaemonClient } = await import("@cueloop/daemon/client");
|
|
147
|
+
const { createTestSdkHarness } = await import("../../../../test/helpers/sdk-harness");
|
|
148
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-agent-ui-running-"));
|
|
149
|
+
const harness = createTestSdkHarness();
|
|
150
|
+
const server = new DaemonServer({
|
|
151
|
+
home,
|
|
152
|
+
idleExitMs: 0,
|
|
153
|
+
threadAgent: { enabled: true, adapter: harness.adapter },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
server.start();
|
|
157
|
+
const client = await DaemonClient.connect({ home });
|
|
158
|
+
const thread = await client.sessionCreate(
|
|
159
|
+
{ repoRoot: home, branch: "main" },
|
|
160
|
+
{ type: "plan", content: "Watch this", meta: {} },
|
|
161
|
+
);
|
|
162
|
+
let connection: InstanceType<typeof DaemonClient> | undefined;
|
|
163
|
+
let connections = 0;
|
|
164
|
+
|
|
165
|
+
function TestRunningSubscription(): React.ReactNode {
|
|
166
|
+
const agent = useThreadAgent(thread.id, home);
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (agent.client instanceof DaemonClient && agent.client !== connection) {
|
|
170
|
+
connection = agent.client;
|
|
171
|
+
connections++;
|
|
172
|
+
}
|
|
173
|
+
}, [agent.client]);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<text>
|
|
177
|
+
{connections} {agent.state.phase.kind} {agent.error || "No error"}
|
|
178
|
+
</text>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const setup = await testRender(<TestRunningSubscription />, { width: 100, height: 4 });
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await waitForText(setup, "idle");
|
|
185
|
+
await client.agentPrompt({ id: thread.id, text: "Question" });
|
|
186
|
+
await waitForText(setup, "running");
|
|
187
|
+
expect((await client.agentConfigure({ id: thread.id })).phase.kind).toBe("running");
|
|
188
|
+
const rejected = await client
|
|
189
|
+
.agentConfigure({ id: thread.id, configId: "model", value: "other" })
|
|
190
|
+
.catch((error: Error) => error);
|
|
191
|
+
|
|
192
|
+
expect(rejected).toMatchObject({
|
|
193
|
+
code: "internal",
|
|
194
|
+
message: "Thread agent configuration waits for the current turn",
|
|
195
|
+
});
|
|
196
|
+
connection!.close();
|
|
197
|
+
await waitForText(setup, "2 running");
|
|
198
|
+
harness.complete();
|
|
199
|
+
await waitForText(setup, "2 idle");
|
|
200
|
+
expect(setup.captureCharFrame()).toContain("No error");
|
|
201
|
+
expect(setup.captureCharFrame()).not.toContain("configuration waits");
|
|
202
|
+
} finally {
|
|
203
|
+
setup.renderer.destroy();
|
|
204
|
+
client.close();
|
|
205
|
+
server.stop();
|
|
206
|
+
rmSync(home, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|