@cueloop/client 0.1.0-alpha.96 → 0.1.0-alpha.98
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.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +29 -6
- package/src/annotations/use-annotation-surface.tsx +245 -30
- package/src/app/App.thread-agent.test.ts +12 -0
- package/src/app/App.tsx +217 -97
- package/src/app/app-screens.tsx +2 -0
- package/src/app/components/AppShell.zoom.test.tsx +4 -3
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/diff/components/AnnotatableFileView.tsx +6 -0
- package/src/diff/components/DiffContentView.tsx +9 -0
- package/src/markdown/components/ThreadView.tsx +60 -16
- 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/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 +332 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +110 -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 +780 -0
- package/src/thread/components/AgentThreadPane.tsx +356 -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/thread-controller.ts +34 -0
- package/src/thread/use-thread-agent.test.tsx +65 -0
- package/src/thread/use-thread-agent.ts +101 -0
- package/src/workbench/components/GridTabContent.tsx +6 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import React, { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
newAnnotationId,
|
|
4
|
+
agentCommentRoot,
|
|
5
|
+
type Thread,
|
|
6
|
+
type ThreadAgentState,
|
|
7
|
+
} from "@cueloop/schema";
|
|
8
|
+
import type { Theme } from "../../appearance/theme";
|
|
9
|
+
import { ThreadView, type ThreadViewProps } from "../../markdown/components/ThreadView";
|
|
10
|
+
import { buildDisplay, marksByDisplay } from "../../markdown/view-plan";
|
|
11
|
+
import { projectThreadConversation, commentOnAgentSpan } from "../agent-transcript";
|
|
12
|
+
import { useThreadAgent, type ThreadAgentClient } from "../use-thread-agent";
|
|
13
|
+
import { agentContinuation } from "../agent-continuation";
|
|
14
|
+
import { AgentConfigControls } from "./AgentConfigControls";
|
|
15
|
+
|
|
16
|
+
/** The opt-in prototype extends the existing Thread body and retains its surrounding chrome. */
|
|
17
|
+
export interface AgentThreadPaneProps {
|
|
18
|
+
thread: Thread;
|
|
19
|
+
home?: string;
|
|
20
|
+
theme: Theme;
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
focused: boolean;
|
|
23
|
+
suspended?: boolean;
|
|
24
|
+
onActiveChange: (active: boolean) => void;
|
|
25
|
+
onOpenFile: (path: string) => void;
|
|
26
|
+
onNextPane?: (backward: boolean) => void;
|
|
27
|
+
onControlsChange?: (controls: React.ReactNode) => void;
|
|
28
|
+
onActionsChange?: (
|
|
29
|
+
action: (id: string) => { label: string; run: () => void } | undefined,
|
|
30
|
+
) => void;
|
|
31
|
+
onRevealReply?: () => void;
|
|
32
|
+
onInvokeChange?: (invoke: (() => void) | undefined) => void;
|
|
33
|
+
onStateChange?: (state: ThreadAgentState) => void;
|
|
34
|
+
flushMutations?: () => Promise<void>;
|
|
35
|
+
client?: ThreadAgentClient;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Inline input and the final blank line invoke the harness without replacing Send message. */
|
|
39
|
+
export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
40
|
+
const { thread, theme } = props;
|
|
41
|
+
const agent = useThreadAgent(thread.id, props.home, props.client);
|
|
42
|
+
const child =
|
|
43
|
+
React.isValidElement<ThreadViewProps>(props.children) && props.children.type === ThreadView
|
|
44
|
+
? props.children.props
|
|
45
|
+
: undefined;
|
|
46
|
+
const baseDisplay =
|
|
47
|
+
child?.display ??
|
|
48
|
+
buildDisplay(
|
|
49
|
+
thread.artifact.type === "diff"
|
|
50
|
+
? (thread.artifact.meta.prBrief ?? "")
|
|
51
|
+
: (thread.workingCopy ?? thread.artifact.content),
|
|
52
|
+
);
|
|
53
|
+
const baseMarks = child?.marks ?? marksByDisplay(thread.annotations, baseDisplay);
|
|
54
|
+
const projection = useMemo(
|
|
55
|
+
() =>
|
|
56
|
+
projectThreadConversation(
|
|
57
|
+
thread,
|
|
58
|
+
agent.state,
|
|
59
|
+
baseDisplay,
|
|
60
|
+
baseMarks,
|
|
61
|
+
agent.state.phase.kind !== "idle" || Boolean(agent.error),
|
|
62
|
+
),
|
|
63
|
+
[thread, agent.state, agent.error, baseDisplay, baseMarks],
|
|
64
|
+
);
|
|
65
|
+
const [requestedBlock, setRequestedBlock] = useState<{ blockIndex: number }>();
|
|
66
|
+
const draft = useRef("");
|
|
67
|
+
const [promptRestoreRequest, setPromptRestoreRequest] =
|
|
68
|
+
useState<ThreadViewProps["promptRestoreRequest"]>();
|
|
69
|
+
const restoreSequence = useRef(0);
|
|
70
|
+
const invoking = useRef(false);
|
|
71
|
+
const invocations = useRef<{ text: string; writes: Promise<boolean>[] }[]>([]);
|
|
72
|
+
const pendingWrites = useRef<Promise<boolean>[]>([]);
|
|
73
|
+
const state = agent.state;
|
|
74
|
+
const continuation = agentContinuation(state);
|
|
75
|
+
const busy = state.phase.kind === "running" || state.phase.kind === "permission";
|
|
76
|
+
|
|
77
|
+
const [pulse, setPulse] = useState(false);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!busy) return;
|
|
80
|
+
const timer = setInterval(() => setPulse((value) => !value), 600);
|
|
81
|
+
|
|
82
|
+
return () => clearInterval(timer);
|
|
83
|
+
}, [busy]);
|
|
84
|
+
const notifyActive = useEffectEvent((active: boolean) => props.onActiveChange(active));
|
|
85
|
+
const notifyState = useEffectEvent(() => props.onStateChange?.(state));
|
|
86
|
+
const notifyControls = useEffectEvent(() =>
|
|
87
|
+
props.onControlsChange?.(
|
|
88
|
+
<AgentConfigControls
|
|
89
|
+
state={state}
|
|
90
|
+
theme={theme}
|
|
91
|
+
onConfigure={(configId, value) =>
|
|
92
|
+
void agent.act((client) => {
|
|
93
|
+
if (!client.agentConfigure)
|
|
94
|
+
return Promise.reject(new Error("Agent configuration is unavailable"));
|
|
95
|
+
|
|
96
|
+
return client.agentConfigure({ id: thread.id, configId, value });
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
/>,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
notifyActive(true);
|
|
104
|
+
return () => notifyActive(false);
|
|
105
|
+
}, []);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
notifyState();
|
|
108
|
+
}, [state]);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
notifyControls();
|
|
111
|
+
}, [state, theme]);
|
|
112
|
+
|
|
113
|
+
const invoke = async (): Promise<void> => {
|
|
114
|
+
if (thread.status !== "pending") return;
|
|
115
|
+
invocations.current.push({ text: draft.current, writes: pendingWrites.current.splice(0) });
|
|
116
|
+
draft.current = "";
|
|
117
|
+
if (invoking.current) return;
|
|
118
|
+
invoking.current = true;
|
|
119
|
+
|
|
120
|
+
const rejected: string[] = [];
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
while (invocations.current.length) {
|
|
124
|
+
const input = invocations.current.shift()!;
|
|
125
|
+
try {
|
|
126
|
+
const writesSaved = (await Promise.all(input.writes)).every(Boolean);
|
|
127
|
+
if (!writesSaved) {
|
|
128
|
+
rejected.push(input.text);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
await props.flushMutations?.();
|
|
132
|
+
const accepted = await agent.act((client) =>
|
|
133
|
+
client.agentPrompt({ id: thread.id, text: input.text }),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (!accepted) rejected.push(input.text);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
rejected.push(input.text);
|
|
139
|
+
agent.setError(String(error));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
invoking.current = false;
|
|
144
|
+
const text = rejected.filter(Boolean).join("\n");
|
|
145
|
+
|
|
146
|
+
if (text) setPromptRestoreRequest({ id: ++restoreSequence.current, text });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const invokeRef = useRef<() => Promise<void>>(async () => {});
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
invokeRef.current = invoke;
|
|
152
|
+
});
|
|
153
|
+
const notifyInvoke = useEffectEvent((invoke: (() => void) | undefined) =>
|
|
154
|
+
props.onInvokeChange?.(invoke),
|
|
155
|
+
);
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
notifyInvoke(() => void invokeRef.current());
|
|
158
|
+
|
|
159
|
+
return () => notifyInvoke(undefined);
|
|
160
|
+
}, []);
|
|
161
|
+
const reply = (id: string, body: string): void => {
|
|
162
|
+
const origin = projection.mirrors.get(id)?.commentId ?? id;
|
|
163
|
+
const root = agentCommentRoot(state, origin);
|
|
164
|
+
|
|
165
|
+
if (root)
|
|
166
|
+
pendingWrites.current.push(
|
|
167
|
+
agent.act((client) =>
|
|
168
|
+
client.agentComment({
|
|
169
|
+
id: thread.id,
|
|
170
|
+
comment: {
|
|
171
|
+
...root,
|
|
172
|
+
id: newAnnotationId(),
|
|
173
|
+
body,
|
|
174
|
+
sent: false,
|
|
175
|
+
author: undefined,
|
|
176
|
+
replyTo: root.replyTo ?? root.id,
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
else child?.onReply(origin, body);
|
|
182
|
+
};
|
|
183
|
+
const actionFor = (id: string) => {
|
|
184
|
+
const mirror = projection.mirrors.get(id);
|
|
185
|
+
const submission = state.submissions?.find(
|
|
186
|
+
(entry) => entry.id === mirror?.submissionId || entry.commentId === id,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (!submission) return undefined;
|
|
190
|
+
if (submission.status === "failed")
|
|
191
|
+
return {
|
|
192
|
+
label: "Retry",
|
|
193
|
+
run: () =>
|
|
194
|
+
void agent.act((client) =>
|
|
195
|
+
client.agentPrompt({ id: thread.id, text: "", retry: submission.id }),
|
|
196
|
+
),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
label: "View reply",
|
|
201
|
+
run: () => {
|
|
202
|
+
props.onRevealReply?.();
|
|
203
|
+
setRequestedBlock({
|
|
204
|
+
blockIndex: projection.destinations.get(submission.id) ?? projection.tailIndex,
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const notifyActions = useEffectEvent(() => props.onActionsChange?.(actionFor));
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
notifyActions();
|
|
213
|
+
}, [state, projection]);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<ThreadView
|
|
217
|
+
{...child}
|
|
218
|
+
session={projection.session}
|
|
219
|
+
display={projection.display}
|
|
220
|
+
marks={projection.marks}
|
|
221
|
+
quickActions={child?.quickActions ?? []}
|
|
222
|
+
observer={false}
|
|
223
|
+
resolved={child?.resolved ?? thread.status !== "pending"}
|
|
224
|
+
suspended={!props.focused || props.suspended}
|
|
225
|
+
theme={theme}
|
|
226
|
+
requestedBlock={requestedBlock}
|
|
227
|
+
isAnnotationReadOnly={(id) =>
|
|
228
|
+
projection.mirrors.has(id) ||
|
|
229
|
+
Boolean(state.submissions?.some((entry) => entry.commentId === id)) ||
|
|
230
|
+
Boolean(state.comments.find((entry) => entry.id === id)?.sent)
|
|
231
|
+
}
|
|
232
|
+
annotationAction={actionFor}
|
|
233
|
+
onInvoke={() => void invoke()}
|
|
234
|
+
isPromptBlock={(index) => index === projection.tailIndex}
|
|
235
|
+
promptRestoreRequest={promptRestoreRequest}
|
|
236
|
+
promptFocusRequest={
|
|
237
|
+
continuation.kind === "ready"
|
|
238
|
+
? { replyId: continuation.replyId, blockIndex: projection.tailIndex }
|
|
239
|
+
: undefined
|
|
240
|
+
}
|
|
241
|
+
canAnnotateBlock={(index) => {
|
|
242
|
+
const source = projection.sources[index];
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
source?.kind !== "activity" &&
|
|
246
|
+
(source?.kind !== "message" ||
|
|
247
|
+
Boolean(
|
|
248
|
+
state.messages.find(
|
|
249
|
+
(entry) => entry.id === source.messageId && entry.role === "agent",
|
|
250
|
+
)?.complete,
|
|
251
|
+
))
|
|
252
|
+
);
|
|
253
|
+
}}
|
|
254
|
+
onAnnotate={(span, body) => {
|
|
255
|
+
const source = projection.sources[span.start.blockIndex];
|
|
256
|
+
const end = projection.sources[span.end.blockIndex];
|
|
257
|
+
|
|
258
|
+
if (source?.kind === "tail") {
|
|
259
|
+
draft.current = body;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (source?.kind === "mirror") {
|
|
263
|
+
if (source.commentId) reply(source.commentId, body);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (source?.kind === "artifact" && end?.kind === "artifact") {
|
|
267
|
+
child?.onAnnotate(span, body);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (
|
|
271
|
+
source?.kind !== "message" ||
|
|
272
|
+
end?.kind !== "message" ||
|
|
273
|
+
source.messageId !== end.messageId
|
|
274
|
+
)
|
|
275
|
+
return;
|
|
276
|
+
const message = state.messages.find((entry) => entry.id === source.messageId)!;
|
|
277
|
+
const localState = { ...state, messages: [message] };
|
|
278
|
+
const comment = commentOnAgentSpan(
|
|
279
|
+
thread,
|
|
280
|
+
localState,
|
|
281
|
+
{
|
|
282
|
+
start: { ...span.start, blockIndex: source.blockIndex + 1 },
|
|
283
|
+
end: { ...span.end, blockIndex: end.blockIndex + 1 },
|
|
284
|
+
},
|
|
285
|
+
body,
|
|
286
|
+
newAnnotationId(),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
pendingWrites.current.push(
|
|
290
|
+
agent.act((client) => client.agentComment({ id: thread.id, comment })),
|
|
291
|
+
);
|
|
292
|
+
}}
|
|
293
|
+
onReply={reply}
|
|
294
|
+
onUpdateAnnotation={(id, body) => {
|
|
295
|
+
const comment = state.comments.find((entry) => entry.id === id);
|
|
296
|
+
|
|
297
|
+
if (comment)
|
|
298
|
+
pendingWrites.current.push(
|
|
299
|
+
agent.act((client) =>
|
|
300
|
+
client.agentComment({ id: thread.id, comment: { ...comment, body } }),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
else child?.onUpdateAnnotation(id, body);
|
|
304
|
+
}}
|
|
305
|
+
onExit={child?.onExit ?? (() => {})}
|
|
306
|
+
renderBlock={(index) =>
|
|
307
|
+
index === projection.activityIndex ? (
|
|
308
|
+
<box style={{ flexDirection: "column", paddingLeft: 2 }}>
|
|
309
|
+
{busy ? <text fg={pulse ? theme.textDim : theme.textMuted}>Thinking…</text> : null}
|
|
310
|
+
{state.phase.kind === "permission" ? (
|
|
311
|
+
<>
|
|
312
|
+
<text fg={theme.text}>{state.phase.permission.title}</text>
|
|
313
|
+
<box style={{ flexDirection: "row", gap: 2 }}>
|
|
314
|
+
{state.phase.permission.options.map((option) => (
|
|
315
|
+
<text
|
|
316
|
+
key={option.optionId}
|
|
317
|
+
fg={theme.blue}
|
|
318
|
+
onMouseUp={() => {
|
|
319
|
+
if (state.phase.kind === "permission")
|
|
320
|
+
void agent.act((client) =>
|
|
321
|
+
client.agentPermission({
|
|
322
|
+
id: thread.id,
|
|
323
|
+
requestId:
|
|
324
|
+
state.phase.kind === "permission" ? state.phase.permission.id : "",
|
|
325
|
+
optionId: option.optionId,
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
}}
|
|
329
|
+
>
|
|
330
|
+
{option.name}
|
|
331
|
+
</text>
|
|
332
|
+
))}
|
|
333
|
+
</box>
|
|
334
|
+
</>
|
|
335
|
+
) : null}
|
|
336
|
+
{agent.error ? <text fg={theme.red}>{agent.error}</text> : null}
|
|
337
|
+
</box>
|
|
338
|
+
) : undefined
|
|
339
|
+
}
|
|
340
|
+
/>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The prototype is local and opt-in; shared views keep their existing renderer. */
|
|
345
|
+
export function AgentThreadPrototype(
|
|
346
|
+
props: AgentThreadPaneProps & { enabled: boolean; observer: boolean; pixelPrototype: boolean },
|
|
347
|
+
): React.ReactNode {
|
|
348
|
+
if (!props.enabled || props.observer || props.pixelPrototype) return props.children;
|
|
349
|
+
|
|
350
|
+
return <AgentThreadPane {...props} />;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Embedded-agent chords apply only while the Thread owns focus. */
|
|
354
|
+
export function agentOwnsKeyboard(active: boolean, focusedPane: string): boolean {
|
|
355
|
+
return active && focusedPane === "thread";
|
|
356
|
+
}
|
|
@@ -10,6 +10,7 @@ import { truncateTitle } from "../../ui/components/truncate-title";
|
|
|
10
10
|
import { NERD } from "../../ui/components/primitives/icons";
|
|
11
11
|
|
|
12
12
|
export interface ThreadFooterProps {
|
|
13
|
+
controls?: React.ReactNode;
|
|
13
14
|
repo: string;
|
|
14
15
|
branch: string;
|
|
15
16
|
onSubmit?: () => void;
|
|
@@ -27,6 +28,7 @@ const SEPARATOR = " / ";
|
|
|
27
28
|
export const THREAD_FOOTER_HEIGHT = 2;
|
|
28
29
|
|
|
29
30
|
export function ThreadFooter({
|
|
31
|
+
controls,
|
|
30
32
|
repo,
|
|
31
33
|
branch,
|
|
32
34
|
onSubmit,
|
|
@@ -66,6 +68,7 @@ export function ThreadFooter({
|
|
|
66
68
|
</text>
|
|
67
69
|
</box>
|
|
68
70
|
<box style={{ flexGrow: 1 }} />
|
|
71
|
+
{controls}
|
|
69
72
|
<box onMouseUp={canSubmit ? onSubmit : undefined} style={{ flexShrink: 0 }}>
|
|
70
73
|
<text fg={canSubmit ? tokens.accent : tokens.textDim}>
|
|
71
74
|
{`Send message (${pendingAnnotations})`}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { ThreadTree } from "./ThreadTree";
|
|
5
|
+
import { TooltipProvider } from "../../ui/components/Tooltip";
|
|
6
|
+
import { locateText, waitForText } from "../../testing/test-support";
|
|
7
|
+
|
|
8
|
+
test("New Thread button sits beside Threads and exposes its hover tooltip", async () => {
|
|
9
|
+
let created = 0;
|
|
10
|
+
const setup = await testRender(
|
|
11
|
+
<TooltipProvider>
|
|
12
|
+
<ThreadTree rows={[]} cursor={0} onCreateThread={() => created++} />
|
|
13
|
+
</TooltipProvider>,
|
|
14
|
+
{ width: 30, height: 8 },
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await waitForText(setup, "Threads");
|
|
19
|
+
const button = locateText(setup, "+");
|
|
20
|
+
const heading = locateText(setup, "Threads");
|
|
21
|
+
|
|
22
|
+
expect(button.row).toBe(heading.row);
|
|
23
|
+
await setup.mockMouse.moveTo(button.column, button.row);
|
|
24
|
+
await waitForText(setup, "New Thread");
|
|
25
|
+
await setup.mockMouse.click(button.column, button.row);
|
|
26
|
+
expect(created).toBe(1);
|
|
27
|
+
} finally {
|
|
28
|
+
setup.renderer.destroy();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("ordinary sidebar has no New Thread button", async () => {
|
|
33
|
+
const setup = await testRender(<ThreadTree rows={[]} cursor={0} />, { width: 30, height: 8 });
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
await waitForText(setup, "no threads");
|
|
37
|
+
expect(setup.captureCharFrame()).not.toContain("+");
|
|
38
|
+
} finally {
|
|
39
|
+
setup.renderer.destroy();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -37,6 +37,8 @@ export interface ThreadTreeProps {
|
|
|
37
37
|
onPin?: (id: string) => void;
|
|
38
38
|
/** Rename a thread's title (the menu's Rename). */
|
|
39
39
|
onRename?: (id: string, title: string) => void;
|
|
40
|
+
/** Create an empty Thread; omitting it retains the ordinary sidebar. */
|
|
41
|
+
onCreateThread?: () => void;
|
|
40
42
|
theme?: Theme;
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -192,6 +194,7 @@ export function ThreadTree({
|
|
|
192
194
|
onRequestDelete,
|
|
193
195
|
onPin,
|
|
194
196
|
onRename,
|
|
197
|
+
onCreateThread,
|
|
195
198
|
theme,
|
|
196
199
|
}: ThreadTreeProps): React.ReactNode {
|
|
197
200
|
const tokens = useComponentTheme(theme);
|
|
@@ -201,17 +204,34 @@ export function ThreadTree({
|
|
|
201
204
|
const titleWidth = Math.max(8, width - 9);
|
|
202
205
|
const closeMenu = (): void => menuControl.closeMenu();
|
|
203
206
|
|
|
207
|
+
const visibleRows =
|
|
208
|
+
onCreateThread && !rows.some((row) => row.id === "section:threads")
|
|
209
|
+
? [...rows, { kind: "section" as const, id: "section:threads", label: "Threads" }]
|
|
210
|
+
: rows;
|
|
211
|
+
|
|
204
212
|
return (
|
|
205
213
|
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1 }}>
|
|
206
|
-
{
|
|
214
|
+
{visibleRows.length === 0 ? (
|
|
207
215
|
<text fg={tokens.textDim}>no threads</text>
|
|
208
216
|
) : (
|
|
209
|
-
|
|
217
|
+
visibleRows.map((row, index) => {
|
|
210
218
|
if (row.kind === "section") {
|
|
211
219
|
return (
|
|
212
|
-
<
|
|
213
|
-
{row.label}
|
|
214
|
-
|
|
220
|
+
<box key={row.id} style={{ flexDirection: "row", marginTop: index > 0 ? 1 : 0 }}>
|
|
221
|
+
<text fg={tokens.textDim}>{row.label}</text>
|
|
222
|
+
{row.id === "section:threads" && onCreateThread ? (
|
|
223
|
+
<>
|
|
224
|
+
<box style={{ flexGrow: 1 }} />
|
|
225
|
+
<IconButton
|
|
226
|
+
glyph="+"
|
|
227
|
+
tip="New Thread"
|
|
228
|
+
onPress={onCreateThread}
|
|
229
|
+
marginRight={1}
|
|
230
|
+
theme={theme}
|
|
231
|
+
/>
|
|
232
|
+
</>
|
|
233
|
+
) : null}
|
|
234
|
+
</box>
|
|
215
235
|
);
|
|
216
236
|
}
|
|
217
237
|
|
|
@@ -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>(() => ({
|
|
@@ -956,7 +960,14 @@ class Controller implements ReviewController {
|
|
|
956
960
|
* builds on the first instead of on the stale snapshot. Answers arrive in
|
|
957
961
|
* request order over the socket and replace the guess with the daemon's copy.
|
|
958
962
|
*/
|
|
963
|
+
/** Agent invocation waits until the preceding comments reach the daemon. */
|
|
964
|
+
async flushMutations(): Promise<void> {
|
|
965
|
+
await Promise.all(this.pendingMutations);
|
|
966
|
+
}
|
|
967
|
+
|
|
959
968
|
private applyOptimistic(expected: Thread, mutation: Promise<Thread>): void {
|
|
969
|
+
this.pendingMutations.add(mutation);
|
|
970
|
+
void mutation.finally(() => this.pendingMutations.delete(mutation)).catch(() => {});
|
|
960
971
|
this.update({ session: expected });
|
|
961
972
|
mutation
|
|
962
973
|
.then((session) => this.update({ session }))
|
|
@@ -980,6 +991,29 @@ class Controller implements ReviewController {
|
|
|
980
991
|
void this.refreshSession(id);
|
|
981
992
|
}
|
|
982
993
|
|
|
994
|
+
async createEmptyThread(): Promise<void> {
|
|
995
|
+
if (this.readOnly || !this.client?.sessionCreate) return;
|
|
996
|
+
const workspace = this.snapshot.session?.workspace ?? {
|
|
997
|
+
repoRoot: this.options.cwd ?? process.cwd(),
|
|
998
|
+
branch: "detached",
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
try {
|
|
1002
|
+
const thread = await this.client.sessionCreate(workspace, {
|
|
1003
|
+
type: "plan",
|
|
1004
|
+
content: "",
|
|
1005
|
+
meta: { title: "New Thread" },
|
|
1006
|
+
});
|
|
1007
|
+
this.locallyViewed.clear();
|
|
1008
|
+
this.viewingId = thread.id;
|
|
1009
|
+
this.update({ session: thread });
|
|
1010
|
+
} catch (cause) {
|
|
1011
|
+
this.setStatus(
|
|
1012
|
+
`New Thread failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
983
1017
|
deleteSession(id: string): void {
|
|
984
1018
|
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
985
1019
|
// a deleted thread's links must not outlive it; revoke every one first, best-effort, so an
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
function TestAgent(): React.ReactNode {
|
|
42
|
+
const agent = useThreadAgent("test", undefined, client);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
request = () => agent.act((api) => api.agentPrompt({ id: "test", text: "Prompt" }));
|
|
45
|
+
}, [agent]);
|
|
46
|
+
|
|
47
|
+
return <text>{agent.state.phase.kind}</text>;
|
|
48
|
+
}
|
|
49
|
+
const setup = await testRender(<TestAgent />, { width: 40, height: 4 });
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await waitForText(setup, "idle");
|
|
53
|
+
const pending = request();
|
|
54
|
+
await request();
|
|
55
|
+
await waitForText(setup, "running");
|
|
56
|
+
first!(initial);
|
|
57
|
+
await pending;
|
|
58
|
+
configure!(initial);
|
|
59
|
+
await settle(setup);
|
|
60
|
+
expect(setup.captureCharFrame()).toContain("running");
|
|
61
|
+
expect(setup.captureCharFrame()).not.toContain("idle");
|
|
62
|
+
} finally {
|
|
63
|
+
setup.renderer.destroy();
|
|
64
|
+
}
|
|
65
|
+
});
|