@cueloop/client 0.1.0-alpha.97 → 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.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +31 -8
- package/src/annotations/use-annotation-surface.tsx +257 -31
- 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.thread-agent.test.ts +12 -0
- package/src/app/App.tsx +223 -97
- package/src/app/App.visual.test.tsx +2 -0
- package/src/app/App.workbench.test.tsx +53 -0
- package/src/app/app-screens.tsx +3 -0
- package/src/app/components/AppShell.tsx +1 -0
- package/src/app/components/AppShell.zoom.test.tsx +4 -3
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/app/run.ts +1 -0
- package/src/diff/components/AnnotatableFileView.test.tsx +2 -0
- package/src/diff/components/AnnotatableFileView.tsx +8 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +1 -0
- package/src/diff/components/DiffContentView.tsx +10 -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 +62 -16
- 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/settings/config.test.ts +15 -0
- package/src/settings/config.ts +14 -4
- package/src/stories/__snapshots__/stories.test.tsx.snap +74 -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-continuation.test.ts +32 -0
- package/src/thread/agent-continuation.ts +13 -0
- package/src/thread/agent-transcript.test.ts +161 -0
- package/src/thread/agent-transcript.ts +336 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +112 -0
- package/src/thread/components/AgentConfigControls.tsx +114 -0
- package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
- package/src/thread/components/AgentThreadPane.test.tsx +829 -0
- package/src/thread/components/AgentThreadPane.tsx +367 -0
- package/src/thread/components/ThreadFooter.test.tsx +2 -0
- package/src/thread/components/ThreadFooter.tsx +3 -0
- package/src/thread/components/ThreadTree.test.tsx +41 -0
- package/src/thread/components/ThreadTree.tsx +25 -5
- 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 +42 -0
- package/src/thread/use-thread-agent.test.tsx +208 -0
- package/src/thread/use-thread-agent.ts +111 -0
- 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 +7 -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
|
@@ -324,6 +324,8 @@ export interface ReviewController {
|
|
|
324
324
|
refreshDiff(): Promise<void>;
|
|
325
325
|
/** Open a session from the inbox. */
|
|
326
326
|
open(id: string): void;
|
|
327
|
+
/** Create and open an empty local Thread for a new conversation. */
|
|
328
|
+
createEmptyThread(): Promise<void>;
|
|
327
329
|
/** Delete a session for good (inbox delete); the inbox refreshes on the event. */
|
|
328
330
|
deleteSession(id: string): void;
|
|
329
331
|
/** Rename a session's title; the inbox refreshes on the event. */
|
|
@@ -386,6 +388,7 @@ export interface ReviewController {
|
|
|
386
388
|
annotatePrototype(selector: string, quote: string, body: string): string | undefined;
|
|
387
389
|
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
388
390
|
updateAnnotation(id: string, body: string): void;
|
|
391
|
+
flushMutations?(): Promise<void>;
|
|
389
392
|
removeAnnotation(id: string): void;
|
|
390
393
|
setWorkingCopy(content: string | undefined): void;
|
|
391
394
|
/** Enter the guided walk at the first unviewed file (diff sessions). */
|
|
@@ -472,6 +475,7 @@ const DERIVED_CACHE_LIMIT = 8;
|
|
|
472
475
|
class Controller implements ReviewController {
|
|
473
476
|
readonly readOnly: boolean;
|
|
474
477
|
private client: ThreadClient | null = null;
|
|
478
|
+
private pendingMutations = new Set<Promise<Thread>>();
|
|
475
479
|
private closed = false;
|
|
476
480
|
/** The controller's state lives in a zustand store; internal reads go through the snapshot getter. */
|
|
477
481
|
private readonly store: StoreApi<ControllerSnapshot> = createStore<ControllerSnapshot>(() => ({
|
|
@@ -825,6 +829,7 @@ class Controller implements ReviewController {
|
|
|
825
829
|
|
|
826
830
|
async projectFiles(): Promise<string[]> {
|
|
827
831
|
const session = this.snapshot.session;
|
|
832
|
+
|
|
828
833
|
if (this.client?.projectFiles === undefined || !session) return [];
|
|
829
834
|
|
|
830
835
|
return this.client.projectFiles(session.id);
|
|
@@ -832,6 +837,7 @@ class Controller implements ReviewController {
|
|
|
832
837
|
|
|
833
838
|
async readFile(path: string): Promise<string | null> {
|
|
834
839
|
const session = this.snapshot.session;
|
|
840
|
+
|
|
835
841
|
if (this.client?.fileContents === undefined || !session) return null;
|
|
836
842
|
|
|
837
843
|
return this.client.fileContents(session.id, path);
|
|
@@ -857,6 +863,7 @@ class Controller implements ReviewController {
|
|
|
857
863
|
async repoChanges(): Promise<readonly DiffFileContents[]> {
|
|
858
864
|
// a plain diff review pins its captured snapshot; a workbench thread and every other thread reflect the live working tree
|
|
859
865
|
const session = this.snapshot.session;
|
|
866
|
+
|
|
860
867
|
if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
|
|
861
868
|
const initialFiles = this.consumeInitialDiffFiles(session);
|
|
862
869
|
|
|
@@ -867,6 +874,7 @@ class Controller implements ReviewController {
|
|
|
867
874
|
this.sidebarRepoRoot(),
|
|
868
875
|
session?.artifact.meta.workbench ? (session.artifact.meta.vcs ?? "git") : undefined,
|
|
869
876
|
);
|
|
877
|
+
|
|
870
878
|
// a thread switch during the request would let this response overwrite the new thread's diff,
|
|
871
879
|
// showing changes from the wrong repo; compare the thread id, not the object, so an unrelated
|
|
872
880
|
// re-render that replaced the snapshot for the SAME thread does not drop its diff to "No changes"
|
|
@@ -881,6 +889,7 @@ class Controller implements ReviewController {
|
|
|
881
889
|
file.oldContents !== diff.files[index]?.oldContents ||
|
|
882
890
|
file.newContents !== diff.files[index]?.newContents,
|
|
883
891
|
);
|
|
892
|
+
|
|
884
893
|
if (changed) {
|
|
885
894
|
this.liveDiff = diff;
|
|
886
895
|
// rows() derive from the fresh patch; re-render so an open diff tab repaints
|
|
@@ -956,7 +965,14 @@ class Controller implements ReviewController {
|
|
|
956
965
|
* builds on the first instead of on the stale snapshot. Answers arrive in
|
|
957
966
|
* request order over the socket and replace the guess with the daemon's copy.
|
|
958
967
|
*/
|
|
968
|
+
/** Agent invocation waits until the preceding comments reach the daemon. */
|
|
969
|
+
async flushMutations(): Promise<void> {
|
|
970
|
+
await Promise.all(this.pendingMutations);
|
|
971
|
+
}
|
|
972
|
+
|
|
959
973
|
private applyOptimistic(expected: Thread, mutation: Promise<Thread>): void {
|
|
974
|
+
this.pendingMutations.add(mutation);
|
|
975
|
+
void mutation.finally(() => this.pendingMutations.delete(mutation)).catch(() => {});
|
|
960
976
|
this.update({ session: expected });
|
|
961
977
|
mutation
|
|
962
978
|
.then((session) => this.update({ session }))
|
|
@@ -980,6 +996,30 @@ class Controller implements ReviewController {
|
|
|
980
996
|
void this.refreshSession(id);
|
|
981
997
|
}
|
|
982
998
|
|
|
999
|
+
async createEmptyThread(): Promise<void> {
|
|
1000
|
+
if (this.readOnly || !this.client?.sessionCreate) return;
|
|
1001
|
+
const workspace = this.snapshot.session?.workspace ?? {
|
|
1002
|
+
repoRoot: this.options.cwd ?? process.cwd(),
|
|
1003
|
+
branch: "detached",
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
try {
|
|
1007
|
+
const thread = await this.client.sessionCreate(workspace, {
|
|
1008
|
+
type: "plan",
|
|
1009
|
+
content: "",
|
|
1010
|
+
meta: { title: "New Thread" },
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
this.locallyViewed.clear();
|
|
1014
|
+
this.viewingId = thread.id;
|
|
1015
|
+
this.update({ session: thread });
|
|
1016
|
+
} catch (cause) {
|
|
1017
|
+
this.setStatus(
|
|
1018
|
+
`New Thread failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
983
1023
|
deleteSession(id: string): void {
|
|
984
1024
|
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
985
1025
|
// a deleted thread's links must not outlive it; revoke every one first, best-effort, so an
|
|
@@ -1703,6 +1743,7 @@ class Controller implements ReviewController {
|
|
|
1703
1743
|
const actionBodies = Object.fromEntries(
|
|
1704
1744
|
slashItemsFrom(this.quickActions).map((item) => [item.name, item.body]),
|
|
1705
1745
|
);
|
|
1746
|
+
|
|
1706
1747
|
this.client!.sessionSendMessage(session.id, message, summary, actionBodies)
|
|
1707
1748
|
.then((resolved) => {
|
|
1708
1749
|
// The completion overlay heading already states the message, so the
|
|
@@ -1801,6 +1842,7 @@ class Controller implements ReviewController {
|
|
|
1801
1842
|
|
|
1802
1843
|
if (!links.some((link) => link.id === id)) return;
|
|
1803
1844
|
const allowlist = input.requireAuth ? input.allowlist : [];
|
|
1845
|
+
|
|
1804
1846
|
// re-push the link's access to its blob: an allowlist for private, "public" clears it
|
|
1805
1847
|
void this.shareTransport
|
|
1806
1848
|
.push(id, [], input.requireAuth ? { githubLogins: allowlist } : "public")
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import type { ThreadAgentState } from "@cueloop/schema";
|
|
5
|
+
import { useThreadAgent, type ThreadAgentClient } from "./use-thread-agent";
|
|
6
|
+
import { waitForText, settle } from "../testing/test-support";
|
|
7
|
+
|
|
8
|
+
const initial: ThreadAgentState = {
|
|
9
|
+
threadId: "test",
|
|
10
|
+
phase: { kind: "idle" },
|
|
11
|
+
messages: [],
|
|
12
|
+
tools: [],
|
|
13
|
+
comments: [],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("late configuration and action responses cannot replace a newer accepted state", async () => {
|
|
17
|
+
let state = initial;
|
|
18
|
+
let configure: ((value: ThreadAgentState) => void) | undefined;
|
|
19
|
+
let first: ((value: ThreadAgentState) => void) | undefined;
|
|
20
|
+
let request = async () => false;
|
|
21
|
+
let calls = 0;
|
|
22
|
+
const client: ThreadAgentClient = {
|
|
23
|
+
agentGet: async () => state,
|
|
24
|
+
agentConfigure: async () =>
|
|
25
|
+
new Promise((resolve) => {
|
|
26
|
+
configure = resolve;
|
|
27
|
+
}),
|
|
28
|
+
agentPrompt: async () => {
|
|
29
|
+
if (++calls === 1)
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
first = resolve;
|
|
32
|
+
});
|
|
33
|
+
state = { ...initial, phase: { kind: "running" } };
|
|
34
|
+
|
|
35
|
+
return state;
|
|
36
|
+
},
|
|
37
|
+
agentCancel: async () => state,
|
|
38
|
+
agentComment: async () => state,
|
|
39
|
+
agentPermission: async () => state,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function TestAgent(): React.ReactNode {
|
|
43
|
+
const agent = useThreadAgent("test", undefined, client);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
request = () => agent.act((api) => api.agentPrompt({ id: "test", text: "Prompt" }));
|
|
47
|
+
}, [agent]);
|
|
48
|
+
|
|
49
|
+
return <text>{agent.state.phase.kind}</text>;
|
|
50
|
+
}
|
|
51
|
+
const setup = await testRender(<TestAgent />, { width: 40, height: 4 });
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await waitForText(setup, "idle");
|
|
55
|
+
const pending = request();
|
|
56
|
+
|
|
57
|
+
await request();
|
|
58
|
+
await waitForText(setup, "running");
|
|
59
|
+
first!(initial);
|
|
60
|
+
await pending;
|
|
61
|
+
configure!(initial);
|
|
62
|
+
await settle(setup);
|
|
63
|
+
expect(setup.captureCharFrame()).toContain("running");
|
|
64
|
+
expect(setup.captureCharFrame()).not.toContain("idle");
|
|
65
|
+
} finally {
|
|
66
|
+
setup.renderer.destroy();
|
|
67
|
+
}
|
|
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
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { subscribeThreadState, connectThreadObserver } from "@cueloop/daemon/thread-subscription";
|
|
3
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
4
|
+
import type { ThreadAgentState } from "@cueloop/schema";
|
|
5
|
+
|
|
6
|
+
/** Agent transport is injectable for frame tests; production uses the daemon socket. */
|
|
7
|
+
export type ThreadAgentClient = Pick<
|
|
8
|
+
DaemonClient,
|
|
9
|
+
"agentGet" | "agentPrompt" | "agentCancel" | "agentComment" | "agentPermission"
|
|
10
|
+
> &
|
|
11
|
+
Partial<Pick<DaemonClient, "agentConfigure">>;
|
|
12
|
+
|
|
13
|
+
/** Subscribe to durable agent state; unmount closes only this client connection. */
|
|
14
|
+
export function useThreadAgent(id: string, home?: string, injected?: ThreadAgentClient) {
|
|
15
|
+
const [client, setClient] = useState<ThreadAgentClient | null>(injected ?? null);
|
|
16
|
+
const [state, setState] = useState<ThreadAgentState>({
|
|
17
|
+
threadId: id,
|
|
18
|
+
phase: { kind: "idle" },
|
|
19
|
+
messages: [],
|
|
20
|
+
tools: [],
|
|
21
|
+
comments: [],
|
|
22
|
+
});
|
|
23
|
+
const revision = useRef(0);
|
|
24
|
+
const [error, setError] = useState("");
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
let cancelled = false;
|
|
28
|
+
let connection: DaemonClient | undefined;
|
|
29
|
+
let unsubscribe: (() => void) | undefined;
|
|
30
|
+
|
|
31
|
+
revision.current++;
|
|
32
|
+
const refresh = async (api: ThreadAgentClient) => {
|
|
33
|
+
const requested = ++revision.current;
|
|
34
|
+
const result = await api.agentGet(id);
|
|
35
|
+
|
|
36
|
+
if (!cancelled && requested === revision.current) setState(result);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const configure = async (api: ThreadAgentClient): Promise<void> => {
|
|
40
|
+
if (!api.agentConfigure) return;
|
|
41
|
+
const requested = ++revision.current;
|
|
42
|
+
const configured = await api.agentConfigure({ id });
|
|
43
|
+
|
|
44
|
+
if (!cancelled && requested === revision.current) setState(configured);
|
|
45
|
+
await refresh(api);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
void (async () => {
|
|
49
|
+
try {
|
|
50
|
+
if (injected) {
|
|
51
|
+
await refresh(injected);
|
|
52
|
+
await configure(injected);
|
|
53
|
+
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
unsubscribe = subscribeThreadState({
|
|
57
|
+
connect: connectThreadObserver({ home, autostart: true }),
|
|
58
|
+
matches: (event) => event.event === "agent.updated" && event.sessionId === id,
|
|
59
|
+
read: async (api, signal) => {
|
|
60
|
+
const requested = ++revision.current;
|
|
61
|
+
const state = await api.agentGet(id, { signal });
|
|
62
|
+
|
|
63
|
+
return { state, requested };
|
|
64
|
+
},
|
|
65
|
+
onConnect: (api) => {
|
|
66
|
+
connection = api;
|
|
67
|
+
setClient(api);
|
|
68
|
+
void configure(api).catch((error) => {
|
|
69
|
+
if (!cancelled) setError(String(error));
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
onValue: ({ state, requested }) => {
|
|
73
|
+
if (!cancelled && requested === revision.current) setState(state);
|
|
74
|
+
},
|
|
75
|
+
onError: (error) => {
|
|
76
|
+
if (!cancelled) setError(String(error));
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!cancelled) setError(error instanceof Error ? error.message : String(error));
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
cancelled = true;
|
|
86
|
+
unsubscribe?.();
|
|
87
|
+
connection?.close();
|
|
88
|
+
};
|
|
89
|
+
}, [id, home, injected]);
|
|
90
|
+
|
|
91
|
+
const act = async (
|
|
92
|
+
request: (client: ThreadAgentClient) => Promise<ThreadAgentState>,
|
|
93
|
+
): Promise<boolean> => {
|
|
94
|
+
if (!client) return false;
|
|
95
|
+
try {
|
|
96
|
+
const requested = ++revision.current;
|
|
97
|
+
const result = await request(client);
|
|
98
|
+
|
|
99
|
+
if (requested === revision.current) setState(result);
|
|
100
|
+
setError("");
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
setError(error instanceof Error ? error.message : String(error));
|
|
105
|
+
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return { state, client, error, setError, act };
|
|
111
|
+
}
|
|
@@ -21,6 +21,7 @@ test("the input is focused on open, so typing lands in the dialog", async () =>
|
|
|
21
21
|
/>,
|
|
22
22
|
{ width: 60, height: 12 },
|
|
23
23
|
);
|
|
24
|
+
|
|
24
25
|
await settle(setup);
|
|
25
26
|
|
|
26
27
|
await typeText(setup, "!");
|
|
@@ -45,14 +46,17 @@ test("clicking save fires onSave and clicking cancel fires onCancel", async () =
|
|
|
45
46
|
/>,
|
|
46
47
|
{ width: 60, height: 12 },
|
|
47
48
|
);
|
|
49
|
+
|
|
48
50
|
await settle(setup);
|
|
49
51
|
|
|
50
52
|
const save = locateText(setup, "save");
|
|
53
|
+
|
|
51
54
|
await setup.mockMouse.click(save.column, save.row);
|
|
52
55
|
expect(saved).toBe(1);
|
|
53
56
|
expect(cancelled).toBe(0);
|
|
54
57
|
|
|
55
58
|
const cancel = locateText(setup, "cancel");
|
|
59
|
+
|
|
56
60
|
await setup.mockMouse.click(cancel.column, cancel.row);
|
|
57
61
|
expect(cancelled).toBe(1);
|
|
58
62
|
expect(saved).toBe(1);
|
|
@@ -140,6 +140,7 @@ export function ChangesFileTree({
|
|
|
140
140
|
return setCursor(Math.min(cursorIndex + 1, rows.length - 1));
|
|
141
141
|
if (key.name === "k" || key.name === "up") return setCursor(Math.max(cursorIndex - 1, 0));
|
|
142
142
|
const row = rows[cursorIndex];
|
|
143
|
+
|
|
143
144
|
if (!row) return;
|
|
144
145
|
if (key.name === "return" || key.name === "enter")
|
|
145
146
|
return row.isFolder ? toggle(row.id) : onSelectFile(row.id, true);
|
|
@@ -41,6 +41,7 @@ test("j moves onto a file and Enter opens it", async () => {
|
|
|
41
41
|
/>,
|
|
42
42
|
{ width: 40, height: 12 },
|
|
43
43
|
);
|
|
44
|
+
|
|
44
45
|
await settle(setup);
|
|
45
46
|
await waitForText(setup, "a.ts");
|
|
46
47
|
|
|
@@ -117,6 +118,7 @@ test("without focus the keyboard opens nothing", async () => {
|
|
|
117
118
|
<ChangesFileTree files={files} onSelectFile={(path) => opened.push(path)} theme={DARK} />,
|
|
118
119
|
{ width: 40, height: 12 },
|
|
119
120
|
);
|
|
121
|
+
|
|
120
122
|
await settle(setup);
|
|
121
123
|
await waitForText(setup, "a.ts");
|
|
122
124
|
|
|
@@ -34,12 +34,14 @@ export function FileContentsView({
|
|
|
34
34
|
// instance; a state-backed ref rebinds the target once the code renderable exists.
|
|
35
35
|
const [codeTarget, setCodeTarget] = useState<CodeRenderable | null>(null);
|
|
36
36
|
const loadRef = useRef(loadContents);
|
|
37
|
+
|
|
37
38
|
useEffect(() => {
|
|
38
39
|
loadRef.current = loadContents;
|
|
39
40
|
});
|
|
40
41
|
|
|
41
42
|
useEffect(() => {
|
|
42
43
|
let alive = true;
|
|
44
|
+
|
|
43
45
|
void loadRef.current(path).then(
|
|
44
46
|
(contents) => {
|
|
45
47
|
if (alive) setLoaded({ path, lines: contents === null ? null : contents.split("\n") });
|
|
@@ -34,6 +34,9 @@ export type DiffSurfaceProps = Pick<
|
|
|
34
34
|
| "onAnnotate"
|
|
35
35
|
| "onReply"
|
|
36
36
|
| "onUpdateAnnotation"
|
|
37
|
+
| "isAnnotationReadOnly"
|
|
38
|
+
| "annotationAction"
|
|
39
|
+
| "onInvoke"
|
|
37
40
|
| "resolveAuthorLabel"
|
|
38
41
|
| "onNavCommand"
|
|
39
42
|
| "onExit"
|
|
@@ -108,6 +111,7 @@ export function GridTabContent(props: {
|
|
|
108
111
|
theme: Theme;
|
|
109
112
|
}): React.ReactNode {
|
|
110
113
|
const { tab } = props;
|
|
114
|
+
|
|
111
115
|
if (tab.kind === "file" && tab.fileView === "contents" && tab.path !== undefined) {
|
|
112
116
|
const filePath = tab.path;
|
|
113
117
|
|
|
@@ -128,6 +132,9 @@ export function GridTabContent(props: {
|
|
|
128
132
|
onAddComment={(anchor, body) => props.onAddFileComment(filePath, anchor, body)}
|
|
129
133
|
onReply={props.surface.onReply}
|
|
130
134
|
onUpdateAnnotation={props.surface.onUpdateAnnotation}
|
|
135
|
+
isAnnotationReadOnly={props.surface.isAnnotationReadOnly}
|
|
136
|
+
annotationAction={props.surface.annotationAction}
|
|
137
|
+
onInvoke={props.surface.onInvoke}
|
|
131
138
|
resolveAuthorLabel={props.surface.resolveAuthorLabel}
|
|
132
139
|
onExit={props.surface.onExit}
|
|
133
140
|
theme={props.theme}
|
|
@@ -31,6 +31,7 @@ test("Enter expands a folder, then j moves down and Enter opens the file", async
|
|
|
31
31
|
/>,
|
|
32
32
|
{ width: 40, height: 12 },
|
|
33
33
|
);
|
|
34
|
+
|
|
34
35
|
await settle(setup);
|
|
35
36
|
await waitForText(setup, "src");
|
|
36
37
|
|
|
@@ -144,6 +145,7 @@ test("without focus the keyboard does nothing", async () => {
|
|
|
144
145
|
/>,
|
|
145
146
|
{ width: 40, height: 12 },
|
|
146
147
|
);
|
|
148
|
+
|
|
147
149
|
await settle(setup);
|
|
148
150
|
await waitForText(setup, "src");
|
|
149
151
|
|
|
@@ -223,6 +225,7 @@ test("a double-click requests a persistent file tab", async () => {
|
|
|
223
225
|
|
|
224
226
|
await waitForText(setup, "README.md");
|
|
225
227
|
const file = locateText(setup, "README.md");
|
|
228
|
+
|
|
226
229
|
await setup.mockMouse.doubleClick(file.column, file.row);
|
|
227
230
|
expect(opened).toEqual([
|
|
228
231
|
{ path: "README.md", persistent: false },
|
|
@@ -56,6 +56,7 @@ export function ProjectTreeView({
|
|
|
56
56
|
if (key.name === "k" || key.name === "up")
|
|
57
57
|
return setCursorId(rows[Math.max(cursorIndex - 1, 0)]!.id);
|
|
58
58
|
const row = rows[cursorIndex];
|
|
59
|
+
|
|
59
60
|
if (!row) return;
|
|
60
61
|
if (key.name === "return" || key.name === "enter")
|
|
61
62
|
return row.isFolder ? toggle(row.id) : onSelectFile(row.id, true);
|
|
@@ -19,10 +19,12 @@ import {
|
|
|
19
19
|
|
|
20
20
|
function asBranch(node: EditorNode | null): EditorBranch {
|
|
21
21
|
if (node === null || node.type !== "branch") throw new Error("expected a branch node");
|
|
22
|
+
|
|
22
23
|
return node;
|
|
23
24
|
}
|
|
24
25
|
function asGroup(node: EditorNode | null): EditorGroup {
|
|
25
26
|
if (node === null || node.type !== "group") throw new Error("expected a group node");
|
|
27
|
+
|
|
26
28
|
return node;
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -30,6 +32,7 @@ describe("editor grid", () => {
|
|
|
30
32
|
test("a fresh group holds its tabs with the first active", () => {
|
|
31
33
|
const tab = changesTab();
|
|
32
34
|
const group = makeGroup([tab]);
|
|
35
|
+
|
|
33
36
|
expect(group.type).toBe("group");
|
|
34
37
|
expect(group.activeTabId).toBe(tab.id);
|
|
35
38
|
});
|
|
@@ -38,6 +41,7 @@ describe("editor grid", () => {
|
|
|
38
41
|
const group = makeGroup([changesTab()]);
|
|
39
42
|
const file = fileTab("App.tsx", "src/App.tsx", "diff");
|
|
40
43
|
const next = asGroup(addTab(group, group.id, file));
|
|
44
|
+
|
|
41
45
|
expect(next.tabs).toHaveLength(2);
|
|
42
46
|
expect(next.activeTabId).toBe(file.id);
|
|
43
47
|
});
|
|
@@ -81,8 +85,10 @@ describe("editor grid", () => {
|
|
|
81
85
|
test("splitting right makes a horizontal branch and focuses the new group", () => {
|
|
82
86
|
const group = makeGroup([fileTab("App.tsx", "src/App.tsx", "diff")]);
|
|
83
87
|
const { tree, focusGroupId } = splitGroup(group, group.id, "right");
|
|
88
|
+
|
|
84
89
|
expect(tree.type).toBe("branch");
|
|
85
90
|
const branch = asBranch(tree);
|
|
91
|
+
|
|
86
92
|
expect(branch.orientation).toBe("horizontal");
|
|
87
93
|
expect(branch.children).toHaveLength(2);
|
|
88
94
|
// the new group is the second child (right edge) and takes focus
|
|
@@ -95,6 +101,7 @@ describe("editor grid", () => {
|
|
|
95
101
|
const group = makeGroup([fileTab("a.ts", "a.ts", "contents")]);
|
|
96
102
|
const { tree, focusGroupId } = splitGroup(group, group.id, "up");
|
|
97
103
|
const branch = asBranch(tree);
|
|
104
|
+
|
|
98
105
|
expect(branch.orientation).toBe("vertical");
|
|
99
106
|
expect(branch.children[0]!.id).toBe(focusGroupId);
|
|
100
107
|
});
|
|
@@ -131,6 +138,7 @@ describe("editor grid", () => {
|
|
|
131
138
|
const branch = asBranch(tree);
|
|
132
139
|
const rightGroup = asGroup(branch.children[1]!);
|
|
133
140
|
const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
|
|
141
|
+
|
|
134
142
|
expect(collapsed?.type).toBe("group");
|
|
135
143
|
expect(asGroup(collapsed).id).toBe(group.id);
|
|
136
144
|
});
|
|
@@ -138,6 +146,7 @@ describe("editor grid", () => {
|
|
|
138
146
|
test("closing the only tab of the whole grid returns null", () => {
|
|
139
147
|
const tab = changesTab();
|
|
140
148
|
const group = makeGroup([tab]);
|
|
149
|
+
|
|
141
150
|
expect(closeTab(group, group.id, tab.id)).toBeNull();
|
|
142
151
|
});
|
|
143
152
|
|
|
@@ -146,8 +155,10 @@ describe("editor grid", () => {
|
|
|
146
155
|
const { tree } = splitGroup(group, group.id, "right");
|
|
147
156
|
const branch = asBranch(tree);
|
|
148
157
|
const rightGroup = asGroup(branch.children[1]!);
|
|
158
|
+
|
|
149
159
|
expect(containsGroup(tree, group.id)).toBe(true);
|
|
150
160
|
const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
|
|
161
|
+
|
|
151
162
|
expect(containsGroup(asGroup(collapsed), rightGroup.id)).toBe(false);
|
|
152
163
|
});
|
|
153
164
|
|