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
@@ -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>
);
}