feat(webui): render file edit activity
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FileReferenceKind =
|
||||
| "default"
|
||||
| "css"
|
||||
| "html"
|
||||
| "json"
|
||||
| "markdown"
|
||||
| "notebook"
|
||||
| "python"
|
||||
| "react"
|
||||
| "typescript";
|
||||
|
||||
interface FileReferenceChipProps {
|
||||
path: string;
|
||||
display?: "name" | "path";
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function FileReferenceChip({
|
||||
path,
|
||||
display = "name",
|
||||
active = false,
|
||||
className,
|
||||
textClassName,
|
||||
testId = "inline-file-path",
|
||||
}: FileReferenceChipProps) {
|
||||
const { name } = splitFilePath(path);
|
||||
const kind = fileKindForPath(path);
|
||||
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn("not-prose inline-flex max-w-full align-[0.14em]", className)}
|
||||
>
|
||||
<span
|
||||
data-testid={testId}
|
||||
aria-label={path}
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1 font-medium leading-[1.1]",
|
||||
"text-sky-600 transition-colors hover:text-sky-700",
|
||||
"dark:text-sky-300 dark:hover:text-sky-200",
|
||||
)}
|
||||
>
|
||||
<FileReferenceIcon kind={kind} />
|
||||
<span
|
||||
data-sheen-text={active ? displayText : undefined}
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
active && "streaming-text-sheen",
|
||||
textClassName,
|
||||
)}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className={cn(
|
||||
"max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]",
|
||||
"border-border/60 bg-popover/95 px-2.5 py-1.5",
|
||||
"break-all font-mono text-[11px] leading-snug text-popover-foreground",
|
||||
"shadow-lg backdrop-blur",
|
||||
)}
|
||||
>
|
||||
{path}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function isLikelyFilePath(value: string): boolean {
|
||||
const raw = value.trim();
|
||||
if (!raw || raw.includes("\n")) return false;
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
|
||||
if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) {
|
||||
return false;
|
||||
}
|
||||
const normalized = raw.replace(/\\/g, "/");
|
||||
const name = normalized.split("/").filter(Boolean).pop() ?? normalized;
|
||||
if (!name || name === "." || name === "..") return false;
|
||||
if (/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(name)) return true;
|
||||
return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
|
||||
}
|
||||
|
||||
function splitFilePath(path: string): { directory: string; name: string } {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const slash = normalized.lastIndexOf("/");
|
||||
if (slash < 0) return { directory: "", name: path };
|
||||
return {
|
||||
directory: normalized.slice(0, slash + 1),
|
||||
name: normalized.slice(slash + 1) || normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function fileKindForPath(path: string): FileReferenceKind {
|
||||
const normalized = path.toLowerCase();
|
||||
const name = normalized.split(/[\\/]/).pop() ?? normalized;
|
||||
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||
if (name === "dockerfile") {
|
||||
return "default";
|
||||
}
|
||||
switch (ext) {
|
||||
case "py":
|
||||
case "pyi":
|
||||
return "python";
|
||||
case "jsx":
|
||||
case "tsx":
|
||||
return "react";
|
||||
case "ts":
|
||||
return "typescript";
|
||||
case "html":
|
||||
case "htm":
|
||||
return "html";
|
||||
case "css":
|
||||
case "scss":
|
||||
case "sass":
|
||||
return "css";
|
||||
case "json":
|
||||
case "jsonl":
|
||||
return "json";
|
||||
case "md":
|
||||
case "mdx":
|
||||
return "markdown";
|
||||
case "ipynb":
|
||||
return "notebook";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
||||
if (kind === "react") {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none" />
|
||||
<ellipse cx="12" cy="12" rx="9" ry="3.7" />
|
||||
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(60 12 12)" />
|
||||
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(120 12 12)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (kind === "default") {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="h-[0.98em] w-[0.98em] shrink-0 text-sky-500 dark:text-sky-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.9"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7z" />
|
||||
<path d="M14 2v5h5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const label = fileKindLabel(kind);
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-flex h-[1.05em] min-w-[1.05em] shrink-0 items-center justify-center",
|
||||
"rounded-[4px] bg-sky-500/12 px-[0.22em] text-[0.58em] font-bold uppercase leading-none",
|
||||
"text-sky-600 dark:bg-sky-400/15 dark:text-sky-300",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function fileKindLabel(kind: FileReferenceKind): string {
|
||||
switch (kind) {
|
||||
case "css":
|
||||
return "#";
|
||||
case "html":
|
||||
return "H";
|
||||
case "json":
|
||||
return "{}";
|
||||
case "markdown":
|
||||
return "M";
|
||||
case "notebook":
|
||||
return "N";
|
||||
case "python":
|
||||
return "PY";
|
||||
case "typescript":
|
||||
return "TS";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChevronRight, Layers } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { UIFileEdit, UIMessage } from "@/lib/types";
|
||||
|
||||
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||
@@ -20,7 +21,29 @@ export function isAgentActivityMember(m: UIMessage): boolean {
|
||||
return isReasoningOnlyAssistant(m) || m.kind === "trace";
|
||||
}
|
||||
|
||||
function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCalls: number } {
|
||||
interface ActivityCounts {
|
||||
reasoningSteps: number;
|
||||
toolCalls: number;
|
||||
fileCount: number;
|
||||
added: number;
|
||||
deleted: number;
|
||||
hasEditingFiles: boolean;
|
||||
hasFailedFiles: boolean;
|
||||
primaryFilePath?: string;
|
||||
}
|
||||
|
||||
interface FileEditSummary {
|
||||
key: string;
|
||||
path: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate: boolean;
|
||||
binary: boolean;
|
||||
status: UIFileEdit["status"];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
|
||||
let reasoningSteps = 0;
|
||||
let toolCalls = 0;
|
||||
for (const m of messages) {
|
||||
@@ -30,10 +53,38 @@ function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCal
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
toolCalls += Math.max(lines, 1);
|
||||
toolCalls += lines;
|
||||
}
|
||||
}
|
||||
return { reasoningSteps, toolCalls };
|
||||
let added = 0;
|
||||
let deleted = 0;
|
||||
let hasEditingFiles = false;
|
||||
let failedFileCount = 0;
|
||||
let primaryFilePath: string | undefined;
|
||||
for (const edit of fileEdits) {
|
||||
primaryFilePath = edit.path;
|
||||
if (edit.status === "editing") {
|
||||
hasEditingFiles = true;
|
||||
}
|
||||
if (edit.status === "error") {
|
||||
failedFileCount += 1;
|
||||
}
|
||||
if (edit.status === "error" || edit.binary) {
|
||||
continue;
|
||||
}
|
||||
added += edit.added;
|
||||
deleted += edit.deleted;
|
||||
}
|
||||
return {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
fileCount: fileEdits.length,
|
||||
added,
|
||||
deleted,
|
||||
hasEditingFiles,
|
||||
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
||||
primaryFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentActivityClusterProps {
|
||||
@@ -53,7 +104,20 @@ export function AgentActivityCluster({
|
||||
hasBodyBelow,
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { reasoningSteps, toolCalls } = countActivity(messages);
|
||||
const fileEdits = useMemo(
|
||||
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||
[messages, isTurnStreaming],
|
||||
);
|
||||
const {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
fileCount,
|
||||
added,
|
||||
deleted,
|
||||
hasEditingFiles,
|
||||
hasFailedFiles,
|
||||
primaryFilePath,
|
||||
} = countActivity(messages, fileEdits);
|
||||
|
||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
|
||||
@@ -64,16 +128,32 @@ export function AgentActivityCluster({
|
||||
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
|
||||
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
||||
|
||||
const headerBusy = isTurnStreaming;
|
||||
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
|
||||
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
||||
|
||||
const summary =
|
||||
isTurnStreaming
|
||||
const fileActivitySummary = fileCount > 0
|
||||
? fileCount === 1 && primaryFilePath
|
||||
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
||||
file: shortFileName(primaryFilePath),
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
|
||||
})
|
||||
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
||||
count: fileCount,
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`,
|
||||
})
|
||||
: "";
|
||||
|
||||
const summary = fileCount > 0
|
||||
? fileActivitySummary
|
||||
: isTurnStreaming
|
||||
? reasoningSteps > 0
|
||||
? t("message.agentActivityLiveSummary", {
|
||||
reasoning: reasoningSteps,
|
||||
tools: toolCalls,
|
||||
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||
})
|
||||
: toolCalls === 0 && fileCount > 0
|
||||
? t("message.agentActivityLiveFilesOnly", { defaultValue: "Working…" })
|
||||
: t("message.agentActivityLiveToolsOnly", {
|
||||
tools: toolCalls,
|
||||
defaultValue: "Working… · {{tools}} tool calls",
|
||||
@@ -84,6 +164,8 @@ export function AgentActivityCluster({
|
||||
tools: toolCalls,
|
||||
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
|
||||
})
|
||||
: toolCalls === 0 && fileCount > 0
|
||||
? t("message.agentActivityFilesOnly", { defaultValue: "File changes" })
|
||||
: t("message.agentActivityToolsOnly", {
|
||||
tools: toolCalls,
|
||||
defaultValue: "{{tools}} tool calls",
|
||||
@@ -161,12 +243,19 @@ export function AgentActivityCluster({
|
||||
aria-expanded={outerExpanded}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<StreamingLabelSheen
|
||||
active={headerBusy}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
{summary}
|
||||
</StreamingLabelSheen>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
||||
<StreamingLabelSheen
|
||||
active={headerBusy}
|
||||
className="min-w-0"
|
||||
>
|
||||
{summary}
|
||||
</StreamingLabelSheen>
|
||||
{fileCount > 0 && (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
|
||||
<DiffPair added={added} deleted={deleted} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
@@ -198,17 +287,23 @@ export function AgentActivityCluster({
|
||||
<ReasoningBubble
|
||||
key={m.id}
|
||||
text={m.reasoning ?? ""}
|
||||
streaming={!!m.reasoningStreaming}
|
||||
streaming={isTurnStreaming && !!m.reasoningStreaming}
|
||||
hasBodyBelow={false}
|
||||
embeddedInCluster
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
return <TraceGroup key={m.id} message={m} animClass="" />;
|
||||
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
|
||||
return hasTraceLines ? (
|
||||
<div key={m.id} className="flex flex-col gap-1">
|
||||
<TraceGroup message={m} animClass="" />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,3 +311,231 @@ export function AgentActivityCluster({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortFileName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() || path;
|
||||
}
|
||||
|
||||
function fileActivityVerb(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "Failed";
|
||||
return editing ? "Editing" : "Edited";
|
||||
}
|
||||
|
||||
function fileActivitySummaryKey(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "message.fileActivityFailedOne";
|
||||
return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne";
|
||||
}
|
||||
|
||||
function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "message.fileActivityFailedMany";
|
||||
return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany";
|
||||
}
|
||||
|
||||
function fileEditCallKey(edit: UIFileEdit): string {
|
||||
return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
||||
}
|
||||
|
||||
function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
|
||||
const edits: UIFileEdit[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.kind === "trace" && message.fileEdits?.length) {
|
||||
edits.push(...message.fileEdits);
|
||||
}
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
||||
const order: string[] = [];
|
||||
const byKey = new Map<string, UIFileEdit>();
|
||||
for (const edit of edits) {
|
||||
const key = fileEditCallKey(edit);
|
||||
if (!byKey.has(key)) order.push(key);
|
||||
byKey.set(key, edit);
|
||||
}
|
||||
return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
|
||||
}
|
||||
|
||||
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
||||
interface MutableSummary {
|
||||
key: string;
|
||||
path: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate: boolean;
|
||||
binary: boolean;
|
||||
hasSuccessfulChange: boolean;
|
||||
hasActiveEditing: boolean;
|
||||
hasFailed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const order: string[] = [];
|
||||
const byPath = new Map<string, MutableSummary>();
|
||||
for (const edit of latestFileEditEvents(edits)) {
|
||||
const key = edit.path;
|
||||
let summary = byPath.get(key);
|
||||
if (!summary) {
|
||||
summary = {
|
||||
key,
|
||||
path: edit.path,
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
approximate: false,
|
||||
binary: false,
|
||||
hasSuccessfulChange: false,
|
||||
hasActiveEditing: false,
|
||||
hasFailed: false,
|
||||
};
|
||||
byPath.set(key, summary);
|
||||
order.push(key);
|
||||
}
|
||||
|
||||
if (active && edit.status === "editing") {
|
||||
summary.hasActiveEditing = true;
|
||||
summary.binary = summary.binary || !!edit.binary;
|
||||
summary.approximate = summary.approximate || !!edit.approximate;
|
||||
if (!edit.binary) {
|
||||
summary.added += edit.added;
|
||||
summary.deleted += edit.deleted;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edit.status === "error") {
|
||||
summary.hasFailed = true;
|
||||
summary.error = edit.error ?? summary.error;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.hasSuccessfulChange = true;
|
||||
summary.binary = summary.binary || !!edit.binary;
|
||||
summary.approximate = active && (summary.approximate || !!edit.approximate);
|
||||
if (!edit.binary) {
|
||||
summary.added += edit.added;
|
||||
summary.deleted += edit.deleted;
|
||||
}
|
||||
}
|
||||
|
||||
return order.map((key) => {
|
||||
const summary = byPath.get(key)!;
|
||||
const status: UIFileEdit["status"] = summary.hasActiveEditing
|
||||
? "editing"
|
||||
: summary.hasSuccessfulChange
|
||||
? "done"
|
||||
: summary.hasFailed
|
||||
? "error"
|
||||
: "done";
|
||||
return {
|
||||
key: summary.key,
|
||||
path: summary.path,
|
||||
added: summary.added,
|
||||
deleted: summary.deleted,
|
||||
approximate: summary.approximate,
|
||||
binary: summary.binary,
|
||||
status,
|
||||
error: summary.error,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
|
||||
if (edits.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-1 border-l border-muted-foreground/15 pl-3">
|
||||
{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;
|
||||
return (
|
||||
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileReferenceChip
|
||||
path={edit.path}
|
||||
display="path"
|
||||
active={editing}
|
||||
className="min-w-0"
|
||||
textClassName="text-[12px]"
|
||||
testId="activity-file-reference"
|
||||
/>
|
||||
{failed ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
|
||||
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||
{t("message.fileEditFailed", { defaultValue: "Failed" })}
|
||||
</span>
|
||||
) : null}
|
||||
{edit.approximate && !failed ? (
|
||||
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground/55">
|
||||
{t("message.fileEditApproximate", { defaultValue: "estimated" })}
|
||||
</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-center gap-1.5 tabular-nums">
|
||||
<span className="text-emerald-600/75 dark:text-emerald-300/75">
|
||||
+<AnimatedNumber value={added} />
|
||||
</span>
|
||||
<span className="text-rose-600/70 dark:text-rose-300/75">
|
||||
-<AnimatedNumber value={deleted} />
|
||||
</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 <>{display}</>;
|
||||
}
|
||||
|
||||
@@ -42,26 +42,77 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
const m = messages[i];
|
||||
if (isAgentActivityMember(m)) {
|
||||
const cluster: UIMessage[] = [];
|
||||
while (i < messages.length && isAgentActivityMember(messages[i])) {
|
||||
cluster.push(messages[i]);
|
||||
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;
|
||||
}
|
||||
out.push({ type: "cluster", messages: cluster });
|
||||
continue;
|
||||
}
|
||||
const previous = out[out.length - 1];
|
||||
if (previous?.type === "cluster" && assistantHasInlineReasoning(m)) {
|
||||
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 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 assistantHasInlineReasoning(message: UIMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant"
|
||||
@@ -80,6 +131,7 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
||||
reasoning: message.reasoning,
|
||||
reasoningStreaming: message.reasoningStreaming,
|
||||
isStreaming: message.reasoningStreaming,
|
||||
activitySegmentId: message.activitySegmentId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +168,10 @@ 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,
|
||||
[isStreaming, units],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
@@ -150,7 +206,7 @@ export function ThreadMessages({
|
||||
{unit.type === "cluster" ? (
|
||||
<AgentActivityCluster
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={isStreaming}
|
||||
isTurnStreaming={index === liveActivityClusterIndex}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
/>
|
||||
) : (
|
||||
@@ -170,6 +226,11 @@ export function ThreadMessages({
|
||||
);
|
||||
}
|
||||
|
||||
function currentActivityClusterIndex(units: DisplayUnit[]): number {
|
||||
const last = units.length - 1;
|
||||
return units[last]?.type === "cluster" ? last : -1;
|
||||
}
|
||||
|
||||
function unitKey(unit: DisplayUnit, index: number): string {
|
||||
if (unit.type === "cluster") {
|
||||
const anchor = unit.messages[0]?.id;
|
||||
|
||||
Reference in New Issue
Block a user