@tangle-network/ui 8.1.0 → 9.1.0

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