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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user