@tangle-network/ui 11.6.0 → 11.8.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 +12 -0
- package/README.md +43 -0
- package/dist/chat.d.ts +18 -8
- package/dist/chat.js +9 -7
- package/dist/chunk-4CLN43XT.js +45 -0
- package/dist/{chunk-AZWDI2JG.js → chunk-565EATZZ.js} +1 -1
- package/dist/{chunk-XROQW5J7.js → chunk-BWOUSEHM.js} +1 -1
- package/dist/{chunk-J2G7KCYH.js → chunk-KNYSKNZX.js} +113 -69
- package/dist/chunk-L57WRIND.js +59 -0
- package/dist/{chunk-ULDNFLIM.js → chunk-ND3ZFXQK.js} +11 -58
- package/dist/{chunk-AAUNOHVL.js → chunk-NWECTSOH.js} +1 -1
- package/dist/{chunk-R5GVQDL3.js → chunk-OXZRCXJK.js} +9 -7
- package/dist/{chunk-XPYPZMYT.js → chunk-Q7ZR6LSD.js} +1 -1
- package/dist/{chunk-RKC3GXHE.js → chunk-X53RZSX6.js} +99 -104
- package/dist/{chunk-PLWRAS5Z.js → chunk-XGOWUDDW.js} +270 -106
- package/dist/{chunk-ZRVH3WCA.js → chunk-XU3HB5QL.js} +21 -1
- package/dist/chunk-YTO5V6QS.js +19 -0
- package/dist/files.d.ts +41 -12
- package/dist/files.js +13 -5
- package/dist/hooks.js +9 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +37 -24
- package/dist/primitives.d.ts +15 -1
- package/dist/primitives.js +5 -1
- package/dist/run.d.ts +22 -17
- package/dist/run.js +11 -8
- package/dist/sdk-hooks.js +9 -7
- package/dist/tool-previews.js +2 -2
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +6 -4
- 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/files/file-artifact-pane.stories.tsx +149 -0
- package/src/files/file-artifact-pane.test.tsx +192 -0
- package/src/files/file-artifact-pane.tsx +6 -1
- package/src/files/file-format.ts +42 -4
- package/src/files/file-preview-kind.test.ts +109 -0
- package/src/files/file-preview.tsx +315 -132
- package/src/files/index.ts +8 -1
- 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
|
@@ -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
|
+
})
|
|
@@ -17,9 +17,11 @@ function formatTime(date: Date): string {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* The single user message bubble — a quiet, right-aligned
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* The single user message bubble — a quiet, right-aligned fill on the muted
|
|
21
|
+
* surface (no border, no loud fill, no uppercase label). The timestamp sits
|
|
22
|
+
* beside the bubble and appears on hover, so it costs no height. Used by both
|
|
23
|
+
* the run/message list (session-model `parts`) and AgentTimeline (direct
|
|
24
|
+
* `content`/`timestamp`).
|
|
23
25
|
*/
|
|
24
26
|
export const UserMessage = memo(
|
|
25
27
|
({ message: _message, parts, content, timestamp, actions }: UserMessageProps) => {
|
|
@@ -34,14 +36,17 @@ export const UserMessage = memo(
|
|
|
34
36
|
|
|
35
37
|
return (
|
|
36
38
|
<div className="flex justify-end">
|
|
37
|
-
<div className="flex max-w-[78%] flex-col items-end gap-2">
|
|
38
|
-
<div className="w-full rounded-2xl
|
|
39
|
+
<div className="group flex max-w-[78%] flex-col items-end gap-2">
|
|
40
|
+
<div className="relative w-full rounded-2xl bg-muted/50 px-4 py-3">
|
|
39
41
|
{timestamp ? (
|
|
40
|
-
<
|
|
42
|
+
<span
|
|
43
|
+
data-user-message-time=""
|
|
44
|
+
className="pointer-events-none absolute right-full top-1/2 mr-3 -translate-y-1/2 whitespace-nowrap text-[var(--font-size-xs)] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
|
45
|
+
>
|
|
41
46
|
{formatTime(timestamp)}
|
|
42
|
-
</
|
|
47
|
+
</span>
|
|
43
48
|
) : null}
|
|
44
|
-
<div className="whitespace-pre-wrap text-[var(--font-size-base)] leading-[
|
|
49
|
+
<div className="whitespace-pre-wrap text-[var(--font-size-base)] leading-[1.5] text-foreground">
|
|
45
50
|
{text}
|
|
46
51
|
</div>
|
|
47
52
|
</div>
|