From 48126f049d9d1c257a02a60f42a343c9184a9206 Mon Sep 17 00:00:00 2001 From: Zhou <32321321@qq.com> Date: Thu, 13 Aug 2026 16:25:37 +0800 Subject: [PATCH] fix(webui): hide actions until turn end --- webui/src/components/MessageBubble.tsx | 6 +- .../src/components/thread/ThreadMessages.tsx | 35 +++++- webui/src/components/thread/ThreadShell.tsx | 16 ++- webui/src/tests/thread-messages.test.tsx | 116 ++++++++++++++++++ webui/src/tests/thread-shell.test.tsx | 48 ++++++++ 5 files changed, 213 insertions(+), 8 deletions(-) diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 56b357a37..506576f9d 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -52,6 +52,8 @@ import type { interface MessageBubbleProps { message: UIMessage; + /** The containing agent turn has not received turn_end yet. */ + isTurnStreaming?: boolean; /** Give temporary-chat user turns the dashed private-mode treatment. */ temporary?: boolean; /** When false, hide this message's copy button. Default true. */ @@ -260,6 +262,7 @@ function UserDeliveryStatus({ /** Render user turns as compact bubbles and assistant turns as document-like prose. */ export function MessageBubble({ message, + isTurnStreaming = false, temporary = false, showCopyAction = true, cliApps = [], @@ -381,7 +384,8 @@ export function MessageBubble({ : ""; const automationTriggeredLabel = t("message.automationTriggered"); - const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; + const showAssistantActions = + message.role === "assistant" && !message.isStreaming && !isTurnStreaming && !empty; const showCopyButton = showCopyAction && showAssistantActions; const showForkButton = showAssistantActions && !!onForkFromHere; const forkLabel = t("message.forkFromHere"); diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index fd7d672f8..8ed3286d4 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -91,6 +91,9 @@ export function ThreadMessages({ && pendingTurn !== null && !pendingTurn.hasVisibleOutput ) ? pendingTurn : null; + const currentTurnStartIndex = isStreaming + ? activeTurnStartIndex(units, activeTurnId) + : units.length; const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]); let nextUserIndex = hiddenUserMessageCount; @@ -140,7 +143,15 @@ export function ThreadMessages({ userPromptId={userPromptId} hasBodyBelow={hasBodyBelow} deferOffscreenRender={deferOffscreenRender} - isTurnStreaming={liveActivityClusterIndices.has(index)} + isTurnStreaming={ + unit.type === "activity" + ? liveActivityClusterIndices.has(index) + : isStreaming && ( + unit.message.turnId + ? unit.message.turnId === activeTurnId + : index > currentTurnStartIndex + ) + } forkIndex={forkIndex} showForkBoundary={index === forkBoundaryAfterUnitIndex} forkBoundaryLabel={t("thread.forkedFromHistory")} @@ -280,6 +291,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({ ) : ( ( + unit.type === "message" + && unit.message.role === "user" + && unit.message.deliveryStatus !== "failed" + && unit.message.turnId === activeTurnId + )); + if (index >= 0) return index; + } + for (let i = units.length - 1; i >= 0; i -= 1) { + const unit = units[i]; + if ( + unit.type === "message" + && unit.message.role === "user" + && unit.message.deliveryStatus !== "failed" + ) return i; + } + return -1; +} + function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean { if (previous.type !== next.type) return false; if (previous.type === "message" && next.type === "message") { diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 64360e9e3..d186bb977 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -216,17 +216,18 @@ function isStaleThreadSnapshot( return snapshot.every((message, index) => sameMessageShape(current[index], message)); } -function latestActiveTurnId(messages: UIMessage[]): string | null { +function latestActiveTurnId(messages: UIMessage[], runStartedAt: number | null): string | null { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message.isStreaming && message.turnId) return message.turnId; } + if (runStartedAt === null) return null; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if ( - message.role === "user" - && message.deliveryStatus !== "failed" + message.role !== "user" && message.turnId + && message.createdAt >= runStartedAt * 1000 ) return message.turnId; } return null; @@ -808,8 +809,8 @@ export function ThreadShell({ const currentGoalState = messagesReady ? goalState : undefined; const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null); const restoredViewportTurnId = useMemo( - () => turnActive ? latestActiveTurnId(displayMessages) : null, - [displayMessages, turnActive], + () => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null, + [currentRunStartedAt, displayMessages, turnActive], ); const rememberedViewportTurnId = chatId ? activeViewportTurnByChatIdRef.current.get(chatId) ?? null @@ -818,7 +819,10 @@ export function ThreadShell({ ? client.getRunTurnId(chatId) : null; const viewportTurnId = messagesReady && turnActive - ? canonicalRunTurnId ?? rememberedViewportTurnId ?? restoredViewportTurnId + ? canonicalRunTurnId + ?? rememberedViewportTurnId + ?? historyActiveTurnId + ?? restoredViewportTurnId : null; const activeTurnStartedHere = viewportTurnId !== null && viewportTurnId === submittedViewportTurnId; diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index d5944df02..d2e48b19d 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -1055,6 +1055,122 @@ describe("ThreadMessages", () => { expect(screen.getByText("final reply")).toBeInTheDocument(); }); + it("hides current turn actions until turn_end", () => { + const activeTurnId = "turn-2"; + const messages: UIMessage[] = [ + { id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", turnId: activeTurnId, createdAt: 3 }, + { + id: "a2", + role: "assistant", + content: "first answer slice", + turnId: activeTurnId, + createdAt: 4, + }, + { + id: "t2", + role: "tool", + kind: "trace", + content: "search()", + traces: ["search()"], + turnId: activeTurnId, + createdAt: 5, + }, + { + id: "a3", + role: "assistant", + content: "second answer slice", + turnId: activeTurnId, + createdAt: 6, + }, + ]; + const props = { messages, onForkFromMessage: vi.fn() }; + const { container, rerender } = render( + , + ); + + expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(1); + expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(1); + + rerender(); + + expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(3); + expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2); + }); + + it("keeps active turn actions hidden across guidance and failed user rows", () => { + const activeTurnId = "turn-active"; + const messages: UIMessage[] = [ + { id: "old-user", role: "user", content: "old question", turnId: "turn-old", createdAt: 1 }, + { id: "old", role: "assistant", content: "old answer", turnId: "turn-old", createdAt: 2 }, + { id: "active-user", role: "user", content: "new question", turnId: activeTurnId, createdAt: 3 }, + { id: "live", role: "assistant", content: "live slice", createdAt: 4 }, + { id: "guide", role: "user", content: "focus", turnId: "turn-guide", createdAt: 5 }, + { + id: "failed", + role: "user", + content: "retry", + turnId: "turn-failed", + deliveryStatus: "failed", + createdAt: 6, + }, + ]; + const { container } = render( + , + ); + + expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(1); + expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(1); + }); + + it("only hides the active assistant-only automation turn", () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(1); + expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(0); + }); + + it("falls back to the latest user boundary for untagged active slices", () => { + const { container } = render( + , + ); + + expect(container.querySelector('[data-assistant-footer] [aria-label="Copy"]')) + .not.toBeInTheDocument(); + expect(container.querySelector('[data-assistant-footer] [aria-label="Fork"]')) + .not.toBeInTheDocument(); + }); + it("shows copy on adjacent assistant text slices", () => { const messages: UIMessage[] = [ { id: "a1", role: "assistant", content: "part one", createdAt: 1 }, diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 3ca9a2a0b..19774fca9 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -471,6 +471,54 @@ describe("ThreadShell", () => { expect(screen.queryByText("failed to read file")).not.toBeInTheDocument(); }); + it("hides actions for a complete assistant-only message until turn_end", async () => { + const client = makeClient(); + vi.mocked(fetch).mockImplementation(async (input) => ( + String(input).includes("websocket%3Aassistant-only-actions/webui-thread") + ? httpJson(transcriptFromSimpleMessages([ + { role: "assistant", content: "old automation", turnId: "turn-old" }, + ])) + : { ok: false, status: 404, json: async () => ({}) } + ) as Response); + + render(wrap( + client, + {}} + />, + )); + + await waitFor(() => expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1)); + const turnId = "turn-automation"; + const startedAt = Date.now() / 1000; + act(() => client._emitChat("assistant-only-actions", { + event: "goal_status", + chat_id: "assistant-only-actions", + status: "running", + started_at: startedAt, + turn_id: turnId, + })); + expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1); + + act(() => client._emitChat("assistant-only-actions", { + event: "message", + chat_id: "assistant-only-actions", + text: "new automation", + turn_id: turnId, + })); + await waitFor(() => expect(screen.getByText("new automation")).toBeInTheDocument()); + expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1); + + act(() => client._emitChat("assistant-only-actions", { + event: "turn_end", + chat_id: "assistant-only-actions", + turn_id: turnId, + })); + await waitFor(() => expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2)); + }); + it("does not navigate away when clicking the chat title", async () => { const client = makeClient(); const onGoHome = vi.fn();