From fc3656b5abc326841a701bbdfc09977ca4475fb2 Mon Sep 17 00:00:00 2001 From: Zhou <32321321@qq.com> Date: Thu, 13 Aug 2026 16:25:37 +0800 Subject: [PATCH 1/2] 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 56b357a37e1..506576f9d55 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 fd7d672f869..8ed3286d47c 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 64360e9e3d9..d186bb977a3 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 d5944df023f..d2e48b19d00 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 3ca9a2a0bfc..19774fca9bb 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(); From 696f716b47ec395b5316edf5935cc0d69486cecf Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:14:03 +0800 Subject: [PATCH 2/2] fix(webui): keep actions hidden while turn id resolves --- .../src/components/thread/ThreadMessages.tsx | 2 +- webui/src/tests/thread-messages.test.tsx | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 8ed3286d47c..fcd51608662 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -147,7 +147,7 @@ export function ThreadMessages({ unit.type === "activity" ? liveActivityClusterIndices.has(index) : isStreaming && ( - unit.message.turnId + unit.message.turnId && activeTurnId !== null ? unit.message.turnId === activeTurnId : index > currentTurnStartIndex ) diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index d2e48b19d00..e9198d072ee 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -1171,6 +1171,27 @@ describe("ThreadMessages", () => { .not.toBeInTheDocument(); }); + it("falls back to the latest user boundary while the active turn id is pending", () => { + 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("shows copy on adjacent assistant text slices", () => { const messages: UIMessage[] = [ { id: "a1", role: "assistant", content: "part one", createdAt: 1 },