@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +43 -0
  3. package/dist/chat.d.ts +18 -8
  4. package/dist/chat.js +6 -5
  5. package/dist/{chunk-XROQW5J7.js → chunk-4ENFLPWQ.js} +1 -1
  6. package/dist/{chunk-XPYPZMYT.js → chunk-6RIX7M27.js} +1 -1
  7. package/dist/{chunk-R5GVQDL3.js → chunk-CVX53NCC.js} +7 -5
  8. package/dist/{chunk-ULDNFLIM.js → chunk-EIUE5GV2.js} +10 -14
  9. package/dist/chunk-L57WRIND.js +59 -0
  10. package/dist/chunk-LFVF2HFB.js +19 -0
  11. package/dist/{chunk-AZWDI2JG.js → chunk-UYTRNR7Z.js} +1 -1
  12. package/dist/{chunk-RKC3GXHE.js → chunk-VDCZHOOO.js} +99 -104
  13. package/dist/{chunk-J2G7KCYH.js → chunk-XT4JMYVB.js} +109 -67
  14. package/dist/hooks.js +6 -5
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +15 -10
  17. package/dist/primitives.d.ts +15 -1
  18. package/dist/primitives.js +5 -1
  19. package/dist/run.d.ts +22 -17
  20. package/dist/run.js +8 -6
  21. package/dist/sdk-hooks.js +6 -5
  22. package/dist/utils.d.ts +6 -0
  23. package/dist/utils.js +1 -1
  24. package/package.json +1 -1
  25. package/src/chat/agent-timeline.stories.tsx +163 -10
  26. package/src/chat/agent-timeline.test.tsx +114 -7
  27. package/src/chat/agent-timeline.tsx +120 -158
  28. package/src/chat/chat-container.test.tsx +59 -0
  29. package/src/chat/user-message.test.tsx +33 -0
  30. package/src/chat/user-message.tsx +13 -8
  31. package/src/primitives/index.ts +3 -0
  32. package/src/primitives/text-shimmer.test.tsx +34 -0
  33. package/src/primitives/text-shimmer.tsx +82 -0
  34. package/src/run/inline-thinking-item.stories.tsx +2 -2
  35. package/src/run/inline-thinking-item.tsx +2 -2
  36. package/src/run/inline-tool-item.test.tsx +76 -0
  37. package/src/run/inline-tool-item.tsx +9 -7
  38. package/src/run/run-group.tsx +4 -3
  39. package/src/run/run-row-shell.test.tsx +74 -4
  40. package/src/run/run-row-shell.tsx +81 -53
  41. package/src/run/tool-call-step.tsx +9 -7
  42. package/src/utils/tool-display.test.ts +53 -0
  43. package/src/utils/tool-display.ts +16 -15
  44. package/dist/chunk-LQS34IGP.js +0 -0
  45. /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 src/batch-writer.ts",
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 label and clean detail", () => {
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 src/batch-writer.ts")).toBeInTheDocument()
49
- expect(screen.getByText("src/batch-writer.ts")).toBeInTheDocument()
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 src/batch-writer.ts")).toBeInTheDocument()
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 src/batch-writer.ts",
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 src/lib/jitter.ts",
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 spine rows behind a
92
- * "Show N more steps" toggle. Omit to always show every row. */
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, { dot: string; card: string; text: string; icon: typeof Info }> = {
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
- function formatTime(date: Date): string {
130
- return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
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
- interface AgentTimelineRowProps {
134
- isLast: boolean;
135
- accentClassName: string;
136
- /** Dot top-offset. Defaults to aligning with the first text line; card rows
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
- // Centers the dot on a RunRowShell card header (2.5rem: h-6 badge + py-2),
143
- // so tool and reasoning rows read as dot-aligned rather than top-anchored.
144
- const CARD_ROW_DOT = "mt-[calc((2.5rem-var(--timeline-dot-size))/2)]";
145
-
146
- function AgentTimelineRow({ isLast, accentClassName, dotClassName = "mt-2", children }: AgentTimelineRowProps) {
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 className="-mt-0.5">
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-[var(--line-height-base)]">{item.content}</Markdown>
163
+ <Markdown className="tangle-prose text-[var(--font-size-base)] leading-[1.5]">{item.content}</Markdown>
170
164
  )}
171
165
  {item.isStreaming && (
172
- <span className="ml-0.5 inline-block h-4 w-2 animate-pulse rounded-sm bg-primary align-text-bottom" />
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-3">{item.toolCalls}</div>}
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 timeline for agent-backed sandbox
249
- * sessions. Renders messages, tool steps, status cards, and artifact handoffs in
250
- * a single execution narrative.
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
- // Items on the vertical connector (user messages render off-spine).
278
- const timelineItems = renderedItems.filter((item) => !isUserMessage(item));
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 = timelineItems.length > limit;
280
+ const collapsible = stepItems.length > limit;
282
281
  const collapsed = collapsible && !expanded;
283
282
 
284
- // While collapsed, keep only the first `limit` spine rows (and any user
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 spineCount = 0;
289
+ let stepCount = 0;
291
290
  for (const item of renderedItems) {
292
- if (spineCount >= limit) break;
291
+ if (stepCount >= limit) break;
293
292
  visible.push(item);
294
- if (!isUserMessage(item)) spineCount += 1;
293
+ if (!isUserMessage(item)) stepCount += 1;
295
294
  }
296
295
  renderList = visible;
297
- hiddenCount = timelineItems.length - limit;
296
+ hiddenCount = stepItems.length - limit;
298
297
  }
299
298
 
300
- const visibleSpine = renderList.filter((item) => !isUserMessage(item));
301
- // When a toggle row follows, no real row is last — the toggle owns the tail.
302
- const lastSpineItem = collapsible
303
- ? undefined
304
- : visibleSpine[visibleSpine.length - 1];
305
-
306
- return (
307
- <div className={cn("mx-auto w-full max-w-5xl px-4 py-4", className)}>
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
- if (item.kind === "message") {
322
- return (
323
- <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--brand-glow)]">
324
- <AssistantMessage item={item} />
325
- </AgentTimelineRow>
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
- if (item.kind === "tool") {
330
- return (
331
- <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]" dotClassName={CARD_ROW_DOT}>
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
- type={item.call.type}
334
- label={item.call.label}
335
- status={item.call.status}
336
- detail={item.call.detail}
337
- output={item.call.output}
338
- duration={item.call.duration}
339
- part={item.part}
340
- actions={item.part ? renderToolActions?.(item.part) : undefined}
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
- </AgentTimelineRow>
343
- );
344
- }
345
-
346
- if (item.kind === "tool_group") {
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
- if (item.kind === "artifact") {
384
- return (
385
- <AgentTimelineRow
386
- key={item.id}
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
- // custom
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
- <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]" dotClassName={CARD_ROW_DOT}>
398
- {(item as AgentTimelineCustomItem).content}
399
- </AgentTimelineRow>
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
- <AgentTimelineRow isLast accentClassName="bg-[var(--border-hover)]">
404
- <button
405
- type="button"
406
- onClick={() => setExpanded((value) => !value)}
407
- className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
408
- >
409
- {collapsed
410
- ? `Show ${hiddenCount} more step${hiddenCount === 1 ? "" : "s"}`
411
- : "Show less"}
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
+ })