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
+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();