@tangle-network/ui 9.0.0 → 9.1.1

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 9.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - a8d770e: Give run/timeline tool rows a proper elevation ladder. Rows read as the same value as the canvas: `InlineToolItem` used `bg-card/40` (near-transparent) and RunGroup's OpenUI/running blocks used `bg-[var(--bg-root)]` (literally the page background). Both now use `--md3-surface-container` — one clear step above the `--bg-root` canvas — with hover/open stepping to `--md3-surface-container-high`. Rows now separate from the background instead of blending into it.
8
+
9
+ ## 9.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - deb065a: AgentTimeline now accepts `renderToolActions` (and carries the source `ToolPart` on its tool items) so consumers can render actions beside a tool call — e.g. "open in artifacts". Previously these hooks reached only the run-grouped `MessageList`, not the timeline presentation. ChatContainer exposes this through a new `renderTimelineToolActions?(part)` prop; the existing `renderToolActions(part, options)` contract for the `runs` presentation is unchanged.
14
+
15
+ The timeline tool-call summary now shows a human-readable detail (file path / command via `getToolDisplayMetadata`) instead of the raw input JSON, and drops the redundant `title: description` label. The source `ToolPart` is threaded into `ToolCallStep`, so the expanded detail renders the full input + output via `ExpandedToolDetail` (previously the timeline's expanded view showed only the output).
16
+
3
17
  ## 9.0.0
4
18
 
5
19
  ### Major Changes
package/dist/chat.d.ts CHANGED
@@ -32,18 +32,22 @@ interface ChatContainerProps {
32
32
  renderRunActions?: (run: Run) => ReactNode;
33
33
  /** Optional actions rendered below each user message bubble. */
34
34
  renderUserMessageActions?: (message: SessionMessage, parts: SessionPart[]) => ReactNode;
35
- /** Optional actions rendered beside individual tool items. */
35
+ /** Optional actions rendered beside individual tool items in the run-grouped
36
+ * (`runs`) presentation. */
36
37
  renderToolActions?: (part: ToolPart, options: {
37
38
  run: Run;
38
39
  messageId: string;
39
40
  partIndex: number;
40
41
  }) => ReactNode;
42
+ /** Optional actions rendered beside individual tool items in the `timeline`
43
+ * presentation, which has no run/message grouping (hence part-only). */
44
+ renderTimelineToolActions?: (part: ToolPart) => ReactNode;
41
45
  }
42
46
  /**
43
47
  * Chat transcript container: message list + auto-scroll.
44
48
  * Orchestrates useRunGroups, useRunCollapseState, and useAutoScroll.
45
49
  */
46
- declare const ChatContainer: React.MemoExoticComponent<({ messages, partMap, isStreaming, branding, className, renderToolDetail, presentation, onOpenUIAction, enableOpenUI, renderRunActions, renderUserMessageActions, renderToolActions, }: ChatContainerProps) => react_jsx_runtime.JSX.Element>;
50
+ declare const ChatContainer: React.MemoExoticComponent<({ messages, partMap, isStreaming, branding, className, renderToolDetail, presentation, onOpenUIAction, enableOpenUI, renderRunActions, renderUserMessageActions, renderToolActions, renderTimelineToolActions, }: ChatContainerProps) => react_jsx_runtime.JSX.Element>;
47
51
 
48
52
  interface MessageListProps {
49
53
  groups: GroupedMessage[];
@@ -121,12 +125,17 @@ interface AgentTimelineToolItem {
121
125
  id: string;
122
126
  kind: "tool";
123
127
  call: ToolCallData;
128
+ /** Source tool part, so a consumer's `renderToolActions` gets the real
129
+ * input/output (the flat `call` is display-only). */
130
+ part?: ToolPart;
124
131
  }
125
132
  interface AgentTimelineToolGroupItem {
126
133
  id: string;
127
134
  kind: "tool_group";
128
135
  title?: string;
129
136
  calls: ToolCallData[];
137
+ /** Source tool parts, parallel to `calls`. */
138
+ parts?: ToolPart[];
130
139
  }
131
140
  interface AgentTimelineStatusItem {
132
141
  id: string;
@@ -157,12 +166,15 @@ interface AgentTimelineProps {
157
166
  isThinking?: boolean;
158
167
  emptyState?: ReactNode;
159
168
  className?: string;
169
+ /** Optional actions rendered beside each tool item (e.g. "open in artifacts").
170
+ * Receives the source tool part carried on the item. */
171
+ renderToolActions?: (part: ToolPart) => ReactNode;
160
172
  }
161
173
  /**
162
174
  * AgentTimeline — unified mixed-content timeline for agent-backed sandbox
163
175
  * sessions. Renders messages, tool steps, status cards, and artifact handoffs in
164
176
  * a single execution narrative.
165
177
  */
166
- declare function AgentTimeline({ items, isThinking, emptyState, className, }: AgentTimelineProps): react_jsx_runtime.JSX.Element | null;
178
+ declare function AgentTimeline({ items, isThinking, emptyState, className, renderToolActions, }: AgentTimelineProps): react_jsx_runtime.JSX.Element | null;
167
179
 
168
180
  export { AgentTimeline, type AgentTimelineArtifactItem, type AgentTimelineCustomItem, type AgentTimelineItem, type AgentTimelineMessageItem, type AgentTimelineProps, type AgentTimelineStatusItem, type AgentTimelineTone, type AgentTimelineToolGroupItem, type AgentTimelineToolItem, ChatContainer, type ChatContainerProps, ChatMessage, type ChatMessageProps, MessageList, type MessageListProps, type MessageRole, ThinkingIndicator, type ThinkingIndicatorProps, UserMessage, type UserMessageProps };
package/dist/chat.js CHANGED
@@ -5,10 +5,10 @@ import {
5
5
  MessageList,
6
6
  ThinkingIndicator,
7
7
  UserMessage
8
- } from "./chunk-LHOGIUGY.js";
8
+ } from "./chunk-YUY6D62P.js";
9
9
  import "./chunk-AZWDI2JG.js";
10
- import "./chunk-2TRMNB6L.js";
11
- import "./chunk-RKQDBRTC.js";
10
+ import "./chunk-KWRLQ3MR.js";
11
+ import "./chunk-WVW4KHEH.js";
12
12
  import "./chunk-ULDNFLIM.js";
13
13
  import "./chunk-AAUNOHVL.js";
14
14
  import "./chunk-52Y3FMFI.js";
@@ -11,7 +11,7 @@ import {
11
11
  } from "./chunk-OEX7NZE3.js";
12
12
  import {
13
13
  parseToolEvent
14
- } from "./chunk-IWQZXL6A.js";
14
+ } from "./chunk-V6BF4AZ7.js";
15
15
 
16
16
  // src/hooks/use-dropdown-menu.ts
17
17
  import { useEffect, useRef, useState } from "react";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  InlineToolItem,
3
3
  LiveDuration
4
- } from "./chunk-RKQDBRTC.js";
4
+ } from "./chunk-WVW4KHEH.js";
5
5
  import {
6
6
  formatDuration,
7
7
  truncateText
@@ -347,7 +347,7 @@ var RunGroup = memo2(
347
347
  return /* @__PURE__ */ jsxs2(
348
348
  "div",
349
349
  {
350
- className: "overflow-hidden rounded-[22px] border border-[var(--border-subtle)] bg-[var(--bg-root)]",
350
+ className: "overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)]",
351
351
  children: [
352
352
  summary ? /* @__PURE__ */ jsx2("div", { className: "border-b border-[var(--border-subtle)] px-4 py-3 text-sm leading-6 text-foreground", children: summary }) : null,
353
353
  /* @__PURE__ */ jsx2("div", { className: "p-4", children: /* @__PURE__ */ jsx2(OpenUIArtifactRenderer, { schema }) })
@@ -360,7 +360,7 @@ var RunGroup = memo2(
360
360
  return /* @__PURE__ */ jsxs2(
361
361
  "div",
362
362
  {
363
- className: "flex items-center gap-2 rounded-[18px] border border-[var(--border-subtle)] bg-[var(--bg-root)] px-4 py-3 text-sm text-muted-foreground",
363
+ className: "flex items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)] px-4 py-3 text-sm text-muted-foreground",
364
364
  children: [
365
365
  /* @__PURE__ */ jsx2(Loader2, { className: "h-4 w-4 animate-spin text-primary" }),
366
366
  "Building view\u2026"
@@ -423,12 +423,12 @@ var RunGroup = memo2(
423
423
  const schema = extractOpenUISchema(toolPart.state.output);
424
424
  const summary = getOpenUISummary(toolPart.state.output);
425
425
  if (toolPart.state.status === "completed" && schema) {
426
- node = /* @__PURE__ */ jsxs2("div", { className: "overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-card)]", children: [
426
+ node = /* @__PURE__ */ jsxs2("div", { className: "overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)]", children: [
427
427
  summary ? /* @__PURE__ */ jsx2("div", { className: "border-b border-[var(--border-subtle)] px-4 py-3 text-sm leading-6 text-foreground", children: summary }) : null,
428
428
  /* @__PURE__ */ jsx2("div", { className: "p-4", children: /* @__PURE__ */ jsx2(OpenUIArtifactRenderer, { schema }) })
429
429
  ] });
430
430
  } else if (toolPart.state.status === "running") {
431
- node = /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-card)] px-4 py-3 text-sm text-muted-foreground", children: [
431
+ node = /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)] px-4 py-3 text-sm text-muted-foreground", children: [
432
432
  /* @__PURE__ */ jsx2(Loader2, { className: "h-4 w-4 animate-spin text-primary" }),
433
433
  "Building view\u2026"
434
434
  ] });
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ToolCallGroup,
3
3
  ToolCallStep
4
- } from "./chunk-RKQDBRTC.js";
4
+ } from "./chunk-WVW4KHEH.js";
5
5
  import {
6
6
  Markdown
7
7
  } from "./chunk-FJBTCTZM.js";
@@ -227,8 +227,8 @@ var InlineToolItem = memo2(
227
227
  {
228
228
  className: cn(
229
229
  "w-full border text-left transition-colors",
230
- "border-[var(--border-subtle)] bg-card/40 hover:border-border hover:bg-accent/25",
231
- open && "border-border bg-accent/20",
230
+ "border-[var(--border-subtle)] bg-[var(--md3-surface-container)] hover:border-border hover:bg-[var(--md3-surface-container-high)]",
231
+ open && "border-border bg-[var(--md3-surface-container-high)]",
232
232
  shapeClass,
233
233
  className
234
234
  ),
@@ -318,9 +318,11 @@ function ToolCallStep({
318
318
  output,
319
319
  language,
320
320
  duration,
321
- className
321
+ className,
322
+ actions,
323
+ part
322
324
  }) {
323
- const part = {
325
+ const resolvedPart = part ?? {
324
326
  type: "tool",
325
327
  id: `${type}:${label}`,
326
328
  tool: type,
@@ -335,11 +337,12 @@ function ToolCallStep({
335
337
  return /* @__PURE__ */ jsx4(
336
338
  InlineToolItem,
337
339
  {
338
- part,
340
+ part: resolvedPart,
339
341
  title: label,
340
342
  description: detail,
341
343
  className,
342
- renderToolDetail: output ? () => /* @__PURE__ */ jsx4(
344
+ actions,
345
+ renderToolDetail: part ? void 0 : output ? () => /* @__PURE__ */ jsx4(
343
346
  CodeBlock,
344
347
  {
345
348
  code: output,
@@ -6,11 +6,11 @@ import {
6
6
  import {
7
7
  InlineThinkingItem,
8
8
  RunGroup
9
- } from "./chunk-2TRMNB6L.js";
9
+ } from "./chunk-KWRLQ3MR.js";
10
10
  import {
11
11
  ToolCallGroup,
12
12
  ToolCallStep
13
- } from "./chunk-RKQDBRTC.js";
13
+ } from "./chunk-WVW4KHEH.js";
14
14
  import {
15
15
  getToolDisplayMetadata
16
16
  } from "./chunk-ULDNFLIM.js";
@@ -233,7 +233,8 @@ function AgentTimeline({
233
233
  items,
234
234
  isThinking,
235
235
  emptyState,
236
- className
236
+ className,
237
+ renderToolActions
237
238
  }) {
238
239
  if (items.length === 0 && !isThinking) {
239
240
  return emptyState ? /* @__PURE__ */ jsx4("div", { className: cn("flex h-full items-center justify-center p-4", className), children: emptyState }) : null;
@@ -258,23 +259,30 @@ function AgentTimeline({
258
259
  status: item.call.status,
259
260
  detail: item.call.detail,
260
261
  output: item.call.output,
261
- duration: item.call.duration
262
+ duration: item.call.duration,
263
+ part: item.part,
264
+ actions: item.part ? renderToolActions?.(item.part) : void 0
262
265
  }
263
266
  ) }, item.id);
264
267
  }
265
268
  if (item.kind === "tool_group") {
266
- return /* @__PURE__ */ jsx4(AgentTimelineRow, { isLast, accentClassName: "bg-[var(--border-hover)]", children: /* @__PURE__ */ jsx4(ToolCallGroup, { title: item.title, children: item.calls.map((call) => /* @__PURE__ */ jsx4(
267
- ToolCallStep,
268
- {
269
- type: call.type,
270
- label: call.label,
271
- status: call.status,
272
- detail: call.detail,
273
- output: call.output,
274
- duration: call.duration
275
- },
276
- call.id
277
- )) }) }, item.id);
269
+ return /* @__PURE__ */ jsx4(AgentTimelineRow, { isLast, accentClassName: "bg-[var(--border-hover)]", children: /* @__PURE__ */ jsx4(ToolCallGroup, { title: item.title, children: item.calls.map((call, callIndex) => {
270
+ const part = item.parts?.[callIndex];
271
+ return /* @__PURE__ */ jsx4(
272
+ ToolCallStep,
273
+ {
274
+ type: call.type,
275
+ label: call.label,
276
+ status: call.status,
277
+ detail: call.detail,
278
+ output: call.output,
279
+ duration: call.duration,
280
+ part,
281
+ actions: part ? renderToolActions?.(part) : void 0
282
+ },
283
+ call.id
284
+ );
285
+ }) }) }, item.id);
278
286
  }
279
287
  if (item.kind === "status") {
280
288
  return /* @__PURE__ */ jsx4(
@@ -407,9 +415,11 @@ function buildTimelineItems(messages, partMap, isStreaming, onOpenUIAction, enab
407
415
  return {
408
416
  id: part.id,
409
417
  type: mapToolPartToTimelineType(part),
410
- label: meta.description ? `${meta.title}: ${meta.description}` : meta.title,
418
+ label: meta.title,
411
419
  status: part.state.status === "completed" ? "success" : part.state.status === "error" ? "error" : "running",
412
- detail: formatUnknown(part.state.input),
420
+ // A human-readable summary (file path / command), not the raw input JSON —
421
+ // the full input still renders in the expanded ExpandedToolDetail.
422
+ detail: meta.commandSnippet ?? meta.targetPath ?? meta.description,
413
423
  output: formatUnknown(part.state.output),
414
424
  duration: start && end ? end - start : void 0
415
425
  };
@@ -435,13 +445,15 @@ function buildTimelineItems(messages, partMap, isStreaming, onOpenUIAction, enab
435
445
  items.push({
436
446
  id: `${message.id}-tool-${toolBuffer[0].id}`,
437
447
  kind: "tool",
438
- call: toToolCall(toolBuffer[0])
448
+ call: toToolCall(toolBuffer[0]),
449
+ part: toolBuffer[0]
439
450
  });
440
451
  } else {
441
452
  items.push({
442
453
  id: `${message.id}-tool-group-${index}`,
443
454
  kind: "tool_group",
444
455
  title: "Tool activity",
456
+ parts: [...toolBuffer],
445
457
  calls: toolBuffer.map((part) => toToolCall(part))
446
458
  });
447
459
  }
@@ -540,7 +552,8 @@ var ChatContainer = memo3(
540
552
  enableOpenUI = true,
541
553
  renderRunActions,
542
554
  renderUserMessageActions,
543
- renderToolActions
555
+ renderToolActions,
556
+ renderTimelineToolActions
544
557
  }) => {
545
558
  const scrollRef = useRef(null);
546
559
  const groups = useRunGroups({ messages, partMap, isStreaming });
@@ -561,7 +574,14 @@ var ChatContainer = memo3(
561
574
  {
562
575
  ref: scrollRef,
563
576
  className: "flex-1 overflow-y-auto [scrollbar-gutter:stable]",
564
- children: messages.length === 0 ? /* @__PURE__ */ jsx5("div", { className: "flex h-full items-center justify-center", children: /* @__PURE__ */ jsx5("div", { className: "max-w-md text-center", children: /* @__PURE__ */ jsx5("div", { className: "text-sm font-medium text-muted-foreground", children: "Start a conversation." }) }) }) : presentation === "timeline" ? /* @__PURE__ */ jsx5("div", { className: "mx-auto flex min-h-full w-full max-w-3xl flex-col justify-end", children: /* @__PURE__ */ jsx5(AgentTimeline, { items: timeline.items, isThinking: timeline.showThinking }) }) : /* @__PURE__ */ jsx5("div", { className: "mx-auto flex min-h-full w-full max-w-3xl flex-col justify-end", children: /* @__PURE__ */ jsx5(
577
+ children: messages.length === 0 ? /* @__PURE__ */ jsx5("div", { className: "flex h-full items-center justify-center", children: /* @__PURE__ */ jsx5("div", { className: "max-w-md text-center", children: /* @__PURE__ */ jsx5("div", { className: "text-sm font-medium text-muted-foreground", children: "Start a conversation." }) }) }) : presentation === "timeline" ? /* @__PURE__ */ jsx5("div", { className: "mx-auto flex min-h-full w-full max-w-3xl flex-col justify-end", children: /* @__PURE__ */ jsx5(
578
+ AgentTimeline,
579
+ {
580
+ items: timeline.items,
581
+ isThinking: timeline.showThinking,
582
+ renderToolActions: renderTimelineToolActions
583
+ }
584
+ ) }) : /* @__PURE__ */ jsx5("div", { className: "mx-auto flex min-h-full w-full max-w-3xl flex-col justify-end", children: /* @__PURE__ */ jsx5(
565
585
  MessageList,
566
586
  {
567
587
  groups,
package/dist/hooks.js CHANGED
@@ -11,15 +11,15 @@ import {
11
11
  useSSEStream,
12
12
  useSdkSession,
13
13
  useToolCallStream
14
- } from "./chunk-DLSGUNRD.js";
14
+ } from "./chunk-IVQYFG5D.js";
15
15
  import "./chunk-OEX7NZE3.js";
16
16
  import {
17
17
  useAutoScroll,
18
18
  useRunCollapseState,
19
19
  useRunGroups
20
20
  } from "./chunk-AZWDI2JG.js";
21
- import "./chunk-IWQZXL6A.js";
22
- import "./chunk-RKQDBRTC.js";
21
+ import "./chunk-V6BF4AZ7.js";
22
+ import "./chunk-WVW4KHEH.js";
23
23
  import "./chunk-ULDNFLIM.js";
24
24
  import "./chunk-AAUNOHVL.js";
25
25
  import "./chunk-ZRVH3WCA.js";
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  useSSEStream,
18
18
  useSdkSession,
19
19
  useToolCallStream
20
- } from "./chunk-DLSGUNRD.js";
20
+ } from "./chunk-IVQYFG5D.js";
21
21
  import {
22
22
  addMessage,
23
23
  addParts,
@@ -137,7 +137,7 @@ import {
137
137
  MessageList,
138
138
  ThinkingIndicator,
139
139
  UserMessage
140
- } from "./chunk-LHOGIUGY.js";
140
+ } from "./chunk-YUY6D62P.js";
141
141
  import {
142
142
  useAutoScroll,
143
143
  useRunCollapseState,
@@ -147,16 +147,16 @@ import "./chunk-47XH56SV.js";
147
147
  import {
148
148
  ToolCallFeed,
149
149
  parseToolEvent
150
- } from "./chunk-IWQZXL6A.js";
150
+ } from "./chunk-V6BF4AZ7.js";
151
151
  import {
152
152
  InlineThinkingItem,
153
153
  RunGroup
154
- } from "./chunk-2TRMNB6L.js";
154
+ } from "./chunk-KWRLQ3MR.js";
155
155
  import {
156
156
  ExpandedToolDetail,
157
157
  InlineToolItem,
158
158
  LiveDuration
159
- } from "./chunk-RKQDBRTC.js";
159
+ } from "./chunk-WVW4KHEH.js";
160
160
  import {
161
161
  TOOL_CATEGORY_ICONS,
162
162
  formatBytes,
package/dist/run.js CHANGED
@@ -2,16 +2,16 @@ import "./chunk-47XH56SV.js";
2
2
  import {
3
3
  ToolCallFeed,
4
4
  parseToolEvent
5
- } from "./chunk-IWQZXL6A.js";
5
+ } from "./chunk-V6BF4AZ7.js";
6
6
  import {
7
7
  InlineThinkingItem,
8
8
  RunGroup
9
- } from "./chunk-2TRMNB6L.js";
9
+ } from "./chunk-KWRLQ3MR.js";
10
10
  import {
11
11
  ExpandedToolDetail,
12
12
  InlineToolItem,
13
13
  LiveDuration
14
- } from "./chunk-RKQDBRTC.js";
14
+ } from "./chunk-WVW4KHEH.js";
15
15
  import "./chunk-ULDNFLIM.js";
16
16
  import "./chunk-AAUNOHVL.js";
17
17
  import "./chunk-52Y3FMFI.js";
package/dist/sdk-hooks.js CHANGED
@@ -5,15 +5,15 @@ import {
5
5
  useSSEStream,
6
6
  useSdkSession,
7
7
  useToolCallStream
8
- } from "./chunk-DLSGUNRD.js";
8
+ } from "./chunk-IVQYFG5D.js";
9
9
  import "./chunk-OEX7NZE3.js";
10
10
  import {
11
11
  useAutoScroll,
12
12
  useRunCollapseState,
13
13
  useRunGroups
14
14
  } from "./chunk-AZWDI2JG.js";
15
- import "./chunk-IWQZXL6A.js";
16
- import "./chunk-RKQDBRTC.js";
15
+ import "./chunk-V6BF4AZ7.js";
16
+ import "./chunk-WVW4KHEH.js";
17
17
  import "./chunk-ULDNFLIM.js";
18
18
  import "./chunk-AAUNOHVL.js";
19
19
  import "./chunk-ZRVH3WCA.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "9.0.0",
3
+ "version": "9.1.1",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ })
@@ -12,6 +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 type { ToolPart } from "../types/parts";
15
16
 
16
17
  export type AgentTimelineTone = "default" | "info" | "success" | "warning" | "error";
17
18
 
@@ -30,6 +31,9 @@ export interface AgentTimelineToolItem {
30
31
  id: string;
31
32
  kind: "tool";
32
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;
33
37
  }
34
38
 
35
39
  export interface AgentTimelineToolGroupItem {
@@ -37,6 +41,8 @@ export interface AgentTimelineToolGroupItem {
37
41
  kind: "tool_group";
38
42
  title?: string;
39
43
  calls: ToolCallData[];
44
+ /** Source tool parts, parallel to `calls`. */
45
+ parts?: ToolPart[];
40
46
  }
41
47
 
42
48
  export interface AgentTimelineStatusItem {
@@ -78,6 +84,9 @@ export interface AgentTimelineProps {
78
84
  isThinking?: boolean;
79
85
  emptyState?: ReactNode;
80
86
  className?: string;
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;
81
90
  }
82
91
 
83
92
  const TONE_STYLES: Record<AgentTimelineTone, { dot: string; card: string; text: string; icon: typeof Info }> = {
@@ -253,6 +262,7 @@ export function AgentTimeline({
253
262
  isThinking,
254
263
  emptyState,
255
264
  className,
265
+ renderToolActions,
256
266
  }: AgentTimelineProps) {
257
267
  if (items.length === 0 && !isThinking) {
258
268
  return emptyState ? (
@@ -299,6 +309,8 @@ export function AgentTimeline({
299
309
  detail={item.call.detail}
300
310
  output={item.call.output}
301
311
  duration={item.call.duration}
312
+ part={item.part}
313
+ actions={item.part ? renderToolActions?.(item.part) : undefined}
302
314
  />
303
315
  </AgentTimelineRow>
304
316
  );
@@ -308,17 +320,22 @@ export function AgentTimeline({
308
320
  return (
309
321
  <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
310
322
  <ToolCallGroup title={item.title}>
311
- {item.calls.map((call) => (
312
- <ToolCallStep
313
- key={call.id}
314
- type={call.type}
315
- label={call.label}
316
- status={call.status}
317
- detail={call.detail}
318
- output={call.output}
319
- duration={call.duration}
320
- />
321
- ))}
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
+ })}
322
339
  </ToolCallGroup>
323
340
  </AgentTimelineRow>
324
341
  );
@@ -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.description ? `${meta.title}: ${meta.description}` : meta.title,
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
- detail: formatUnknown(part.state.input),
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 items={timeline.items} isThinking={timeline.showThinking} />
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">
@@ -109,8 +109,8 @@ export const InlineToolItem = memo(
109
109
  <button
110
110
  className={cn(
111
111
  "w-full border text-left transition-colors",
112
- "border-[var(--border-subtle)] bg-card/40 hover:border-border hover:bg-accent/25",
113
- open && "border-border bg-accent/20",
112
+ "border-[var(--border-subtle)] bg-[var(--md3-surface-container)] hover:border-border hover:bg-[var(--md3-surface-container-high)]",
113
+ open && "border-border bg-[var(--md3-surface-container-high)]",
114
114
  shapeClass,
115
115
  className,
116
116
  )}
@@ -366,7 +366,7 @@ export const RunGroup = memo(
366
366
  return (
367
367
  <div
368
368
  key={key}
369
- className="overflow-hidden rounded-[22px] border border-[var(--border-subtle)] bg-[var(--bg-root)]"
369
+ className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)]"
370
370
  >
371
371
  {summary ? (
372
372
  <div className="border-b border-[var(--border-subtle)] px-4 py-3 text-sm leading-6 text-foreground">
@@ -384,7 +384,7 @@ export const RunGroup = memo(
384
384
  return (
385
385
  <div
386
386
  key={key}
387
- className="flex items-center gap-2 rounded-[18px] border border-[var(--border-subtle)] bg-[var(--bg-root)] px-4 py-3 text-sm text-muted-foreground"
387
+ className="flex items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)] px-4 py-3 text-sm text-muted-foreground"
388
388
  >
389
389
  <Loader2 className="h-4 w-4 animate-spin text-primary" />
390
390
  Building view…
@@ -493,7 +493,7 @@ export const RunGroup = memo(
493
493
 
494
494
  if (toolPart.state.status === "completed" && schema) {
495
495
  node = (
496
- <div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-card)]">
496
+ <div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)]">
497
497
  {summary ? (
498
498
  <div className="border-b border-[var(--border-subtle)] px-4 py-3 text-sm leading-6 text-foreground">
499
499
  {summary}
@@ -506,7 +506,7 @@ export const RunGroup = memo(
506
506
  );
507
507
  } else if (toolPart.state.status === "running") {
508
508
  node = (
509
- <div className="flex items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-card)] px-4 py-3 text-sm text-muted-foreground">
509
+ <div className="flex items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)] px-4 py-3 text-sm text-muted-foreground">
510
510
  <Loader2 className="h-4 w-4 animate-spin text-primary" />
511
511
  Building view…
512
512
  </div>
@@ -36,6 +36,11 @@ export interface ToolCallStepProps {
36
36
  language?: string;
37
37
  duration?: number;
38
38
  className?: string;
39
+ /** Actions rendered beside the row (e.g. "open in artifacts"). */
40
+ actions?: ReactNode;
41
+ /** Source tool part. When provided, the expanded detail renders from the real
42
+ * input/output (via ExpandedToolDetail) rather than the flat summary props. */
43
+ part?: ToolPart;
39
44
  }
40
45
 
41
46
  const EXT_LANGUAGE: Record<string, string> = {
@@ -77,8 +82,12 @@ export function ToolCallStep({
77
82
  language,
78
83
  duration,
79
84
  className,
85
+ actions,
86
+ part,
80
87
  }: ToolCallStepProps) {
81
- const part: ToolPart = {
88
+ // Fall back to a synthesized part for callers that only have flat props
89
+ // (e.g. ToolCallFeed); the real part, when supplied, drives the expanded view.
90
+ const resolvedPart: ToolPart = part ?? {
82
91
  type: "tool",
83
92
  id: `${type}:${label}`,
84
93
  tool: type,
@@ -94,20 +103,26 @@ export function ToolCallStep({
94
103
 
95
104
  return (
96
105
  <InlineToolItem
97
- part={part}
106
+ part={resolvedPart}
98
107
  title={label}
99
108
  description={detail}
100
109
  className={className}
110
+ actions={actions}
111
+ // With a real part, InlineToolItem's default ExpandedToolDetail renders
112
+ // the full input + output. The synthesized-part path keeps the output-only
113
+ // fallback (it has no real input to show).
101
114
  renderToolDetail={
102
- output
103
- ? () => (
104
- <CodeBlock
105
- code={output}
106
- language={lang}
107
- className="max-h-72 overflow-auto text-xs"
108
- />
109
- )
110
- : () => null
115
+ part
116
+ ? undefined
117
+ : output
118
+ ? () => (
119
+ <CodeBlock
120
+ code={output}
121
+ language={lang}
122
+ className="max-h-72 overflow-auto text-xs"
123
+ />
124
+ )
125
+ : () => null
111
126
  }
112
127
  />
113
128
  );