@tangle-network/ui 11.6.0 → 11.7.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 +6 -0
- package/README.md +43 -0
- package/dist/chat.d.ts +18 -8
- package/dist/chat.js +6 -5
- package/dist/{chunk-XROQW5J7.js → chunk-4ENFLPWQ.js} +1 -1
- package/dist/{chunk-XPYPZMYT.js → chunk-6RIX7M27.js} +1 -1
- package/dist/{chunk-R5GVQDL3.js → chunk-CVX53NCC.js} +7 -5
- package/dist/{chunk-ULDNFLIM.js → chunk-EIUE5GV2.js} +10 -14
- package/dist/chunk-L57WRIND.js +59 -0
- package/dist/chunk-LFVF2HFB.js +19 -0
- package/dist/{chunk-AZWDI2JG.js → chunk-UYTRNR7Z.js} +1 -1
- package/dist/{chunk-RKC3GXHE.js → chunk-VDCZHOOO.js} +99 -104
- package/dist/{chunk-J2G7KCYH.js → chunk-XT4JMYVB.js} +109 -67
- package/dist/hooks.js +6 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +15 -10
- package/dist/primitives.d.ts +15 -1
- package/dist/primitives.js +5 -1
- package/dist/run.d.ts +22 -17
- package/dist/run.js +8 -6
- package/dist/sdk-hooks.js +6 -5
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +1 -1
- package/package.json +1 -1
- package/src/chat/agent-timeline.stories.tsx +163 -10
- package/src/chat/agent-timeline.test.tsx +114 -7
- package/src/chat/agent-timeline.tsx +120 -158
- package/src/chat/chat-container.test.tsx +59 -0
- package/src/chat/user-message.test.tsx +33 -0
- package/src/chat/user-message.tsx +13 -8
- package/src/primitives/index.ts +3 -0
- package/src/primitives/text-shimmer.test.tsx +34 -0
- package/src/primitives/text-shimmer.tsx +82 -0
- package/src/run/inline-thinking-item.stories.tsx +2 -2
- package/src/run/inline-thinking-item.tsx +2 -2
- package/src/run/inline-tool-item.test.tsx +76 -0
- package/src/run/inline-tool-item.tsx +9 -7
- package/src/run/run-group.tsx +4 -3
- package/src/run/run-row-shell.test.tsx +74 -4
- package/src/run/run-row-shell.tsx +81 -53
- package/src/run/tool-call-step.tsx +9 -7
- package/src/utils/tool-display.test.ts +53 -0
- package/src/utils/tool-display.ts +16 -15
- package/dist/chunk-LQS34IGP.js +0 -0
- /package/dist/{chunk-EJ5NBW7V.js → chunk-LDLOJKPC.js} +0 -0
|
@@ -33,7 +33,7 @@ function toolItem(part: ToolPart): AgentTimelineItem {
|
|
|
33
33
|
call: {
|
|
34
34
|
id: part.id,
|
|
35
35
|
type: "edit",
|
|
36
|
-
label: "Edit
|
|
36
|
+
label: "Edit",
|
|
37
37
|
status: "success",
|
|
38
38
|
detail: "src/batch-writer.ts",
|
|
39
39
|
output: "Applied 1 edit",
|
|
@@ -43,10 +43,10 @@ function toolItem(part: ToolPart): AgentTimelineItem {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
describe("AgentTimeline tool actions", () => {
|
|
46
|
-
it("renders a tool call's
|
|
46
|
+
it("renders a tool call's verb and its path, once each", () => {
|
|
47
47
|
render(<AgentTimeline items={[toolItem(editPart)]} />)
|
|
48
|
-
expect(screen.getByText("Edit
|
|
49
|
-
expect(screen.
|
|
48
|
+
expect(screen.getByText("Edit")).toBeInTheDocument()
|
|
49
|
+
expect(screen.getAllByText("src/batch-writer.ts")).toHaveLength(1)
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
it("renders renderToolActions beside the tool item, called with the source part", () => {
|
|
@@ -81,7 +81,7 @@ describe("AgentTimeline tool actions", () => {
|
|
|
81
81
|
|
|
82
82
|
it("renders tool items unchanged when no renderToolActions is provided", () => {
|
|
83
83
|
render(<AgentTimeline items={[toolItem(editPart)]} />)
|
|
84
|
-
expect(screen.getByText("Edit
|
|
84
|
+
expect(screen.getByText("Edit")).toBeInTheDocument()
|
|
85
85
|
expect(screen.queryByRole("button", { name: /open/i })).not.toBeInTheDocument()
|
|
86
86
|
})
|
|
87
87
|
|
|
@@ -97,14 +97,14 @@ describe("AgentTimeline tool actions", () => {
|
|
|
97
97
|
{
|
|
98
98
|
id: "tool-1",
|
|
99
99
|
type: "edit",
|
|
100
|
-
label: "Edit
|
|
100
|
+
label: "Edit",
|
|
101
101
|
status: "success",
|
|
102
102
|
detail: "src/batch-writer.ts",
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
id: "tool-2",
|
|
106
106
|
type: "read",
|
|
107
|
-
label: "Read
|
|
107
|
+
label: "Read",
|
|
108
108
|
status: "success",
|
|
109
109
|
detail: "src/lib/jitter.ts",
|
|
110
110
|
},
|
|
@@ -160,6 +160,113 @@ describe("AgentTimeline tool actions", () => {
|
|
|
160
160
|
expect(screen.getByRole("button", { name: /show less/i })).toBeInTheDocument()
|
|
161
161
|
})
|
|
162
162
|
|
|
163
|
+
it("names a tool group for assistive tech but draws no header above it", () => {
|
|
164
|
+
const groupItem: AgentTimelineItem = {
|
|
165
|
+
id: "group-1",
|
|
166
|
+
kind: "tool_group",
|
|
167
|
+
title: "Tool activity",
|
|
168
|
+
calls: [
|
|
169
|
+
{ id: "tool-1", type: "edit", label: "Edit", status: "success", detail: "src/a.ts" },
|
|
170
|
+
{ id: "tool-2", type: "read", label: "Read", status: "success", detail: "src/b.ts" },
|
|
171
|
+
],
|
|
172
|
+
parts: [editPart, readPart],
|
|
173
|
+
}
|
|
174
|
+
render(<AgentTimeline items={[groupItem]} />)
|
|
175
|
+
expect(screen.queryByText("Tool activity")).not.toBeInTheDocument()
|
|
176
|
+
expect(screen.getByRole("group", { name: "Tool activity" })).toBeInTheDocument()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it("renders assistant prose without a timestamp header", () => {
|
|
180
|
+
const stamp = new Date(2026, 0, 1, 15, 42)
|
|
181
|
+
render(
|
|
182
|
+
<AgentTimeline
|
|
183
|
+
items={[
|
|
184
|
+
{ id: "a-1", kind: "message", role: "assistant", content: "Done.", timestamp: stamp },
|
|
185
|
+
]}
|
|
186
|
+
/>,
|
|
187
|
+
)
|
|
188
|
+
expect(screen.getByText("Done.")).toBeInTheDocument()
|
|
189
|
+
expect(
|
|
190
|
+
screen.queryByText(
|
|
191
|
+
stamp.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }),
|
|
192
|
+
),
|
|
193
|
+
).not.toBeInTheDocument()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("keeps the user bubble's timestamp", () => {
|
|
197
|
+
const stamp = new Date(2026, 0, 1, 15, 42)
|
|
198
|
+
render(
|
|
199
|
+
<AgentTimeline
|
|
200
|
+
items={[{ id: "u-1", kind: "message", role: "user", content: "Hi", timestamp: stamp }]}
|
|
201
|
+
/>,
|
|
202
|
+
)
|
|
203
|
+
expect(
|
|
204
|
+
screen.getByText(
|
|
205
|
+
stamp.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }),
|
|
206
|
+
),
|
|
207
|
+
).toBeInTheDocument()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("lays rows out in a plain column — no spine gutter, no connector", () => {
|
|
211
|
+
const { container } = render(
|
|
212
|
+
<AgentTimeline
|
|
213
|
+
items={[
|
|
214
|
+
{ id: "a-1", kind: "message", role: "assistant", content: "Reading." },
|
|
215
|
+
toolItem(editPart),
|
|
216
|
+
{ id: "s-1", kind: "status", label: "Type-checks clean", tone: "success" },
|
|
217
|
+
]}
|
|
218
|
+
/>,
|
|
219
|
+
)
|
|
220
|
+
expect(container.querySelector('[class*="grid-cols-"]')).toBeNull()
|
|
221
|
+
expect(container.querySelector(".ring-4")).toBeNull()
|
|
222
|
+
const steps = container.querySelectorAll("[data-timeline-step]")
|
|
223
|
+
expect(Array.from(steps).map((el) => el.getAttribute("data-timeline-step"))).toEqual([
|
|
224
|
+
"prose",
|
|
225
|
+
"tool",
|
|
226
|
+
"card",
|
|
227
|
+
])
|
|
228
|
+
// Tool rows bleed past the prose edge so the glyph sits on it.
|
|
229
|
+
expect(steps[1].className).toContain("-mx-3")
|
|
230
|
+
expect(steps[0].className).not.toContain("-mx-3")
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("spaces rows by their neighbours: turns widest, tool sequences tightest", () => {
|
|
234
|
+
const tool2 = { ...toolItem(readPart), id: "item-2" }
|
|
235
|
+
const { container } = render(
|
|
236
|
+
<AgentTimeline
|
|
237
|
+
items={[
|
|
238
|
+
{ id: "u-1", kind: "message", role: "user", content: "Go" },
|
|
239
|
+
toolItem(editPart),
|
|
240
|
+
tool2,
|
|
241
|
+
{ id: "a-1", kind: "message", role: "assistant", content: "Done." },
|
|
242
|
+
{ id: "u-2", kind: "message", role: "user", content: "Thanks" },
|
|
243
|
+
]}
|
|
244
|
+
/>,
|
|
245
|
+
)
|
|
246
|
+
const classes = Array.from(container.querySelectorAll("[data-timeline-step]")).map(
|
|
247
|
+
(el) => el.className,
|
|
248
|
+
)
|
|
249
|
+
expect(classes[0]).not.toMatch(/mt-\d/)
|
|
250
|
+
expect(classes[1]).toContain("mt-4") // user → tool: turn boundary
|
|
251
|
+
expect(classes[2]).toContain("mt-1") // tool → tool
|
|
252
|
+
expect(classes[3]).toContain("mt-1") // tool → prose
|
|
253
|
+
expect(classes[4]).toContain("mt-4") // prose → user
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("softens the streaming cursor to a muted caret", () => {
|
|
257
|
+
const { container } = render(
|
|
258
|
+
<AgentTimeline
|
|
259
|
+
items={[
|
|
260
|
+
{ id: "a-1", kind: "message", role: "assistant", content: "Typing", isStreaming: true },
|
|
261
|
+
]}
|
|
262
|
+
/>,
|
|
263
|
+
)
|
|
264
|
+
const caret = container.querySelector("[data-streaming-caret]")
|
|
265
|
+
expect(caret).not.toBeNull()
|
|
266
|
+
expect(caret!.className).toContain("bg-muted-foreground")
|
|
267
|
+
expect(caret!.className).not.toContain("bg-primary")
|
|
268
|
+
})
|
|
269
|
+
|
|
163
270
|
it("renders the source part's real input in the expanded detail", async () => {
|
|
164
271
|
const user = userEvent.setup()
|
|
165
272
|
const probePart: ToolPart = {
|
|
@@ -24,6 +24,8 @@ export interface AgentTimelineMessageItem {
|
|
|
24
24
|
content: string;
|
|
25
25
|
toolCalls?: ReactNode;
|
|
26
26
|
isStreaming?: boolean;
|
|
27
|
+
/** Shown beside a user bubble on hover. Assistant prose carries no
|
|
28
|
+
* timestamp of its own — the turn's user message dates the exchange. */
|
|
27
29
|
timestamp?: Date;
|
|
28
30
|
after?: ReactNode;
|
|
29
31
|
}
|
|
@@ -40,6 +42,7 @@ export interface AgentTimelineToolItem {
|
|
|
40
42
|
export interface AgentTimelineToolGroupItem {
|
|
41
43
|
id: string;
|
|
42
44
|
kind: "tool_group";
|
|
45
|
+
/** Accessible name of the group. Not drawn — the rows are the label. */
|
|
43
46
|
title?: string;
|
|
44
47
|
calls: ToolCallData[];
|
|
45
48
|
/** Source tool parts, parallel to `calls`. */
|
|
@@ -69,6 +72,9 @@ export interface AgentTimelineArtifactItem {
|
|
|
69
72
|
export interface AgentTimelineCustomItem {
|
|
70
73
|
id: string;
|
|
71
74
|
kind: "custom";
|
|
75
|
+
/** Laid out as a run row: it takes the same horizontal bleed as tool rows,
|
|
76
|
+
* so a `RunRowShell`-based row (reasoning) aligns its glyph with the prose
|
|
77
|
+
* edge. */
|
|
72
78
|
content: ReactNode;
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -88,90 +94,82 @@ export interface AgentTimelineProps {
|
|
|
88
94
|
/** Optional actions rendered beside each tool item (e.g. "open in artifacts").
|
|
89
95
|
* Receives the source tool part carried on the item. */
|
|
90
96
|
renderToolActions?: (part: ToolPart) => ReactNode;
|
|
91
|
-
/** When set, collapse the timeline to the first N
|
|
92
|
-
* "Show N more steps" toggle. Omit to always
|
|
97
|
+
/** When set, collapse the timeline to the first N step rows (every item
|
|
98
|
+
* except user messages) behind a "Show N more steps" toggle. Omit to always
|
|
99
|
+
* show every row. */
|
|
93
100
|
collapseAfter?: number;
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
const TONE_STYLES: Record<AgentTimelineTone, {
|
|
103
|
+
const TONE_STYLES: Record<AgentTimelineTone, { card: string; text: string; icon: typeof Info }> = {
|
|
97
104
|
default: {
|
|
98
|
-
dot: "bg-[var(--border-hover)]",
|
|
99
105
|
card: "border-border bg-card",
|
|
100
106
|
text: "text-foreground",
|
|
101
107
|
icon: CircleDot,
|
|
102
108
|
},
|
|
103
109
|
info: {
|
|
104
|
-
dot: "bg-[var(--surface-info-text)]",
|
|
105
110
|
card: "border-[var(--surface-info-border)] bg-[var(--surface-info-bg)]",
|
|
106
111
|
text: "text-[var(--surface-info-text)]",
|
|
107
112
|
icon: Info,
|
|
108
113
|
},
|
|
109
114
|
success: {
|
|
110
|
-
dot: "bg-[var(--surface-success-text)]",
|
|
111
115
|
card: "border-[var(--surface-success-border)] bg-[var(--surface-success-bg)]",
|
|
112
116
|
text: "text-[var(--surface-success-text)]",
|
|
113
117
|
icon: CheckCircle2,
|
|
114
118
|
},
|
|
115
119
|
warning: {
|
|
116
|
-
dot: "bg-[var(--surface-warning-text)]",
|
|
117
120
|
card: "border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)]",
|
|
118
121
|
text: "text-[var(--surface-warning-text)]",
|
|
119
122
|
icon: AlertTriangle,
|
|
120
123
|
},
|
|
121
124
|
error: {
|
|
122
|
-
dot: "bg-[var(--surface-danger-text)]",
|
|
123
125
|
card: "border-[var(--surface-danger-border)] bg-[var(--surface-danger-bg)]",
|
|
124
126
|
text: "text-[var(--surface-danger-text)]",
|
|
125
127
|
icon: AlertTriangle,
|
|
126
128
|
},
|
|
127
129
|
};
|
|
128
130
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Vertical rhythm. Rows are grouped by what they are, and the gap between two
|
|
133
|
+
* neighbours follows from the pair: a turn boundary (user message) separates
|
|
134
|
+
* most, a tool sequence — and prose beside it — sits tightest, cards and
|
|
135
|
+
* consecutive paragraphs fall between.
|
|
136
|
+
*/
|
|
137
|
+
type StepKind = "user" | "prose" | "tool" | "card";
|
|
132
138
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
* pass CARD_ROW_DOT to center the dot on the card header instead. */
|
|
138
|
-
dotClassName?: string;
|
|
139
|
-
children: ReactNode;
|
|
139
|
+
function stepKind(item: AgentTimelineItem): StepKind {
|
|
140
|
+
if (item.kind === "message") return item.role === "user" ? "user" : "prose";
|
|
141
|
+
if (item.kind === "status" || item.kind === "artifact") return "card";
|
|
142
|
+
return "tool";
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
148
|
-
<div className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-4">
|
|
149
|
-
<div className="relative flex justify-center">
|
|
150
|
-
{!isLast && (
|
|
151
|
-
<span className="absolute top-4 bottom-[-0.75rem] left-1/2 w-px -translate-x-1/2 bg-border" />
|
|
152
|
-
)}
|
|
153
|
-
<span className={cn("relative h-[var(--timeline-dot-size)] w-[var(--timeline-dot-size)] rounded-full ring-4 ring-[var(--bg-root)]", dotClassName, accentClassName)} />
|
|
154
|
-
</div>
|
|
155
|
-
<div className="min-w-0 pb-3">{children}</div>
|
|
156
|
-
</div>
|
|
157
|
-
);
|
|
145
|
+
function spacingBefore(prev: StepKind | undefined, next: StepKind): string {
|
|
146
|
+
if (prev === undefined) return "";
|
|
147
|
+
if (prev === "user" || next === "user") return "mt-4";
|
|
148
|
+
if (prev === "card" || next === "card") return "mt-2";
|
|
149
|
+
if (prev === "tool" || next === "tool") return "mt-1";
|
|
150
|
+
return "mt-3";
|
|
158
151
|
}
|
|
159
152
|
|
|
153
|
+
// Tool rows bleed past the prose edge so the resting glyph sits ON the edge:
|
|
154
|
+
// RunRowShell's 8px header inset plus the 4px that centers a 14px glyph in its
|
|
155
|
+
// 22px slot = 12px. The hover fill then wraps the row. The column's `px-4` is
|
|
156
|
+
// wider than the bleed, so nothing clips.
|
|
157
|
+
const ROW_BLEED = "-mx-3";
|
|
158
|
+
|
|
160
159
|
function AssistantMessage({ item }: { item: AgentTimelineMessageItem }) {
|
|
161
160
|
return (
|
|
162
|
-
<div
|
|
163
|
-
{item.timestamp && (
|
|
164
|
-
<div className="mb-2 text-[var(--font-size-xs)] text-muted-foreground">
|
|
165
|
-
{formatTime(item.timestamp)}
|
|
166
|
-
</div>
|
|
167
|
-
)}
|
|
161
|
+
<div>
|
|
168
162
|
{item.content && (
|
|
169
|
-
<Markdown className="tangle-prose text-[var(--font-size-base)] leading-[
|
|
163
|
+
<Markdown className="tangle-prose text-[var(--font-size-base)] leading-[1.5]">{item.content}</Markdown>
|
|
170
164
|
)}
|
|
171
165
|
{item.isStreaming && (
|
|
172
|
-
<span
|
|
166
|
+
<span
|
|
167
|
+
aria-hidden
|
|
168
|
+
data-streaming-caret=""
|
|
169
|
+
className="ml-0.5 inline-block h-[1em] w-0.5 animate-pulse rounded-full bg-muted-foreground align-text-bottom"
|
|
170
|
+
/>
|
|
173
171
|
)}
|
|
174
|
-
{item.toolCalls && <div className="mt-
|
|
172
|
+
{item.toolCalls && <div className="mt-1">{item.toolCalls}</div>}
|
|
175
173
|
{item.after && (
|
|
176
174
|
<div className="mt-3 border-t border-border pt-3">
|
|
177
175
|
{item.after}
|
|
@@ -245,9 +243,10 @@ function ArtifactCard({ item }: { item: AgentTimelineArtifactItem }) {
|
|
|
245
243
|
}
|
|
246
244
|
|
|
247
245
|
/**
|
|
248
|
-
* AgentTimeline — unified mixed-content
|
|
249
|
-
* sessions. Renders messages, tool
|
|
250
|
-
*
|
|
246
|
+
* AgentTimeline — unified mixed-content transcript for agent-backed sandbox
|
|
247
|
+
* sessions. Renders messages, tool rows, status cards, and artifact handoffs
|
|
248
|
+
* as one plain column: prose at the reading size, tool calls as quiet
|
|
249
|
+
* one-liners between the paragraphs, no connector chrome.
|
|
251
250
|
*/
|
|
252
251
|
export function AgentTimeline({
|
|
253
252
|
items,
|
|
@@ -274,143 +273,106 @@ export function AgentTimeline({
|
|
|
274
273
|
const isUserMessage = (item: AgentTimelineItem) =>
|
|
275
274
|
item.kind === "message" && item.role === "user";
|
|
276
275
|
|
|
277
|
-
//
|
|
278
|
-
const
|
|
276
|
+
// Step rows are everything but user messages; the collapse limit counts them.
|
|
277
|
+
const stepItems = renderedItems.filter((item) => !isUserMessage(item));
|
|
279
278
|
|
|
280
279
|
const limit = collapseAfter ?? Infinity;
|
|
281
|
-
const collapsible =
|
|
280
|
+
const collapsible = stepItems.length > limit;
|
|
282
281
|
const collapsed = collapsible && !expanded;
|
|
283
282
|
|
|
284
|
-
// While collapsed, keep only the first `limit`
|
|
283
|
+
// While collapsed, keep only the first `limit` step rows (and any user
|
|
285
284
|
// messages that precede them); the rest hides behind the toggle.
|
|
286
285
|
let renderList = renderedItems;
|
|
287
286
|
let hiddenCount = 0;
|
|
288
287
|
if (collapsed) {
|
|
289
288
|
const visible: AgentTimelineItem[] = [];
|
|
290
|
-
let
|
|
289
|
+
let stepCount = 0;
|
|
291
290
|
for (const item of renderedItems) {
|
|
292
|
-
if (
|
|
291
|
+
if (stepCount >= limit) break;
|
|
293
292
|
visible.push(item);
|
|
294
|
-
if (!isUserMessage(item))
|
|
293
|
+
if (!isUserMessage(item)) stepCount += 1;
|
|
295
294
|
}
|
|
296
295
|
renderList = visible;
|
|
297
|
-
hiddenCount =
|
|
296
|
+
hiddenCount = stepItems.length - limit;
|
|
298
297
|
}
|
|
299
298
|
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
{renderList.map((item) => {
|
|
309
|
-
// User messages: right-aligned bubble, off-spine — needs its own vertical
|
|
310
|
-
// rhythm so it doesn't sit flush against the status/tool/agent row below.
|
|
311
|
-
if (item.kind === "message" && item.role === "user") {
|
|
312
|
-
return (
|
|
313
|
-
<div key={item.id} className="mt-6 mb-4 first:mt-0">
|
|
314
|
-
<UserMessage content={item.content} timestamp={item.timestamp} />
|
|
315
|
-
</div>
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const isLast = item === lastSpineItem;
|
|
299
|
+
const renderItem = (item: AgentTimelineItem): ReactNode => {
|
|
300
|
+
if (item.kind === "message") {
|
|
301
|
+
return item.role === "user" ? (
|
|
302
|
+
<UserMessage content={item.content} timestamp={item.timestamp} />
|
|
303
|
+
) : (
|
|
304
|
+
<AssistantMessage item={item} />
|
|
305
|
+
);
|
|
306
|
+
}
|
|
320
307
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
308
|
+
if (item.kind === "tool") {
|
|
309
|
+
return (
|
|
310
|
+
<ToolCallStep
|
|
311
|
+
type={item.call.type}
|
|
312
|
+
label={item.call.label}
|
|
313
|
+
status={item.call.status}
|
|
314
|
+
detail={item.call.detail}
|
|
315
|
+
output={item.call.output}
|
|
316
|
+
duration={item.call.duration}
|
|
317
|
+
part={item.part}
|
|
318
|
+
actions={item.part ? renderToolActions?.(item.part) : undefined}
|
|
319
|
+
/>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
328
322
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
323
|
+
if (item.kind === "tool_group") {
|
|
324
|
+
return (
|
|
325
|
+
<ToolCallGroup title={item.title}>
|
|
326
|
+
{item.calls.map((call, callIndex) => {
|
|
327
|
+
const part = item.parts?.[callIndex];
|
|
328
|
+
return (
|
|
332
329
|
<ToolCallStep
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
330
|
+
key={call.id}
|
|
331
|
+
type={call.type}
|
|
332
|
+
label={call.label}
|
|
333
|
+
status={call.status}
|
|
334
|
+
detail={call.detail}
|
|
335
|
+
output={call.output}
|
|
336
|
+
duration={call.duration}
|
|
337
|
+
part={part}
|
|
338
|
+
actions={part ? renderToolActions?.(part) : undefined}
|
|
341
339
|
/>
|
|
342
|
-
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return (
|
|
348
|
-
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]" dotClassName={CARD_ROW_DOT}>
|
|
349
|
-
<ToolCallGroup title={item.title}>
|
|
350
|
-
{item.calls.map((call, callIndex) => {
|
|
351
|
-
const part = item.parts?.[callIndex];
|
|
352
|
-
return (
|
|
353
|
-
<ToolCallStep
|
|
354
|
-
key={call.id}
|
|
355
|
-
type={call.type}
|
|
356
|
-
label={call.label}
|
|
357
|
-
status={call.status}
|
|
358
|
-
detail={call.detail}
|
|
359
|
-
output={call.output}
|
|
360
|
-
duration={call.duration}
|
|
361
|
-
part={part}
|
|
362
|
-
actions={part ? renderToolActions?.(part) : undefined}
|
|
363
|
-
/>
|
|
364
|
-
);
|
|
365
|
-
})}
|
|
366
|
-
</ToolCallGroup>
|
|
367
|
-
</AgentTimelineRow>
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
if (item.kind === "status") {
|
|
372
|
-
return (
|
|
373
|
-
<AgentTimelineRow
|
|
374
|
-
key={item.id}
|
|
375
|
-
isLast={isLast}
|
|
376
|
-
accentClassName={TONE_STYLES[item.tone ?? "default"].dot}
|
|
377
|
-
>
|
|
378
|
-
<StatusCard item={item} />
|
|
379
|
-
</AgentTimelineRow>
|
|
380
|
-
);
|
|
381
|
-
}
|
|
340
|
+
);
|
|
341
|
+
})}
|
|
342
|
+
</ToolCallGroup>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
382
345
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
isLast={isLast}
|
|
388
|
-
accentClassName={TONE_STYLES[item.tone ?? "default"].dot}
|
|
389
|
-
>
|
|
390
|
-
<ArtifactCard item={item} />
|
|
391
|
-
</AgentTimelineRow>
|
|
392
|
-
);
|
|
393
|
-
}
|
|
346
|
+
if (item.kind === "status") return <StatusCard item={item} />;
|
|
347
|
+
if (item.kind === "artifact") return <ArtifactCard item={item} />;
|
|
348
|
+
return item.content;
|
|
349
|
+
};
|
|
394
350
|
|
|
395
|
-
|
|
351
|
+
return (
|
|
352
|
+
<div className={cn("mx-auto flex w-full max-w-5xl flex-col px-4 py-4", className)}>
|
|
353
|
+
{renderList.map((item, index) => {
|
|
354
|
+
const kind = stepKind(item);
|
|
355
|
+
const prev = index > 0 ? stepKind(renderList[index - 1]) : undefined;
|
|
396
356
|
return (
|
|
397
|
-
<
|
|
398
|
-
{
|
|
399
|
-
|
|
357
|
+
<div
|
|
358
|
+
key={item.id}
|
|
359
|
+
data-timeline-step={kind}
|
|
360
|
+
className={cn(spacingBefore(prev, kind), kind === "tool" && ROW_BLEED)}
|
|
361
|
+
>
|
|
362
|
+
{renderItem(item)}
|
|
363
|
+
</div>
|
|
400
364
|
);
|
|
401
365
|
})}
|
|
402
366
|
{collapsible ? (
|
|
403
|
-
<
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
{
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
</button>
|
|
413
|
-
</AgentTimelineRow>
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
onClick={() => setExpanded((value) => !value)}
|
|
370
|
+
className="mt-2 self-start text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
371
|
+
>
|
|
372
|
+
{collapsed
|
|
373
|
+
? `Show ${hiddenCount} more step${hiddenCount === 1 ? "" : "s"}`
|
|
374
|
+
: "Show less"}
|
|
375
|
+
</button>
|
|
414
376
|
) : null}
|
|
415
377
|
</div>
|
|
416
378
|
);
|
|
@@ -104,6 +104,65 @@ describe("ChatContainer timeline tool rendering", () => {
|
|
|
104
104
|
expect(screen.queryByText(/"path"/)).not.toBeInTheDocument()
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
+
it("titles the tool row with the bare verb and prints the path once", () => {
|
|
108
|
+
const { container } = render(
|
|
109
|
+
<ChatContainer
|
|
110
|
+
messages={messages}
|
|
111
|
+
partMap={partMap}
|
|
112
|
+
isStreaming={false}
|
|
113
|
+
presentation="timeline"
|
|
114
|
+
/>,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
expect(screen.getByText("Edit")).toBeInTheDocument()
|
|
118
|
+
expect(screen.getAllByText("src/batch-writer.ts")).toHaveLength(1)
|
|
119
|
+
expect(container.textContent).not.toContain("Edit src/batch-writer.ts")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("stacks consecutive tool calls with no visible group header", () => {
|
|
123
|
+
const readPart: ToolPart = {
|
|
124
|
+
type: "tool",
|
|
125
|
+
id: "tool-2",
|
|
126
|
+
tool: "read",
|
|
127
|
+
state: { status: "completed", input: { path: "src/lib/jitter.ts" }, output: "ok" },
|
|
128
|
+
}
|
|
129
|
+
render(
|
|
130
|
+
<ChatContainer
|
|
131
|
+
messages={messages}
|
|
132
|
+
partMap={{ m1: [editPart, readPart] }}
|
|
133
|
+
isStreaming={false}
|
|
134
|
+
presentation="timeline"
|
|
135
|
+
/>,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
expect(screen.queryByText(/tool activity/i)).not.toBeInTheDocument()
|
|
139
|
+
expect(screen.getByRole("group", { name: "Tool activity" })).toBeInTheDocument()
|
|
140
|
+
expect(screen.getByText("Edit")).toBeInTheDocument()
|
|
141
|
+
expect(screen.getByText("Read")).toBeInTheDocument()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("shimmers a running tool's verb and shows no spinner", () => {
|
|
145
|
+
const running: ToolPart = {
|
|
146
|
+
type: "tool",
|
|
147
|
+
id: "tool-3",
|
|
148
|
+
tool: "bash",
|
|
149
|
+
state: { status: "running", input: { command: "pnpm build" }, time: { start: 1 } },
|
|
150
|
+
}
|
|
151
|
+
const { container } = render(
|
|
152
|
+
<ChatContainer
|
|
153
|
+
messages={[{ id: "user-1", role: "user" }, { id: "m1", role: "assistant" }]}
|
|
154
|
+
partMap={{ "user-1": [{ type: "text", text: "build" }], m1: [running] }}
|
|
155
|
+
isStreaming
|
|
156
|
+
presentation="timeline"
|
|
157
|
+
/>,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const shimmer = container.querySelector(".tangle-text-shimmer")
|
|
161
|
+
expect(shimmer).not.toBeNull()
|
|
162
|
+
expect(shimmer!.textContent).toBe("Shell")
|
|
163
|
+
expect(container.querySelector(".animate-spin")).toBeNull()
|
|
164
|
+
})
|
|
165
|
+
|
|
107
166
|
it("threads renderTimelineToolActions to the timeline tool item with its source part", () => {
|
|
108
167
|
const renderTimelineToolActions = vi.fn((part: ToolPart) => (
|
|
109
168
|
<button type="button">Open {part.id}</button>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { UserMessage } from "./user-message"
|
|
4
|
+
|
|
5
|
+
describe("UserMessage", () => {
|
|
6
|
+
it("renders the text in a borderless muted bubble", () => {
|
|
7
|
+
const { container } = render(<UserMessage content="Run the tests." />)
|
|
8
|
+
expect(screen.getByText("Run the tests.")).toBeInTheDocument()
|
|
9
|
+
const bubble = container.querySelector(".rounded-2xl")
|
|
10
|
+
expect(bubble).not.toBeNull()
|
|
11
|
+
expect(bubble!.className).toContain("bg-muted/50")
|
|
12
|
+
expect(bubble!.className).not.toMatch(/(^|\s)border(\s|$)/)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("keeps the timestamp beside the bubble as a hover-revealed label", () => {
|
|
16
|
+
const stamp = new Date(2026, 0, 1, 15, 42)
|
|
17
|
+
const { container } = render(
|
|
18
|
+
<UserMessage content="Run the tests." timestamp={stamp} />,
|
|
19
|
+
)
|
|
20
|
+
const label = container.querySelector("[data-user-message-time]")
|
|
21
|
+
expect(label).not.toBeNull()
|
|
22
|
+
expect(label!.textContent).toBe(
|
|
23
|
+
stamp.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }),
|
|
24
|
+
)
|
|
25
|
+
expect(label!.className).toContain("opacity-0")
|
|
26
|
+
expect(label!.className).toContain("group-hover:opacity-100")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("renders nothing for an empty message", () => {
|
|
30
|
+
const { container } = render(<UserMessage content=" " />)
|
|
31
|
+
expect(container).toBeEmptyDOMElement()
|
|
32
|
+
})
|
|
33
|
+
})
|