@saadnvd1/agent-os 0.3.737 → 0.3.742
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/components/Chat/Activity.test.ts +2 -1
- package/components/Chat/Activity.tsx +102 -64
- package/components/Chat/ChatPanel.tsx +16 -3
- package/components/Chat/LiveOutput.test.ts +36 -0
- package/components/Chat/Messages.test.ts +13 -1
- package/components/Chat/Messages.tsx +19 -2
- package/components/Chat/StepOutcomes.tsx +66 -0
- package/components/Chat/Tools.test.ts +52 -0
- package/components/Chat/Tools.tsx +63 -9
- package/data/chat/useChat.ts +10 -1
- package/lib/chat/drivers/claude-mapper.ts +10 -0
- package/lib/chat/drivers/claude.ts +9 -1
- package/lib/chat/drivers/edit-guard.ts +311 -0
- package/lib/chat/drivers/opencode-config.ts +111 -0
- package/lib/chat/drivers/opencode-mapper.ts +125 -13
- package/lib/chat/drivers/opencode-plugin.ts +78 -0
- package/lib/chat/drivers/opencode-server.ts +7 -2
- package/lib/chat/drivers/opencode.ts +108 -9
- package/lib/chat/drivers/pi-edit-guard.ts +87 -0
- package/lib/chat/drivers/pi-mapper.ts +91 -2
- package/lib/chat/drivers/pi.ts +95 -4
- package/lib/chat/drivers/transient-retry.ts +87 -0
- package/lib/chat/drivers/turn-items.ts +74 -1
- package/lib/chat/drivers/turn-outcome.ts +73 -1
- package/lib/chat/events.ts +17 -1
- package/lib/chat/group.ts +23 -3
- package/lib/chat/model-wait-live.ts +19 -0
- package/lib/chat/model-wait.ts +88 -0
- package/lib/chat/page.ts +3 -0
- package/lib/chat/progress.ts +169 -0
- package/lib/chat/registry.ts +5 -0
- package/lib/chat/runner-watch.ts +68 -0
- package/lib/chat/runner.ts +4 -70
- package/lib/chat/tool-outcome.ts +45 -0
- package/lib/chat/tools.ts +14 -0
- package/package.json +1 -1
- package/scripts/screenshots/chat-verify.ts +68 -0
- package/scripts/screenshots/seed.ts +9 -0
|
@@ -5,7 +5,7 @@ describe("currentActivity while compacting", () => {
|
|
|
5
5
|
it("says so, with the size going in and when it began", () => {
|
|
6
6
|
expect(
|
|
7
7
|
currentActivity([], "running", { since: 5, before: 182_000 })
|
|
8
|
-
).
|
|
8
|
+
).toMatchObject({
|
|
9
9
|
label: "Compacting context… 182k tokens",
|
|
10
10
|
since: 5,
|
|
11
11
|
compacting: true,
|
|
@@ -18,5 +18,6 @@ describe("currentActivity while compacting", () => {
|
|
|
18
18
|
it("is plain work without it, and nothing once the turn is over", () => {
|
|
19
19
|
expect(currentActivity([], "running")?.compacting).toBeUndefined();
|
|
20
20
|
expect(currentActivity([], "idle", { since: 5 })).toBeNull();
|
|
21
|
+
expect(currentActivity([], "running")?.label).toBe("Waiting for the model");
|
|
21
22
|
});
|
|
22
23
|
});
|
|
@@ -1,91 +1,129 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
Loader2,
|
|
6
|
+
MessageCircleQuestion,
|
|
7
|
+
Shrink,
|
|
8
|
+
Square,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import type {
|
|
11
|
+
ChatItem,
|
|
12
|
+
ChatState,
|
|
13
|
+
Compacting,
|
|
14
|
+
ModelWait,
|
|
15
|
+
} from "@/lib/chat/events";
|
|
5
16
|
import { formatTokens } from "@/lib/chat/context";
|
|
6
17
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
18
|
+
import { currentActivity, type Phase } from "@/lib/chat/progress";
|
|
19
|
+
import { cn } from "@/lib/utils";
|
|
7
20
|
import { useNow } from "./useNow";
|
|
8
21
|
|
|
9
|
-
|
|
22
|
+
export { currentActivity };
|
|
10
23
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
): { label: string; since?: number; compacting?: boolean } | null {
|
|
18
|
-
if (state === "waiting") return { label: "Waiting for your answer" };
|
|
19
|
-
if (state !== "running") return null;
|
|
20
|
-
if (compacting)
|
|
21
|
-
return {
|
|
22
|
-
label: `Compacting context…${compacting.before ? ` ${formatTokens(compacting.before)} tokens` : ""}`,
|
|
23
|
-
since: compacting.since,
|
|
24
|
-
compacting: true,
|
|
25
|
-
};
|
|
26
|
-
const lastUser = items.findLast((i) => i.kind === "user");
|
|
27
|
-
const tool = items.findLast(
|
|
28
|
-
(i): i is Tool => i.kind === "tool" && i.status === "running"
|
|
29
|
-
);
|
|
30
|
-
if (tool) return { label: tool.title, since: tool.createdAt };
|
|
31
|
-
const last = items.at(-1);
|
|
32
|
-
const writing =
|
|
33
|
-
last && (last.kind === "assistant" || last.kind === "reasoning");
|
|
34
|
-
return {
|
|
35
|
-
label:
|
|
36
|
-
writing && last.streaming
|
|
37
|
-
? last.kind === "assistant"
|
|
38
|
-
? "Writing"
|
|
39
|
-
: "Thinking"
|
|
40
|
-
: "Working",
|
|
41
|
-
since: lastUser?.createdAt,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
24
|
+
const PHASE: Record<Phase, string> = {
|
|
25
|
+
planning: "Planning",
|
|
26
|
+
coding: "Coding",
|
|
27
|
+
testing: "Testing",
|
|
28
|
+
error: "Error",
|
|
29
|
+
};
|
|
44
30
|
|
|
45
|
-
//
|
|
46
|
-
// looks like nothing is happening
|
|
31
|
+
// Up to three lines above the composer while a turn runs, so a long wait
|
|
32
|
+
// never looks like nothing is happening: what it's doing and for how long,
|
|
33
|
+
// the phase and step count, and a hint when a model call drags on.
|
|
47
34
|
export function ActivityLine({
|
|
48
35
|
items,
|
|
49
36
|
state,
|
|
50
37
|
compacting = null,
|
|
38
|
+
modelWait = null,
|
|
39
|
+
model,
|
|
51
40
|
onStop,
|
|
52
41
|
}: {
|
|
53
42
|
items: ChatItem[];
|
|
54
43
|
state: ChatState;
|
|
55
44
|
compacting?: Compacting | null;
|
|
45
|
+
modelWait?: ModelWait | null;
|
|
46
|
+
model?: string;
|
|
56
47
|
onStop: () => void;
|
|
57
48
|
}) {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
49
|
+
const now = useNow(state === "running");
|
|
50
|
+
const activity = currentActivity(
|
|
51
|
+
items,
|
|
52
|
+
state,
|
|
53
|
+
compacting,
|
|
54
|
+
modelWait,
|
|
55
|
+
model,
|
|
56
|
+
now
|
|
57
|
+
);
|
|
60
58
|
if (!activity) return null;
|
|
61
59
|
const waiting = state === "waiting";
|
|
60
|
+
const failed = activity.phase === "error" && activity.kind === "waiting";
|
|
62
61
|
return (
|
|
63
|
-
<div className="text-muted-foreground
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
{formatElapsed(now - activity.since)}
|
|
62
|
+
<div className="text-muted-foreground px-2 text-xs">
|
|
63
|
+
<div className="flex min-h-9 items-center gap-2">
|
|
64
|
+
{waiting ? (
|
|
65
|
+
<MessageCircleQuestion className="text-primary h-3.5 w-3.5 shrink-0" />
|
|
66
|
+
) : activity.compacting ? (
|
|
67
|
+
<Shrink className="h-3.5 w-3.5 shrink-0 animate-pulse text-blue-600 dark:text-blue-400" />
|
|
68
|
+
) : failed ? (
|
|
69
|
+
<AlertTriangle className="text-destructive h-3.5 w-3.5 shrink-0" />
|
|
70
|
+
) : (
|
|
71
|
+
<Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
|
|
72
|
+
)}
|
|
73
|
+
<span className="text-foreground/80 min-w-0 flex-1 truncate">
|
|
74
|
+
{activity.label}
|
|
77
75
|
</span>
|
|
76
|
+
{activity.tokens && (
|
|
77
|
+
<span className="shrink-0 whitespace-nowrap">
|
|
78
|
+
{formatTokens(activity.tokens)}+ tokens
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
{activity.since && (
|
|
82
|
+
<span className="shrink-0 font-mono tabular-nums">
|
|
83
|
+
{formatElapsed(now - activity.since)}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
{!waiting && (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={onStop}
|
|
90
|
+
aria-label="Stop"
|
|
91
|
+
className="hover:text-foreground hover:bg-foreground/[0.06] -my-1 flex h-11 shrink-0 items-center gap-1 rounded-md px-2 md:my-0 md:h-7"
|
|
92
|
+
>
|
|
93
|
+
<Square className="h-3 w-3 fill-current" />
|
|
94
|
+
Stop
|
|
95
|
+
</button>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
{!waiting && activity.steps > 0 && activity.phase && (
|
|
99
|
+
<div className="flex items-center gap-1.5 pb-1 pl-5 text-[11px]">
|
|
100
|
+
<span
|
|
101
|
+
className={cn(
|
|
102
|
+
"shrink-0 font-medium",
|
|
103
|
+
activity.phase === "error"
|
|
104
|
+
? "text-destructive"
|
|
105
|
+
: "text-foreground/70"
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
{PHASE[activity.phase]}
|
|
109
|
+
</span>
|
|
110
|
+
<span className="shrink-0">
|
|
111
|
+
· {activity.steps} {activity.steps === 1 ? "step" : "steps"}
|
|
112
|
+
</span>
|
|
113
|
+
{activity.lastStep && (
|
|
114
|
+
<span className="min-w-0 flex-1 truncate">
|
|
115
|
+
· {activity.lastStep}
|
|
116
|
+
</span>
|
|
117
|
+
)}
|
|
118
|
+
{activity.turnSince && (
|
|
119
|
+
<span className="ml-auto shrink-0 pl-2 font-mono tabular-nums">
|
|
120
|
+
turn {formatElapsed(now - activity.turnSince)}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
78
124
|
)}
|
|
79
|
-
{
|
|
80
|
-
<
|
|
81
|
-
type="button"
|
|
82
|
-
onClick={onStop}
|
|
83
|
-
aria-label="Stop"
|
|
84
|
-
className="hover:text-foreground hover:bg-foreground/[0.06] -my-1 flex h-11 shrink-0 items-center gap-1 rounded-md px-2 md:my-0 md:h-7"
|
|
85
|
-
>
|
|
86
|
-
<Square className="h-3 w-3 fill-current" />
|
|
87
|
-
Stop
|
|
88
|
-
</button>
|
|
125
|
+
{activity.hint && (
|
|
126
|
+
<p className="pb-1.5 pl-5 text-[11px]">{activity.hint}</p>
|
|
89
127
|
)}
|
|
90
128
|
</div>
|
|
91
129
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { StepCounts } from "@/lib/chat/tool-outcome";
|
|
3
4
|
import {
|
|
4
5
|
memo,
|
|
5
6
|
useCallback,
|
|
@@ -32,6 +33,7 @@ import { sentPrompts } from "@/lib/chat/history";
|
|
|
32
33
|
import { quoteInsert, quoteMarkdown } from "@/lib/chat/quote";
|
|
33
34
|
import { OrchestratorBar } from "@/components/Orchestrator/OrchestratorBar";
|
|
34
35
|
import { ActivityLine } from "./Activity";
|
|
36
|
+
import { modelLabel as pickedModelLabel } from "./ModelPicker";
|
|
35
37
|
import { Approval } from "./Approval";
|
|
36
38
|
import { BackgroundTasks } from "./BackgroundTasks";
|
|
37
39
|
import { SubagentCard } from "./Subagent";
|
|
@@ -99,7 +101,15 @@ interface ItemActions {
|
|
|
99
101
|
onQuote?: (text: string) => void;
|
|
100
102
|
}
|
|
101
103
|
|
|
102
|
-
function Item({
|
|
104
|
+
function Item({
|
|
105
|
+
item,
|
|
106
|
+
actions,
|
|
107
|
+
steps,
|
|
108
|
+
}: {
|
|
109
|
+
item: ChatItem;
|
|
110
|
+
actions: ItemActions;
|
|
111
|
+
steps?: StepCounts;
|
|
112
|
+
}) {
|
|
103
113
|
switch (item.kind) {
|
|
104
114
|
case "user":
|
|
105
115
|
return (
|
|
@@ -119,7 +129,7 @@ function Item({ item, actions }: { item: ChatItem; actions: ItemActions }) {
|
|
|
119
129
|
case "todos":
|
|
120
130
|
return <Todos item={item} />;
|
|
121
131
|
case "turn_end":
|
|
122
|
-
return <TurnEnd item={item} />;
|
|
132
|
+
return <TurnEnd item={item} steps={steps} />;
|
|
123
133
|
case "error":
|
|
124
134
|
return <ErrorMessage item={item} />;
|
|
125
135
|
case "command_output":
|
|
@@ -209,7 +219,7 @@ const Block = memo(
|
|
|
209
219
|
</UndoneBlock>
|
|
210
220
|
) : (
|
|
211
221
|
<div data-item={b.item.id}>
|
|
212
|
-
<Item item={b.item} actions={actions} />
|
|
222
|
+
<Item item={b.item} actions={actions} steps={b.steps} />
|
|
213
223
|
</div>
|
|
214
224
|
);
|
|
215
225
|
},
|
|
@@ -310,6 +320,7 @@ export function ChatPanel({
|
|
|
310
320
|
requestFiles,
|
|
311
321
|
search,
|
|
312
322
|
compacting,
|
|
323
|
+
modelWait,
|
|
313
324
|
loadedFor,
|
|
314
325
|
} = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
|
|
315
326
|
const startFresh = useFreshStart();
|
|
@@ -787,6 +798,8 @@ export function ChatPanel({
|
|
|
787
798
|
items={items}
|
|
788
799
|
state={state}
|
|
789
800
|
compacting={compacting}
|
|
801
|
+
modelWait={modelWait}
|
|
802
|
+
model={pickedModelLabel(models, model, modelLabel)}
|
|
790
803
|
onStop={interrupt}
|
|
791
804
|
/>
|
|
792
805
|
<BackgroundTasks
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { ToolItem } from "@/lib/chat/group";
|
|
5
|
+
import { ToolGroup } from "./Tools";
|
|
6
|
+
|
|
7
|
+
const tool = (over: Partial<ToolItem>): ToolItem => ({
|
|
8
|
+
id: "t1",
|
|
9
|
+
kind: "tool",
|
|
10
|
+
name: "Bash",
|
|
11
|
+
title: "npm test",
|
|
12
|
+
input: { command: "npm test" },
|
|
13
|
+
status: "running",
|
|
14
|
+
createdAt: Date.now(),
|
|
15
|
+
...over,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const html = (t: ToolItem) =>
|
|
19
|
+
renderToStaticMarkup(createElement(ToolGroup, { tools: [t] }));
|
|
20
|
+
|
|
21
|
+
describe("a running step", () => {
|
|
22
|
+
it("shows its output so far without being opened", () => {
|
|
23
|
+
const out = html(tool({ output: "PASS a.test.ts\nRUNS b.test.ts" }));
|
|
24
|
+
expect(out).toContain('data-testid="tool-output"');
|
|
25
|
+
expect(out).toContain("RUNS b.test.ts");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("shows no output block before it prints anything", () => {
|
|
29
|
+
expect(html(tool({}))).not.toContain("tool-output");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps a finished step collapsed", () => {
|
|
33
|
+
const out = html(tool({ status: "done", output: "ok" }));
|
|
34
|
+
expect(out).not.toContain("tool-output");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createElement } from "react";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { AssistantMessage, UserMessage } from "./Messages";
|
|
4
|
+
import { AssistantMessage, TurnEnd, UserMessage } from "./Messages";
|
|
5
5
|
|
|
6
6
|
const item = {
|
|
7
7
|
id: "a1",
|
|
@@ -119,3 +119,15 @@ describe("UserMessage code blocks", () => {
|
|
|
119
119
|
}
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
describe("TurnEnd steps", () => {
|
|
124
|
+
it("says what a stopped turn got through", () => {
|
|
125
|
+
const html = renderToStaticMarkup(
|
|
126
|
+
createElement(TurnEnd, {
|
|
127
|
+
item: { id: "e", kind: "turn_end", interrupted: true } as never,
|
|
128
|
+
steps: { total: 5, ok: 3, failed: 1, stopped: 1, running: 0 },
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
expect(html).toContain("Stopped · 4 of 5 steps done, 1 failed, 1 not run");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { StepCounts } from "@/lib/chat/tool-outcome";
|
|
3
4
|
import { memo } from "react";
|
|
4
5
|
import { useSnapshot } from "valtio";
|
|
5
6
|
import {
|
|
@@ -378,7 +379,23 @@ export function Todos({ item }: { item: Of<"todos"> }) {
|
|
|
378
379
|
);
|
|
379
380
|
}
|
|
380
381
|
|
|
381
|
-
|
|
382
|
+
// What a stopped or failed turn got through.
|
|
383
|
+
export function stepsLabel(s: StepCounts): string {
|
|
384
|
+
const done = s.ok + s.failed;
|
|
385
|
+
const parts = [`${done} of ${s.total} steps done`];
|
|
386
|
+
if (s.failed) parts.push(`${s.failed} failed`);
|
|
387
|
+
const unfinished = s.stopped + s.running;
|
|
388
|
+
if (unfinished) parts.push(`${unfinished} not run`);
|
|
389
|
+
return parts.join(", ");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function TurnEnd({
|
|
393
|
+
item,
|
|
394
|
+
steps,
|
|
395
|
+
}: {
|
|
396
|
+
item: Of<"turn_end">;
|
|
397
|
+
steps?: StepCounts;
|
|
398
|
+
}) {
|
|
382
399
|
const secs = item.durationMs ? Math.round(item.durationMs / 1000) : null;
|
|
383
400
|
const early = !item.interrupted && item.ended;
|
|
384
401
|
const label = item.interrupted
|
|
@@ -407,7 +424,7 @@ export function TurnEnd({ item }: { item: Of<"turn_end"> }) {
|
|
|
407
424
|
)}
|
|
408
425
|
<div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
|
|
409
426
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
410
|
-
{label}
|
|
427
|
+
{steps ? `${label} · ${stepsLabel(steps)}` : label}
|
|
411
428
|
<TurnCopy turnEndId={item.id} />
|
|
412
429
|
<TurnBookmark turnEndId={item.id} />
|
|
413
430
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ToolItem } from "@/lib/chat/group";
|
|
4
|
+
import { toolOutcome } from "@/lib/chat/tool-outcome";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { StatusIcon } from "./Tools";
|
|
7
|
+
|
|
8
|
+
export const MAX_CHIPS = 24;
|
|
9
|
+
|
|
10
|
+
const WORD = {
|
|
11
|
+
running: "running",
|
|
12
|
+
done: "ok",
|
|
13
|
+
error: "failed",
|
|
14
|
+
stopped: "not run",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
// One line per collapsed group: how each step ended, so the results aren't
|
|
18
|
+
// lost behind the fold.
|
|
19
|
+
export function StepOutcomes({
|
|
20
|
+
tools,
|
|
21
|
+
onOpen,
|
|
22
|
+
}: {
|
|
23
|
+
tools: ToolItem[];
|
|
24
|
+
onOpen: () => void;
|
|
25
|
+
}) {
|
|
26
|
+
const shown = tools.slice(0, MAX_CHIPS);
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={onOpen}
|
|
31
|
+
aria-label="Show steps"
|
|
32
|
+
className="flex min-h-11 w-full flex-wrap items-center gap-x-3 gap-y-1 px-2 pb-1.5 text-left md:min-h-0"
|
|
33
|
+
>
|
|
34
|
+
{shown.map((t) => {
|
|
35
|
+
const code = toolOutcome(t);
|
|
36
|
+
return (
|
|
37
|
+
<span
|
|
38
|
+
key={t.id}
|
|
39
|
+
title={`${t.title}: ${WORD[t.status]}${code ? `, ${code}` : ""}`}
|
|
40
|
+
aria-label={`${t.title}: ${WORD[t.status]}${code ? `, ${code}` : ""}`}
|
|
41
|
+
className="flex min-w-0 items-center gap-1"
|
|
42
|
+
>
|
|
43
|
+
<StatusIcon status={t.status} />
|
|
44
|
+
{code && (
|
|
45
|
+
<span
|
|
46
|
+
className={cn(
|
|
47
|
+
"font-mono text-[11px]",
|
|
48
|
+
t.status === "error"
|
|
49
|
+
? "text-destructive"
|
|
50
|
+
: "text-muted-foreground"
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
{code}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
{tools.length > shown.length && (
|
|
60
|
+
<span className="text-muted-foreground/70 font-mono text-[11px]">
|
|
61
|
+
+{tools.length - shown.length} more
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
</button>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { ToolItem } from "@/lib/chat/group";
|
|
5
|
+
import { MAX_CHIPS } from "./StepOutcomes";
|
|
6
|
+
import { ToolGroup } from "./Tools";
|
|
7
|
+
|
|
8
|
+
const tool = (id: string, over: Partial<ToolItem> = {}): ToolItem => ({
|
|
9
|
+
id,
|
|
10
|
+
kind: "tool",
|
|
11
|
+
name: "Bash",
|
|
12
|
+
title: `check ${id}`,
|
|
13
|
+
input: {},
|
|
14
|
+
status: "done",
|
|
15
|
+
createdAt: 0,
|
|
16
|
+
...over,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const render = (tools: ToolItem[]) =>
|
|
20
|
+
renderToStaticMarkup(createElement(ToolGroup, { tools }));
|
|
21
|
+
|
|
22
|
+
describe("collapsed ToolGroup", () => {
|
|
23
|
+
it("keeps each step's outcome in view", () => {
|
|
24
|
+
const html = render([
|
|
25
|
+
tool("a", { output: "HTTP/2 200 \n" }),
|
|
26
|
+
tool("b", { status: "error", output: "Exit code 7" }),
|
|
27
|
+
tool("c", { status: "error", deferred: true, outcome: "HTTP 404" }),
|
|
28
|
+
tool("d", { status: "stopped" }),
|
|
29
|
+
]);
|
|
30
|
+
expect(html).toContain("4 steps");
|
|
31
|
+
expect(html).toContain("1 ok");
|
|
32
|
+
expect(html).toContain("2 failed");
|
|
33
|
+
expect(html).toContain("1 not run");
|
|
34
|
+
expect(html).toContain("check a: ok, HTTP 200");
|
|
35
|
+
expect(html).toContain("check b: failed, exit 7");
|
|
36
|
+
expect(html).toContain("check c: failed, HTTP 404");
|
|
37
|
+
expect(html).toContain("check d: not run");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("shows no strip while a step runs", () => {
|
|
41
|
+
expect(render([tool("a", { status: "running" })])).not.toContain(
|
|
42
|
+
"Show steps"
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("caps the strip", () => {
|
|
47
|
+
const tools = Array.from({ length: MAX_CHIPS + 3 }, (_, i) =>
|
|
48
|
+
tool(`x${i}`)
|
|
49
|
+
);
|
|
50
|
+
expect(render(tools)).toContain("+3 more");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { createContext, useContext, useEffect } from "react";
|
|
3
|
+
import { createContext, useContext, useEffect, useRef } from "react";
|
|
4
4
|
import { Check, ChevronRight, Layers, Loader2, Square, X } from "lucide-react";
|
|
5
5
|
import type { FileDiff, ToolBody } from "@/lib/chat/events";
|
|
6
6
|
import type { ToolItem } from "@/lib/chat/group";
|
|
@@ -8,6 +8,8 @@ import { lineDiff, shortPath, type DiffLine } from "@/lib/chat/diff";
|
|
|
8
8
|
import { needsBody } from "@/lib/chat/bodyCache";
|
|
9
9
|
import { cn } from "@/lib/utils";
|
|
10
10
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
11
|
+
import { stepCounts, toolOutcome } from "@/lib/chat/tool-outcome";
|
|
12
|
+
import { StepOutcomes } from "./StepOutcomes";
|
|
11
13
|
import { Highlighted } from "./Code";
|
|
12
14
|
import { BackgroundButton, BackgroundState, useBackground } from "./background";
|
|
13
15
|
import { useNow } from "./useNow";
|
|
@@ -109,6 +111,36 @@ export function ToolPreview({
|
|
|
109
111
|
);
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
// A step's output. While it runs the view follows the end, unless the
|
|
115
|
+
// reader scrolled up to read.
|
|
116
|
+
export function LiveOutput({
|
|
117
|
+
text,
|
|
118
|
+
running,
|
|
119
|
+
}: {
|
|
120
|
+
text: string;
|
|
121
|
+
running: boolean;
|
|
122
|
+
}) {
|
|
123
|
+
const ref = useRef<HTMLPreElement>(null);
|
|
124
|
+
const pinned = useRef(true);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const el = ref.current;
|
|
127
|
+
if (running && el && pinned.current) el.scrollTop = el.scrollHeight;
|
|
128
|
+
}, [text, running]);
|
|
129
|
+
return (
|
|
130
|
+
<pre
|
|
131
|
+
ref={ref}
|
|
132
|
+
data-testid="tool-output"
|
|
133
|
+
onScroll={(e) => {
|
|
134
|
+
const el = e.currentTarget;
|
|
135
|
+
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
|
136
|
+
}}
|
|
137
|
+
className="text-muted-foreground max-h-64 overflow-auto rounded-lg px-2 font-mono text-[11px] whitespace-pre-wrap"
|
|
138
|
+
>
|
|
139
|
+
{text}
|
|
140
|
+
</pre>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
112
144
|
function ToolDetail({ tool }: { tool: ToolItem }) {
|
|
113
145
|
const body = useToolBody(tool);
|
|
114
146
|
if (body === undefined && tool.hasDiff)
|
|
@@ -121,9 +153,7 @@ function ToolDetail({ tool }: { tool: ToolItem }) {
|
|
|
121
153
|
<div className="space-y-2 pb-2 pl-6">
|
|
122
154
|
<ToolPreview name={tool.name} input={tool.input} diff={body?.diff} />
|
|
123
155
|
{body?.output && !body.diff && (
|
|
124
|
-
<
|
|
125
|
-
{body.output}
|
|
126
|
-
</pre>
|
|
156
|
+
<LiveOutput text={body.output} running={tool.status === "running"} />
|
|
127
157
|
)}
|
|
128
158
|
{body === undefined && tool.hasOutput && (
|
|
129
159
|
<Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" />
|
|
@@ -146,6 +176,10 @@ function Elapsed({ tool }: { tool: ToolItem }) {
|
|
|
146
176
|
);
|
|
147
177
|
}
|
|
148
178
|
|
|
179
|
+
// A running step that has printed something: its output shows as it comes.
|
|
180
|
+
const streaming = (tool: ToolItem) =>
|
|
181
|
+
tool.status === "running" && !!tool.output && !tool.deferred;
|
|
182
|
+
|
|
149
183
|
function ToolRow({ tool }: { tool: ToolItem }) {
|
|
150
184
|
const [open, setOpen] = useRowState(
|
|
151
185
|
`tool:${tool.id}`,
|
|
@@ -163,6 +197,18 @@ function ToolRow({ tool }: { tool: ToolItem }) {
|
|
|
163
197
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
|
164
198
|
{tool.title}
|
|
165
199
|
</span>
|
|
200
|
+
{toolOutcome(tool) && (
|
|
201
|
+
<span
|
|
202
|
+
className={cn(
|
|
203
|
+
"shrink-0 font-mono text-[11px]",
|
|
204
|
+
tool.status === "error"
|
|
205
|
+
? "text-destructive"
|
|
206
|
+
: "text-muted-foreground"
|
|
207
|
+
)}
|
|
208
|
+
>
|
|
209
|
+
{toolOutcome(tool)}
|
|
210
|
+
</span>
|
|
211
|
+
)}
|
|
166
212
|
{tool.backgroundTaskId ? (
|
|
167
213
|
<BackgroundState tool={tool} />
|
|
168
214
|
) : (
|
|
@@ -172,7 +218,7 @@ function ToolRow({ tool }: { tool: ToolItem }) {
|
|
|
172
218
|
<BackgroundButton tool={tool} />
|
|
173
219
|
<BookmarkButton itemId={tool.id} />
|
|
174
220
|
</div>
|
|
175
|
-
{open && <ToolDetail tool={tool} />}
|
|
221
|
+
{(open || streaming(tool)) && <ToolDetail tool={tool} />}
|
|
176
222
|
</div>
|
|
177
223
|
);
|
|
178
224
|
}
|
|
@@ -180,8 +226,7 @@ function ToolRow({ tool }: { tool: ToolItem }) {
|
|
|
180
226
|
// A run of tool calls: one quiet line, expandable to every step.
|
|
181
227
|
export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
182
228
|
const running = tools.some((t) => t.status === "running");
|
|
183
|
-
const
|
|
184
|
-
const stopped = tools.filter((t) => t.status === "stopped").length;
|
|
229
|
+
const { ok, failed, stopped } = stepCounts(tools);
|
|
185
230
|
const edits = tools.filter((t) => t.diff || t.hasDiff).length;
|
|
186
231
|
const moved = tools.filter((t) => t.backgroundTaskId).length;
|
|
187
232
|
const [open, setOpen] = useRowState(`steps:${tools[0].id}`, false);
|
|
@@ -211,13 +256,14 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
|
211
256
|
{!running &&
|
|
212
257
|
edits > 0 &&
|
|
213
258
|
` · ${edits} edit${edits === 1 ? "" : "s"}`}
|
|
259
|
+
{!running && (failed > 0 || stopped > 0) && ` · ${ok} ok`}
|
|
214
260
|
</span>
|
|
215
261
|
{running && <Elapsed tool={latest} />}
|
|
216
262
|
{failed > 0 && (
|
|
217
263
|
<span className="text-destructive">{failed} failed</span>
|
|
218
264
|
)}
|
|
219
|
-
{stopped > 0 &&
|
|
220
|
-
<span className="text-muted-foreground/70">stopped</span>
|
|
265
|
+
{stopped > 0 && (
|
|
266
|
+
<span className="text-muted-foreground/70">{stopped} not run</span>
|
|
221
267
|
)}
|
|
222
268
|
{!running && moved > 0 && (
|
|
223
269
|
<span className="text-muted-foreground/70">
|
|
@@ -227,6 +273,14 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
|
227
273
|
</button>
|
|
228
274
|
{running && <BackgroundButton tool={latest} />}
|
|
229
275
|
</div>
|
|
276
|
+
{!open && !running && (
|
|
277
|
+
<StepOutcomes tools={tools} onOpen={() => setOpen(true)} />
|
|
278
|
+
)}
|
|
279
|
+
{!open && streaming(latest) && (
|
|
280
|
+
<div className="pb-1">
|
|
281
|
+
<ToolDetail tool={latest} />
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
230
284
|
{open && (
|
|
231
285
|
<div className="pb-1">
|
|
232
286
|
{tools.map((t) => (
|