Compare commits

...

21 Commits

Author SHA1 Message Date
Michael Suchacz 75682b351e fix(site): lighter tool cards for simple status tools 2026-03-20 23:31:43 +00:00
Michael Suchacz 4d1d77b68a fix(site): collapse thinking blocks into inline disclosure 2026-03-20 23:16:10 +00:00
Michael Suchacz 42e28bc89b fix(site): remove role labels, turn separators, and right chevron 2026-03-20 23:01:11 +00:00
Michael Suchacz 2d8efef968 fix(site): boost tool header contrast and add right collapse chevron 2026-03-20 22:45:00 +00:00
Michael Suchacz 176e02befc fix(site/src/components/ai-elements/tool): rich path display in EditFilesTool 2026-03-20 21:51:04 +00:00
Michael Suchacz 271ac680f0 fix(site/src/components/ai-elements/tool): rich path display in WriteFileTool 2026-03-20 21:51:04 +00:00
Michael Suchacz 972dd5c160 refactor(site): update ToolCollapsible chrome 2026-03-20 21:51:04 +00:00
Michael Suchacz a5768f60e3 feat(site/src/components/ai-elements/tool): add splitPath, computeDiffStats helpers and CSS overrides 2026-03-20 21:51:04 +00:00
Michael Suchacz ae98b899b6 fix(site): show deleted+modified lines in diff story fixture 2026-03-20 21:04:58 +00:00
Michael Suchacz 16ee6b72f9 fix(site): use realistic multi-line diff fixture in stories 2026-03-20 20:59:09 +00:00
Michael Suchacz 7c3b828e00 fix(site): clean tool headers — remove wrench icon and duplicate file paths 2026-03-20 20:49:34 +00:00
Michael Suchacz 675ce98fd4 fix(site): seamless tool card styling — remove double borders 2026-03-20 20:34:45 +00:00
Michael Suchacz d61b69af7e fix(site): slim down code block header and copy button 2026-03-20 20:19:35 +00:00
Michael Suchacz 00b13cfe2d feat(site): visual polish — line numbers, scroll, separators, role icons, tool cards 2026-03-20 20:08:48 +00:00
Michael Suchacz fdc8f88971 refactor(site): align thinking.tsx padding with blockShell convention 2026-03-20 18:35:00 +00:00
Michael Suchacz 4a9403fda9 feat(site): harmonize transcript tool, reasoning, and source blocks 2026-03-20 18:27:21 +00:00
Michael Suchacz 91c27cb5de feat(site): upgrade transcript code block and markdown rendering 2026-03-20 18:27:11 +00:00
Michael Suchacz 2d0f560a3f feat(site/src): share agent transcript turn chrome 2026-03-20 18:15:06 +00:00
Michael Suchacz 4902e0ecda feat(site): expand ConversationTimeline storybook coverage 2026-03-20 18:14:55 +00:00
Michael Suchacz 7acc0dd338 feat(site): expand ai-elements conversation storybook coverage 2026-03-20 18:14:55 +00:00
Michael Suchacz f5961923d4 feat(site): expand StreamingOutput storybook coverage 2026-03-20 18:14:55 +00:00
23 changed files with 2055 additions and 295 deletions
@@ -45,7 +45,8 @@
"envs": [
{
"name": "DEVCONTAINER_ENV",
"value": "devcontainer-value"
"value": "devcontainer-value",
"merge_strategy": "replace"
}
]
}
@@ -48,7 +48,8 @@
"envs": [
{
"name": "DEVCONTAINER_ENV",
"value": "devcontainer-value"
"value": "devcontainer-value",
"merge_strategy": "replace"
}
]
}
@@ -83,6 +83,7 @@
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 1,
"values": {
"merge_strategy": "replace",
"name": "DEVCONTAINER_ENV",
"value": "devcontainer-value"
},
@@ -243,6 +244,7 @@
],
"before": null,
"after": {
"merge_strategy": "replace",
"name": "DEVCONTAINER_ENV",
"value": "devcontainer-value"
},
@@ -111,6 +111,7 @@
"values": {
"agent_id": "b4db82a1-1cba-4d97-8893-cf2ca9a9fe1a",
"id": "0982d946-8a12-423a-a316-d4263f94a124",
"merge_strategy": "replace",
"name": "DEVCONTAINER_ENV",
"value": "devcontainer-value"
},
@@ -21,11 +21,13 @@
"extra_envs": [
{
"name": "ENV_1",
"value": "Env 1"
"value": "Env 1",
"merge_strategy": "replace"
},
{
"name": "ENV_2",
"value": "Env 2"
"value": "Env 2",
"merge_strategy": "replace"
}
],
"resources_monitoring": {},
@@ -54,7 +56,8 @@
"extra_envs": [
{
"name": "ENV_3",
"value": "Env 3"
"value": "Env 3",
"merge_strategy": "replace"
}
],
"resources_monitoring": {},
@@ -22,11 +22,13 @@
"extra_envs": [
{
"name": "ENV_1",
"value": "Env 1"
"value": "Env 1",
"merge_strategy": "replace"
},
{
"name": "ENV_2",
"value": "Env 2"
"value": "Env 2",
"merge_strategy": "replace"
}
],
"resources_monitoring": {},
@@ -56,7 +58,8 @@
"extra_envs": [
{
"name": "ENV_3",
"value": "Env 3"
"value": "Env 3",
"merge_strategy": "replace"
}
],
"resources_monitoring": {},
@@ -74,6 +74,7 @@
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 1,
"values": {
"merge_strategy": "replace",
"name": "ENV_1",
"value": "Env 1"
},
@@ -87,6 +88,7 @@
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 1,
"values": {
"merge_strategy": "replace",
"name": "ENV_2",
"value": "Env 2"
},
@@ -100,6 +102,7 @@
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 1,
"values": {
"merge_strategy": "replace",
"name": "ENV_3",
"value": "Env 3"
},
@@ -235,6 +238,7 @@
],
"before": null,
"after": {
"merge_strategy": "replace",
"name": "ENV_1",
"value": "Env 1"
},
@@ -258,6 +262,7 @@
],
"before": null,
"after": {
"merge_strategy": "replace",
"name": "ENV_2",
"value": "Env 2"
},
@@ -281,6 +286,7 @@
],
"before": null,
"after": {
"merge_strategy": "replace",
"name": "ENV_3",
"value": "Env 3"
},
@@ -104,6 +104,7 @@
"values": {
"agent_id": "fac6034b-1d42-4407-b266-265e35795241",
"id": "fd793e28-41fb-4d56-8b22-6a4ad905245a",
"merge_strategy": "replace",
"name": "ENV_1",
"value": "Env 1"
},
@@ -122,6 +123,7 @@
"values": {
"agent_id": "fac6034b-1d42-4407-b266-265e35795241",
"id": "809a9f24-48c9-4192-8476-31bca05f2545",
"merge_strategy": "replace",
"name": "ENV_2",
"value": "Env 2"
},
@@ -140,6 +142,7 @@
"values": {
"agent_id": "a02262af-b94b-4d6d-98ec-6e36b775e328",
"id": "cb8f717f-0654-48a7-939b-84936be0096d",
"merge_strategy": "replace",
"name": "ENV_3",
"value": "Env 3"
},
@@ -73,3 +73,144 @@ export const LoadingState: Story = {
);
},
};
export const MultipleConsecutiveMessages: Story = {
render: () => {
const userItemProps = { role: "user" as const };
const assistantItemProps = { role: "assistant" as const };
return (
<Conversation>
<ConversationItem {...userItemProps}>
<Message className="my-2 w-full max-w-none">
<MessageContent className="rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm">
Why is the API returning 500 errors?
</MessageContent>
</Message>
</ConversationItem>
<ConversationItem {...assistantItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
<div className="text-sm text-content-primary">
Let me check the server logs. It looks like the database
connection pool is exhausted.
</div>
</div>
</MessageContent>
</Message>
</ConversationItem>
<ConversationItem {...userItemProps}>
<Message className="my-2 w-full max-w-none">
<MessageContent className="rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm">
How do I increase the pool size?
</MessageContent>
</Message>
</ConversationItem>
<ConversationItem {...assistantItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
<div className="text-sm text-content-primary">
You can update the <code>DB_MAX_CONNECTIONS</code>
environment variable in your deployment config. The default is
25 connections.
</div>
</div>
</MessageContent>
</Message>
</ConversationItem>
<ConversationItem {...userItemProps}>
<Message className="my-2 w-full max-w-none">
<MessageContent className="rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm">
Done. Should I restart the service?
</MessageContent>
</Message>
</ConversationItem>
<ConversationItem {...assistantItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
<div className="text-sm text-content-primary">
Yes, restart the coderd service for the change to take effect.
</div>
</div>
</MessageContent>
</Message>
</ConversationItem>
</Conversation>
);
},
};
export const MixedContentTypes: Story = {
render: () => {
const userItemProps = { role: "user" as const };
const assistantItemProps = { role: "assistant" as const };
return (
<Conversation>
<ConversationItem {...userItemProps}>
<Message className="my-2 w-full max-w-none">
<MessageContent className="rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm">
Can you help diagnose this deployment issue?
</MessageContent>
</Message>
</ConversationItem>
<ConversationItem {...assistantItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
<Thinking>
Reviewing the latest rollout events and recent health checks
before suggesting the next step.
</Thinking>
<div className="text-sm text-content-primary">
The new replica started, but it never passed readiness. The
probe is timing out before the app finishes booting.
</div>
</div>
</MessageContent>
</Message>
</ConversationItem>
<ConversationItem {...userItemProps}>
<Message className="my-2 w-full max-w-none">
<MessageContent className="rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm">
The app does a schema check on startup and warms the cache before
serving traffic. Should I raise the readiness timeout, or is there
a better way to confirm the boot sequence is actually healthy?
</MessageContent>
</Message>
</ConversationItem>
<ConversationItem {...assistantItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
<div className="text-sm text-content-primary">
Start by increasing the readiness timeout so the probe matches
the real startup path. That prevents the rollout controller
from recycling pods that are still initializing.
</div>
<div className="text-sm text-content-primary">
After that, add a lightweight health endpoint that checks
dependencies without running the full warm-up routine. That
gives you a faster signal that the process is alive while you
keep readiness focused on serving traffic safely.
</div>
</div>
</MessageContent>
</Message>
</ConversationItem>
<ConversationItem {...assistantItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<Shimmer as="span" className="text-sm">
Drafting rollout recommendations...
</Shimmer>
</MessageContent>
</Message>
</ConversationItem>
</Conversation>
);
},
};
+5 -1
View File
@@ -5,7 +5,11 @@ type MessageProps = ComponentPropsWithRef<"div">;
export const Message = ({ className, ref, ...props }: MessageProps) => {
return (
<div ref={ref} className={cn("max-w-full min-w-0", className)} {...props} />
<div
ref={ref}
className={cn("flex flex-col max-w-full min-w-0", className)}
{...props}
/>
);
};
+166 -25
View File
@@ -3,8 +3,9 @@ import {
File as FileViewer,
type SupportedLanguages,
} from "@pierre/diffs/react";
import { CheckIcon, ClipboardIcon } from "lucide-react";
import type { ComponentPropsWithRef, ReactNode } from "react";
import { useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
type Components,
defaultRehypePlugins,
@@ -53,10 +54,18 @@ type MarkdownComponentProps = {
type?: string;
checked?: boolean;
disabled?: boolean;
className?: string;
className?: string[] | string;
};
type FileViewerThemeType = "light" | "dark";
type FileViewerThemeName = (typeof fileViewerTheme)[FileViewerThemeType];
type CodeBlockProps = {
content: string;
lang: string;
fileViewerThemeType: FileViewerThemeType;
viewerTheme: FileViewerThemeName;
};
/**
* Recursively extracts text from a HAST node tree. This is plain
@@ -83,9 +92,94 @@ const getClassNames = (className: string[] | string | undefined): string[] => {
);
};
const CodeBlock = ({
content,
lang,
fileViewerThemeType,
viewerTheme,
}: CodeBlockProps) => {
const [copied, setCopied] = useState(false);
const resetCopiedTimeoutRef = useRef<number | null>(null);
const showHeader = lang !== "text";
useEffect(() => {
return () => {
if (resetCopiedTimeoutRef.current !== null) {
window.clearTimeout(resetCopiedTimeoutRef.current);
}
};
}, []);
const handleCopy = async () => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
return;
}
try {
await navigator.clipboard.writeText(content);
setCopied(true);
if (resetCopiedTimeoutRef.current !== null) {
window.clearTimeout(resetCopiedTimeoutRef.current);
}
resetCopiedTimeoutRef.current = window.setTimeout(() => {
setCopied(false);
}, 1500);
} catch {
setCopied(false);
}
};
return (
<div className="group/code my-4 overflow-hidden rounded-lg border border-solid border-border-default/50 text-2xs">
{showHeader && (
<div className="flex items-center justify-between px-3 pt-2 pb-0">
<span className="text-2xs font-medium text-content-secondary/60">
{lang}
</span>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-2xs text-content-secondary/60 opacity-0 transition-opacity group-hover/code:opacity-100 hover:text-content-primary"
aria-label={
copied ? "Code copied to clipboard" : `Copy ${lang} code block`
}
>
{copied ? (
<>
<CheckIcon className="size-3" />
<span>Copied</span>
</>
) : (
<ClipboardIcon className="size-3" />
)}
</button>
</div>
)}
<div className="max-h-96 overflow-auto">
<FileViewer
file={{
name: `block.${lang}`,
lang: lang as SupportedLanguages,
contents: content,
cacheKey: content,
}}
options={{
overflow: "scroll",
themeType: fileViewerThemeType,
disableFileHeader: true,
disableLineNumbers: content.split("\n").length <= 3,
theme: viewerTheme,
unsafeCSS: fileViewerCSS,
}}
/>
</div>
</div>
);
};
const createComponents = (
fileViewerThemeType: FileViewerThemeType,
viewerTheme: (typeof fileViewerTheme)[FileViewerThemeType],
viewerTheme: FileViewerThemeName,
): Components => {
return {
a: ({ href, children }: MarkdownComponentProps) => (
@@ -130,6 +224,54 @@ const createComponents = (
{children}
</h6>
),
blockquote: ({ children }: MarkdownComponentProps) => (
<blockquote className="mx-0 my-3 border-l-2 border-content-secondary/30 pl-3 text-content-secondary [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{children}
</blockquote>
),
ol: ({ children, className }: MarkdownComponentProps) => (
<ol
className={cn(
"my-3 list-decimal pl-5 marker:text-content-secondary [&>li>ol]:mt-1 [&>li>ul]:mt-1",
"space-y-0.5",
className,
)}
>
{children}
</ol>
),
ul: ({ children, className }: MarkdownComponentProps) => {
const classes = getClassNames(className);
const isTaskList = classes.includes("contains-task-list");
return (
<ul
className={cn(
"my-3 space-y-0.5 [&>li>ol]:mt-1 [&>li>ul]:mt-1",
isTaskList
? "list-none pl-0"
: "list-disc pl-5 marker:text-content-secondary",
className,
)}
>
{children}
</ul>
);
},
li: ({ children, className }: MarkdownComponentProps) => {
const classes = getClassNames(className);
const isTaskListItem = classes.includes("task-list-item");
return (
<li
className={cn(
"leading-relaxed",
isTaskListItem && "list-none",
className,
)}
>
{children}
</li>
);
},
// GFM task-list checkboxes: render a styled replacement
// for the native <input type="checkbox" disabled> element.
input: ({ type, checked, disabled }: MarkdownComponentProps) => {
@@ -142,7 +284,7 @@ const createComponents = (
className={cn(
"mr-2 inline-flex size-4 shrink-0 items-center justify-center",
"rounded-sm border border-solid",
"align-middle relative -top-px",
"relative -top-px align-middle",
checked
? "border-content-link bg-content-link text-white"
: "border-border-default bg-surface-primary",
@@ -172,15 +314,26 @@ const createComponents = (
hr: () => (
<hr className="my-6 border-0 border-t border-solid border-border-default" />
),
table: ({ children }: MarkdownComponentProps) => (
<div className="my-4 overflow-x-auto">
<table className="w-full border-collapse text-xs">{children}</table>
</div>
),
thead: ({ children }: MarkdownComponentProps) => (
<thead className="bg-surface-secondary">{children}</thead>
),
// Table cells: streamdown defaults to text-sm (14px).
// Drop the explicit size so cells inherit the 13px base.
// Keep transcript tables compact so they read comfortably
// alongside prose and code blocks.
th: ({ children }: MarkdownComponentProps) => (
<th className="whitespace-nowrap px-4 py-2 text-left font-semibold">
<th className="border border-solid border-border-default bg-surface-secondary px-3 py-1.5 text-left text-xs font-medium">
{children}
</th>
),
td: ({ children }: MarkdownComponentProps) => (
<td className="px-4 py-2">{children}</td>
<td className="border border-solid border-border-default px-3 py-1.5 text-xs align-top">
{children}
</td>
),
// Inline code only — fenced blocks are handled by the pre override.
code: ({ children }: MarkdownComponentProps) => (
@@ -201,24 +354,12 @@ const createComponents = (
const content = getHastText(codeChild).trimEnd();
if (content) {
return (
<div className="my-4 overflow-hidden rounded-xl border border-solid border-border-default text-2xs">
<FileViewer
file={{
name: `block.${lang}`,
lang: lang as SupportedLanguages,
contents: content,
cacheKey: content,
}}
options={{
overflow: "scroll",
themeType: fileViewerThemeType,
disableFileHeader: true,
disableLineNumbers: true,
theme: viewerTheme,
unsafeCSS: fileViewerCSS,
}}
/>
</div>
<CodeBlock
content={content}
lang={lang}
fileViewerThemeType={fileViewerThemeType}
viewerTheme={viewerTheme}
/>
);
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const Thinking = ({ className, ref, ...props }: ThinkingProps) => {
<div
ref={ref}
className={cn(
"rounded-lg border border-border bg-surface-primary px-3 py-2 text-xs text-content-secondary",
"rounded-md border-l-2 border-content-secondary/40 bg-surface-secondary/30 pl-3 pr-1 py-1.5 text-[13px] text-content-secondary",
className,
)}
{...props}
@@ -12,9 +12,11 @@ import type React from "react";
import { cn } from "utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
import {
computeDiffStats,
DIFFS_FONT_STYLE,
type EditFilesFileEntry,
getDiffViewerOptions,
splitPath,
type ToolStatus,
} from "./utils";
@@ -34,20 +36,34 @@ export const EditFilesTool: React.FC<{
const isDark = theme.palette.mode === "dark";
const isRunning = status === "running";
const hasDiffs = diffs.some((d) => d !== null);
const isSingleFile = files.length === 1;
const isMultiFile = files.length > 1;
const singleFilePath = isSingleFile ? splitPath(files[0].path) : null;
const singleFileStats = isSingleFile ? computeDiffStats(diffs[0]) : null;
const totalStats = diffs.reduce(
(acc, diff) => {
const stats = computeDiffStats(diff);
return {
additions: acc.additions + stats.additions,
deletions: acc.deletions + stats.deletions,
};
},
{ additions: 0, deletions: 0 },
);
const headerStats = isSingleFile ? singleFileStats : totalStats;
const hasHeaderStats =
headerStats !== null &&
(headerStats.additions > 0 || headerStats.deletions > 0);
let label: string;
if (isRunning) {
if (files.length === 1) {
label = `Editing ${files[0].path.split("/").pop() || files[0].path}`;
} else if (files.length > 1) {
if (isMultiFile) {
label = `Editing ${files.length} files…`;
} else {
label = "Editing files…";
}
} else if (files.length === 1) {
const filename = files[0].path.split("/").pop() || files[0].path;
label = `Edited ${filename}`;
} else if (files.length > 1) {
} else if (isMultiFile) {
label = `Edited ${files.length} files`;
} else {
label = "Edited files";
@@ -60,14 +76,43 @@ export const EditFilesTool: React.FC<{
defaultExpanded
header={
<>
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{label}
</span>
{isSingleFile && singleFilePath ? (
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{isRunning ? "Editing " : "Edited "}
{singleFilePath.directory && (
<span className="opacity-70">{singleFilePath.directory}</span>
)}
<span className="font-semibold">{singleFilePath.filename}</span>
</span>
) : (
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{label}
</span>
)}
{hasHeaderStats && headerStats && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-xs tabular-nums">
{headerStats.additions > 0 && (
<span className="text-content-success">
+{headerStats.additions}
</span>
)}
{headerStats.deletions > 0 && (
<span className="text-content-destructive">
-{headerStats.deletions}
</span>
)}
</span>
)}
{isError && (
<Tooltip>
<TooltipTrigger asChild>
@@ -84,23 +129,80 @@ export const EditFilesTool: React.FC<{
</>
}
>
<div className="mt-1.5 space-y-1.5">
{diffs.map((diff, i) =>
diff ? (
<ScrollArea
key={files[i].path}
className="rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={diff}
options={getDiffViewerOptions(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
) : null,
)}
<div className="space-y-px">
{diffs.map((diff, i) => {
if (!diff) {
return null;
}
const { directory: perFileDir, filename: perFileName } = splitPath(
files[i].path,
);
const perFileStats = computeDiffStats(diff);
const hasPerFileStats =
perFileStats.additions > 0 || perFileStats.deletions > 0;
if (!isMultiFile) {
return (
<ScrollArea
key={files[i].path}
className="text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={diff}
options={{
...getDiffViewerOptions(isDark),
disableFileHeader: true,
}}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
);
}
return (
<div key={files[i].path}>
<div className="flex items-center gap-2 border-t border-border-default/20 bg-surface-tertiary/30 px-3 py-1 text-xs text-content-secondary">
<span className="min-w-0 truncate">
{perFileDir && (
<span className="opacity-70">{perFileDir}</span>
)}
<span className="font-semibold">{perFileName}</span>
</span>
{hasPerFileStats && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 tabular-nums">
{perFileStats.additions > 0 && (
<span className="text-content-success">
+{perFileStats.additions}
</span>
)}
{perFileStats.deletions > 0 && (
<span className="text-content-destructive">
-{perFileStats.deletions}
</span>
)}
</span>
)}
</div>
<ScrollArea
className="text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={diff}
options={{
...getDiffViewerOptions(isDark),
disableFileHeader: true,
}}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
</div>
);
})}
</div>
</ToolCollapsible>
);
@@ -44,7 +44,7 @@ export const ExecuteTool: React.FC<{
};
return (
<div className="group/exec w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
<div className="group/exec w-full overflow-hidden rounded-lg border border-solid border-border-default/40 bg-surface-secondary/30">
{/* Header: $ command + copy button */}
<div className="flex w-full items-center justify-between gap-2 px-2.5 py-0.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
@@ -63,7 +63,7 @@ export const ReadFileTool: React.FC<{
}
>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
className="text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
@@ -21,33 +21,62 @@ export const ToolCollapsible: FC<ToolCollapsibleProps> = ({
headerClassName,
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
const headerContent = (
<>
{hasContent && (
<ChevronDownIcon
className={cn(
"h-3.5 w-3.5 shrink-0 text-content-secondary transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
)}
<div className="min-w-0 flex flex-1 items-center gap-2">{header}</div>
</>
);
const containerClasses = hasContent
? expanded
? "overflow-hidden rounded-lg border border-solid border-border-default/50 bg-surface-secondary/20"
: "rounded-lg border border-solid border-border-default/30 bg-surface-secondary/20"
: "rounded-lg bg-surface-secondary/30";
const headerBg = hasContent
? expanded
? "bg-surface-tertiary"
: "bg-surface-tertiary/50"
: "";
return (
<div className={className}>
<div className={cn(containerClasses, className)}>
{hasContent ? (
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded(!expanded)}
className={cn(
"border-0 bg-transparent p-0 m-0 font-[inherit] text-[inherit] text-left",
"flex w-full items-center gap-2 cursor-pointer",
"m-0 flex w-full cursor-pointer items-center gap-2 border-0 px-3 py-1.5 text-left font-[inherit] text-[inherit] transition-colors hover:bg-surface-tertiary/60",
headerBg,
headerClassName,
)}
>
{header}
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-content-secondary transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
{headerContent}
</button>
) : (
<div className={cn("flex items-center gap-2", headerClassName)}>
{header}
<div
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5",
headerBg,
headerClassName,
)}
>
{headerContent}
</div>
)}
{expanded && hasContent && children}
{expanded && hasContent && (
<div className="border-t border-border-default/20">{children}</div>
)}
</div>
);
};
@@ -1,26 +1,19 @@
import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
import { ExternalLinkIcon } from "lucide-react";
import { type FC, useMemo } from "react";
import { cn } from "utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
interface WebSearchSourcesProps {
sources: Array<{ url: string; title: string }>;
}
/**
* Renders web search sources as a collapsible tool card, consistent
* with other tool call renderings. The collapsed header shows a globe
* icon and "Searched N sources"; expanding reveals clickable pills.
*/
const WebSearchSources: FC<WebSearchSourcesProps> = ({ sources }) => {
// Deduplicate sources by URL, keeping the first occurrence.
const unique = useMemo(() => {
const seen = new Set<string>();
return sources.filter((s) => {
if (!s.url || seen.has(s.url)) {
return sources.filter((source) => {
if (!source.url || seen.has(source.url)) {
return false;
}
seen.add(s.url);
seen.add(source.url);
return true;
});
}, [sources]);
@@ -29,48 +22,30 @@ const WebSearchSources: FC<WebSearchSourcesProps> = ({ sources }) => {
return null;
}
const detail = unique.length === 1 ? "1 result" : `${unique.length} results`;
return (
<ToolCollapsible
hasContent={unique.length > 0}
header={
<>
<GlobeIcon className="h-4 w-4 shrink-0 text-content-secondary" />
<span className="text-sm text-content-secondary">
Searched <span className="text-content-secondary/60">{detail}</span>
</span>
</>
}
>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<div className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium text-content-secondary">
Sources
</span>
<div className="flex flex-wrap gap-1.5">
{unique.map((source) => (
<SourcePill key={source.url} source={source} />
))}
</div>
</ToolCollapsible>
</div>
);
};
/**
* A single source citation pill. Shows a favicon from Google's S2
* service, a truncated title, and an external-link icon on hover.
*/
const SourcePill: FC<{ source: { url: string; title: string } }> = ({
source,
}) => {
let hostname: string;
let hostname = "";
try {
hostname = new URL(source.url).hostname;
hostname = new URL(source.url).hostname.replace(/^www\./, "");
} catch {
hostname = "";
}
const faviconUrl = hostname
? `https://www.google.com/s2/favicons?domain=${hostname}&sz=16`
: undefined;
// Use the title if available, otherwise fall back to the hostname.
const label = source.title || hostname || source.url;
return (
@@ -80,30 +55,14 @@ const SourcePill: FC<{ source: { url: string; title: string } }> = ({
rel="noopener noreferrer"
title={source.title || source.url}
className={cn(
"group inline-flex items-center gap-1.5 rounded-full",
"border border-solid border-border-default bg-surface-secondary",
"px-2.5 py-1 text-xs leading-none text-content-secondary",
"no-underline transition-colors",
"hover:bg-surface-tertiary hover:text-content-primary",
"hover:border-border-hover",
"max-w-[200px]",
"inline-flex max-w-[220px] items-center gap-1 rounded-full",
"bg-surface-secondary px-2.5 py-0.5 text-xs text-content-link",
"no-underline transition-colors hover:bg-surface-tertiary",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link/30",
)}
>
{faviconUrl && (
<img
src={faviconUrl}
alt=""
width={14}
height={14}
className="shrink-0 rounded-sm"
// Hide the broken-image icon if the favicon fails to load.
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="truncate">{label}</span>
<ExternalLinkIcon className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
<ExternalLinkIcon className="h-3 w-3 shrink-0" />
</a>
);
};
@@ -12,14 +12,16 @@ import type React from "react";
import { cn } from "utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
import {
computeDiffStats,
DIFFS_FONT_STYLE,
getDiffViewerOptions,
splitPath,
type ToolStatus,
} from "./utils";
/**
* Collapsed-by-default rendering for `write_file` tool calls. Shows
* "Wrote <filename>" with a chevron; expanding reveals the unified diff.
* Collapsed-by-default rendering for `write_file` tool calls. Shows the
* written path with a chevron; expanding reveals the unified diff.
*/
export const WriteFileTool: React.FC<{
path: string;
@@ -32,9 +34,8 @@ export const WriteFileTool: React.FC<{
const isDark = theme.palette.mode === "dark";
const hasDiff = diff !== null;
const isRunning = status === "running";
const filename = path.split("/").pop() || path;
const label = isRunning ? `Writing ${filename}` : `Wrote ${filename}`;
const { directory, filename } = splitPath(path);
const stats = computeDiffStats(diff);
return (
<ToolCollapsible
@@ -48,7 +49,9 @@ export const WriteFileTool: React.FC<{
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{label}
{isRunning ? "Writing " : "Wrote "}
{directory && <span className="opacity-70">{directory}</span>}
<span className="font-semibold">{filename}</span>
</span>
{isError && (
<Tooltip>
@@ -63,18 +66,26 @@ export const WriteFileTool: React.FC<{
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{stats.additions > 0 && (
<span className="ml-auto shrink-0 text-xs tabular-nums text-content-success">
+{stats.additions}
</span>
)}
</>
}
>
{hasDiff && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
className="text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={diff}
options={getDiffViewerOptions(isDark)}
options={{
...getDiffViewerOptions(isDark),
disableFileHeader: true,
}}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
@@ -5,6 +5,7 @@ import {
buildWriteFileDiff,
COLLAPSED_OUTPUT_HEIGHT,
COLLAPSED_REPORT_HEIGHT,
computeDiffStats,
DIFFS_FONT_STYLE,
diffViewerCSS,
fileViewerCSS,
@@ -22,6 +23,7 @@ import {
parseArgs,
parseEditFilesArgs,
shortDurationMs,
splitPath,
stripSvnIndexHeaders,
toProviderLabel,
} from "./utils";
@@ -271,6 +273,33 @@ describe("formatResultOutput", () => {
});
});
describe("splitPath", () => {
it("splits a nested path into directory and filename", () => {
expect(splitPath("src/components/Foo.tsx")).toEqual({
directory: "src/components/",
filename: "Foo.tsx",
});
});
it("returns an empty directory for single-segment paths", () => {
expect(splitPath("file.ts")).toEqual({
directory: "",
filename: "file.ts",
});
});
it("keeps all parent segments in the directory", () => {
expect(splitPath("a/b/c/d.ts")).toEqual({
directory: "a/b/c/",
filename: "d.ts",
});
});
it("handles empty paths", () => {
expect(splitPath("")).toEqual({ directory: "", filename: "" });
});
});
describe("getDiffViewerOptions", () => {
it("returns dark theme options", () => {
const opts = getDiffViewerOptions(true);
@@ -605,6 +634,30 @@ describe("buildEditDiff", () => {
});
});
describe("computeDiffStats", () => {
it("counts additions in write_file diffs", () => {
const diff = buildWriteFileDiff("test.ts", "line1\nline2\nline3\n");
const stats = computeDiffStats(diff);
expect(stats.additions).toBeGreaterThan(0);
expect(stats.deletions).toBe(0);
});
it("counts additions and deletions in edit diffs", () => {
const diff = buildEditDiff("test.ts", [
{ search: "const x = 1;", replace: "const x = 2;" },
]);
const stats = computeDiffStats(diff);
expect(stats.additions).toBeGreaterThan(0);
expect(stats.deletions).toBeGreaterThan(0);
});
it("returns zero stats for null diffs", () => {
expect(computeDiffStats(null)).toEqual({ additions: 0, deletions: 0 });
});
});
describe("stripSvnIndexHeaders", () => {
it("removes Index: headers from SVN-style patches", () => {
const input = [
@@ -693,6 +746,14 @@ describe("constants", () => {
expect(fileViewerCSS.length).toBeGreaterThan(0);
});
it("diffViewerCSS includes gutter indicator overrides", () => {
expect(diffViewerCSS).toContain("data-diff-indicator");
});
it("diffViewerCSS includes inline token highlight overrides", () => {
expect(diffViewerCSS).toContain("data-diff-highlight");
});
it("diffViewerCSS includes border-left style", () => {
expect(diffViewerCSS).toContain("border-left");
});
@@ -147,6 +147,19 @@ export const formatResultOutput = (result: unknown): string | null => {
return String(result);
};
export const splitPath = (
fullPath: string,
): { directory: string; filename: string } => {
const lastSlashIndex = fullPath.lastIndexOf("/");
if (lastSlashIndex === -1) {
return { directory: "", filename: fullPath };
}
return {
directory: fullPath.slice(0, lastSlashIndex + 1),
filename: fullPath.slice(lastSlashIndex + 1),
};
};
export const fileViewerCSS =
"pre, [data-line], [data-diffs-header] { background-color: transparent !important; }";
@@ -229,11 +242,31 @@ const SEPARATOR_CSS = [
"}",
].join(" ");
// These selectors depend on @pierre/diffs DOM hooks and should
// be verified against rendered output when the library changes.
const GUTTER_INDICATOR_CSS = [
"[data-diff-indicator] {",
" width: 3px !important;",
" min-width: 3px !important;",
"}",
].join(" ");
const INLINE_TOKEN_HIGHLIGHT_CSS = [
"[data-diff-highlight='added'] {",
" background-color: hsl(var(--content-link) / 0.18) !important;",
"}",
"[data-diff-highlight='removed'] {",
" background-color: hsl(var(--highlight-red) / 0.18) !important;",
"}",
].join(" ");
export const diffViewerCSS = [
"pre, [data-line]:not([data-selected-line]), [data-diffs-header] { background-color: transparent !important; }",
"[data-diffs-header] { border-left: 1px solid var(--border); }",
SELECTION_OVERRIDE_CSS,
SEPARATOR_CSS,
GUTTER_INDICATOR_CSS,
INLINE_TOKEN_HIGHLIGHT_CSS,
].join(" ");
// Theme-aware option factories shared across tool renderers.
@@ -438,6 +471,31 @@ export const buildEditDiff = (
return parsed[0].files[0];
};
type DiffStatContent = {
type: "context" | "addition" | "deletion" | "change";
additions?: number;
deletions?: number;
};
export const computeDiffStats = (
diff: FileDiffMetadata | null,
): { additions: number; deletions: number } => {
let additions = 0;
let deletions = 0;
for (const hunk of diff?.hunks ?? []) {
for (const content of hunk.hunkContent as DiffStatContent[]) {
if (content.type === "context") {
continue;
}
additions += content.additions ?? 0;
deletions += content.deletions ?? 0;
}
}
return { additions, deletions };
};
// Re-export runtime type utils used by sub-components so they
// can import from a single location.
export { asNumber, asRecord, asString } from "../runtimeTypeUtils";
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
import { useTheme } from "@emotion/react";
import { File as FileViewer } from "@pierre/diffs/react";
import type * as TypesGen from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import {
@@ -9,15 +11,21 @@ import {
Tool,
} from "components/ai-elements";
import { WebSearchSources } from "components/ai-elements/tool";
import { ToolCollapsible } from "components/ai-elements/tool/ToolCollapsible";
import {
DIFFS_FONT_STYLE,
getFileViewerOptionsMinimal,
} from "components/ai-elements/tool/utils";
import { Button } from "components/Button/Button";
import { FileReferenceChip } from "components/ChatMessageInput/FileReferenceNode";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { PencilIcon } from "lucide-react";
import { ChevronDownIcon, FileTextIcon, PencilIcon } from "lucide-react";
import {
type FC,
Fragment,
@@ -47,6 +55,7 @@ const ReasoningDisclosure: FC<{
isStreaming?: boolean;
urlTransform?: UrlTransform;
}> = ({ id, text, isStreaming = false, urlTransform }) => {
const [expanded, setExpanded] = useState(false);
const { visibleText } = useSmoothStreamingText({
fullText: text,
isStreaming,
@@ -56,30 +65,96 @@ const ReasoningDisclosure: FC<{
const displayText = isStreaming ? visibleText : text;
const hasText = displayText.trim().length > 0;
if (hasText) {
return (
<div className="w-full">
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
>
{displayText}
</Response>
</div>
);
}
return (
<div className="w-full">
<div className="flex items-center gap-2 text-content-secondary transition-colors hover:text-content-primary">
<span className="text-sm">
{isStreaming ? <Shimmer as="span">Thinking...</Shimmer> : "Thinking"}
{hasText ? (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="m-0 inline-flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 font-[inherit] text-[11px] font-medium text-content-secondary/70 transition-colors hover:text-content-secondary"
>
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
Reasoning
</button>
) : isStreaming ? (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-content-secondary/70">
<Shimmer as="span">Thinking...</Shimmer>
</span>
</div>
) : (
<span className="text-[11px] font-medium text-content-secondary/70">
Thinking
</span>
)}
{expanded && hasText && (
<div className="mt-1.5 pl-4">
<Response
className="text-[13px] text-content-secondary/70"
urlTransform={urlTransform}
>
{displayText}
</Response>
</div>
)}
</div>
);
};
const FileReferenceDisclosure: FC<{
fileName: string;
startLine: number;
endLine: number;
content: string;
}> = ({ fileName, startLine, endLine, content }) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const shortFileName = fileName.split("/").pop() || fileName;
const lineLabel =
startLine === endLine
? `line ${startLine}`
: `lines ${startLine}-${endLine}`;
const hasContent = content.trim().length > 0;
return (
<ToolCollapsible
className="w-full"
hasContent={hasContent}
header={
<span
className={cn(
"inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md",
"border border-border-default bg-surface-secondary px-2.5 py-1",
"text-xs font-mono text-content-primary",
)}
>
<FileTextIcon className="h-3 w-3 shrink-0 text-content-secondary" />
<span className="truncate">{shortFileName}</span>
<span className="shrink-0 text-content-secondary">({lineLabel})</span>
</span>
}
>
<ScrollArea
className="mt-2 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileViewer
file={{
name: fileName,
contents: content,
}}
options={getFileViewerOptionsMinimal(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
</ToolCollapsible>
);
};
// Shared block renderer used by both ChatMessageItem (historical
// messages) and StreamingOutput (live stream). Encapsulates the
// response / thinking / tool switch so the two consumers stay in sync.
@@ -116,6 +191,41 @@ type RenderBlockListResult = {
renderedToolIDs: ReadonlySet<string>;
};
const ASSISTANT_CONTENT_CLASSES = "whitespace-normal";
const BLOCK_STACK_CLASSES = "space-y-2";
const TOOL_SHELL_CLASSES = "";
const FILE_REFERENCE_SHELL_CLASSES =
"rounded-md border-l-2 border-border-default/70 bg-surface-secondary/40 py-1.5 pl-3 pr-1";
// Per-block-type visual wrapper applied inside renderBlockList().
function blockShell(type: string, key: string, children: ReactNode): ReactNode {
switch (type) {
case "tool":
return (
<div key={key} className={TOOL_SHELL_CLASSES}>
{children}
</div>
);
case "thinking":
return <Fragment key={key}>{children}</Fragment>;
case "sources":
return (
<div key={key} className="text-content-secondary">
{children}
</div>
);
case "file-reference":
return (
<div key={key} className={FILE_REFERENCE_SHELL_CLASSES}>
{children}
</div>
);
default:
// "response", "file" (images), etc. — keep light, no extra wrapper.
return <Fragment key={key}>{children}</Fragment>;
}
}
function renderBlockList({
blocks,
toolByID,
@@ -129,47 +239,40 @@ function renderBlockList({
const renderedToolIDs = new Set<string>();
const elements = blocks
.map((block, index) => {
const key = `${keyPrefix}-${block.type}-${index}`;
let element: ReactNode | null = null;
switch (block.type) {
case "response":
return isStreaming ? (
element = isStreaming ? (
<SmoothedResponse
key={`${keyPrefix}-response-${index}`}
text={block.text}
streamKey={keyPrefix}
urlTransform={urlTransform}
/>
) : (
<Response
key={`${keyPrefix}-response-${index}`}
urlTransform={urlTransform}
>
{block.text}
</Response>
<Response urlTransform={urlTransform}>{block.text}</Response>
);
break;
case "thinking":
return (
element = (
<ReasoningDisclosure
key={`${keyPrefix}-thinking-${index}`}
id={`${keyPrefix}-thinking-${index}`}
text={block.text}
isStreaming={isStreaming}
urlTransform={urlTransform}
/>
);
break;
case "file-reference":
return (
<div
key={`${keyPrefix}-file-reference-${index}`}
className="my-1 flex items-start gap-2 rounded-md border border-content-link/20 bg-content-link/5 px-2.5 py-1.5"
>
<span className="shrink-0 text-xs font-medium text-content-link">
{block.file_name}:
{block.start_line === block.end_line
? block.start_line
: `${block.start_line}\u2013${block.end_line}`}
</span>
</div>
element = (
<FileReferenceDisclosure
fileName={block.file_name}
startLine={block.start_line}
endLine={block.end_line}
content={block.content}
/>
);
break;
case "tool": {
const tool = toolByID.get(block.id);
if (!tool) {
@@ -178,9 +281,8 @@ function renderBlockList({
}
// Streaming placeholder for not-yet-resolved tool.
renderedToolIDs.add(block.id);
return (
element = (
<Tool
key={block.id}
name="Tool"
status="running"
isError={false}
@@ -188,11 +290,11 @@ function renderBlockList({
subagentStatusOverrides={subagentStatusOverrides}
/>
);
break;
}
renderedToolIDs.add(tool.id);
return (
element = (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
@@ -204,15 +306,15 @@ function renderBlockList({
}
/>
);
break;
}
case "file":
if (block.media_type.startsWith("image/")) {
const src = block.file_id
? `/api/experimental/chats/files/${block.file_id}`
: `data:${block.media_type};base64,${block.data}`;
return (
element = (
<button
key={`${keyPrefix}-file-${index}`}
type="button"
aria-label="View image"
className="inline-block rounded-md border-0 bg-transparent p-0"
@@ -229,22 +331,36 @@ function renderBlockList({
</button>
);
}
return null;
break;
case "sources":
return (
<WebSearchSources
key={`${keyPrefix}-sources-${index}`}
sources={block.sources}
/>
);
element = <WebSearchSources sources={block.sources} />;
break;
default:
return null;
break;
}
return element ? blockShell(block.type, key, element) : null;
})
.filter((el): el is NonNullable<typeof el> => el != null);
return { elements, renderedToolIDs };
}
// Shared wrapper providing consistent chrome for every transcript turn.
// Supplies: deep-link anchor and the standard ConversationItem →
// Message → MessageContent stack.
const TurnChrome: FC<{
id?: string;
turnRole: "user" | "assistant";
messageClassName?: string;
contentClassName?: string;
children: ReactNode;
}> = ({ id, turnRole, messageClassName, contentClassName, children }) => (
<ConversationItem id={id} role={turnRole}>
<Message className={cn("w-full", messageClassName)}>
<MessageContent className={contentClassName}>{children}</MessageContent>
</Message>
</ConversationItem>
);
const ChatMessageItem = memo<{
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
@@ -315,9 +431,11 @@ const ChatMessageItem = memo<{
)
: [];
const conversationItemProps: { role: "user" | "assistant" } = {
role: isUser ? "user" : "assistant",
const userConversationItemProps = {
id: `message-${message.id}`,
role: "user" as const,
};
const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({
blocks: parsed.blocks,
toolByID,
@@ -336,8 +454,8 @@ const ChatMessageItem = memo<{
"transition-opacity duration-200",
)}
>
<ConversationItem {...conversationItemProps}>
{isUser ? (
{isUser ? (
<ConversationItem {...userConversationItemProps}>
<Message className="w-full max-w-none">
<MessageContent
className={cn(
@@ -456,31 +574,34 @@ const ChatMessageItem = memo<{
</div>
</MessageContent>
</Message>
) : (
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
{orderedBlocks}
{remainingTools.map((tool) => (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
/>
))}
{!hasRenderableContent && (
<div className="text-xs text-content-secondary">
Message has no renderable content.
</div>
)}
</ConversationItem>
) : (
<TurnChrome
id={`message-${message.id}`}
turnRole="assistant"
contentClassName={ASSISTANT_CONTENT_CLASSES}
>
<div className={BLOCK_STACK_CLASSES}>
{orderedBlocks}
{remainingTools.map((tool) => (
<div key={tool.id} className={TOOL_SHELL_CLASSES}>
<Tool
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
/>
</div>
</MessageContent>
</Message>
)}
</ConversationItem>
))}
{!hasRenderableContent && (
<div className="text-xs text-content-secondary">
Message has no renderable content.
</div>
)}
</div>
</TurnChrome>
)}
{previewImage && (
<ImageLightbox
src={previewImage}
@@ -511,7 +632,6 @@ export const StreamingOutput = memo<{
retryState,
urlTransform,
}) => {
const conversationItemProps = { role: "assistant" as const };
const toolByID = new Map(streamTools.map((tool) => [tool.id, tool]));
const blocks = streamState?.blocks ?? [];
const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({
@@ -528,47 +648,48 @@ export const StreamingOutput = memo<{
);
return (
<ConversationItem {...conversationItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
{orderedBlocks}
{showInitialPlaceholder ||
(streamState &&
orderedBlocks.length === 0 &&
streamTools.length === 0) ? (
<div className="relative">
<Response aria-hidden className="invisible">
{`Thinking...${retryState ? ` attempt ${retryState.attempt}` : ""}`}
</Response>
<div className="pointer-events-none absolute inset-0 flex items-baseline gap-2">
<Shimmer as="div" className="text-[13px] leading-relaxed">
Thinking...
</Shimmer>
{retryState && (
<span className="text-[11px] text-content-secondary">
attempt {retryState.attempt}
</span>
)}
</div>
</div>
) : null}
{remainingTools.map((tool) => (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
/>
))}
<TurnChrome
id="agent-message-stream"
turnRole="assistant"
contentClassName={ASSISTANT_CONTENT_CLASSES}
>
<div className={BLOCK_STACK_CLASSES}>
{orderedBlocks}
{showInitialPlaceholder ||
(streamState &&
orderedBlocks.length === 0 &&
streamTools.length === 0) ? (
<div className="relative">
<Response aria-hidden className="invisible">
{`Thinking...${retryState ? ` attempt ${retryState.attempt}` : ""}`}
</Response>
<div className="pointer-events-none absolute inset-0 flex items-baseline gap-2">
<Shimmer as="div" className="text-[13px] leading-relaxed">
Thinking...
</Shimmer>
{retryState && (
<span className="text-[11px] text-content-secondary">
attempt {retryState.attempt}
</span>
)}
</div>
</div>
</MessageContent>
</Message>
</ConversationItem>
) : null}
{remainingTools.map((tool) => (
<div key={tool.id} className={TOOL_SHELL_CLASSES}>
<Tool
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
/>
</div>
))}
</div>
</TurnChrome>
);
},
);
@@ -907,28 +1028,28 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
</div>
) : (
<div className="flex flex-col gap-3">
{parsedMessages.map(({ message, parsed }) =>
message.role === "user" ? (
<StickyUserMessage
key={message.id}
message={message}
parsed={parsed}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
/>
) : (
<ChatMessageItem
key={message.id}
message={message}
parsed={parsed}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
/>
),
)}
{parsedMessages.map(({ message, parsed }) => (
<Fragment key={message.id}>
{message.role === "user" ? (
<StickyUserMessage
message={message}
parsed={parsed}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
/>
) : (
<ChatMessageItem
message={message}
parsed={parsed}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
/>
)}
</Fragment>
))}
{shouldRenderStreamAfterMessages && (
<StreamingOutput
streamState={streamState}
@@ -1,5 +1,76 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { StreamingOutput } from "./ConversationTimeline";
import type { MergedTool, StreamState } from "./types";
const streamingToolSources: StreamState["sources"] = [
{
url: "https://coder.com/docs/admin/setup",
title: "Coder Admin Setup Guide",
},
{
url: "https://coder.com/docs/api/general",
title: "Coder API Reference",
},
{
url: "https://github.com/coder/coder/wiki/Configuration",
title: "Configuration Wiki",
},
];
const streamingReadFileTool: MergedTool = {
id: "stream_tc_1",
name: "read_file",
args: { path: "src/index.ts" },
status: "running",
isError: false,
};
const streamingWithToolCallsState: StreamState = {
blocks: [
{ type: "response", text: "Let me check the file structure first." },
{ type: "tool", id: "stream_tc_1" },
{ type: "response", text: "Based on what I see..." },
],
toolCalls: {
stream_tc_1: {
id: "stream_tc_1",
name: "read_file",
args: { path: "src/index.ts" },
},
},
toolResults: {},
sources: [],
};
const streamingWithThinkingState: StreamState = {
blocks: [
{
type: "thinking",
text: "I need to analyze the error log and determine the root cause. The stack trace suggests a null pointer in the auth middleware. Let me trace through the request lifecycle to identify where the session object becomes undefined.",
},
{
type: "response",
text: "I've identified the issue. The auth middleware...",
},
],
toolCalls: {},
toolResults: {},
sources: [],
};
const streamingWithSourcesState: StreamState = {
blocks: [
{
type: "response",
text: "Based on the documentation, the configuration requires...",
},
{ type: "sources", sources: streamingToolSources },
],
toolCalls: {},
toolResults: {},
sources: streamingToolSources,
};
// StreamingOutput renders inside a ConversationItem > Message > MessageContent
// chain, but it's self-contained enough to render standalone.
@@ -63,7 +134,7 @@ export const StreamingWithText: Story = {
streamState: {
blocks: [
{
type: "response" as const,
type: "response",
text: "Here is a partial response that is still being generated...",
},
],
@@ -81,7 +152,7 @@ export const StreamingAfterRetry: Story = {
streamState: {
blocks: [
{
type: "response" as const,
type: "response",
text: "Successfully connected after retry. Here is your answer...",
},
],
@@ -93,3 +164,48 @@ export const StreamingAfterRetry: Story = {
retryState: null,
},
};
/** Active streaming with an in-progress tool call in the transcript. */
export const StreamingWithToolCalls: Story = {
args: {
streamState: streamingWithToolCallsState,
streamTools: [streamingReadFileTool],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toolHeader = await canvas.findByText(/Read index\.ts/i);
await expect(toolHeader).toBeInTheDocument();
},
};
/** Active streaming that includes reasoning before the response text. */
export const StreamingWithThinking: Story = {
args: {
streamState: streamingWithThinkingState,
streamTools: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const thinkingText = await canvas.findByText(/analyze the error log/i);
const responseText = await canvas.findByText(/identified the issue/i);
await expect(thinkingText).toBeInTheDocument();
await expect(responseText).toBeInTheDocument();
},
};
/** Active streaming that includes sources before the response finalizes. */
export const StreamingWithSources: Story = {
args: {
streamState: streamingWithSourcesState,
streamTools: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const sourcesToggle = await canvas.findByRole("button", {
name: /searched 3 results/i,
});
await userEvent.click(sourcesToggle);
const sourceTitle = await canvas.findByText(/Coder Admin Setup Guide/i);
await expect(sourceTitle).toBeInTheDocument();
},
};