@tangle-network/ui 8.1.0 → 9.1.0
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/CHANGELOG.md +20 -0
- package/dist/chat.d.ts +15 -12
- package/dist/chat.js +3 -3
- package/dist/{chunk-QUAU6ZNC.js → chunk-4RDW4GFG.js} +67 -95
- package/dist/{chunk-DLSGUNRD.js → chunk-A5HKP3NA.js} +1 -1
- package/dist/{chunk-C3BIVG72.js → chunk-CVXAXVYZ.js} +124 -154
- package/dist/{chunk-RKQDBRTC.js → chunk-J6RQHBHR.js} +7 -4
- package/dist/{chunk-IWQZXL6A.js → chunk-N5DJ4TUO.js} +1 -1
- package/dist/hooks.js +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -8
- package/dist/run.d.ts +1 -29
- package/dist/run.js +4 -6
- package/dist/sdk-hooks.js +3 -3
- package/package.json +1 -1
- package/src/chat/agent-timeline.test.tsx +162 -0
- package/src/chat/agent-timeline.tsx +63 -140
- package/src/chat/chat-container.test.tsx +68 -0
- package/src/chat/chat-container.tsx +17 -4
- package/src/run/index.ts +0 -1
- package/src/run/run-group.tsx +166 -105
- package/src/run/tool-call-step.tsx +26 -11
- package/src/run/assistant-run-shell.tsx +0 -115
- /package/dist/{chunk-LQS34IGP.js → chunk-47XH56SV.js} +0 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react"
|
|
2
|
+
import userEvent from "@testing-library/user-event"
|
|
3
|
+
import { describe, expect, it, vi } from "vitest"
|
|
4
|
+
import type { ToolPart } from "../types/parts"
|
|
5
|
+
import { AgentTimeline, type AgentTimelineItem } from "./agent-timeline"
|
|
6
|
+
|
|
7
|
+
const editPart: ToolPart = {
|
|
8
|
+
type: "tool",
|
|
9
|
+
id: "tool-1",
|
|
10
|
+
tool: "edit",
|
|
11
|
+
state: {
|
|
12
|
+
status: "completed",
|
|
13
|
+
input: { path: "src/batch-writer.ts" },
|
|
14
|
+
output: "Applied 1 edit",
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const readPart: ToolPart = {
|
|
19
|
+
type: "tool",
|
|
20
|
+
id: "tool-2",
|
|
21
|
+
tool: "read",
|
|
22
|
+
state: {
|
|
23
|
+
status: "completed",
|
|
24
|
+
input: { path: "src/lib/jitter.ts" },
|
|
25
|
+
output: "export function jitter() {}",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toolItem(part: ToolPart): AgentTimelineItem {
|
|
30
|
+
return {
|
|
31
|
+
id: "item-1",
|
|
32
|
+
kind: "tool",
|
|
33
|
+
call: {
|
|
34
|
+
id: part.id,
|
|
35
|
+
type: "edit",
|
|
36
|
+
label: "Edit src/batch-writer.ts",
|
|
37
|
+
status: "success",
|
|
38
|
+
detail: "src/batch-writer.ts",
|
|
39
|
+
output: "Applied 1 edit",
|
|
40
|
+
},
|
|
41
|
+
part,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("AgentTimeline tool actions", () => {
|
|
46
|
+
it("renders a tool call's label and clean detail", () => {
|
|
47
|
+
render(<AgentTimeline items={[toolItem(editPart)]} />)
|
|
48
|
+
expect(screen.getByText("Edit src/batch-writer.ts")).toBeInTheDocument()
|
|
49
|
+
expect(screen.getByText("src/batch-writer.ts")).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("renders renderToolActions beside the tool item, called with the source part", () => {
|
|
53
|
+
const renderToolActions = vi.fn((part: ToolPart) => (
|
|
54
|
+
<button type="button">Open {part.id}</button>
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
render(
|
|
58
|
+
<AgentTimeline
|
|
59
|
+
items={[toolItem(editPart)]}
|
|
60
|
+
renderToolActions={renderToolActions}
|
|
61
|
+
/>,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(renderToolActions).toHaveBeenCalledWith(editPart)
|
|
65
|
+
expect(
|
|
66
|
+
screen.getByRole("button", { name: /open tool-1/i }),
|
|
67
|
+
).toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("renders no action when the item carries no source part", () => {
|
|
71
|
+
const renderToolActions = vi.fn(() => <button type="button">Open</button>)
|
|
72
|
+
const item = { ...toolItem(editPart), part: undefined }
|
|
73
|
+
|
|
74
|
+
render(
|
|
75
|
+
<AgentTimeline items={[item]} renderToolActions={renderToolActions} />,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
expect(renderToolActions).not.toHaveBeenCalled()
|
|
79
|
+
expect(screen.queryByRole("button", { name: /open/i })).not.toBeInTheDocument()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("renders tool items unchanged when no renderToolActions is provided", () => {
|
|
83
|
+
render(<AgentTimeline items={[toolItem(editPart)]} />)
|
|
84
|
+
expect(screen.getByText("Edit src/batch-writer.ts")).toBeInTheDocument()
|
|
85
|
+
expect(screen.queryByRole("button", { name: /open/i })).not.toBeInTheDocument()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("renders renderToolActions for each call in a tool group, with its part", () => {
|
|
89
|
+
const renderToolActions = vi.fn((part: ToolPart) => (
|
|
90
|
+
<button type="button">Open {part.id}</button>
|
|
91
|
+
))
|
|
92
|
+
const groupItem: AgentTimelineItem = {
|
|
93
|
+
id: "group-1",
|
|
94
|
+
kind: "tool_group",
|
|
95
|
+
title: "Tool activity",
|
|
96
|
+
calls: [
|
|
97
|
+
{
|
|
98
|
+
id: "tool-1",
|
|
99
|
+
type: "edit",
|
|
100
|
+
label: "Edit src/batch-writer.ts",
|
|
101
|
+
status: "success",
|
|
102
|
+
detail: "src/batch-writer.ts",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "tool-2",
|
|
106
|
+
type: "read",
|
|
107
|
+
label: "Read src/lib/jitter.ts",
|
|
108
|
+
status: "success",
|
|
109
|
+
detail: "src/lib/jitter.ts",
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
parts: [editPart, readPart],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
render(
|
|
116
|
+
<AgentTimeline items={[groupItem]} renderToolActions={renderToolActions} />,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
expect(renderToolActions).toHaveBeenCalledWith(editPart)
|
|
120
|
+
expect(renderToolActions).toHaveBeenCalledWith(readPart)
|
|
121
|
+
expect(
|
|
122
|
+
screen.getByRole("button", { name: /open tool-1/i }),
|
|
123
|
+
).toBeInTheDocument()
|
|
124
|
+
expect(
|
|
125
|
+
screen.getByRole("button", { name: /open tool-2/i }),
|
|
126
|
+
).toBeInTheDocument()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("renders the source part's real input in the expanded detail", async () => {
|
|
130
|
+
const user = userEvent.setup()
|
|
131
|
+
const probePart: ToolPart = {
|
|
132
|
+
type: "tool",
|
|
133
|
+
id: "tool-3",
|
|
134
|
+
tool: "custom_probe",
|
|
135
|
+
state: {
|
|
136
|
+
status: "completed",
|
|
137
|
+
input: { marker: "deep-input-value" },
|
|
138
|
+
output: "done",
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
const item: AgentTimelineItem = {
|
|
142
|
+
id: "item-3",
|
|
143
|
+
kind: "tool",
|
|
144
|
+
call: {
|
|
145
|
+
id: "tool-3",
|
|
146
|
+
type: "unknown",
|
|
147
|
+
label: "custom_probe",
|
|
148
|
+
status: "success",
|
|
149
|
+
detail: "probe",
|
|
150
|
+
},
|
|
151
|
+
part: probePart,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
render(<AgentTimeline items={[item]} />)
|
|
155
|
+
await user.click(screen.getByRole("button", { name: /custom_probe/i }))
|
|
156
|
+
|
|
157
|
+
// ExpandedToolDetail (fed the real part) renders labelled Input/Output
|
|
158
|
+
// sections — the synthesized-part path showed only a bare output block.
|
|
159
|
+
expect(screen.getByText("Input")).toBeInTheDocument()
|
|
160
|
+
expect(screen.getByText("Output")).toBeInTheDocument()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type KeyboardEvent, type ReactNode
|
|
1
|
+
import { type KeyboardEvent, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
3
|
AlertTriangle,
|
|
4
4
|
CheckCircle2,
|
|
@@ -12,7 +12,7 @@ import { Markdown } from "../markdown/markdown";
|
|
|
12
12
|
import { ThinkingIndicator } from "./thinking-indicator";
|
|
13
13
|
import { type ToolCallData } from "../run/tool-call-feed";
|
|
14
14
|
import { ToolCallGroup, ToolCallStep } from "../run/tool-call-step";
|
|
15
|
-
import {
|
|
15
|
+
import type { ToolPart } from "../types/parts";
|
|
16
16
|
|
|
17
17
|
export type AgentTimelineTone = "default" | "info" | "success" | "warning" | "error";
|
|
18
18
|
|
|
@@ -31,6 +31,9 @@ export interface AgentTimelineToolItem {
|
|
|
31
31
|
id: string;
|
|
32
32
|
kind: "tool";
|
|
33
33
|
call: ToolCallData;
|
|
34
|
+
/** Source tool part, so a consumer's `renderToolActions` gets the real
|
|
35
|
+
* input/output (the flat `call` is display-only). */
|
|
36
|
+
part?: ToolPart;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export interface AgentTimelineToolGroupItem {
|
|
@@ -38,6 +41,8 @@ export interface AgentTimelineToolGroupItem {
|
|
|
38
41
|
kind: "tool_group";
|
|
39
42
|
title?: string;
|
|
40
43
|
calls: ToolCallData[];
|
|
44
|
+
/** Source tool parts, parallel to `calls`. */
|
|
45
|
+
parts?: ToolPart[];
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export interface AgentTimelineStatusItem {
|
|
@@ -79,71 +84,9 @@ export interface AgentTimelineProps {
|
|
|
79
84
|
isThinking?: boolean;
|
|
80
85
|
emptyState?: ReactNode;
|
|
81
86
|
className?: string;
|
|
82
|
-
/**
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
* reads as a single toggleable step instead of a long ladder of rows.
|
|
86
|
-
* Default true; pass false for the flat one-row-per-tool timeline.
|
|
87
|
-
*/
|
|
88
|
-
collapsibleToolRuns?: boolean;
|
|
89
|
-
/** Start collapsed tool runs open (true) or collapsed (false). Default open. */
|
|
90
|
-
defaultToolRunsOpen?: boolean;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** A run of consecutive tool / tool-group items folded into one shell. */
|
|
94
|
-
interface ToolRunGroup {
|
|
95
|
-
id: string;
|
|
96
|
-
kind: "tool_run";
|
|
97
|
-
items: (AgentTimelineToolItem | AgentTimelineToolGroupItem)[];
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
type TimelineNode = AgentTimelineItem | ToolRunGroup;
|
|
101
|
-
|
|
102
|
-
function foldToolRuns(items: AgentTimelineItem[]): TimelineNode[] {
|
|
103
|
-
const nodes: TimelineNode[] = [];
|
|
104
|
-
let run: (AgentTimelineToolItem | AgentTimelineToolGroupItem)[] = [];
|
|
105
|
-
|
|
106
|
-
const flush = () => {
|
|
107
|
-
if (run.length === 0) return;
|
|
108
|
-
// A single tool stays a plain row; two or more fold into a collapsible run.
|
|
109
|
-
if (run.length === 1) {
|
|
110
|
-
nodes.push(run[0]);
|
|
111
|
-
} else {
|
|
112
|
-
nodes.push({ id: `tool-run-${run[0].id}`, kind: "tool_run", items: run });
|
|
113
|
-
}
|
|
114
|
-
run = [];
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
for (const item of items) {
|
|
118
|
-
if (item.kind === "tool" || item.kind === "tool_group") {
|
|
119
|
-
run.push(item);
|
|
120
|
-
} else {
|
|
121
|
-
flush();
|
|
122
|
-
nodes.push(item);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
flush();
|
|
126
|
-
return nodes;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function countTools(group: ToolRunGroup): number {
|
|
130
|
-
return group.items.reduce(
|
|
131
|
-
(n, item) => n + (item.kind === "tool_group" ? item.calls.length : 1),
|
|
132
|
-
0,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function ToolCallRow({ call }: { call: ToolCallData }) {
|
|
137
|
-
return (
|
|
138
|
-
<ToolCallStep
|
|
139
|
-
type={call.type}
|
|
140
|
-
label={call.label}
|
|
141
|
-
status={call.status}
|
|
142
|
-
detail={call.detail}
|
|
143
|
-
output={call.output}
|
|
144
|
-
duration={call.duration}
|
|
145
|
-
/>
|
|
146
|
-
);
|
|
87
|
+
/** Optional actions rendered beside each tool item (e.g. "open in artifacts").
|
|
88
|
+
* Receives the source tool part carried on the item. */
|
|
89
|
+
renderToolActions?: (part: ToolPart) => ReactNode;
|
|
147
90
|
}
|
|
148
91
|
|
|
149
92
|
const TONE_STYLES: Record<AgentTimelineTone, { dot: string; card: string; text: string; icon: typeof Info }> = {
|
|
@@ -319,17 +262,8 @@ export function AgentTimeline({
|
|
|
319
262
|
isThinking,
|
|
320
263
|
emptyState,
|
|
321
264
|
className,
|
|
322
|
-
|
|
323
|
-
defaultToolRunsOpen = true,
|
|
265
|
+
renderToolActions,
|
|
324
266
|
}: AgentTimelineProps) {
|
|
325
|
-
// Collapse state for folded tool runs, keyed by run id. Absent → default.
|
|
326
|
-
const [collapsedRuns, setCollapsedRuns] = useState<Record<string, boolean>>({});
|
|
327
|
-
const toggleRun = (id: string) =>
|
|
328
|
-
setCollapsedRuns((prev) => ({
|
|
329
|
-
...prev,
|
|
330
|
-
[id]: prev[id] === undefined ? defaultToolRunsOpen : !prev[id],
|
|
331
|
-
}));
|
|
332
|
-
|
|
333
267
|
if (items.length === 0 && !isThinking) {
|
|
334
268
|
return emptyState ? (
|
|
335
269
|
<div className={cn("flex h-full items-center justify-center p-4", className)}>
|
|
@@ -342,110 +276,99 @@ export function AgentTimeline({
|
|
|
342
276
|
? [...items, { id: "__thinking__", kind: "custom", content: <ThinkingIndicator /> }]
|
|
343
277
|
: items;
|
|
344
278
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// Non-user rows participate in the connector spine.
|
|
350
|
-
const timelineNodes = nodes.filter(
|
|
351
|
-
(node) => !(node.kind === "message" && node.role === "user"),
|
|
352
|
-
);
|
|
279
|
+
// Determine which items participate in the timeline connector (non-user-message items)
|
|
280
|
+
// User messages are rendered outside the timeline grid
|
|
281
|
+
const timelineItems = renderedItems.filter((item) => !(item.kind === "message" && item.role === "user"));
|
|
353
282
|
|
|
354
283
|
return (
|
|
355
284
|
<div className={cn("mx-auto w-full max-w-5xl px-4 py-4", className)}>
|
|
356
|
-
{
|
|
285
|
+
{renderedItems.map((item, index) => {
|
|
357
286
|
// User messages: right-aligned bubble, no connector
|
|
358
|
-
if (
|
|
359
|
-
return <UserMessage key={
|
|
287
|
+
if (item.kind === "message" && item.role === "user") {
|
|
288
|
+
return <UserMessage key={item.id} item={item} />;
|
|
360
289
|
}
|
|
361
290
|
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
if (node.kind === "tool_run") {
|
|
365
|
-
const collapsed = collapsedRuns[node.id] ?? !defaultToolRunsOpen;
|
|
366
|
-
const total = countTools(node);
|
|
367
|
-
return (
|
|
368
|
-
<AgentTimelineRow key={node.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
|
|
369
|
-
<AssistantRunShell
|
|
370
|
-
label="Tools"
|
|
371
|
-
summary={`${total} ${total === 1 ? "tool" : "tools"}`}
|
|
372
|
-
collapsed={collapsed}
|
|
373
|
-
onToggle={() => toggleRun(node.id)}
|
|
374
|
-
>
|
|
375
|
-
<div className="space-y-px">
|
|
376
|
-
{node.items.map((item) =>
|
|
377
|
-
item.kind === "tool_group" ? (
|
|
378
|
-
<ToolCallGroup key={item.id} title={item.title}>
|
|
379
|
-
{item.calls.map((call) => (
|
|
380
|
-
<ToolCallRow key={call.id} call={call} />
|
|
381
|
-
))}
|
|
382
|
-
</ToolCallGroup>
|
|
383
|
-
) : (
|
|
384
|
-
<ToolCallRow key={item.id} call={item.call} />
|
|
385
|
-
),
|
|
386
|
-
)}
|
|
387
|
-
</div>
|
|
388
|
-
</AssistantRunShell>
|
|
389
|
-
</AgentTimelineRow>
|
|
390
|
-
);
|
|
391
|
-
}
|
|
291
|
+
const timelineIndex = timelineItems.indexOf(item);
|
|
292
|
+
const isLast = timelineIndex === timelineItems.length - 1;
|
|
392
293
|
|
|
393
|
-
if (
|
|
294
|
+
if (item.kind === "message") {
|
|
394
295
|
return (
|
|
395
|
-
<AgentTimelineRow key={
|
|
396
|
-
<AssistantMessage item={
|
|
296
|
+
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--brand-glow)]">
|
|
297
|
+
<AssistantMessage item={item} />
|
|
397
298
|
</AgentTimelineRow>
|
|
398
299
|
);
|
|
399
300
|
}
|
|
400
301
|
|
|
401
|
-
if (
|
|
302
|
+
if (item.kind === "tool") {
|
|
402
303
|
return (
|
|
403
|
-
<AgentTimelineRow key={
|
|
404
|
-
<
|
|
304
|
+
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
|
|
305
|
+
<ToolCallStep
|
|
306
|
+
type={item.call.type}
|
|
307
|
+
label={item.call.label}
|
|
308
|
+
status={item.call.status}
|
|
309
|
+
detail={item.call.detail}
|
|
310
|
+
output={item.call.output}
|
|
311
|
+
duration={item.call.duration}
|
|
312
|
+
part={item.part}
|
|
313
|
+
actions={item.part ? renderToolActions?.(item.part) : undefined}
|
|
314
|
+
/>
|
|
405
315
|
</AgentTimelineRow>
|
|
406
316
|
);
|
|
407
317
|
}
|
|
408
318
|
|
|
409
|
-
if (
|
|
319
|
+
if (item.kind === "tool_group") {
|
|
410
320
|
return (
|
|
411
|
-
<AgentTimelineRow key={
|
|
412
|
-
<ToolCallGroup title={
|
|
413
|
-
{
|
|
414
|
-
|
|
415
|
-
|
|
321
|
+
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
|
|
322
|
+
<ToolCallGroup title={item.title}>
|
|
323
|
+
{item.calls.map((call, callIndex) => {
|
|
324
|
+
const part = item.parts?.[callIndex];
|
|
325
|
+
return (
|
|
326
|
+
<ToolCallStep
|
|
327
|
+
key={call.id}
|
|
328
|
+
type={call.type}
|
|
329
|
+
label={call.label}
|
|
330
|
+
status={call.status}
|
|
331
|
+
detail={call.detail}
|
|
332
|
+
output={call.output}
|
|
333
|
+
duration={call.duration}
|
|
334
|
+
part={part}
|
|
335
|
+
actions={part ? renderToolActions?.(part) : undefined}
|
|
336
|
+
/>
|
|
337
|
+
);
|
|
338
|
+
})}
|
|
416
339
|
</ToolCallGroup>
|
|
417
340
|
</AgentTimelineRow>
|
|
418
341
|
);
|
|
419
342
|
}
|
|
420
343
|
|
|
421
|
-
if (
|
|
344
|
+
if (item.kind === "status") {
|
|
422
345
|
return (
|
|
423
346
|
<AgentTimelineRow
|
|
424
|
-
key={
|
|
347
|
+
key={item.id}
|
|
425
348
|
isLast={isLast}
|
|
426
|
-
accentClassName={TONE_STYLES[
|
|
349
|
+
accentClassName={TONE_STYLES[item.tone ?? "default"].dot}
|
|
427
350
|
>
|
|
428
|
-
<StatusCard item={
|
|
351
|
+
<StatusCard item={item} />
|
|
429
352
|
</AgentTimelineRow>
|
|
430
353
|
);
|
|
431
354
|
}
|
|
432
355
|
|
|
433
|
-
if (
|
|
356
|
+
if (item.kind === "artifact") {
|
|
434
357
|
return (
|
|
435
358
|
<AgentTimelineRow
|
|
436
|
-
key={
|
|
359
|
+
key={item.id}
|
|
437
360
|
isLast={isLast}
|
|
438
|
-
accentClassName={TONE_STYLES[
|
|
361
|
+
accentClassName={TONE_STYLES[item.tone ?? "default"].dot}
|
|
439
362
|
>
|
|
440
|
-
<ArtifactCard item={
|
|
363
|
+
<ArtifactCard item={item} />
|
|
441
364
|
</AgentTimelineRow>
|
|
442
365
|
);
|
|
443
366
|
}
|
|
444
367
|
|
|
445
368
|
// custom
|
|
446
369
|
return (
|
|
447
|
-
<AgentTimelineRow key={
|
|
448
|
-
{(
|
|
370
|
+
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
|
|
371
|
+
{(item as AgentTimelineCustomItem).content}
|
|
449
372
|
</AgentTimelineRow>
|
|
450
373
|
);
|
|
451
374
|
})}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react"
|
|
2
|
+
import { beforeAll, describe, expect, it, vi } from "vitest"
|
|
3
|
+
import type { SessionMessage } from "../types/message"
|
|
4
|
+
import type { SessionPart, ToolPart } from "../types/parts"
|
|
5
|
+
import { ChatContainer } from "./chat-container"
|
|
6
|
+
|
|
7
|
+
// jsdom has no layout engine; the auto-scroll hook calls scrollIntoView.
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const messages: SessionMessage[] = [
|
|
13
|
+
{ id: "m1", role: "assistant" },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const editPart: ToolPart = {
|
|
17
|
+
type: "tool",
|
|
18
|
+
id: "tool-1",
|
|
19
|
+
tool: "edit",
|
|
20
|
+
state: {
|
|
21
|
+
status: "completed",
|
|
22
|
+
input: { path: "src/batch-writer.ts" },
|
|
23
|
+
output: "Applied 1 edit to src/batch-writer.ts",
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const partMap: Record<string, SessionPart[]> = { m1: [editPart] }
|
|
28
|
+
|
|
29
|
+
describe("ChatContainer timeline tool rendering", () => {
|
|
30
|
+
it("shows a clean tool detail (the file path), not the raw input JSON", () => {
|
|
31
|
+
render(
|
|
32
|
+
<ChatContainer
|
|
33
|
+
messages={messages}
|
|
34
|
+
partMap={partMap}
|
|
35
|
+
isStreaming={false}
|
|
36
|
+
presentation="timeline"
|
|
37
|
+
/>,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(screen.getByText("src/batch-writer.ts")).toBeInTheDocument()
|
|
41
|
+
// The raw `{ "path": ... }` input JSON must not leak into the summary row.
|
|
42
|
+
expect(screen.queryByText(/"path"/)).not.toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("threads renderTimelineToolActions to the timeline tool item with its source part", () => {
|
|
46
|
+
const renderTimelineToolActions = vi.fn((part: ToolPart) => (
|
|
47
|
+
<button type="button">Open {part.id}</button>
|
|
48
|
+
))
|
|
49
|
+
|
|
50
|
+
render(
|
|
51
|
+
<ChatContainer
|
|
52
|
+
messages={messages}
|
|
53
|
+
partMap={partMap}
|
|
54
|
+
isStreaming={false}
|
|
55
|
+
presentation="timeline"
|
|
56
|
+
renderTimelineToolActions={renderTimelineToolActions}
|
|
57
|
+
/>,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
expect(renderTimelineToolActions).toHaveBeenCalled()
|
|
61
|
+
expect(renderTimelineToolActions.mock.calls[0][0]).toMatchObject({
|
|
62
|
+
id: "tool-1",
|
|
63
|
+
})
|
|
64
|
+
expect(
|
|
65
|
+
screen.getByRole("button", { name: /open tool-1/i }),
|
|
66
|
+
).toBeInTheDocument()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -50,7 +50,8 @@ export interface ChatContainerProps {
|
|
|
50
50
|
renderRunActions?: (run: Run) => ReactNode;
|
|
51
51
|
/** Optional actions rendered below each user message bubble. */
|
|
52
52
|
renderUserMessageActions?: (message: SessionMessage, parts: SessionPart[]) => ReactNode;
|
|
53
|
-
/** Optional actions rendered beside individual tool items
|
|
53
|
+
/** Optional actions rendered beside individual tool items in the run-grouped
|
|
54
|
+
* (`runs`) presentation. */
|
|
54
55
|
renderToolActions?: (
|
|
55
56
|
part: ToolPart,
|
|
56
57
|
options: {
|
|
@@ -59,6 +60,9 @@ export interface ChatContainerProps {
|
|
|
59
60
|
partIndex: number;
|
|
60
61
|
},
|
|
61
62
|
) => ReactNode;
|
|
63
|
+
/** Optional actions rendered beside individual tool items in the `timeline`
|
|
64
|
+
* presentation, which has no run/message grouping (hence part-only). */
|
|
65
|
+
renderTimelineToolActions?: (part: ToolPart) => ReactNode;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
const OPENUI_NODE_TYPES = new Set([
|
|
@@ -181,14 +185,16 @@ function buildTimelineItems(
|
|
|
181
185
|
return {
|
|
182
186
|
id: part.id,
|
|
183
187
|
type: mapToolPartToTimelineType(part),
|
|
184
|
-
label: meta.
|
|
188
|
+
label: meta.title,
|
|
185
189
|
status:
|
|
186
190
|
part.state.status === "completed"
|
|
187
191
|
? "success"
|
|
188
192
|
: part.state.status === "error"
|
|
189
193
|
? "error"
|
|
190
194
|
: "running",
|
|
191
|
-
|
|
195
|
+
// A human-readable summary (file path / command), not the raw input JSON —
|
|
196
|
+
// the full input still renders in the expanded ExpandedToolDetail.
|
|
197
|
+
detail: meta.commandSnippet ?? meta.targetPath ?? meta.description,
|
|
192
198
|
output: formatUnknown(part.state.output),
|
|
193
199
|
duration: start && end ? end - start : undefined,
|
|
194
200
|
} as const;
|
|
@@ -225,12 +231,14 @@ function buildTimelineItems(
|
|
|
225
231
|
id: `${message.id}-tool-${toolBuffer[0].id}`,
|
|
226
232
|
kind: "tool",
|
|
227
233
|
call: toToolCall(toolBuffer[0]),
|
|
234
|
+
part: toolBuffer[0],
|
|
228
235
|
});
|
|
229
236
|
} else {
|
|
230
237
|
items.push({
|
|
231
238
|
id: `${message.id}-tool-group-${index}`,
|
|
232
239
|
kind: "tool_group",
|
|
233
240
|
title: "Tool activity",
|
|
241
|
+
parts: [...toolBuffer],
|
|
234
242
|
calls: toolBuffer.map((part) => toToolCall(part)),
|
|
235
243
|
});
|
|
236
244
|
}
|
|
@@ -364,6 +372,7 @@ export const ChatContainer = memo(
|
|
|
364
372
|
renderRunActions,
|
|
365
373
|
renderUserMessageActions,
|
|
366
374
|
renderToolActions,
|
|
375
|
+
renderTimelineToolActions,
|
|
367
376
|
}: ChatContainerProps) => {
|
|
368
377
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
369
378
|
|
|
@@ -401,7 +410,11 @@ export const ChatContainer = memo(
|
|
|
401
410
|
</div>
|
|
402
411
|
) : presentation === "timeline" ? (
|
|
403
412
|
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col justify-end">
|
|
404
|
-
<AgentTimeline
|
|
413
|
+
<AgentTimeline
|
|
414
|
+
items={timeline.items}
|
|
415
|
+
isThinking={timeline.showThinking}
|
|
416
|
+
renderToolActions={renderTimelineToolActions}
|
|
417
|
+
/>
|
|
405
418
|
</div>
|
|
406
419
|
) : (
|
|
407
420
|
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col justify-end">
|
package/src/run/index.ts
CHANGED