fix(webui): auto-collapse completed activity

This commit is contained in:
Xubin Ren 2026-05-24 18:23:22 +08:00
parent 547f81e4aa
commit 8fedee276b
2 changed files with 70 additions and 3 deletions

View File

@ -225,13 +225,18 @@ export function AgentActivityCluster({
const [userToggledOuter, setUserToggledOuter] = useState(false); const [userToggledOuter, setUserToggledOuter] = useState(false);
const [outerOpenLocal, setOuterOpenLocal] = useState(false); const [outerOpenLocal, setOuterOpenLocal] = useState(false);
const [completionHoldOpen, setCompletionHoldOpen] = useState(false);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const activityScrollRef = useRef<HTMLDivElement>(null); const activityScrollRef = useRef<HTMLDivElement>(null);
const activityContentRef = useRef<HTMLDivElement>(null); const activityContentRef = useRef<HTMLDivElement>(null);
const autoFollowActivityRef = useRef(true); const autoFollowActivityRef = useRef(true);
const scrollFrameRef = useRef<number | null>(null); const scrollFrameRef = useRef<number | null>(null);
/** Live work and the trace directly attached to an answer read like a visible trail. */ const wasTurnStreamingRef = useRef(isTurnStreaming);
const outerExpanded = userToggledOuter ? outerOpenLocal : isTurnStreaming || hasBodyBelow; const wasTurnStreaming = wasTurnStreamingRef.current;
/** Live work stays open; completed work briefly shows the done state, then tucks away. */
const outerExpanded = userToggledOuter
? outerOpenLocal
: isTurnStreaming || completionHoldOpen || (wasTurnStreaming && !isTurnStreaming);
const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles; const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined; const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
@ -377,6 +382,19 @@ export function AgentActivityCluster({
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [isTurnStreaming]); }, [isTurnStreaming]);
useEffect(() => {
const wasStreaming = wasTurnStreamingRef.current;
wasTurnStreamingRef.current = isTurnStreaming;
if (isTurnStreaming) {
setCompletionHoldOpen(false);
return undefined;
}
if (!wasStreaming || userToggledOuter) return undefined;
setCompletionHoldOpen(true);
const timeout = window.setTimeout(() => setCompletionHoldOpen(false), 900);
return () => window.clearTimeout(timeout);
}, [isTurnStreaming, userToggledOuter]);
const onActivityScroll = useCallback(() => { const onActivityScroll = useCallback(() => {
const el = activityScrollRef.current; const el = activityScrollRef.current;
if (!el) return; if (!el) return;

View File

@ -1,5 +1,5 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
@ -293,6 +293,53 @@ describe("AgentActivityCluster", () => {
await waitFor(() => expect(marker).toHaveClass("animate-in")); await waitFor(() => expect(marker).toHaveClass("animate-in"));
}); });
it("briefly shows completed activity, then auto-collapses before the answer", () => {
vi.useFakeTimers();
const liveReasoning: UIMessage = {
id: "r-collapse",
role: "assistant",
content: "",
reasoning: "checking files",
reasoningStreaming: true,
isStreaming: true,
createdAt: 1,
};
try {
const { rerender } = render(
<AgentActivityCluster
messages={[liveReasoning]}
isTurnStreaming
hasBodyBelow
/>,
);
expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument();
rerender(
<AgentActivityCluster
messages={[{
...liveReasoning,
reasoningStreaming: false,
isStreaming: false,
}]}
isTurnStreaming={false}
hasBodyBelow
/>,
);
expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(901);
});
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /1 steps/i })).toHaveAttribute(
"aria-expanded",
"false",
);
} finally {
vi.useRealTimers();
}
});
it("renders file edit totals and a compact expanded file list", async () => { it("renders file edit totals and a compact expanded file list", async () => {
const restoreMotion = installReducedMotion(); const restoreMotion = installReducedMotion();
try { try {
@ -583,6 +630,8 @@ describe("AgentActivityCluster", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("button", { name: /1 tool calls/i }));
expect(screen.getByText("Shell")).toBeInTheDocument(); expect(screen.getByText("Shell")).toBeInTheDocument();
expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument(); expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument();
expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument(); expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument();