Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75682b351e | |||
| 4d1d77b68a | |||
| 42e28bc89b | |||
| 2d8efef968 | |||
| 176e02befc | |||
| 271ac680f0 | |||
| 972dd5c160 | |||
| a5768f60e3 | |||
| ae98b899b6 | |||
| 16ee6b72f9 | |||
| 7c3b828e00 | |||
| 675ce98fd4 | |||
| d61b69af7e | |||
| 00b13cfe2d | |||
| fdc8f88971 | |||
| 4a9403fda9 | |||
| 91c27cb5de | |||
| 2d0f560a3f | |||
| 4902e0ecda | |||
| 7acc0dd338 | |||
| f5961923d4 |
Vendored
+2
-1
@@ -45,7 +45,8 @@
|
||||
"envs": [
|
||||
{
|
||||
"name": "DEVCONTAINER_ENV",
|
||||
"value": "devcontainer-value"
|
||||
"value": "devcontainer-value",
|
||||
"merge_strategy": "replace"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -48,7 +48,8 @@
|
||||
"envs": [
|
||||
{
|
||||
"name": "DEVCONTAINER_ENV",
|
||||
"value": "devcontainer-value"
|
||||
"value": "devcontainer-value",
|
||||
"merge_strategy": "replace"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
Vendored
+2
@@ -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"
|
||||
},
|
||||
|
||||
Generated
Vendored
+1
@@ -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"
|
||||
},
|
||||
|
||||
Vendored
+6
-3
@@ -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": {},
|
||||
|
||||
Vendored
+6
-3
@@ -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": {},
|
||||
|
||||
Generated
Vendored
+6
@@ -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"
|
||||
},
|
||||
|
||||
Generated
Vendored
+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,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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user