feat(webui): refine output timeline and model controls (#4108)

* feat(webui): refine output timeline and composer queue

* feat(webui): add provider model picker

* fix(webui): polish model settings and heartbeat checks

* chore: keep heartbeat changes out of webui pr

* refactor(webui): isolate settings routes

* fix(providers): align minimax anthropic test

* fix(providers): keep minimax anthropic base sdk-compatible

* fix(providers): normalize anthropic base urls
This commit is contained in:
Xubin Ren
2026-05-30 23:45:26 +08:00
committed by GitHub
parent b2e43955e3
commit 3dcf511c84
65 changed files with 4526 additions and 1428 deletions
+173
View File
@@ -0,0 +1,173 @@
import { useState, type ReactNode } from "react";
import { FileIcon, ImageIcon, PlaySquare } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import type { UIMediaAttachment } from "@/lib/types";
interface AttachmentTileProps {
attachment: UIMediaAttachment;
className?: string;
inline?: boolean;
variant?: "default" | "compact";
}
export function AttachmentTile({ attachment, className, inline = false, variant = "default" }: AttachmentTileProps) {
const { t } = useTranslation();
const [failed, setFailed] = useState(false);
const hasUrl = typeof attachment.url === "string" && attachment.url.length > 0;
const label = attachmentLabel(attachment, t);
if (attachment.kind === "image" && hasUrl && !failed) {
return (
<AttachmentFrame
attachment={attachment}
className={className}
inline={inline}
variant={variant}
>
<a
href={attachment.url}
target="_blank"
rel="noreferrer noopener"
className="block bg-muted/20"
aria-label={attachment.name ? `Open ${attachment.name}` : t("lightbox.open", { defaultValue: "Open image" })}
>
<img
src={attachment.url}
alt={attachment.name ?? ""}
loading="lazy"
decoding="async"
draggable={false}
onError={() => setFailed(true)}
className={cn(
"block h-auto max-w-full bg-background object-contain",
variant === "compact" ? "max-h-40" : "max-h-[34rem]",
)}
/>
</a>
</AttachmentFrame>
);
}
if (attachment.kind === "video" && hasUrl) {
return (
<AttachmentFrame
attachment={attachment}
className={className}
inline={inline}
variant={variant}
>
<video
src={attachment.url}
controls
preload="auto"
className={cn(
"block w-full bg-black",
variant === "compact" ? "max-h-40" : "max-h-[26rem]",
)}
aria-label={attachment.name ? `${t("message.videoAttachment", { defaultValue: "Video attachment" })}: ${attachment.name}` : t("message.videoAttachment", { defaultValue: "Video attachment" })}
/>
</AttachmentFrame>
);
}
const Icon = attachment.kind === "video"
? PlaySquare
: attachment.kind === "image"
? ImageIcon
: FileIcon;
const body = (
<>
<Icon className="h-4 w-4 flex-none" aria-hidden />
<span className="min-w-0 truncate">{attachment.name ?? label}</span>
</>
);
if (hasUrl && !failed) {
return (
<a
href={attachment.url}
download={attachment.name ?? label}
title={attachment.name ?? undefined}
aria-label={label}
className={cn(
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
"border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
"transition-colors hover:bg-muted/55 hover:text-foreground",
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
className,
)}
>
{body}
</a>
);
}
return (
<div
className={cn(
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
"border border-border/60 bg-muted/35 px-3 py-2 text-xs text-muted-foreground",
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
className,
)}
title={attachment.name ?? undefined}
aria-label={label}
>
{body}
<span className="sr-only">
{t("message.attachmentUnavailable", { defaultValue: "Attachment unavailable" })}
</span>
</div>
);
}
function AttachmentFrame({
attachment,
children,
className,
inline = false,
variant = "default",
}: {
attachment: UIMediaAttachment;
children: ReactNode;
className?: string;
inline?: boolean;
variant?: "default" | "compact";
}) {
const frameClassName = cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
"border border-border/60 bg-muted/40",
attachment.kind === "image" && "bg-background/85",
attachment.kind === "video" ? "w-[min(100%,32rem)]" : "",
variant === "compact" && "my-1 rounded-xl shadow-none",
variant === "compact" && attachment.kind === "video" && "w-[min(100%,20rem)]",
className,
);
const bodyClassName = "block max-w-full";
const body = inline ? (
<span className={bodyClassName}>{children}</span>
) : (
<div className={bodyClassName}>{children}</div>
);
return inline ? (
<span className={frameClassName}>
{body}
</span>
) : (
<figure className={frameClassName}>
{body}
</figure>
);
}
function attachmentLabel(attachment: UIMediaAttachment, t: ReturnType<typeof useTranslation>["t"]): string {
if (attachment.kind === "video") {
return t("message.videoAttachment", { defaultValue: "Video attachment" });
}
if (attachment.kind === "image") {
return t("message.imageAttachment", { defaultValue: "Image attachment" });
}
return t("message.fileAttachment", { defaultValue: "File attachment" });
}
+1 -1
View File
@@ -540,7 +540,7 @@ function SessionActivityIndicator({
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
<span className="h-2 w-2 rounded-full bg-blue-500 dark:bg-blue-400" />
</span>
);
}
+3 -2
View File
@@ -54,9 +54,10 @@ const LazyHighlightedCode = lazy(async () => {
function PlainCodeFallback({ code }: { code: string }) {
return (
<pre
className="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-sm leading-[1.6]"
className="m-0 overflow-x-auto whitespace-pre-wrap bg-background p-4 font-mono text-sm leading-[1.6] text-foreground/90"
data-testid="plain-code-fallback"
>
<code>{code}</code>
<code className="text-inherit">{code}</code>
</pre>
);
}
+4 -1
View File
@@ -40,6 +40,7 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
const SHORT_STREAM_COMMIT_MS = 80;
const MEDIUM_STREAM_COMMIT_MS = 140;
const LONG_STREAM_COMMIT_MS = 220;
const STREAMING_HIGHLIGHT_CHAR_LIMIT = 16_000;
export function preloadMarkdownText(): void {
void loadMarkdownRenderer();
@@ -56,7 +57,9 @@ export function MarkdownText({
streaming = false,
}: MarkdownTextProps) {
const renderedSource = useStreamingMarkdownSource(children, streaming);
const highlightCode = !streaming && renderedSource === children;
const highlightCode = streaming
? renderedSource.length <= STREAMING_HIGHLIGHT_CHAR_LIMIT
: renderedSource === children;
useEffect(() => {
if (streaming) preloadMarkdownText();
+245 -60
View File
@@ -1,11 +1,13 @@
import { Children, isValidElement, useMemo } from "react";
import type { Components } from "react-markdown";
import { Children, isValidElement, useMemo, type ReactNode } from "react";
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import { Check } from "lucide-react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { AttachmentTile } from "@/components/AttachmentTile";
import { CodeBlock } from "@/components/CodeBlock";
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
import { inferMediaKind } from "@/lib/media";
@@ -19,8 +21,181 @@ interface MarkdownTextRendererProps {
highlightCode?: boolean;
}
const remarkPlugins = [remarkBreaks, remarkGfm, remarkMath];
const rehypePlugins = [rehypeKatex];
type MarkdownAstNode = {
type: string;
value?: string;
children?: MarkdownAstNode[];
data?: {
hName?: string;
};
};
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
function extensionOf(value: string): string {
const clean = value.split(/[?#]/, 1)[0]?.trim() ?? "";
const slash = clean.lastIndexOf("/");
const name = slash >= 0 ? clean.slice(slash + 1) : clean;
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot).toLowerCase() : "";
}
function markdownAttachmentKind(source: string, label: string): "image" | "video" | "file" {
const inferredKind = inferMediaKind({ url: source, name: label });
if (inferredKind !== "file") return inferredKind;
return extensionOf(label) || extensionOf(source) ? "file" : "image";
}
function safeHtmlNode(tagName: string, children: MarkdownAstNode[]): MarkdownAstNode {
return {
type: `nanobotSafeHtml${tagName}`,
data: { hName: tagName },
children,
};
}
function safeText(value: string): MarkdownAstNode {
return { type: "text", value };
}
function htmlTag(node: MarkdownAstNode): { tag: string; closing: boolean } | null {
if (node.type !== "html" || typeof node.value !== "string") return null;
const match = /^<\s*(\/?)\s*(mark|sub|sup)\s*>$/i.exec(node.value.trim());
if (!match) return null;
return { tag: match[2].toLowerCase(), closing: match[1] === "/" };
}
function normalizeSafeInlineHtml(children: MarkdownAstNode[]): MarkdownAstNode[] {
const next: MarkdownAstNode[] = [];
for (let index = 0; index < children.length; index += 1) {
const node = children[index];
if (node.children) {
node.children = normalizeSafeInlineHtml(node.children);
}
const tag = htmlTag(node);
if (!tag || tag.closing || !SAFE_INLINE_HTML_TAGS.has(tag.tag)) {
next.push(node);
continue;
}
let closeIndex = -1;
for (let cursor = index + 1; cursor < children.length; cursor += 1) {
const closeTag = htmlTag(children[cursor]);
if (closeTag?.closing && closeTag.tag === tag.tag) {
closeIndex = cursor;
break;
}
}
if (closeIndex === -1) {
next.push(node);
continue;
}
next.push(
safeHtmlNode(
tag.tag,
normalizeSafeInlineHtml(children.slice(index + 1, closeIndex)),
),
);
index = closeIndex;
}
return next;
}
function detailsOpen(node: MarkdownAstNode): { summary: string } | null {
if (node.type !== "html" || typeof node.value !== "string") return null;
const value = node.value.trim();
const match = /^<\s*details\s*>\s*<\s*summary\s*>([\s\S]*?)<\s*\/\s*summary\s*>$/i.exec(value);
if (match) return { summary: match[1].trim() };
if (/^<\s*details\s*>$/i.test(value)) return { summary: "Details" };
return null;
}
function isDetailsClose(node: MarkdownAstNode): boolean {
return node.type === "html"
&& typeof node.value === "string"
&& /^<\s*\/\s*details\s*>$/i.test(node.value.trim());
}
function normalizeSafeDetails(children: MarkdownAstNode[]): MarkdownAstNode[] {
const next: MarkdownAstNode[] = [];
for (let index = 0; index < children.length; index += 1) {
const node = children[index];
const open = detailsOpen(node);
if (!open) {
next.push(node);
continue;
}
const closeIndex = children.findIndex(
(candidate, candidateIndex) => candidateIndex > index && isDetailsClose(candidate),
);
if (closeIndex === -1) {
next.push(node);
continue;
}
const body = normalizeSafeInlineHtml(
normalizeSafeDetails(children.slice(index + 1, closeIndex)),
);
next.push({
type: "nanobotSafeHtmlDetails",
data: { hName: "details" },
children: [
{
type: "nanobotSafeHtmlSummary",
data: { hName: "summary" },
children: [safeText(open.summary)],
},
...body,
],
});
index = closeIndex;
}
return next;
}
function remarkSafeHtmlSubset() {
return (tree: MarkdownAstNode) => {
if (tree.children) {
tree.children = normalizeSafeInlineHtml(normalizeSafeDetails(tree.children));
}
};
}
const remarkPlugins: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [
remarkBreaks,
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
remarkSafeHtmlSubset,
];
const rehypePlugins: NonNullable<ReactMarkdownOptions["rehypePlugins"]> = [rehypeKatex];
function nodeText(value: ReactNode): string {
return Children.toArray(value)
.map((child) => (typeof child === "string" || typeof child === "number" ? String(child) : ""))
.join("");
}
function isRenderedCodeBlock(value: ReactNode): boolean {
if (!isValidElement(value)) return false;
const props = value.props as { code?: unknown };
return value.type === CodeBlock || typeof props.code === "string";
}
function codeFenceFromPreChild(value: ReactNode): { code: string; language?: string } | null {
if (!isValidElement(value)) return null;
const props = value.props as { className?: unknown; children?: ReactNode };
if (!("children" in props)) return null;
const className = typeof props.className === "string" ? props.className : "";
const language = /language-([^\s]+)/.exec(className)?.[1];
return {
code: nodeText(props.children).replace(/\n$/, ""),
language,
};
}
/**
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
@@ -82,9 +257,20 @@ export default function MarkdownTextRenderer({
const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
if (isRenderedCodeBlock(lone)) {
return <>{markdownChildren}</>;
}
const fence = codeFenceFromPreChild(lone);
if (fence) {
return (
<CodeBlock
language={fence.language}
code={fence.code}
className="my-3"
highlight={highlightCode}
/>
);
}
return (
<pre
className={cn(
@@ -110,67 +296,66 @@ export default function MarkdownTextRenderer({
</a>
);
},
input({ type, checked }) {
if (type !== "checkbox") return null;
return (
<span
aria-hidden
data-testid="markdown-task-checkbox"
className={cn(
"mr-2 inline-grid h-4 w-4 translate-y-[2px] place-items-center rounded-[4px]",
"border border-border/70 bg-muted/55 text-background",
checked && "border-foreground/55 bg-foreground/65",
)}
>
{checked ? <Check className="h-3 w-3 stroke-[3]" /> : null}
</span>
);
},
mark({ children: markdownChildren }) {
return (
<mark className="rounded-[5px] bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25">
{markdownChildren}
</mark>
);
},
sub({ children: markdownChildren }) {
return <sub className="text-[0.72em] leading-none">{markdownChildren}</sub>;
},
sup({ children: markdownChildren }) {
return <sup className="text-[0.72em] leading-none">{markdownChildren}</sup>;
},
details({ children: markdownChildren }) {
return (
<details className="my-3 rounded-xl border border-border/65 bg-muted/25 px-4 py-3 open:pb-4">
{markdownChildren}
</details>
);
},
summary({ children: markdownChildren }) {
return (
<summary className="cursor-pointer select-none text-sm font-medium text-foreground/88 marker:text-muted-foreground">
{markdownChildren}
</summary>
);
},
img({ src, alt, node: _node, className: imgClassName, ...props }) {
void _node;
void imgClassName;
void props;
const source = typeof src === "string" ? src : "";
if (!source) return null;
const label = typeof alt === "string" ? alt : "";
if (inferMediaKind({ url: source, name: label }) === "video") {
return (
<span
className={cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
"border border-border/70 bg-background shadow-sm",
)}
>
<video
src={source}
controls
preload="metadata"
className="block max-h-[26rem] max-w-full bg-black"
aria-label={label ? `Video attachment: ${label}` : "Video attachment"}
/>
{label ? (
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
{label}
</span>
) : null}
</span>
);
}
const kind = markdownAttachmentKind(source, label);
return (
<span
className={cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
"border border-border/70 bg-background shadow-sm",
)}
>
<a
href={source}
target="_blank"
rel="noreferrer noopener"
className="block bg-muted/20"
aria-label={label ? `Open ${label}` : "Open image"}
>
<img
src={source}
alt={label}
loading="lazy"
decoding="async"
draggable={false}
className={cn(
"block h-auto max-h-[34rem] max-w-full bg-background object-contain",
imgClassName,
)}
{...props}
/>
</a>
{label ? (
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
{label}
</span>
) : null}
</span>
<AttachmentTile
attachment={{
kind,
url: source,
name: label,
}}
inline
/>
);
},
}),
+8 -66
View File
@@ -6,14 +6,16 @@ import {
useState,
type ReactNode,
} from "react";
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
import { Check, ChevronRight, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AttachmentTile } from "@/components/AttachmentTile";
import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { formatTurnLatency } from "@/lib/format";
import { toMediaAttachment } from "@/lib/media";
import type {
CliAppInfo,
McpPresetInfo,
@@ -258,10 +260,11 @@ function MessageMedia({
const images: UIImage[] = [];
const nonImages: UIMediaAttachment[] = [];
for (const item of media) {
if (item.kind === "image") {
images.push({ url: item.url, name: item.name });
const normalized = toMediaAttachment(item);
if (normalized.kind === "image") {
images.push({ url: normalized.url, name: normalized.name });
} else {
nonImages.push(item);
nonImages.push(normalized);
}
}
@@ -276,73 +279,12 @@ function MessageMedia({
<UserImages images={images} align={align} size={align === "left" ? "large" : "compact"} />
) : null}
{nonImages.map((item, i) => (
<MediaCell key={`${item.url ?? item.name ?? item.kind}-${i}`} media={item} />
<AttachmentTile key={`${item.url ?? item.name ?? item.kind}-${i}`} attachment={item} />
))}
</div>
);
}
function MediaCell({ media }: { media: UIMediaAttachment }) {
const { t } = useTranslation();
const hasUrl = typeof media.url === "string" && media.url.length > 0;
if (media.kind === "video" && hasUrl) {
return (
<figure className="max-w-[min(100%,32rem)] overflow-hidden rounded-[14px] border border-border/60 bg-muted/40">
<video
src={media.url}
controls
preload="metadata"
className="block max-h-[26rem] w-full bg-black"
aria-label={media.name ? `${t("message.videoAttachment", { defaultValue: "Video attachment" })}: ${media.name}` : t("message.videoAttachment", { defaultValue: "Video attachment" })}
/>
{media.name ? (
<figcaption className="truncate px-3 py-1.5 text-[11.5px] text-muted-foreground">
{media.name}
</figcaption>
) : null}
</figure>
);
}
const label =
media.kind === "video"
? t("message.videoAttachment", { defaultValue: "Video attachment" })
: t("message.fileAttachment", { defaultValue: "File attachment" });
const Icon = media.kind === "video" ? PlaySquare : FileIcon;
const inner = (
<>
<Icon className="h-4 w-4 flex-none" aria-hidden />
<span className="truncate">{media.name ?? label}</span>
</>
);
if (hasUrl) {
return (
<a
href={media.url}
download={media.name ?? label}
title={media.name ?? undefined}
aria-label={label}
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground hover:underline"
>
{inner}
</a>
);
}
return (
<div
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
title={media.name ?? undefined}
aria-label={label}
>
{inner}
</div>
);
}
/**
* Right-aligned preview row for images attached to a user turn.
*
+279 -11
View File
@@ -57,6 +57,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -74,6 +75,7 @@ import {
fetchSettings,
fetchCliApps,
fetchMcpPresets,
fetchProviderModels,
importMcpConfig,
loginProviderOAuth,
logoutProviderOAuth,
@@ -105,6 +107,7 @@ import type {
McpPresetInfo,
McpPresetsPayload,
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
SettingsPayload,
WebSearchSettingsUpdate,
WebuiDefaultAccessMode,
@@ -166,6 +169,23 @@ type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
"atomic_chat",
"byteplus",
"byteplus_coding_plan",
"huggingface",
"lm_studio",
"novita",
"ollama",
"openrouter",
"ovms",
"siliconflow",
"vllm",
"volcengine",
"volcengine_coding_plan",
]);
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
const FALLBACK_TIMEZONES = [
"UTC",
@@ -1124,6 +1144,7 @@ export function SettingsView({
return (
<div className="space-y-8">
<ModelsSettings
token={token}
form={form}
setForm={setForm}
settings={settings}
@@ -1754,7 +1775,7 @@ function NewModelConfigurationDialog({
<div className="space-y-4 px-5 py-5">
<label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.models.configurationName", "Name")}
{tx("settings.models.configurationName", "Configuration name")}
</span>
<Input
autoFocus
@@ -1827,6 +1848,7 @@ function NewModelConfigurationDialog({
}
function ModelsSettings({
token,
form,
setForm,
settings,
@@ -1838,6 +1860,7 @@ function ModelsSettings({
onSave,
onCreateConfiguration,
}: {
token: string;
form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
settings: SettingsPayload;
@@ -1876,8 +1899,8 @@ function ModelsSettings({
<section>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.currentModel", "Current model")}
description={tx("settings.help.currentModel", "Choose the model nanobot uses for new replies.")}
title={tx("settings.rows.currentModel", "Current configuration")}
description={tx("settings.help.currentModel", "Used for new replies.")}
>
<ModelPresetPicker
presets={settings.model_presets}
@@ -1906,7 +1929,7 @@ function ModelsSettings({
</SettingsRow>
{selectedPreset && !selectedPreset.is_default ? (
<SettingsRow
title={tx("settings.models.configurationName", "Name")}
title={tx("settings.models.configurationName", "Configuration name")}
description={tx("settings.models.configurationNameHelp", "Rename this saved model configuration.")}
>
<Input
@@ -1927,7 +1950,13 @@ function ModelsSettings({
value={providerValue}
emptyLabel={t("settings.byok.noConfiguredProviders")}
showProviderLogos={showBrandLogos}
onChange={(provider) => setForm((prev) => ({ ...prev, provider }))}
onChange={(provider) =>
setForm((prev) => ({
...prev,
provider,
model: provider === prev.provider ? prev.model : "",
}))
}
/>
</SettingsRow>
{selectedProviderNeedsSignIn ? (
@@ -1958,10 +1987,13 @@ function ModelsSettings({
title={t("settings.rows.model")}
description={t("settings.help.model")}
>
<Input
<ModelIdPicker
token={token}
settings={settings}
provider={form.provider}
value={form.model}
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
showProviderLogos={showBrandLogos}
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
/>
</SettingsRow>
<SettingsRow
@@ -4190,7 +4222,10 @@ function TimezonePicker({
/>
</div>
</div>
<div className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5" data-testid="timezone-picker-list">
<div
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
data-testid="timezone-picker-list"
>
{filteredOptions.length ? (
filteredOptions.map((option) => {
const selected = option.name === value;
@@ -4268,7 +4303,7 @@ function ProviderPicker({
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-[18rem] w-[240px] overflow-y-auto"
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
>
{providers.map((provider) => {
const selected = provider.name === value;
@@ -4300,6 +4335,239 @@ function ProviderPicker({
);
}
function ModelIdPicker({
token,
settings,
provider,
value,
showProviderLogos,
onChange,
}: {
token: string;
settings: SettingsPayload;
provider: string;
value: string;
showProviderLogos: boolean;
onChange: (model: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const effectiveProvider =
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
const canFetchModels = Boolean(effectiveProvider && effectiveProvider !== "auto");
const normalizedQuery = query.trim().toLowerCase();
const providerModels = payload?.models ?? [];
const visibleModels = providerModels
.filter((model) => {
if (!normalizedQuery) return true;
return [model.id, model.label ?? "", model.owned_by ?? ""]
.some((field) => field.toLowerCase().includes(normalizedQuery));
})
.slice(0, 80);
const isCatalog = payload?.catalog_kind === "catalog";
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
const hasDeferredSearchQuery =
normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
const shouldFetchModels =
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
const waitingForModelSearch =
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
const hasModelList = payload?.status === "available";
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
const customCandidate = query.trim();
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const providerModelCount = payload?.model_count ?? providerModels.length;
useEffect(() => {
if (!open) return;
setQuery("");
}, [open, effectiveProvider]);
useEffect(() => {
if (!open || !shouldFetchModels) {
setPayload(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setPayload(null);
setError(null);
setLoading(true);
fetchProviderModels(token, effectiveProvider)
.then((nextPayload) => {
if (!cancelled) setPayload(nextPayload);
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [effectiveProvider, open, shouldFetchModels, token]);
const selectModel = (model: string) => {
onChange(model);
setOpen(false);
};
const renderModelRow = (
model: ProviderModelsPayload["models"][number],
options: { selected?: boolean } = {},
) => (
<DropdownMenuItem
key={model.id}
onSelect={() => selectModel(model.id)}
className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
"focus:bg-muted/85 focus:text-foreground",
options.selected && "bg-muted/80 text-foreground focus:bg-muted",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
<span className="min-w-0 truncate font-medium text-foreground">
{model.label ?? model.id}
</span>
</span>
<span className="ml-2 flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
</span>
</DropdownMenuItem>
);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"h-9 w-[min(360px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[12px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
<span
className={cn(
"min-w-0 truncate font-medium",
value ? "text-foreground" : "text-muted-foreground",
)}
>
{value || tx("settings.models.selectModel", "Select model")}
</span>
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
>
<div className="p-1 pb-1.5">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder={tx("settings.models.searchModels", "Search or type model ID")}
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
/>
</div>
</div>
{!canFetchModels ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
</div>
) : waitingForModelSearch ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
</div>
) : loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{tx("settings.models.loadingModels", "Loading models...")}
</div>
) : error || payload?.status === "error" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
</div>
) : payload?.status === "not_configured" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : isCatalog && !normalizedQuery ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
{providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
</div>
) : null}
{showModels && visibleModels.length ? (
<div className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent">
{visibleModels.map((model) =>
renderModelRow(model, { selected: model.id === value }),
)}
</div>
) : showModels ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{tx("settings.models.noModelResults", "No matching models.")}
</div>
) : null}
{customCandidate && !exactQueryMatch && customCandidate !== value ? (
<>
{showModels ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
onSelect={() => selectModel(customCandidate)}
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85"
>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
<Pencil className="h-3 w-3" aria-hidden />
</span>
<span className="min-w-0 truncate">
{tx("settings.models.useCustomModel", "Use")}{" "}
<span className="font-medium text-foreground">{customCandidate}</span>
</span>
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
function formatContextWindow(tokens: number): string {
if (tokens >= 1_000_000) {
const value = tokens / 1_000_000;
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
}
if (tokens >= 1_000) {
const value = tokens / 1_000;
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
}
return String(tokens);
}
function ProviderPickerIcon({
provider,
showBrandLogos,
@@ -4860,7 +5128,7 @@ function ModelPresetPicker({
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-[20rem] w-[430px] max-w-[calc(100vw-2rem)] overflow-y-auto"
className="max-h-[20rem] w-[430px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
>
{presets.map((preset) => {
const selected = preset.name === value;
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
AlertCircle,
Check,
CheckCircle2,
ChevronRight,
CircleDashed,
FileImage,
Layers,
Search,
Server,
@@ -16,8 +15,20 @@ import { useTranslation } from "react-i18next";
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
import { FileReferenceChip } from "@/components/FileReferenceChip";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { StreamingLabelSheen } from "@/components/MessageBubble";
import { ActivityEvidencePreview } from "@/components/thread/activity/ActivityEvidencePreview";
import { ActivityGroup } from "@/components/thread/activity/ActivityGroup";
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
import { DiffPair } from "@/components/thread/activity/DiffPair";
import { FileEditGroup, hasVisibleDiffStats, type FileEditSummary } from "@/components/thread/activity/FileEditRow";
import { ReasoningRow } from "@/components/thread/activity/ReasoningRow";
import {
activityEvidenceFromMessageMedia,
activityEvidenceFromToolEvent,
isAgentActivityMember,
isReasoningOnlyAssistant,
type ActivityEvidence,
} from "@/lib/activity-timeline";
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
import { formatToolCallTrace } from "@/lib/tool-traces";
import { cn } from "@/lib/utils";
@@ -27,15 +38,7 @@ import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessag
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
if (m.role !== "assistant" || m.kind === "trace") return false;
if (m.content.trim().length > 0) return false;
return !!(m.reasoning?.length || m.reasoningStreaming || m.isStreaming);
}
export function isAgentActivityMember(m: UIMessage): boolean {
return isReasoningOnlyAssistant(m) || m.kind === "trace";
}
export { isAgentActivityMember, isReasoningOnlyAssistant };
interface ActivityCounts {
reasoningSteps: number;
@@ -58,20 +61,6 @@ interface ActivityCounts {
primaryMcpStatus?: McpRunStatus;
}
interface FileEditSummary {
key: string;
path: string;
absolute_path?: string;
added: number;
deleted: number;
approximate: boolean;
binary: boolean;
status: UIFileEdit["status"];
operation?: UIFileEdit["operation"];
pending: boolean;
error?: string;
}
interface CliRunSummary {
key: string;
name: string;
@@ -485,7 +474,7 @@ export function AgentActivityCluster({
{outerExpanded && (
<div
className={cn(
"ml-2 mt-1 overflow-hidden border-l border-muted-foreground/14 pl-4",
"ml-1 mt-1 overflow-hidden pl-1",
)}
>
<div
@@ -497,11 +486,11 @@ export function AgentActivityCluster({
"overflow-y-auto py-1 pr-1 scrollbar-thin scrollbar-track-transparent",
)}
>
<div ref={activityContentRef} className="flex flex-col gap-1.5">
<div ref={activityContentRef} className="flex flex-col gap-0.5">
{messages.map((m) => {
if (isReasoningOnlyAssistant(m)) {
return (
<ActivityReasoningRow
<ReasoningRow
key={m.id}
text={m.reasoning ?? ""}
streaming={isTurnStreaming && !!m.reasoningStreaming}
@@ -638,101 +627,14 @@ function traceLines(message: UIMessage): string[] {
return message.content.trim() ? [message.content] : [];
}
function ActivityReasoningRow({
text,
streaming,
}: {
text: string;
streaming: boolean;
}) {
const { t } = useTranslation();
useEffect(() => {
if (text.length > 0) preloadMarkdownText();
}, [text.length]);
return (
<div className="min-w-0 py-0.5">
<div className="flex min-w-0 items-center gap-2 text-[13px] leading-5 text-muted-foreground/78">
<ReasoningMarker streaming={streaming} />
<StreamingLabelSheen active={streaming} className="min-w-0 font-medium">
{streaming
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
: t("message.reasoning", { defaultValue: "Thinking" })}
</StreamingLabelSheen>
</div>
{text.trim() ? (
<MarkdownText
streaming={streaming}
className={cn(
"mt-1 min-w-0 pl-5 text-[12.5px] italic text-muted-foreground/78",
"prose-p:my-1 prose-li:my-0.5",
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
"prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground",
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
"prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90",
"prose-code:text-[0.92em]",
)}
>
{text}
</MarkdownText>
) : null}
</div>
);
}
function ReasoningMarker({ streaming }: { streaming: boolean }) {
const wasStreamingRef = useRef(streaming);
const [justCompleted, setJustCompleted] = useState(false);
useEffect(() => {
if (wasStreamingRef.current && !streaming) {
setJustCompleted(true);
const timeout = window.setTimeout(() => setJustCompleted(false), 650);
wasStreamingRef.current = streaming;
return () => window.clearTimeout(timeout);
}
wasStreamingRef.current = streaming;
return undefined;
}, [streaming]);
if (streaming) {
return (
<CircleDashed
data-testid="activity-reasoning-marker"
data-state="thinking"
className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/55"
strokeWidth={1.8}
aria-hidden
/>
);
}
return (
<span
data-testid="activity-reasoning-marker"
data-state="done"
className={cn(
"grid h-3.5 w-3.5 shrink-0 place-items-center rounded-full border border-emerald-500/28 text-emerald-500/78",
"bg-emerald-500/[0.035] transition-[border-color,background-color,box-shadow,transform] duration-300 ease-out",
justCompleted
&& "animate-in fade-in-0 zoom-in-75 shadow-[0_0_0_3px_rgba(16,185,129,0.10)] motion-reduce:animate-none",
)}
aria-hidden
>
<Check
className={cn(
"h-2.5 w-2.5 stroke-[2.4]",
justCompleted && "animate-in fade-in-0 zoom-in-50 duration-300 motion-reduce:animate-none",
)}
/>
</span>
);
}
function ActivityTraceList({
lines,
active,
evidenceByLine,
}: {
lines: string[];
active: boolean;
evidenceByLine?: Map<string, ActivityEvidence[]>;
}) {
return (
<ul className="space-y-1">
@@ -741,6 +643,7 @@ function ActivityTraceList({
key={`${line}-${index}`}
line={line}
active={active && index === lines.length - 1}
evidence={evidenceByLine?.get(line) ?? []}
/>
))}
</ul>
@@ -761,6 +664,8 @@ function ActivityTraceTimeline({
const lines = traceLines(message);
const cliRunsByLine = cliRunMapByTraceLine(message);
const mcpRunsByLine = mcpRunMapByTraceLine(message);
const evidenceByLine = toolEvidenceByTraceLine(message);
const trailingEvidence = activityEvidenceFromMessageMedia(message);
const renderedRunKeys = new Set<string>();
const items: ReactNode[] = [];
let normalLines: string[] = [];
@@ -772,6 +677,7 @@ function ActivityTraceTimeline({
key={`${message.id}:trace:${suffix}`}
lines={normalLines}
active={active}
evidenceByLine={evidenceByLine}
/>,
);
normalLines = [];
@@ -790,6 +696,15 @@ function ActivityTraceTimeline({
cliAppsByName={cliAppsByName}
/>,
);
const evidence = evidenceByLine.get(line) ?? [];
if (evidence.length) {
items.push(
<ActivityEvidenceList
key={`${message.id}:cli-evidence:${cliRun.key}:${index}`}
evidence={evidence}
/>,
);
}
return;
}
@@ -805,6 +720,15 @@ function ActivityTraceTimeline({
mcpPresetsByName={mcpPresetsByName}
/>,
);
const evidence = evidenceByLine.get(line) ?? [];
if (evidence.length) {
items.push(
<ActivityEvidenceList
key={`${message.id}:mcp-evidence:${mcpRun.key}:${index}`}
evidence={evidence}
/>,
);
}
return;
}
@@ -836,10 +760,25 @@ function ActivityTraceTimeline({
);
}
return items.length ? <>{items}</> : null;
if (trailingEvidence.length) {
items.push(
<ActivityEvidenceList
key={`${message.id}:media-evidence`}
evidence={trailingEvidence}
/>,
);
}
if (!items.length) return null;
const group = describeActivityGroup(message, evidenceByLine, trailingEvidence);
return (
<ActivityGroup title={group.title} icon={group.icon}>
{items}
</ActivityGroup>
);
}
function ActivityTraceRow({ line, active }: { line: string; active: boolean }) {
function ActivityTraceRow({ line, active, evidence = [] }: { line: string; active: boolean; evidence?: ActivityEvidence[] }) {
const trace = describeTraceLine(line);
const Icon = trace.kind === "search"
? Search
@@ -849,21 +788,90 @@ function ActivityTraceRow({ line, active }: { line: string; active: boolean }) {
? Wrench
: Layers;
return (
<li className="flex min-w-0 items-start gap-2 py-0.5 text-[13px] leading-5">
<TraceIconMark trace={trace} fallbackIcon={Icon} active={active} />
<span className="min-w-0 flex-1">
<span className="font-medium text-muted-foreground/85">{trace.label}</span>
{trace.detail ? (
<>
<span className="text-muted-foreground/55"> </span>
<span className="break-words text-foreground/82">{trace.detail}</span>
</>
) : null}
</span>
</li>
<ActivityStep
as="li"
marker={<TraceIconMark trace={trace} fallbackIcon={Icon} active={active} />}
active={active && trace.kind !== "done"}
tone={trace.kind === "done" ? "success" : active ? "active" : "neutral"}
label={trace.label}
detail={trace.detail}
title={`${trace.label}${trace.detail ? ` ${trace.detail}` : ""}`}
>
<ActivityEvidencePreview evidence={evidence} />
</ActivityStep>
);
}
function ActivityEvidenceList({ evidence }: { evidence: ActivityEvidence[] }) {
return (
<ul className="space-y-1">
<ActivityStep
as="li"
icon={FileImage}
tone="success"
label={evidenceLabel(evidence)}
>
<ActivityEvidencePreview evidence={evidence} />
</ActivityStep>
</ul>
);
}
function evidenceLabel(evidence: ActivityEvidence[]): string {
const first = evidence[0]?.attachment.kind;
if (first === "image") return evidence.length > 1 ? "Found images" : "Found image";
if (first === "video") return evidence.length > 1 ? "Found videos" : "Found video";
return evidence.length > 1 ? "Found files" : "Found file";
}
function toolEvidenceByTraceLine(message: UIMessage): Map<string, ActivityEvidence[]> {
const map = new Map<string, ActivityEvidence[]>();
for (const event of message.toolEvents ?? []) {
const evidence = activityEvidenceFromToolEvent(event);
if (!evidence.length) continue;
const line = formatToolCallTrace(event);
if (!line) continue;
const existing = map.get(line) ?? [];
map.set(line, [...existing, ...evidence]);
}
return map;
}
function allToolEvidence(evidenceByLine: Map<string, ActivityEvidence[]>): ActivityEvidence[] {
return [...evidenceByLine.values()].flat();
}
function describeActivityGroup(
message: UIMessage,
evidenceByLine: Map<string, ActivityEvidence[]>,
mediaEvidence: ActivityEvidence[],
): { title: string; icon: LucideIcon } {
const names = [
...traceLines(message).map((line) => /^([a-zA-Z0-9_.-]+)\(/.exec(line.trim())?.[1] ?? line),
...(message.toolEvents ?? []).map(toolEventDisplayName),
].map((name) => name.toLowerCase());
const evidence = [...allToolEvidence(evidenceByLine), ...mediaEvidence];
const hasVisualEvidence = evidence.some((item) => item.attachment.kind === "image" || item.attachment.kind === "video");
if (hasVisualEvidence && names.some((name) => /browser|screenshot|vision|image|video/.test(name))) {
return { title: "Vision", icon: FileImage };
}
if (names.some((name) => /browser|screenshot/.test(name))) return { title: "Browser", icon: FileImage };
if (names.some((name) => /web|search|fetch|read|open/.test(name))) return { title: "Web", icon: Search };
if (names.some((name) => /exec|shell|terminal|bash|run_cli_app|cli_anything/.test(name))) return { title: "Shell", icon: Terminal };
if (names.some((name) => /^mcp_|mcp/.test(name))) return { title: "MCP", icon: Server };
if (message.fileEdits?.length) return { title: "Files", icon: Layers };
if (evidence.length) return { title: "Media", icon: FileImage };
return { title: "Working", icon: Layers };
}
function toolEventDisplayName(event: ToolProgressEvent): string {
return typeof (event as { function?: { name?: unknown } }).function?.name === "string"
? String((event as { function?: { name?: unknown } }).function?.name)
: typeof event.name === "string"
? event.name
: "";
}
interface TraceDescription {
kind: "search" | "tool" | "done" | "trace";
label: string;
@@ -891,7 +899,7 @@ function TraceIconMark({
<span
data-testid={`activity-web-favicon-${trace.host}`}
className={cn(
"mt-0.5 grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]",
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]",
active && "animate-pulse",
)}
aria-hidden
@@ -909,7 +917,7 @@ function TraceIconMark({
return (
<FallbackIcon
className={cn(
"mt-0.5 h-3.5 w-3.5 shrink-0",
"h-3.5 w-3.5 shrink-0",
trace.kind === "done"
? "text-emerald-500/75"
: active
@@ -945,7 +953,7 @@ function describeTraceLine(line: string): TraceDescription {
if (isShellTraceName(name)) {
return {
kind: "tool",
label: "Shell",
label: "Command",
detail: previewShellTraceDetail(args, trimmed),
};
}
@@ -1633,27 +1641,6 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
});
}
function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
return edit.added > 0 || edit.deleted > 0;
}
function formatFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return "";
const cleaned = firstLine
.replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\s*/i, "");
return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180);
}
function CliRunGroup({
runs,
active,
@@ -1694,40 +1681,42 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
useEffect(() => setLogoIndex(0), [app?.logo_url]);
return (
<li
className="flex min-w-0 items-center gap-2 py-0.5 text-[13px] leading-5"
<ActivityStep
as="li"
active={rowActive}
tone={failed ? "error" : rowActive ? "active" : run.status === "done" ? "success" : "neutral"}
title={`${label} @${run.name}${args ? ` ${args}` : ""}${run.error ? ` ${run.error}` : ""}`}
label={label}
marker={(
<span
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 22),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: rowActive ? `0 0 0 3px ${alphaColor(color, 9)}` : undefined,
}}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : app ? (
cliAppInitials(app).slice(0, 2)
) : (
<Terminal className="h-3 w-3" aria-hidden />
)}
</span>
)}
>
<span
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 22),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: rowActive ? `0 0 0 3px ${alphaColor(color, 9)}` : undefined,
}}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : app ? (
cliAppInitials(app).slice(0, 2)
) : (
<Terminal className="h-3 w-3" aria-hidden />
)}
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<StreamingLabelSheen active={rowActive} className="shrink-0 font-medium text-muted-foreground/85">
{label}
</StreamingLabelSheen>
<div className="-mt-0.5 flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
<span className="max-w-[11rem] shrink-0 truncate font-mono text-[12.5px] font-semibold text-foreground/90">
@{run.name}
</span>
@@ -1758,8 +1747,8 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
</span>
</>
) : null}
</span>
</li>
</div>
</ActivityStep>
);
}
@@ -1803,40 +1792,42 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
useEffect(() => setLogoIndex(0), [preset?.logo_url]);
return (
<li
className="flex min-w-0 items-center gap-2 py-0.5 text-[13px] leading-5"
<ActivityStep
as="li"
active={rowActive}
tone={failed ? "error" : rowActive ? "active" : run.status === "done" ? "success" : "neutral"}
title={`${label} ${displayName} ${run.toolName}${run.argsPreview ? ` ${run.argsPreview}` : ""}${run.error ? ` ${run.error}` : ""}`}
label={label}
marker={(
<span
data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`}
className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 22),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: rowActive ? `0 0 0 3px ${alphaColor(color, 9)}` : undefined,
}}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : preset ? (
mcpPresetInitials(preset).slice(0, 2)
) : (
<Server className="h-3 w-3" aria-hidden />
)}
</span>
)}
>
<span
data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`}
className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 22),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: rowActive ? `0 0 0 3px ${alphaColor(color, 9)}` : undefined,
}}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : preset ? (
mcpPresetInitials(preset).slice(0, 2)
) : (
<Server className="h-3 w-3" aria-hidden />
)}
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<StreamingLabelSheen active={rowActive} className="shrink-0 font-medium text-muted-foreground/85">
{label}
</StreamingLabelSheen>
<div className="-mt-0.5 flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
<span className="max-w-[12rem] shrink-0 truncate text-[12.5px] font-semibold text-foreground/90">
{displayName}
</span>
@@ -1856,8 +1847,8 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
</span>
</>
) : null}
</span>
</li>
</div>
</ActivityStep>
);
}
@@ -1870,180 +1861,3 @@ function alphaColor(color: string, percent: number): string {
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1">
{edits.map((edit) => (
<FileEditRow key={edit.key} edit={edit} />
))}
</ul>
);
}
function FileEditRow({ edit }: { edit: FileEditSummary }) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const failureDetail = failed
? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
: "";
return (
<li
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs"
title={failureDetail || edit.absolute_path || edit.path}
>
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-5 w-5 shrink-0 place-items-center text-muted-foreground/50">
{failed ? (
<AlertCircle className="h-3.5 w-3.5 text-destructive/75" aria-hidden />
) : editing ? (
<CircleDashed className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500/75" aria-hidden />
)}
</span>
{edit.pending && !edit.path ? (
<StreamingLabelSheen
active={editing}
className="min-w-0 text-[12px] font-medium text-muted-foreground"
>
{t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })}
</StreamingLabelSheen>
) : (
<FileReferenceChip
path={edit.path}
tooltipPath={edit.absolute_path}
display="path"
active={editing}
className="min-w-0"
textClassName="text-[12px]"
testId="activity-file-reference"
/>
)}
{failed ? (
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
{failureDetail}
</span>
) : null}
</div>
{hasCountedDiff ? (
<DiffPair added={edit.added} deleted={edit.deleted} />
) : null}
</li>
);
}
function DiffPair({ added, deleted }: { added: number; deleted: number }) {
return (
<span
className="inline-flex shrink-0 items-baseline gap-1.5 leading-[inherit] tabular-nums"
data-testid="activity-diff-pair"
>
<DiffValue
sign="+"
value={added}
className="text-emerald-600/75 dark:text-emerald-300/75"
/>
<DiffValue
sign="-"
value={deleted}
className="text-rose-600/70 dark:text-rose-300/75"
/>
</span>
);
}
function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return (
<span
className={cn("inline-flex items-baseline leading-[inherit]", className)}
aria-label={`${sign}${safeValue}`}
>
<span className="inline-flex items-baseline leading-none" aria-hidden>
{sign}
<AnimatedNumber value={safeValue} />
</span>
<span className="sr-only">{sign}{safeValue}</span>
</span>
);
}
function AnimatedNumber({ value }: { value: number }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
const [display, setDisplay] = useState(0);
const displayRef = useRef(0);
const setAnimatedDisplay = useCallback((next: number) => {
displayRef.current = next;
setDisplay(next);
}, []);
useEffect(() => {
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
setAnimatedDisplay(safeValue);
return;
}
const start = displayRef.current;
const delta = safeValue - start;
if (delta === 0) {
setAnimatedDisplay(safeValue);
return;
}
const duration = 260;
const startedAt = performance.now();
let frame = 0;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
setAnimatedDisplay(Math.round(start + delta * eased));
if (progress < 1) {
frame = window.requestAnimationFrame(tick);
return;
}
displayRef.current = safeValue;
};
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [safeValue, setAnimatedDisplay]);
return <RollingNumber value={display} />;
}
function RollingNumber({ value }: { value: number }) {
const digits = String(value).split("");
return (
<span className="inline-flex items-baseline leading-none" aria-hidden>
{digits.map((digit, index) => (
<RollingDigit
key={`${digits.length}-${index}`}
digit={Number(digit)}
/>
))}
</span>
);
}
function RollingDigit({ digit }: { digit: number }) {
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-baseline leading-none">
<span className="invisible block h-[1em] leading-none">0</span>
<span
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-200 ease-out will-change-transform"
style={{ transform: `translateY(-${safeDigit}em)` }}
>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="block h-[1em] leading-none">
{n}
</span>
))}
</span>
</span>
);
}
File diff suppressed because it is too large Load Diff
+41 -204
View File
@@ -2,15 +2,13 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble";
import {
AgentActivityCluster,
isAgentActivityMember,
} from "@/components/thread/AgentActivityCluster";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
/** When true, agent turn still in flight — keeps activity cluster expanded. */
/** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean;
hiddenMessageCount?: number;
onLoadEarlier?: () => void;
@@ -18,9 +16,7 @@ interface ThreadMessagesProps {
mcpPresets?: McpPresetInfo[];
}
export type DisplayUnit =
| { type: "cluster"; messages: UIMessage[] }
| { type: "single"; message: UIMessage };
export type DisplayUnit = TurnUnit;
/** True when this unit index is the last assistant text slice before the next user message (or end of thread). */
export function isFinalAssistantSliceBeforeNextUser(
@@ -28,170 +24,17 @@ export function isFinalAssistantSliceBeforeNextUser(
index: number,
): boolean {
const u = units[index];
if (u.type !== "single" || u.message.role !== "assistant") return true;
if (u.type !== "message" || u.message.role !== "assistant") return true;
for (let j = index + 1; j < units.length; j++) {
const v = units[j];
if (v.type === "single" && v.message.role === "user") break;
if (v.type === "message" && v.message.role === "user") break;
return false;
}
return true;
}
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
const out: DisplayUnit[] = [];
let i = 0;
while (i < messages.length) {
const m = messages[i];
if (isAgentActivityMember(m)) {
const cluster: UIMessage[] = [];
let segmentId: string | undefined = m.activitySegmentId;
let clusterHasFileEdits = hasFileEdits(m);
while (
i < messages.length
&& isAgentActivityMember(messages[i])
&& canJoinActivityCluster(segmentId, clusterHasFileEdits, messages[i])
) {
const current = messages[i];
if (!segmentId && current.activitySegmentId) {
segmentId = current.activitySegmentId;
}
clusterHasFileEdits = clusterHasFileEdits || hasFileEdits(current);
cluster.push(current);
i += 1;
}
pushActivityCluster(out, cluster);
continue;
}
const previous = out[out.length - 1];
if (
previous?.type === "cluster"
&& assistantHasInlineReasoning(m)
&& canFoldInlineReasoning(previous.messages, m)
) {
previous.messages.push(reasoningOnlyMessageFromAnswer(m));
out.push({ type: "single", message: stripInlineReasoning(m) });
i += 1;
continue;
}
if (assistantHasInlineReasoning(m)) {
out.push({ type: "cluster", messages: [reasoningOnlyMessageFromAnswer(m)] });
out.push({ type: "single", message: stripInlineReasoning(m) });
i += 1;
continue;
}
out.push({ type: "single", message: m });
i += 1;
}
return out;
}
function pushActivityCluster(out: DisplayUnit[], cluster: UIMessage[]) {
const previous = out[out.length - 1];
if (
previous?.type !== "single"
|| !shouldPlaceLateActivityBeforeAssistant(out, previous.message)
) {
out.push({ type: "cluster", messages: cluster });
return;
}
const beforeAssistant = out[out.length - 2];
if (beforeAssistant?.type === "cluster" && canMergeActivityClusters(beforeAssistant.messages, cluster)) {
beforeAssistant.messages.push(...cluster);
return;
}
out.splice(out.length - 1, 0, { type: "cluster", messages: cluster });
}
function shouldPlaceLateActivityBeforeAssistant(out: DisplayUnit[], message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.isStreaming) return true;
if (hasTurnLatency(message)) return true;
const beforeAssistant = out[out.length - 2];
return beforeAssistant?.type === "cluster";
}
function hasTurnLatency(message: UIMessage): boolean {
return (
typeof message.latencyMs === "number"
&& Number.isFinite(message.latencyMs)
&& message.latencyMs >= 0
);
}
function clusterSegmentId(messages: UIMessage[]): string | undefined {
return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
}
function hasFileEdits(message: UIMessage): boolean {
return !!message.fileEdits?.length;
}
function clusterHasFileEdits(messages: UIMessage[]): boolean {
return messages.some(hasFileEdits);
}
function canJoinActivityCluster(
clusterSegmentId: string | undefined,
clusterIncludesFileEdits: boolean,
message: UIMessage,
): boolean {
const messageHasFileEdits = hasFileEdits(message);
if (!clusterIncludesFileEdits && !messageHasFileEdits) return true;
if (!clusterSegmentId || !message.activitySegmentId) return true;
return clusterSegmentId === message.activitySegmentId;
}
function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boolean {
if (!clusterHasFileEdits(cluster) && !hasFileEdits(message)) return true;
const segmentId = clusterSegmentId(cluster);
if (!segmentId || !message.activitySegmentId) return true;
return segmentId === message.activitySegmentId;
}
function canMergeActivityClusters(target: UIMessage[], incoming: UIMessage[]): boolean {
let segmentId = clusterSegmentId(target);
let includesFileEdits = clusterHasFileEdits(target);
for (const message of incoming) {
if (!canJoinActivityCluster(segmentId, includesFileEdits, message)) return false;
if (!segmentId && message.activitySegmentId) {
segmentId = message.activitySegmentId;
}
includesFileEdits = includesFileEdits || hasFileEdits(message);
}
return true;
}
function assistantHasInlineReasoning(message: UIMessage): boolean {
return (
message.role === "assistant"
&& message.kind !== "trace"
&& message.content.trim().length > 0
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
);
}
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
return {
id: `${message.id}-reasoning`,
role: "assistant",
content: "",
createdAt: message.createdAt,
reasoning: message.reasoning,
reasoningStreaming: message.reasoningStreaming,
isStreaming: message.reasoningStreaming,
activitySegmentId: message.activitySegmentId,
latencyMs: message.latencyMs,
};
}
function stripInlineReasoning(message: UIMessage): UIMessage {
const next = { ...message };
delete next.reasoning;
delete next.reasoningStreaming;
return next;
return normalizeActivityTimeline(messages);
}
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
@@ -199,11 +42,11 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
let hasLaterUnitBeforeUser = false;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (unit.type === "single" && unit.message.role === "user") {
if (unit.type === "message" && unit.message.role === "user") {
hasLaterUnitBeforeUser = false;
continue;
}
if (unit.type === "single" && unit.message.role === "assistant") {
if (unit.type === "message" && unit.message.role === "assistant") {
flags[i] = !hasLaterUnitBeforeUser;
}
hasLaterUnitBeforeUser = true;
@@ -222,8 +65,8 @@ export function ThreadMessages({
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
const liveActivityClusterIndex = useMemo(
() => isStreaming ? currentActivityClusterIndex(units) : -1,
const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units],
);
@@ -251,20 +94,18 @@ export function ThreadMessages({
: "";
const next = units[index + 1];
const hasBodyBelow =
unit.type === "cluster"
&& next?.type === "single"
unit.type === "activity"
&& next?.type === "message"
&& next.message.role === "assistant";
const turnLatencyMs =
unit.type === "cluster" ? activityClusterTurnLatencyMs(unit.messages, next) : undefined;
return (
<div key={unitKey(unit, index)} className={marginTop}>
{unit.type === "cluster" ? (
{unit.type === "activity" ? (
<AgentActivityCluster
messages={unit.messages}
isTurnStreaming={index === liveActivityClusterIndex}
isTurnStreaming={liveActivityClusterIndices.has(index)}
hasBodyBelow={hasBodyBelow}
turnLatencyMs={turnLatencyMs}
turnLatencyMs={unit.turnLatencyMs}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
@@ -287,49 +128,45 @@ export function ThreadMessages({
);
}
function activityClusterTurnLatencyMs(
messages: UIMessage[],
next: DisplayUnit | undefined,
): number | undefined {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const latency = messages[i].latencyMs;
if (typeof latency === "number" && Number.isFinite(latency) && latency >= 0) {
return latency;
}
}
if (
next?.type === "single"
&& next.message.role === "assistant"
&& typeof next.message.latencyMs === "number"
&& Number.isFinite(next.message.latencyMs)
&& next.message.latencyMs >= 0
) {
return next.message.latencyMs;
}
return undefined;
}
function currentActivityClusterIndex(units: DisplayUnit[]): number {
function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
const indices = new Set<number>();
let markedCurrentActivity = false;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (unit.type === "cluster") return i;
if (unit.type === "activity") {
if (!markedCurrentActivity) {
indices.add(i);
markedCurrentActivity = true;
continue;
}
if (activityHasLiveFileEdit(unit)) {
indices.add(i);
}
continue;
}
if (unit.message.role === "assistant" && unit.message.isStreaming) continue;
if (unit.message.role === "user") break;
return -1;
}
return -1;
return indices;
}
function activityHasLiveFileEdit(unit: Extract<DisplayUnit, { type: "activity" }>): boolean {
return unit.messages.some((message) => (
message.kind === "trace"
&& message.fileEdits?.some((edit) => edit.status === "editing" || edit.pending || !edit.path)
));
}
function unitKey(unit: DisplayUnit, index: number): string {
if (unit.type === "cluster") {
if (unit.type === "activity") {
const anchor = unit.messages[0]?.id;
return anchor != null ? `cluster-${anchor}` : `cluster-idx-${index}`;
return anchor != null ? `activity-${anchor}` : `activity-idx-${index}`;
}
return unit.message.id;
}
function marginAfterPrevUnit(prev: DisplayUnit): string {
if (prev.type === "cluster") {
if (prev.type === "activity") {
return "mt-4";
}
const p = prev.message;
+1 -9
View File
@@ -167,7 +167,6 @@ export function ThreadShell({
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroImageMode, setHeroImageMode] = useState(false);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
@@ -211,8 +210,6 @@ export function ThreadShell({
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const imageGenerationEnabled = settings?.image_generation.enabled === true;
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
@@ -508,9 +505,6 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
runStartedAt={runStartedAt}
goalState={goalState}
@@ -520,6 +514,7 @@ export function ThreadShell({
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId}
/>
) : (
<ThreadComposer
@@ -538,9 +533,6 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
goalState={goalState}
workspaceScope={workspaceScope}
@@ -0,0 +1,35 @@
import { AttachmentTile } from "@/components/AttachmentTile";
import { cn } from "@/lib/utils";
import type { ActivityEvidence } from "@/lib/activity-timeline";
interface ActivityEvidencePreviewProps {
evidence: ActivityEvidence[];
className?: string;
}
export function ActivityEvidencePreview({ evidence, className }: ActivityEvidencePreviewProps) {
if (evidence.length === 0) return null;
return (
<div
data-testid="activity-evidence-preview"
className={cn(
"flex max-w-full flex-wrap items-start gap-2 pt-0.5",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200",
className,
)}
>
{evidence.slice(0, 4).map((item) => (
<AttachmentTile
key={item.id}
attachment={item.attachment}
variant="compact"
className={cn(
item.attachment.kind === "image" || item.attachment.kind === "video"
? "max-w-[min(100%,20rem)]"
: "max-w-[14rem]",
)}
/>
))}
</div>
);
}
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface ActivityGroupProps {
title: string;
icon?: LucideIcon;
children: ReactNode;
className?: string;
}
export function ActivityGroup({ title, icon: Icon, children, className }: ActivityGroupProps) {
return (
<section
className={cn(
"min-w-0 py-1 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-200",
className,
)}
>
<div className="mb-1 flex min-w-0 items-center gap-1.5 pl-0.5 text-[12px] font-medium text-muted-foreground/70">
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
<span className="min-w-0 truncate">{title}</span>
</div>
<div className="min-w-0">{children}</div>
</section>
);
}
@@ -0,0 +1,95 @@
import type { CSSProperties, ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { StreamingLabelSheen } from "@/components/MessageBubble";
import { cn } from "@/lib/utils";
export type ActivityStepTone = "neutral" | "active" | "success" | "error";
export interface ActivityStepProps {
as?: "div" | "li";
icon?: LucideIcon;
marker?: ReactNode;
label: ReactNode;
detail?: ReactNode;
aside?: ReactNode;
children?: ReactNode;
active?: boolean;
tone?: ActivityStepTone;
title?: string;
className?: string;
contentClassName?: string;
markerClassName?: string;
style?: CSSProperties;
}
export function ActivityStep({
as: Component = "div",
icon: Icon,
marker,
label,
detail,
aside,
children,
active = false,
tone = active ? "active" : "neutral",
title,
className,
contentClassName,
markerClassName,
style,
}: ActivityStepProps) {
return (
<Component
className={cn(
"group/activity-step relative grid min-w-0 grid-cols-[1.125rem_minmax(0,1fr)] gap-2 py-0.5 text-[13px] leading-5",
className,
)}
title={title}
style={style}
>
<span
className={cn(
"relative flex h-5 w-[1.125rem] shrink-0 items-start justify-center pt-[3px]",
"after:absolute after:left-1/2 after:top-[1.25rem] after:h-[calc(100%+0.375rem)] after:w-px after:-translate-x-1/2 after:bg-muted-foreground/14 group-last/activity-step:after:hidden",
)}
aria-hidden
>
{marker ?? (
<span
className={cn(
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
tone === "active" && "border-muted-foreground/28 text-muted-foreground/72",
tone === "success" && "border-emerald-500/28 text-emerald-500/78",
tone === "error" && "border-destructive/30 text-destructive/78",
tone === "neutral" && "border-muted-foreground/18 text-muted-foreground/50",
markerClassName,
)}
>
{Icon ? <Icon className="h-2.5 w-2.5" strokeWidth={2.15} /> : null}
</span>
)}
</span>
<div className={cn("min-w-0", contentClassName)}>
<div className="flex min-w-0 items-baseline gap-1.5">
<StreamingLabelSheen
active={active}
className={cn(
"min-w-0 shrink-0 font-medium",
tone === "error" ? "text-destructive/78" : "text-muted-foreground/85",
)}
>
{label}
</StreamingLabelSheen>
{detail ? (
<span className="min-w-0 break-words text-foreground/82">
{detail}
</span>
) : null}
{aside ? <span className="ml-auto shrink-0">{aside}</span> : null}
</div>
{children ? <div className="mt-1 min-w-0">{children}</div> : null}
</div>
</Component>
);
}
@@ -0,0 +1,114 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export function DiffPair({ added, deleted }: { added: number; deleted: number }) {
return (
<span
className="inline-flex shrink-0 items-baseline gap-1.5 leading-[inherit] tabular-nums"
data-testid="activity-diff-pair"
>
<DiffValue
sign="+"
value={added}
className="text-emerald-600/75 dark:text-emerald-300/75"
/>
<DiffValue
sign="-"
value={deleted}
className="text-rose-600/70 dark:text-rose-300/75"
/>
</span>
);
}
function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return (
<span
className={cn("inline-flex items-baseline leading-[inherit]", className)}
aria-label={`${sign}${safeValue}`}
>
<span className="inline-flex items-baseline leading-none" aria-hidden>
{sign}
<AnimatedNumber value={safeValue} />
</span>
<span className="sr-only">{sign}{safeValue}</span>
</span>
);
}
function AnimatedNumber({ value }: { value: number }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
const [display, setDisplay] = useState(0);
const displayRef = useRef(0);
const setAnimatedDisplay = useCallback((next: number) => {
displayRef.current = next;
setDisplay(next);
}, []);
useEffect(() => {
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
setAnimatedDisplay(safeValue);
return;
}
const start = displayRef.current;
const delta = safeValue - start;
if (delta === 0) {
setAnimatedDisplay(safeValue);
return;
}
const duration = 260;
const startedAt = performance.now();
let frame = 0;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
setAnimatedDisplay(Math.round(start + delta * eased));
if (progress < 1) {
frame = window.requestAnimationFrame(tick);
return;
}
displayRef.current = safeValue;
};
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [safeValue, setAnimatedDisplay]);
return <RollingNumber value={display} />;
}
function RollingNumber({ value }: { value: number }) {
const digits = String(value).split("");
return (
<span className="inline-flex items-baseline leading-none" aria-hidden>
{digits.map((digit, index) => (
<RollingDigit
key={`${digits.length}-${index}`}
digit={Number(digit)}
/>
))}
</span>
);
}
function RollingDigit({ digit }: { digit: number }) {
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-baseline leading-none">
<span className="invisible block h-[1em] leading-none">0</span>
<span
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-200 ease-out will-change-transform"
style={{ transform: `translateY(-${safeDigit}em)` }}
>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="block h-[1em] leading-none">
{n}
</span>
))}
</span>
</span>
);
}
@@ -0,0 +1,114 @@
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FileReferenceChip } from "@/components/FileReferenceChip";
import type { UIFileEdit } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ActivityStep } from "./ActivityStep";
import { DiffPair } from "./DiffPair";
export interface FileEditSummary {
key: string;
path: string;
absolute_path?: string;
added: number;
deleted: number;
approximate: boolean;
binary: boolean;
status: UIFileEdit["status"];
operation?: UIFileEdit["operation"];
pending: boolean;
error?: string;
}
export function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1">
{edits.map((edit) => (
<FileEditRow key={edit.key} edit={edit} />
))}
</ul>
);
}
function FileEditRow({ edit }: { edit: FileEditSummary }) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const failureDetail = failed
? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
: "";
const statusIcon = failed ? (
<AlertCircle className="h-3 w-3" aria-hidden />
) : editing ? (
<CircleDashed className="h-3 w-3 animate-spin" aria-hidden />
) : (
<CheckCircle2 className="h-3 w-3" aria-hidden />
);
return (
<ActivityStep
as="li"
marker={(
<span
className={cn(
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
failed && "border-destructive/30 text-destructive/78",
editing && "border-muted-foreground/24 text-muted-foreground/65",
!failed && !editing && "border-emerald-500/28 text-emerald-500/78",
)}
>
{statusIcon}
</span>
)}
active={editing}
tone={failed ? "error" : editing ? "active" : "success"}
className="text-xs"
contentClassName="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"
title={failureDetail || edit.absolute_path || edit.path}
label={edit.pending && !edit.path
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
: (
<FileReferenceChip
path={edit.path}
tooltipPath={edit.absolute_path}
display="path"
active={editing}
className="min-w-0"
textClassName="text-[12px]"
testId="activity-file-reference"
/>
)}
detail={failed ? (
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
{failureDetail}
</span>
) : null}
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
/>
);
}
export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
return edit.added > 0 || edit.deleted > 0;
}
function formatFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return "";
const cleaned = firstLine
.replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\s*/i, "");
return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180);
}
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react";
import { Check, CircleDashed } from "lucide-react";
import { useTranslation } from "react-i18next";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { ActivityStep } from "./ActivityStep";
export function ReasoningRow({
text,
streaming,
}: {
text: string;
streaming: boolean;
}) {
const { t } = useTranslation();
useEffect(() => {
if (text.length > 0) preloadMarkdownText();
}, [text.length]);
return (
<ActivityStep
marker={<ReasoningMarker streaming={streaming} />}
active={streaming}
tone={streaming ? "active" : "success"}
label={streaming
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
: t("message.reasoning", { defaultValue: "Thinking" })}
>
{text.trim() ? (
<MarkdownText
streaming={streaming}
className={cn(
"min-w-0 text-[12.5px] italic text-muted-foreground/78",
"prose-p:my-1 prose-li:my-0.5",
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
"prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground",
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
"prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90",
"prose-code:text-[0.92em]",
)}
>
{text}
</MarkdownText>
) : null}
</ActivityStep>
);
}
function ReasoningMarker({ streaming }: { streaming: boolean }) {
const wasStreamingRef = useRef(streaming);
const [justCompleted, setJustCompleted] = useState(false);
useEffect(() => {
if (wasStreamingRef.current && !streaming) {
setJustCompleted(true);
const timeout = window.setTimeout(() => setJustCompleted(false), 650);
wasStreamingRef.current = streaming;
return () => window.clearTimeout(timeout);
}
wasStreamingRef.current = streaming;
return undefined;
}, [streaming]);
if (streaming) {
return (
<CircleDashed
data-testid="activity-reasoning-marker"
data-state="thinking"
className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/55"
strokeWidth={1.8}
aria-hidden
/>
);
}
return (
<span
data-testid="activity-reasoning-marker"
data-state="done"
className={cn(
"grid h-3.5 w-3.5 shrink-0 place-items-center rounded-full border border-emerald-500/28 text-emerald-500/78",
"bg-emerald-500/[0.035] transition-[border-color,background-color,box-shadow,transform] duration-300 ease-out",
justCompleted
&& "animate-in fade-in-0 zoom-in-75 shadow-[0_0_0_3px_rgba(16,185,129,0.10)] motion-reduce:animate-none",
)}
aria-hidden
>
<Check
className={cn(
"h-2.5 w-2.5 stroke-[2.4]",
justCompleted && "animate-in fade-in-0 zoom-in-50 duration-300 motion-reduce:animate-none",
)}
/>
</span>
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuContentClassName =
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl scrollbar-thin scrollbar-track-transparent dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
const menuItemClassName =
"relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]";