@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
|
@@ -0,0 +1,367 @@
|
|
|
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
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!busy) return;
|
|
81
|
+
const timer = setInterval(() => setPulse((value) => !value), 600);
|
|
82
|
+
|
|
83
|
+
return () => clearInterval(timer);
|
|
84
|
+
}, [busy]);
|
|
85
|
+
const notifyActive = useEffectEvent((active: boolean) => props.onActiveChange(active));
|
|
86
|
+
const notifyState = useEffectEvent(() => props.onStateChange?.(state));
|
|
87
|
+
const notifyControls = useEffectEvent(() =>
|
|
88
|
+
props.onControlsChange?.(
|
|
89
|
+
<AgentConfigControls
|
|
90
|
+
state={state}
|
|
91
|
+
theme={theme}
|
|
92
|
+
onConfigure={(configId, value) =>
|
|
93
|
+
void agent.act((client) => {
|
|
94
|
+
if (!client.agentConfigure)
|
|
95
|
+
return Promise.reject(new Error("Agent configuration is unavailable"));
|
|
96
|
+
|
|
97
|
+
return client.agentConfigure({ id: thread.id, configId, value });
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
/>,
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
notifyActive(true);
|
|
106
|
+
|
|
107
|
+
return () => notifyActive(false);
|
|
108
|
+
}, []);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
notifyState();
|
|
111
|
+
}, [state]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
notifyControls();
|
|
114
|
+
}, [state, theme]);
|
|
115
|
+
|
|
116
|
+
const invoke = async (): Promise<void> => {
|
|
117
|
+
if (thread.status !== "pending") return;
|
|
118
|
+
invocations.current.push({ text: draft.current, writes: pendingWrites.current.splice(0) });
|
|
119
|
+
draft.current = "";
|
|
120
|
+
if (invoking.current) return;
|
|
121
|
+
invoking.current = true;
|
|
122
|
+
|
|
123
|
+
const rejected: string[] = [];
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
while (invocations.current.length) {
|
|
127
|
+
const input = invocations.current.shift()!;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const writesSaved = (await Promise.all(input.writes)).every(Boolean);
|
|
131
|
+
|
|
132
|
+
if (!writesSaved) {
|
|
133
|
+
rejected.push(input.text);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
await props.flushMutations?.();
|
|
137
|
+
const accepted = await agent.act((client) =>
|
|
138
|
+
client.agentPrompt({ id: thread.id, text: input.text }),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (!accepted) rejected.push(input.text);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
rejected.push(input.text);
|
|
144
|
+
agent.setError(String(error));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
invoking.current = false;
|
|
149
|
+
const text = rejected.filter(Boolean).join("\n");
|
|
150
|
+
|
|
151
|
+
if (text) setPromptRestoreRequest({ id: ++restoreSequence.current, text });
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const invokeRef = useRef<() => Promise<void>>(async () => {});
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
invokeRef.current = invoke;
|
|
158
|
+
});
|
|
159
|
+
const notifyInvoke = useEffectEvent((invoke: (() => void) | undefined) =>
|
|
160
|
+
props.onInvokeChange?.(invoke),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
notifyInvoke(() => void invokeRef.current());
|
|
165
|
+
|
|
166
|
+
return () => notifyInvoke(undefined);
|
|
167
|
+
}, []);
|
|
168
|
+
const reply = (id: string, body: string): void => {
|
|
169
|
+
const origin = projection.mirrors.get(id)?.commentId ?? id;
|
|
170
|
+
const root = agentCommentRoot(state, origin);
|
|
171
|
+
|
|
172
|
+
if (root)
|
|
173
|
+
pendingWrites.current.push(
|
|
174
|
+
agent.act((client) =>
|
|
175
|
+
client.agentComment({
|
|
176
|
+
id: thread.id,
|
|
177
|
+
comment: {
|
|
178
|
+
...root,
|
|
179
|
+
id: newAnnotationId(),
|
|
180
|
+
body,
|
|
181
|
+
sent: false,
|
|
182
|
+
author: undefined,
|
|
183
|
+
replyTo: root.replyTo ?? root.id,
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
else child?.onReply(origin, body);
|
|
189
|
+
};
|
|
190
|
+
const actionFor = (id: string) => {
|
|
191
|
+
const mirror = projection.mirrors.get(id);
|
|
192
|
+
const submission = state.submissions?.find(
|
|
193
|
+
(entry) => entry.id === mirror?.submissionId || entry.commentId === id,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (!submission) return undefined;
|
|
197
|
+
if (submission.status === "failed")
|
|
198
|
+
return {
|
|
199
|
+
label: "Retry",
|
|
200
|
+
run: () =>
|
|
201
|
+
void agent.act((client) =>
|
|
202
|
+
client.agentPrompt({ id: thread.id, text: "", retry: submission.id }),
|
|
203
|
+
),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
label: "View reply",
|
|
208
|
+
run: () => {
|
|
209
|
+
props.onRevealReply?.();
|
|
210
|
+
setRequestedBlock({
|
|
211
|
+
blockIndex: projection.destinations.get(submission.id) ?? projection.tailIndex,
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const notifyActions = useEffectEvent(() => props.onActionsChange?.(actionFor));
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
notifyActions();
|
|
221
|
+
}, [state, projection]);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<ThreadView
|
|
225
|
+
{...child}
|
|
226
|
+
session={projection.session}
|
|
227
|
+
display={projection.display}
|
|
228
|
+
marks={projection.marks}
|
|
229
|
+
quickActions={child?.quickActions ?? []}
|
|
230
|
+
observer={false}
|
|
231
|
+
resolved={child?.resolved ?? thread.status !== "pending"}
|
|
232
|
+
suspended={!props.focused || props.suspended}
|
|
233
|
+
theme={theme}
|
|
234
|
+
requestedBlock={requestedBlock}
|
|
235
|
+
isAnnotationReadOnly={(id) =>
|
|
236
|
+
projection.mirrors.has(id) ||
|
|
237
|
+
Boolean(state.submissions?.some((entry) => entry.commentId === id)) ||
|
|
238
|
+
Boolean(state.comments.find((entry) => entry.id === id)?.sent)
|
|
239
|
+
}
|
|
240
|
+
annotationAction={actionFor}
|
|
241
|
+
onInvoke={() => void invoke()}
|
|
242
|
+
isPromptBlock={(index) => index === projection.tailIndex}
|
|
243
|
+
promptRestoreRequest={promptRestoreRequest}
|
|
244
|
+
promptFocusRequest={
|
|
245
|
+
continuation.kind === "ready"
|
|
246
|
+
? { replyId: continuation.replyId, blockIndex: projection.tailIndex }
|
|
247
|
+
: undefined
|
|
248
|
+
}
|
|
249
|
+
canAnnotateBlock={(index) => {
|
|
250
|
+
const source = projection.sources[index];
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
source?.kind !== "activity" &&
|
|
254
|
+
(source?.kind !== "message" ||
|
|
255
|
+
Boolean(
|
|
256
|
+
state.messages.find(
|
|
257
|
+
(entry) => entry.id === source.messageId && entry.role === "agent",
|
|
258
|
+
)?.complete,
|
|
259
|
+
))
|
|
260
|
+
);
|
|
261
|
+
}}
|
|
262
|
+
onAnnotate={(span, body) => {
|
|
263
|
+
const source = projection.sources[span.start.blockIndex];
|
|
264
|
+
const end = projection.sources[span.end.blockIndex];
|
|
265
|
+
|
|
266
|
+
if (source?.kind === "tail") {
|
|
267
|
+
draft.current = body;
|
|
268
|
+
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (source?.kind === "mirror") {
|
|
272
|
+
if (source.commentId) reply(source.commentId, body);
|
|
273
|
+
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (source?.kind === "artifact" && end?.kind === "artifact") {
|
|
277
|
+
child?.onAnnotate(span, body);
|
|
278
|
+
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (
|
|
282
|
+
source?.kind !== "message" ||
|
|
283
|
+
end?.kind !== "message" ||
|
|
284
|
+
source.messageId !== end.messageId
|
|
285
|
+
)
|
|
286
|
+
return;
|
|
287
|
+
const message = state.messages.find((entry) => entry.id === source.messageId)!;
|
|
288
|
+
const localState = { ...state, messages: [message] };
|
|
289
|
+
const comment = commentOnAgentSpan(
|
|
290
|
+
thread,
|
|
291
|
+
localState,
|
|
292
|
+
{
|
|
293
|
+
start: { ...span.start, blockIndex: source.blockIndex + 1 },
|
|
294
|
+
end: { ...span.end, blockIndex: end.blockIndex + 1 },
|
|
295
|
+
},
|
|
296
|
+
body,
|
|
297
|
+
newAnnotationId(),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
pendingWrites.current.push(
|
|
301
|
+
agent.act((client) => client.agentComment({ id: thread.id, comment })),
|
|
302
|
+
);
|
|
303
|
+
}}
|
|
304
|
+
onReply={reply}
|
|
305
|
+
onUpdateAnnotation={(id, body) => {
|
|
306
|
+
const comment = state.comments.find((entry) => entry.id === id);
|
|
307
|
+
|
|
308
|
+
if (comment)
|
|
309
|
+
pendingWrites.current.push(
|
|
310
|
+
agent.act((client) =>
|
|
311
|
+
client.agentComment({ id: thread.id, comment: { ...comment, body } }),
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
else child?.onUpdateAnnotation(id, body);
|
|
315
|
+
}}
|
|
316
|
+
onExit={child?.onExit ?? (() => {})}
|
|
317
|
+
renderBlock={(index) =>
|
|
318
|
+
index === projection.activityIndex ? (
|
|
319
|
+
<box style={{ flexDirection: "column", paddingLeft: 2 }}>
|
|
320
|
+
{busy ? <text fg={pulse ? theme.textDim : theme.textMuted}>Thinking…</text> : null}
|
|
321
|
+
{state.phase.kind === "permission" ? (
|
|
322
|
+
<>
|
|
323
|
+
<text fg={theme.text}>{state.phase.permission.title}</text>
|
|
324
|
+
<box style={{ flexDirection: "row", gap: 2 }}>
|
|
325
|
+
{state.phase.permission.options.map((option) => (
|
|
326
|
+
<text
|
|
327
|
+
key={option.optionId}
|
|
328
|
+
fg={theme.blue}
|
|
329
|
+
onMouseUp={() => {
|
|
330
|
+
if (state.phase.kind === "permission")
|
|
331
|
+
void agent.act((client) =>
|
|
332
|
+
client.agentPermission({
|
|
333
|
+
id: thread.id,
|
|
334
|
+
requestId:
|
|
335
|
+
state.phase.kind === "permission" ? state.phase.permission.id : "",
|
|
336
|
+
optionId: option.optionId,
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
{option.name}
|
|
342
|
+
</text>
|
|
343
|
+
))}
|
|
344
|
+
</box>
|
|
345
|
+
</>
|
|
346
|
+
) : null}
|
|
347
|
+
{agent.error ? <text fg={theme.red}>{agent.error}</text> : null}
|
|
348
|
+
</box>
|
|
349
|
+
) : undefined
|
|
350
|
+
}
|
|
351
|
+
/>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The prototype is local and opt-in; shared views keep their existing renderer. */
|
|
356
|
+
export function AgentThreadPrototype(
|
|
357
|
+
props: AgentThreadPaneProps & { enabled: boolean; observer: boolean; pixelPrototype: boolean },
|
|
358
|
+
): React.ReactNode {
|
|
359
|
+
if (!props.enabled || props.observer || props.pixelPrototype) return props.children;
|
|
360
|
+
|
|
361
|
+
return <AgentThreadPane {...props} />;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Embedded-agent chords apply only while the Thread owns focus. */
|
|
365
|
+
export function agentOwnsKeyboard(active: boolean, focusedPane: string): boolean {
|
|
366
|
+
return active && focusedPane === "thread";
|
|
367
|
+
}
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -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
|
|