fix(webui): hide actions until turn end

This commit is contained in:
Zhou
2026-08-16 00:14:42 +08:00
committed by Xubin Ren
parent 5e84055dbb
commit 48126f049d
5 changed files with 213 additions and 8 deletions
+5 -1
View File
@@ -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");
+34 -1
View File
@@ -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({
) : (
<MessageBubble
message={unit.message}
isTurnStreaming={isTurnStreaming}
temporary={temporary}
cliApps={cliApps}
mcpPresets={mcpPresets}
@@ -317,6 +329,27 @@ function threadDisplayUnitPropsEqual(
);
}
function activeTurnStartIndex(units: DisplayUnit[], activeTurnId: string | null): number {
if (activeTurnId) {
const index = units.findIndex((unit) => (
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") {
+10 -6
View File
@@ -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;
+116
View File
@@ -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(
<ThreadMessages {...props} isStreaming activeTurnId={activeTurnId} />,
);
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(1);
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(1);
rerender(<ThreadMessages {...props} isStreaming={false} activeTurnId={null} />);
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(
<ThreadMessages
messages={messages}
isStreaming
activeTurnId={activeTurnId}
onForkFromMessage={vi.fn()}
/>,
);
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(
<ThreadMessages
messages={[
{ id: "old", role: "assistant", content: "old answer", turnId: "turn-old", createdAt: 1 },
{
id: "automation",
role: "assistant",
content: "automation result",
turnId: "turn-automation",
createdAt: 2,
},
]}
isStreaming
activeTurnId="turn-automation"
onForkFromMessage={vi.fn()}
/>,
);
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(
<ThreadMessages
messages={[
{ id: "user", role: "user", content: "question", createdAt: 1 },
{ id: "live", role: "assistant", content: "live slice", createdAt: 2 },
]}
isStreaming
activeTurnId="turn-active"
onForkFromMessage={vi.fn()}
/>,
);
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 },
+48
View File
@@ -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,
<ThreadShell
session={session("assistant-only-actions")}
title="Assistant-only actions"
onToggleSidebar={() => {}}
/>,
));
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();