mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 08:02:30 +00:00
feat(webui): render live file edit activity
This commit is contained in:
parent
7e2dbdef7d
commit
0537cc1682
@ -19,6 +19,7 @@ type FileReferenceKind =
|
|||||||
|
|
||||||
interface FileReferenceChipProps {
|
interface FileReferenceChipProps {
|
||||||
path: string;
|
path: string;
|
||||||
|
tooltipPath?: string;
|
||||||
display?: "name" | "path";
|
display?: "name" | "path";
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -28,27 +29,29 @@ interface FileReferenceChipProps {
|
|||||||
|
|
||||||
export function FileReferenceChip({
|
export function FileReferenceChip({
|
||||||
path,
|
path,
|
||||||
|
tooltipPath,
|
||||||
display = "name",
|
display = "name",
|
||||||
active = false,
|
active = false,
|
||||||
className,
|
className,
|
||||||
textClassName,
|
textClassName,
|
||||||
testId = "inline-file-path",
|
testId = "inline-file-path",
|
||||||
}: FileReferenceChipProps) {
|
}: FileReferenceChipProps) {
|
||||||
const { name } = splitFilePath(path);
|
const { directory, name } = splitFilePath(path);
|
||||||
const kind = fileKindForPath(path);
|
const kind = fileKindForPath(path);
|
||||||
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
|
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
|
||||||
|
const fullPath = tooltipPath || path;
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span
|
||||||
className={cn("not-prose inline-flex max-w-full align-[0.14em]", className)}
|
className={cn("not-prose inline-flex max-w-full align-baseline leading-[inherit]", className)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
aria-label={path}
|
aria-label={fullPath}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex max-w-full items-center gap-1 font-medium leading-[1.1]",
|
"inline-flex max-w-full items-center gap-1 font-medium leading-[inherit]",
|
||||||
"text-sky-600 transition-colors hover:text-sky-700",
|
"text-sky-600 transition-colors hover:text-sky-700",
|
||||||
"dark:text-sky-300 dark:hover:text-sky-200",
|
"dark:text-sky-300 dark:hover:text-sky-200",
|
||||||
)}
|
)}
|
||||||
@ -57,12 +60,19 @@ export function FileReferenceChip({
|
|||||||
<span
|
<span
|
||||||
data-sheen-text={active ? displayText : undefined}
|
data-sheen-text={active ? displayText : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 truncate",
|
"min-w-0 max-w-full truncate",
|
||||||
active && "streaming-text-sheen",
|
active && "streaming-text-sheen file-reference-sheen",
|
||||||
textClassName,
|
textClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displayText}
|
{display === "path" && directory ? (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/65">{directory}</span>
|
||||||
|
<span className="font-semibold text-sky-700 dark:text-sky-200">{name}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
displayText
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -79,7 +89,7 @@ export function FileReferenceChip({
|
|||||||
"shadow-lg backdrop-blur",
|
"shadow-lg backdrop-blur",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{path}
|
{fullPath}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@ -30,16 +30,19 @@ interface ActivityCounts {
|
|||||||
hasEditingFiles: boolean;
|
hasEditingFiles: boolean;
|
||||||
hasFailedFiles: boolean;
|
hasFailedFiles: boolean;
|
||||||
primaryFilePath?: string;
|
primaryFilePath?: string;
|
||||||
|
primaryFileTooltipPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileEditSummary {
|
interface FileEditSummary {
|
||||||
key: string;
|
key: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
absolute_path?: string;
|
||||||
added: number;
|
added: number;
|
||||||
deleted: number;
|
deleted: number;
|
||||||
approximate: boolean;
|
approximate: boolean;
|
||||||
binary: boolean;
|
binary: boolean;
|
||||||
status: UIFileEdit["status"];
|
status: UIFileEdit["status"];
|
||||||
|
pending: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,8 +64,10 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
|
|||||||
let hasEditingFiles = false;
|
let hasEditingFiles = false;
|
||||||
let failedFileCount = 0;
|
let failedFileCount = 0;
|
||||||
let primaryFilePath: string | undefined;
|
let primaryFilePath: string | undefined;
|
||||||
|
let primaryFileTooltipPath: string | undefined;
|
||||||
for (const edit of fileEdits) {
|
for (const edit of fileEdits) {
|
||||||
primaryFilePath = edit.path;
|
primaryFilePath = edit.path;
|
||||||
|
primaryFileTooltipPath = edit.absolute_path || edit.path;
|
||||||
if (edit.status === "editing") {
|
if (edit.status === "editing") {
|
||||||
hasEditingFiles = true;
|
hasEditingFiles = true;
|
||||||
}
|
}
|
||||||
@ -84,6 +89,7 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
|
|||||||
hasEditingFiles,
|
hasEditingFiles,
|
||||||
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
||||||
primaryFilePath,
|
primaryFilePath,
|
||||||
|
primaryFileTooltipPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,7 +123,9 @@ export function AgentActivityCluster({
|
|||||||
hasEditingFiles,
|
hasEditingFiles,
|
||||||
hasFailedFiles,
|
hasFailedFiles,
|
||||||
primaryFilePath,
|
primaryFilePath,
|
||||||
|
primaryFileTooltipPath,
|
||||||
} = countActivity(messages, fileEdits);
|
} = countActivity(messages, fileEdits);
|
||||||
|
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
|
||||||
|
|
||||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||||
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
|
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
|
||||||
@ -130,11 +138,15 @@ export function AgentActivityCluster({
|
|||||||
|
|
||||||
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
|
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
|
||||||
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
||||||
|
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
|
||||||
|
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
|
||||||
|
|
||||||
const fileActivitySummary = fileCount > 0
|
const fileActivitySummary = fileCount > 0
|
||||||
? fileCount === 1 && primaryFilePath
|
? hasPendingFileEdit && !singleFilePath
|
||||||
|
? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" })
|
||||||
|
: singleFilePath
|
||||||
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
||||||
file: shortFileName(primaryFilePath),
|
file: shortFileName(singleFilePath),
|
||||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
|
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
|
||||||
})
|
})
|
||||||
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
||||||
@ -241,15 +253,35 @@ export function AgentActivityCluster({
|
|||||||
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
||||||
)}
|
)}
|
||||||
aria-expanded={outerExpanded}
|
aria-expanded={outerExpanded}
|
||||||
|
aria-label={summary}
|
||||||
>
|
>
|
||||||
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
||||||
<StreamingLabelSheen
|
{singleFilePath ? (
|
||||||
active={headerBusy}
|
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||||
className="min-w-0"
|
<StreamingLabelSheen
|
||||||
>
|
active={headerBusy}
|
||||||
{summary}
|
className="shrink-0"
|
||||||
</StreamingLabelSheen>
|
>
|
||||||
|
{fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)}
|
||||||
|
</StreamingLabelSheen>
|
||||||
|
<FileReferenceChip
|
||||||
|
path={singleFilePath}
|
||||||
|
tooltipPath={singleFileTooltipPath}
|
||||||
|
active={hasLiveEditingFiles}
|
||||||
|
className="-my-0.5 min-w-0"
|
||||||
|
textClassName="text-xs"
|
||||||
|
testId="activity-header-file-reference"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<StreamingLabelSheen
|
||||||
|
active={headerBusy}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
</StreamingLabelSheen>
|
||||||
|
)}
|
||||||
{fileCount > 0 && (
|
{fileCount > 0 && (
|
||||||
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
|
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
|
||||||
<DiffPair added={added} deleted={deleted} />
|
<DiffPair added={added} deleted={deleted} />
|
||||||
@ -332,7 +364,8 @@ function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fileEditCallKey(edit: UIFileEdit): string {
|
function fileEditCallKey(edit: UIFileEdit): string {
|
||||||
return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
|
||||||
|
return `${edit.tool}|${edit.path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
|
function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
|
||||||
@ -360,10 +393,12 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
|||||||
interface MutableSummary {
|
interface MutableSummary {
|
||||||
key: string;
|
key: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
absolute_path?: string;
|
||||||
added: number;
|
added: number;
|
||||||
deleted: number;
|
deleted: number;
|
||||||
approximate: boolean;
|
approximate: boolean;
|
||||||
binary: boolean;
|
binary: boolean;
|
||||||
|
pending: boolean;
|
||||||
hasSuccessfulChange: boolean;
|
hasSuccessfulChange: boolean;
|
||||||
hasActiveEditing: boolean;
|
hasActiveEditing: boolean;
|
||||||
hasFailed: boolean;
|
hasFailed: boolean;
|
||||||
@ -373,16 +408,18 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
|||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const byPath = new Map<string, MutableSummary>();
|
const byPath = new Map<string, MutableSummary>();
|
||||||
for (const edit of latestFileEditEvents(edits)) {
|
for (const edit of latestFileEditEvents(edits)) {
|
||||||
const key = edit.path;
|
const key = edit.path || edit.call_id || edit.tool;
|
||||||
let summary = byPath.get(key);
|
let summary = byPath.get(key);
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
summary = {
|
summary = {
|
||||||
key,
|
key,
|
||||||
path: edit.path,
|
path: edit.path || "",
|
||||||
|
absolute_path: edit.absolute_path,
|
||||||
added: 0,
|
added: 0,
|
||||||
deleted: 0,
|
deleted: 0,
|
||||||
approximate: false,
|
approximate: false,
|
||||||
binary: false,
|
binary: false,
|
||||||
|
pending: false,
|
||||||
hasSuccessfulChange: false,
|
hasSuccessfulChange: false,
|
||||||
hasActiveEditing: false,
|
hasActiveEditing: false,
|
||||||
hasFailed: false,
|
hasFailed: false,
|
||||||
@ -391,6 +428,13 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
|||||||
order.push(key);
|
order.push(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (edit.path && !summary.path) {
|
||||||
|
summary.path = edit.path;
|
||||||
|
}
|
||||||
|
if (edit.absolute_path) {
|
||||||
|
summary.absolute_path = edit.absolute_path;
|
||||||
|
}
|
||||||
|
summary.pending = summary.pending || !!edit.pending || !edit.path;
|
||||||
if (active && edit.status === "editing") {
|
if (active && edit.status === "editing") {
|
||||||
summary.hasActiveEditing = true;
|
summary.hasActiveEditing = true;
|
||||||
summary.binary = summary.binary || !!edit.binary;
|
summary.binary = summary.binary || !!edit.binary;
|
||||||
@ -429,11 +473,13 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
|||||||
return {
|
return {
|
||||||
key: summary.key,
|
key: summary.key,
|
||||||
path: summary.path,
|
path: summary.path,
|
||||||
|
absolute_path: summary.absolute_path,
|
||||||
added: summary.added,
|
added: summary.added,
|
||||||
deleted: summary.deleted,
|
deleted: summary.deleted,
|
||||||
approximate: summary.approximate,
|
approximate: summary.approximate,
|
||||||
binary: summary.binary,
|
binary: summary.binary,
|
||||||
status,
|
status,
|
||||||
|
pending: summary.pending && !summary.path,
|
||||||
error: summary.error,
|
error: summary.error,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -458,14 +504,24 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
|||||||
return (
|
return (
|
||||||
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs">
|
<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">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<FileReferenceChip
|
{edit.pending && !edit.path ? (
|
||||||
path={edit.path}
|
<StreamingLabelSheen
|
||||||
display="path"
|
active={editing}
|
||||||
active={editing}
|
className="min-w-0 text-[12px] font-medium text-muted-foreground"
|
||||||
className="min-w-0"
|
>
|
||||||
textClassName="text-[12px]"
|
{t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })}
|
||||||
testId="activity-file-reference"
|
</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 ? (
|
{failed ? (
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
|
<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 />
|
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||||
@ -487,13 +543,30 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
|||||||
|
|
||||||
function DiffPair({ added, deleted }: { added: number; deleted: number }) {
|
function DiffPair({ added, deleted }: { added: number; deleted: number }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex shrink-0 items-center gap-1.5 tabular-nums">
|
<span className="inline-flex shrink-0 translate-y-[0.055em] items-center gap-1.5 tabular-nums">
|
||||||
<span className="text-emerald-600/75 dark:text-emerald-300/75">
|
<DiffValue
|
||||||
+<AnimatedNumber value={added} />
|
sign="+"
|
||||||
</span>
|
value={added}
|
||||||
<span className="text-rose-600/70 dark:text-rose-300/75">
|
className="text-emerald-600/75 dark:text-emerald-300/75"
|
||||||
-<AnimatedNumber value={deleted} />
|
/>
|
||||||
|
<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", className)} aria-label={`${sign}${safeValue}`}>
|
||||||
|
<span className="inline-flex" aria-hidden>
|
||||||
|
{sign}
|
||||||
|
<AnimatedNumber value={safeValue} />
|
||||||
</span>
|
</span>
|
||||||
|
<span className="sr-only">{sign}{safeValue}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -537,5 +610,37 @@ function AnimatedNumber({ value }: { value: number }) {
|
|||||||
return () => window.cancelAnimationFrame(frame);
|
return () => window.cancelAnimationFrame(frame);
|
||||||
}, [safeValue, setAnimatedDisplay]);
|
}, [safeValue, setAnimatedDisplay]);
|
||||||
|
|
||||||
return <>{display}</>;
|
return <RollingNumber value={display} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RollingNumber({ value }: { value: number }) {
|
||||||
|
const digits = String(value).split("");
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-[1em] overflow-hidden align-[-0.13em]" 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">
|
||||||
|
<span
|
||||||
|
className="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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,6 +131,9 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
.file-reference-sheen {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
.streaming-text-sheen::after {
|
.streaming-text-sheen::after {
|
||||||
content: attr(data-sheen-text);
|
content: attr(data-sheen-text);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -215,18 +215,19 @@ function absorbCompleteAssistantMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
||||||
return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
|
||||||
|
return `${edit.tool}|${edit.path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
|
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
|
||||||
if (!edit || !edit.path || !edit.tool) return null;
|
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
|
||||||
const inferredStatus =
|
const inferredStatus =
|
||||||
edit.phase === "error"
|
edit.phase === "error"
|
||||||
? "error"
|
? "error"
|
||||||
: edit.phase === "end"
|
: edit.phase === "end"
|
||||||
? "done"
|
? "done"
|
||||||
: "editing";
|
: "editing";
|
||||||
return {
|
const normalized: UIFileEdit = {
|
||||||
...edit,
|
...edit,
|
||||||
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
|
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
|
||||||
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
|
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
|
||||||
@ -235,6 +236,8 @@ function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
|
|||||||
? edit.status
|
? edit.status
|
||||||
: inferredStatus,
|
: inferredStatus,
|
||||||
};
|
};
|
||||||
|
if (edit.pending && !edit.path) normalized.pending = true;
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
|
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
|
||||||
@ -250,11 +253,31 @@ function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit
|
|||||||
next.push(edit);
|
next.push(edit);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
next[existingIndex] = { ...next[existingIndex], ...edit };
|
const merged = { ...next[existingIndex], ...edit };
|
||||||
|
if (edit.path && !edit.pending) delete merged.pending;
|
||||||
|
next[existingIndex] = merged;
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findFileEditTraceIndex(
|
||||||
|
prev: UIMessage[],
|
||||||
|
segmentId: string | null,
|
||||||
|
incoming: UIFileEdit[],
|
||||||
|
): number | null {
|
||||||
|
const incomingKeys = new Set(incoming.map(fileEditKey));
|
||||||
|
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||||
|
const candidate = prev[i];
|
||||||
|
if (candidate.role === "user") break;
|
||||||
|
if (candidate.kind !== "trace" || !candidate.fileEdits?.length) continue;
|
||||||
|
if (segmentId && candidate.activitySegmentId === segmentId) return i;
|
||||||
|
for (const existing of candidate.fileEdits) {
|
||||||
|
if (incomingKeys.has(fileEditKey(existing))) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
|
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
|
||||||
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
||||||
@ -534,6 +557,7 @@ export function useNanobotStream(
|
|||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
const chunk = typeof ev.text === "string" ? ev.text : "";
|
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||||
if (!chunk) return;
|
if (!chunk) return;
|
||||||
|
clearActivitySegment();
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
|
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
|
||||||
schedulePendingStreamFlush();
|
schedulePendingStreamFlush();
|
||||||
@ -544,6 +568,7 @@ export function useNanobotStream(
|
|||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
const chunk = ev.text;
|
const chunk = ev.text;
|
||||||
if (!chunk) return;
|
if (!chunk) return;
|
||||||
|
if (fileEditSegmentRef.current) clearActivitySegment();
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
|
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
|
||||||
schedulePendingStreamFlush();
|
schedulePendingStreamFlush();
|
||||||
@ -622,6 +647,7 @@ export function useNanobotStream(
|
|||||||
if (ev.kind === "reasoning") {
|
if (ev.kind === "reasoning") {
|
||||||
const line = ev.text;
|
const line = ev.text;
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
|
if (fileEditSegmentRef.current) clearActivitySegment();
|
||||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
|
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
|
||||||
ensure: ensureActivitySegmentId,
|
ensure: ensureActivitySegmentId,
|
||||||
})));
|
})));
|
||||||
@ -685,6 +711,7 @@ export function useNanobotStream(
|
|||||||
// flight, drop the placeholder so we don't render the text twice.
|
// flight, drop the placeholder so we don't render the text twice.
|
||||||
// Do NOT reset isStreaming here — only ``turn_end`` signals that
|
// Do NOT reset isStreaming here — only ``turn_end`` signals that
|
||||||
// the full turn (all tool calls + final text) is complete.
|
// the full turn (all tool calls + final text) is complete.
|
||||||
|
clearActivitySegment();
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const activeId = buffer.current?.messageId;
|
const activeId = buffer.current?.messageId;
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
@ -709,27 +736,32 @@ export function useNanobotStream(
|
|||||||
if (ev.event === "file_edit") {
|
if (ev.event === "file_edit") {
|
||||||
const edits = Array.isArray(ev.edits) ? ev.edits : [];
|
const edits = Array.isArray(ev.edits) ? ev.edits : [];
|
||||||
if (edits.length === 0) return;
|
if (edits.length === 0) return;
|
||||||
|
const normalized = mergeFileEdits(undefined, edits);
|
||||||
|
if (normalized.length === 0) return;
|
||||||
|
const opensFileEditPhase = normalized.some(
|
||||||
|
(edit) => edit.status === "editing" || edit.phase === "start",
|
||||||
|
);
|
||||||
|
let eventSegmentId = fileEditSegmentRef.current;
|
||||||
|
if (!eventSegmentId && opensFileEditPhase) {
|
||||||
|
eventSegmentId = detachedActivitySegmentId();
|
||||||
|
fileEditSegmentRef.current = eventSegmentId;
|
||||||
|
}
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const last = prev[prev.length - 1];
|
let segmentId = eventSegmentId;
|
||||||
let segmentId = fileEditSegmentRef.current;
|
const targetIndex = findFileEditTraceIndex(prev, segmentId, normalized);
|
||||||
if (!segmentId || !(last?.kind === "trace" && last.fileEdits?.length)) {
|
if (targetIndex !== null) {
|
||||||
segmentId = detachedActivitySegmentId();
|
const target = prev[targetIndex];
|
||||||
fileEditSegmentRef.current = segmentId;
|
segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
|
||||||
}
|
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
|
||||||
if (
|
|
||||||
last
|
|
||||||
&& last.kind === "trace"
|
|
||||||
&& !last.isStreaming
|
|
||||||
&& !!last.fileEdits?.length
|
|
||||||
&& last.activitySegmentId === segmentId
|
|
||||||
) {
|
|
||||||
const merged: UIMessage = {
|
const merged: UIMessage = {
|
||||||
...last,
|
...target,
|
||||||
fileEdits: mergeFileEdits(last.fileEdits, edits),
|
fileEdits: mergeFileEdits(target.fileEdits, normalized),
|
||||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
activitySegmentId: segmentId,
|
||||||
};
|
};
|
||||||
return [...prev.slice(0, -1), merged];
|
return replaceMessageAt(prev, targetIndex, merged);
|
||||||
}
|
}
|
||||||
|
segmentId = segmentId ?? detachedActivitySegmentId();
|
||||||
|
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
|
||||||
return [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@ -738,7 +770,7 @@ export function useNanobotStream(
|
|||||||
kind: "trace",
|
kind: "trace",
|
||||||
content: "",
|
content: "",
|
||||||
traces: [],
|
traces: [],
|
||||||
fileEdits: mergeFileEdits(undefined, edits),
|
fileEdits: normalized,
|
||||||
activitySegmentId: segmentId,
|
activitySegmentId: segmentId,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -89,6 +89,7 @@ export interface UIFileEdit {
|
|||||||
call_id: string;
|
call_id: string;
|
||||||
tool: string;
|
tool: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
absolute_path?: string;
|
||||||
phase?: "start" | "end" | "error" | string;
|
phase?: "start" | "end" | "error" | string;
|
||||||
added: number;
|
added: number;
|
||||||
deleted: number;
|
deleted: number;
|
||||||
@ -96,6 +97,7 @@ export interface UIFileEdit {
|
|||||||
status: "editing" | "done" | "error";
|
status: "editing" | "done" | "error";
|
||||||
binary?: boolean;
|
binary?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
pending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatSummary {
|
export interface ChatSummary {
|
||||||
|
|||||||
@ -236,6 +236,7 @@ describe("AgentActivityCluster", () => {
|
|||||||
call_id: "call-edit",
|
call_id: "call-edit",
|
||||||
tool: "edit_file",
|
tool: "edit_file",
|
||||||
path: "src/app.tsx",
|
path: "src/app.tsx",
|
||||||
|
absolute_path: "/Users/renxubin/project/src/app.tsx",
|
||||||
phase: "end",
|
phase: "end",
|
||||||
added: 12,
|
added: 12,
|
||||||
deleted: 3,
|
deleted: 3,
|
||||||
@ -250,13 +251,17 @@ describe("AgentActivityCluster", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("activity-header-file-reference")).toHaveTextContent("app.tsx");
|
||||||
|
expect(screen.getByTestId("activity-header-file-reference")).toHaveAttribute(
|
||||||
|
"aria-label",
|
||||||
|
"/Users/renxubin/project/src/app.tsx",
|
||||||
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
|
fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
|
||||||
|
|
||||||
expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
|
expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Edited")).not.toBeInTheDocument();
|
|
||||||
const fileRef = screen.getByTestId("activity-file-reference");
|
const fileRef = screen.getByTestId("activity-file-reference");
|
||||||
expect(fileRef).toHaveTextContent("src/app.tsx");
|
expect(fileRef).toHaveTextContent("src/app.tsx");
|
||||||
expect(fileRef).toHaveAttribute("aria-label", "src/app.tsx");
|
expect(fileRef).toHaveAttribute("aria-label", "/Users/renxubin/project/src/app.tsx");
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
|
||||||
@ -266,6 +271,38 @@ describe("AgentActivityCluster", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders pending file edit placeholders before the path is known", () => {
|
||||||
|
render(
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={activityMessages("", {
|
||||||
|
id: "t2",
|
||||||
|
role: "tool",
|
||||||
|
kind: "trace",
|
||||||
|
content: "",
|
||||||
|
traces: [],
|
||||||
|
fileEdits: [{
|
||||||
|
call_id: "call-edit",
|
||||||
|
tool: "edit_file",
|
||||||
|
path: "",
|
||||||
|
phase: "start",
|
||||||
|
added: 0,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: true,
|
||||||
|
status: "editing",
|
||||||
|
pending: true,
|
||||||
|
}],
|
||||||
|
createdAt: 3,
|
||||||
|
})}
|
||||||
|
isTurnStreaming
|
||||||
|
hasBodyBelow={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /preparing edit/i })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /preparing edit/i }));
|
||||||
|
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
|
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
|
||||||
const restoreMotion = installReducedMotion();
|
const restoreMotion = installReducedMotion();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -195,7 +195,8 @@ describe("MessageBubble", () => {
|
|||||||
const references = await screen.findAllByTestId("inline-file-path");
|
const references = await screen.findAllByTestId("inline-file-path");
|
||||||
expect(references).toHaveLength(2);
|
expect(references).toHaveLength(2);
|
||||||
expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]");
|
expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]");
|
||||||
expect(references[0].parentElement).toHaveClass("align-[0.14em]");
|
expect(references[0].parentElement).toHaveClass("align-baseline");
|
||||||
|
expect(references[0].parentElement).toHaveClass("leading-[inherit]");
|
||||||
expect(references[0]).toHaveTextContent("MarkdownTextRenderer.tsx");
|
expect(references[0]).toHaveTextContent("MarkdownTextRenderer.tsx");
|
||||||
expect(references[0]).not.toHaveTextContent("webui/src/components");
|
expect(references[0]).not.toHaveTextContent("webui/src/components");
|
||||||
expect(screen.getByText("index.html")).toBeInTheDocument();
|
expect(screen.getByText("index.html")).toBeInTheDocument();
|
||||||
|
|||||||
@ -374,6 +374,121 @@ describe("useNanobotStream", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("upgrades pending file_edit placeholders when the path arrives", () => {
|
||||||
|
const fake = fakeClient();
|
||||||
|
const { result } = renderHook(() => useNanobotStream("chat-file-edit-pending", EMPTY_MESSAGES), {
|
||||||
|
wrapper: wrap(fake.client),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-file-edit-pending", {
|
||||||
|
event: "file_edit",
|
||||||
|
chat_id: "chat-file-edit-pending",
|
||||||
|
edits: [{
|
||||||
|
call_id: "call-write",
|
||||||
|
tool: "write_file",
|
||||||
|
path: "",
|
||||||
|
phase: "start",
|
||||||
|
added: 1,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: true,
|
||||||
|
status: "editing",
|
||||||
|
pending: true,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
fake.emit("chat-file-edit-pending", {
|
||||||
|
event: "file_edit",
|
||||||
|
chat_id: "chat-file-edit-pending",
|
||||||
|
edits: [{
|
||||||
|
call_id: "call-write",
|
||||||
|
tool: "write_file",
|
||||||
|
path: "foo.txt",
|
||||||
|
phase: "start",
|
||||||
|
added: 12,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: true,
|
||||||
|
status: "editing",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileEditMessages = result.current.messages.filter((message) => message.fileEdits?.length);
|
||||||
|
expect(fileEditMessages).toHaveLength(1);
|
||||||
|
expect(fileEditMessages[0].fileEdits).toEqual([{
|
||||||
|
call_id: "call-write",
|
||||||
|
tool: "write_file",
|
||||||
|
path: "foo.txt",
|
||||||
|
phase: "start",
|
||||||
|
added: 12,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: true,
|
||||||
|
status: "editing",
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges file_edit updates after interleaved progress events", () => {
|
||||||
|
const fake = fakeClient();
|
||||||
|
const { result } = renderHook(() => useNanobotStream("chat-file-edit-progress", EMPTY_MESSAGES), {
|
||||||
|
wrapper: wrap(fake.client),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-file-edit-progress", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-file-edit-progress",
|
||||||
|
text: 'write_file({"path":"foo.txt"})',
|
||||||
|
kind: "tool_hint",
|
||||||
|
});
|
||||||
|
fake.emit("chat-file-edit-progress", {
|
||||||
|
event: "file_edit",
|
||||||
|
chat_id: "chat-file-edit-progress",
|
||||||
|
edits: [{
|
||||||
|
call_id: "call-write",
|
||||||
|
tool: "write_file",
|
||||||
|
path: "foo.txt",
|
||||||
|
phase: "start",
|
||||||
|
added: 12,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: true,
|
||||||
|
status: "editing",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
fake.emit("chat-file-edit-progress", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-file-edit-progress",
|
||||||
|
text: "still working",
|
||||||
|
kind: "progress",
|
||||||
|
});
|
||||||
|
fake.emit("chat-file-edit-progress", {
|
||||||
|
event: "file_edit",
|
||||||
|
chat_id: "chat-file-edit-progress",
|
||||||
|
edits: [{
|
||||||
|
call_id: "call-write",
|
||||||
|
tool: "write_file",
|
||||||
|
path: "foo.txt",
|
||||||
|
phase: "end",
|
||||||
|
added: 30,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: false,
|
||||||
|
status: "done",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileEditMessages = result.current.messages.filter((message) => message.fileEdits?.length);
|
||||||
|
expect(fileEditMessages).toHaveLength(1);
|
||||||
|
expect(fileEditMessages[0].fileEdits).toEqual([{
|
||||||
|
call_id: "call-write",
|
||||||
|
tool: "write_file",
|
||||||
|
path: "foo.txt",
|
||||||
|
phase: "end",
|
||||||
|
added: 30,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: false,
|
||||||
|
status: "done",
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
|
it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
|
||||||
@ -472,7 +587,67 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[1].activitySegmentId).toBe(firstSegment);
|
expect(result.current.messages[1].activitySegmentId).toBe(firstSegment);
|
||||||
expect(result.current.messages[2].activitySegmentId).toBeTruthy();
|
expect(result.current.messages[2].activitySegmentId).toBeTruthy();
|
||||||
expect(result.current.messages[2].activitySegmentId).not.toBe(firstSegment);
|
expect(result.current.messages[2].activitySegmentId).not.toBe(firstSegment);
|
||||||
expect(result.current.messages[3].activitySegmentId).toBe(firstSegment);
|
expect(result.current.messages[3].activitySegmentId).toBeTruthy();
|
||||||
|
expect(result.current.messages[3].activitySegmentId).not.toBe(result.current.messages[2].activitySegmentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps file edit blocks ordered across a new reasoning phase", async () => {
|
||||||
|
const fake = fakeClient();
|
||||||
|
const { result } = renderHook(() => useNanobotStream("chat-file-order", EMPTY_MESSAGES), {
|
||||||
|
wrapper: wrap(fake.client),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-file-order", {
|
||||||
|
event: "file_edit",
|
||||||
|
chat_id: "chat-file-order",
|
||||||
|
edits: [{
|
||||||
|
call_id: "call-one",
|
||||||
|
tool: "write_file",
|
||||||
|
path: "one.txt",
|
||||||
|
phase: "start",
|
||||||
|
added: 10,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: true,
|
||||||
|
status: "editing",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
fake.emit("chat-file-order", {
|
||||||
|
event: "reasoning_delta",
|
||||||
|
chat_id: "chat-file-order",
|
||||||
|
text: "Check the next file.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-file-order", {
|
||||||
|
event: "file_edit",
|
||||||
|
chat_id: "chat-file-order",
|
||||||
|
edits: [{
|
||||||
|
call_id: "call-two",
|
||||||
|
tool: "write_file",
|
||||||
|
path: "two.txt",
|
||||||
|
phase: "start",
|
||||||
|
added: 20,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: true,
|
||||||
|
status: "editing",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages.map((message) => message.fileEdits?.[0]?.path ?? message.reasoning)).toEqual([
|
||||||
|
"one.txt",
|
||||||
|
"Check the next file.",
|
||||||
|
"two.txt",
|
||||||
|
]);
|
||||||
|
const fileEditSegments = result.current.messages
|
||||||
|
.filter((message) => message.fileEdits?.length)
|
||||||
|
.map((message) => message.activitySegmentId);
|
||||||
|
expect(fileEditSegments).toHaveLength(2);
|
||||||
|
expect(fileEditSegments[0]).not.toBe(fileEditSegments[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
|
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user