mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(webui): preserve file edit activity order
This commit is contained in:
@@ -1,4 +1,13 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import {
|
||||||
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock3,
|
Clock3,
|
||||||
@@ -43,10 +52,11 @@ import {
|
|||||||
} from "@/lib/activity-timeline";
|
} from "@/lib/activity-timeline";
|
||||||
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
|
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
|
||||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||||
|
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||||
|
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||||
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
|
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
|
||||||
import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||||
@@ -156,10 +166,14 @@ export function AgentActivityCluster({
|
|||||||
const fileEditDisplayMode = useFileEditDisplayMode();
|
const fileEditDisplayMode = useFileEditDisplayMode();
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]);
|
const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]);
|
||||||
const fileEdits = useMemo(
|
const fileEditsByMessage = useMemo(
|
||||||
() => summarizeFileEdits(collectFileEdits(activityMessages), isTurnStreaming),
|
() => summarizeFileEditsByMessage(activityMessages, isTurnStreaming),
|
||||||
[activityMessages, isTurnStreaming],
|
[activityMessages, isTurnStreaming],
|
||||||
);
|
);
|
||||||
|
const fileEdits = useMemo(
|
||||||
|
() => [...fileEditsByMessage.values()].flat(),
|
||||||
|
[fileEditsByMessage],
|
||||||
|
);
|
||||||
const cliRuns = useMemo(() => collectCliRuns(activityMessages), [activityMessages]);
|
const cliRuns = useMemo(() => collectCliRuns(activityMessages), [activityMessages]);
|
||||||
const mcpRuns = useMemo(() => collectMcpRuns(activityMessages), [activityMessages]);
|
const mcpRuns = useMemo(() => collectMcpRuns(activityMessages), [activityMessages]);
|
||||||
const cliAppsByName = useMemo(
|
const cliAppsByName = useMemo(
|
||||||
@@ -348,15 +362,10 @@ export function AgentActivityCluster({
|
|||||||
active={isTurnStreaming}
|
active={isTurnStreaming}
|
||||||
cliAppsByName={cliAppsByName}
|
cliAppsByName={cliAppsByName}
|
||||||
mcpPresetsByName={mcpPresetsByName}
|
mcpPresetsByName={mcpPresetsByName}
|
||||||
|
fileEditsByMessage={fileEditsByMessage}
|
||||||
|
fileEditDisplayMode={fileEditDisplayMode}
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
/>
|
/>
|
||||||
{fileEdits.length ? (
|
|
||||||
<FileEditGroup
|
|
||||||
edits={fileEdits}
|
|
||||||
displayMode={fileEditDisplayMode}
|
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</ThinkingReasoningShell>
|
</ThinkingReasoningShell>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -414,12 +423,16 @@ function ActivityMessageTimeline({
|
|||||||
active,
|
active,
|
||||||
cliAppsByName,
|
cliAppsByName,
|
||||||
mcpPresetsByName,
|
mcpPresetsByName,
|
||||||
|
fileEditsByMessage,
|
||||||
|
fileEditDisplayMode,
|
||||||
onOpenFilePreview,
|
onOpenFilePreview,
|
||||||
}: {
|
}: {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
active: boolean;
|
active: boolean;
|
||||||
cliAppsByName: Map<string, CliAppInfo>;
|
cliAppsByName: Map<string, CliAppInfo>;
|
||||||
mcpPresetsByName: Map<string, McpPresetInfo>;
|
mcpPresetsByName: Map<string, McpPresetInfo>;
|
||||||
|
fileEditsByMessage: Map<string, FileEditSummary[]>;
|
||||||
|
fileEditDisplayMode: FileEditDisplayMode;
|
||||||
onOpenFilePreview?: (path: string) => void;
|
onOpenFilePreview?: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const items: ReactNode[] = [];
|
const items: ReactNode[] = [];
|
||||||
@@ -447,14 +460,21 @@ function ActivityMessageTimeline({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.kind === "trace") {
|
if (message.kind === "trace") {
|
||||||
|
const fileEdits = fileEditsByMessage.get(message.id) ?? [];
|
||||||
items.push(
|
items.push(
|
||||||
<ActivityTraceTimeline
|
<Fragment key={message.id}>
|
||||||
key={message.id}
|
<ActivityTraceTimeline
|
||||||
message={message}
|
message={message}
|
||||||
active={active && index === messages.length - 1}
|
active={active && index === messages.length - 1}
|
||||||
cliAppsByName={cliAppsByName}
|
cliAppsByName={cliAppsByName}
|
||||||
mcpPresetsByName={mcpPresetsByName}
|
mcpPresetsByName={mcpPresetsByName}
|
||||||
/>,
|
/>
|
||||||
|
<FileEditGroup
|
||||||
|
edits={fileEdits}
|
||||||
|
displayMode={fileEditDisplayMode}
|
||||||
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
|
/>
|
||||||
|
</Fragment>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1061,16 +1081,6 @@ function fileEditCallKey(edit: UIFileEdit): string {
|
|||||||
return `${edit.tool}|${edit.path}`;
|
return `${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[] {
|
function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const byKey = new Map<string, UIFileEdit>();
|
const byKey = new Map<string, UIFileEdit>();
|
||||||
@@ -1082,6 +1092,33 @@ function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
|||||||
return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
|
return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep each edit at the point where its call first appeared. Later lifecycle
|
||||||
|
* events update that row in place instead of moving completed edits to the end. */
|
||||||
|
function summarizeFileEditsByMessage(
|
||||||
|
messages: UIMessage[],
|
||||||
|
active: boolean,
|
||||||
|
): Map<string, FileEditSummary[]> {
|
||||||
|
const messageByEdit = new Map<string, string>();
|
||||||
|
const edits: UIFileEdit[] = [];
|
||||||
|
for (const message of messages) {
|
||||||
|
for (const edit of message.fileEdits ?? []) {
|
||||||
|
const key = fileEditCallKey(edit);
|
||||||
|
if (!messageByEdit.has(key)) messageByEdit.set(key, message.id);
|
||||||
|
edits.push(edit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = new Map<string, FileEditSummary[]>();
|
||||||
|
for (const edit of summarizeFileEdits(edits, active)) {
|
||||||
|
const messageId = messageByEdit.get(edit.key);
|
||||||
|
if (!messageId) continue;
|
||||||
|
const group = grouped.get(messageId) ?? [];
|
||||||
|
group.push(edit);
|
||||||
|
grouped.set(messageId, group);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
||||||
return latestFileEditEvents(edits).flatMap((edit) => {
|
return latestFileEditEvents(edits).flatMap((edit) => {
|
||||||
const editing = active && edit.status === "editing";
|
const editing = active && edit.status === "editing";
|
||||||
|
|||||||
@@ -561,6 +561,69 @@ describe("AgentActivityCluster", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a completed file edit at its original position in the turn", () => {
|
||||||
|
const before: UIMessage = {
|
||||||
|
id: "model-before-edit",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Before the edit",
|
||||||
|
activityKind: "model",
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
const after: UIMessage = {
|
||||||
|
id: "model-after-edit",
|
||||||
|
role: "assistant",
|
||||||
|
content: "After the edit",
|
||||||
|
activityKind: "model",
|
||||||
|
createdAt: 3,
|
||||||
|
};
|
||||||
|
const fileEdit = (status: "editing" | "done"): UIMessage => ({
|
||||||
|
id: "file-edit-in-place",
|
||||||
|
role: "tool",
|
||||||
|
kind: "trace",
|
||||||
|
content: "edit_file()",
|
||||||
|
traces: ["edit_file()"],
|
||||||
|
fileEdits: [{
|
||||||
|
call_id: "call-edit-in-place",
|
||||||
|
tool: "edit_file",
|
||||||
|
path: "src/app.tsx",
|
||||||
|
phase: status === "editing" ? "start" : "end",
|
||||||
|
added: status === "editing" ? 0 : 2,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: false,
|
||||||
|
status,
|
||||||
|
}],
|
||||||
|
createdAt: 2,
|
||||||
|
});
|
||||||
|
const assertBetween = (middle: HTMLElement) => {
|
||||||
|
const beforeElement = screen.getByText("Before the edit");
|
||||||
|
const afterElement = screen.getByText("After the edit");
|
||||||
|
expect(beforeElement.compareDocumentPosition(middle) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
.toBeTruthy();
|
||||||
|
expect(middle.compareDocumentPosition(afterElement) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
.toBeTruthy();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={[before, fileEdit("editing"), after]}
|
||||||
|
isTurnStreaming
|
||||||
|
hasBodyBelow={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertBetween(screen.getByText("Editing"));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={[before, fileEdit("done"), after]}
|
||||||
|
isTurnStreaming
|
||||||
|
hasBodyBelow={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertBetween(screen.getByText("Edited"));
|
||||||
|
});
|
||||||
|
|
||||||
it("renders file edit diffs and responds to preference changes", () => {
|
it("renders file edit diffs and responds to preference changes", () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"nanobot-webui.settings-preferences",
|
"nanobot-webui.settings-preferences",
|
||||||
|
|||||||
Reference in New Issue
Block a user