From 8fedee276bdad92b0be74ab73b301d8460eeef58 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 24 May 2026 18:23:22 +0800 Subject: [PATCH] fix(webui): auto-collapse completed activity --- .../thread/AgentActivityCluster.tsx | 22 +++++++- .../src/tests/agent-activity-cluster.test.tsx | 51 ++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index bbba1045a..8af3e5603 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -225,13 +225,18 @@ export function AgentActivityCluster({ const [userToggledOuter, setUserToggledOuter] = useState(false); const [outerOpenLocal, setOuterOpenLocal] = useState(false); + const [completionHoldOpen, setCompletionHoldOpen] = useState(false); const [now, setNow] = useState(() => Date.now()); const activityScrollRef = useRef(null); const activityContentRef = useRef(null); const autoFollowActivityRef = useRef(true); const scrollFrameRef = useRef(null); - /** Live work and the trace directly attached to an answer read like a visible trail. */ - const outerExpanded = userToggledOuter ? outerOpenLocal : isTurnStreaming || hasBodyBelow; + const wasTurnStreamingRef = useRef(isTurnStreaming); + 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 singleFilePath = fileCount === 1 ? primaryFilePath : undefined; @@ -377,6 +382,19 @@ export function AgentActivityCluster({ return () => window.clearInterval(interval); }, [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 el = activityScrollRef.current; if (!el) return; diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index 4ae5bb899..ba151b631 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -1,5 +1,5 @@ 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 type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; @@ -293,6 +293,53 @@ describe("AgentActivityCluster", () => { 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( + , + ); + expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument(); + + rerender( + , + ); + + 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 () => { const restoreMotion = installReducedMotion(); 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(/cat << 'EOF' \| bash ยท script, 6 lines/)).toBeInTheDocument(); expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument();