fix(webui): show copy action on every assistant message

This commit is contained in:
chengyongru
2026-07-20 13:51:53 +08:00
committed by chengyongru
parent e3de01c9f6
commit 949cfad548
2 changed files with 18 additions and 16 deletions
@@ -29,7 +29,7 @@ export function buildDisplayUnits(
}); });
} }
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] { export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
const flags = new Array<boolean>(units.length).fill(true); const flags = new Array<boolean>(units.length).fill(true);
let hasLaterUnitBeforeUser = false; let hasLaterUnitBeforeUser = false;
for (let i = units.length - 1; i >= 0; i -= 1) { for (let i = units.length - 1; i >= 0; i -= 1) {
@@ -63,7 +63,7 @@ export function ThreadMessages({
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount), () => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
[forkBoundaryMessageCount, units], [forkBoundaryMessageCount, units],
); );
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]); const forkFlags = useMemo(() => assistantForkFlags(units), [units]);
const liveActivityClusterIndices = useMemo( const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(), () => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units], [isStreaming, units],
@@ -90,7 +90,7 @@ export function ThreadMessages({
? unit.message.id ? unit.message.id
: undefined; : undefined;
const forkIndex = const forkIndex =
unit.type === "message" && unit.message.role === "assistant" && copyFlags[index] unit.type === "message" && unit.message.role === "assistant" && forkFlags[index]
? nextUserIndex ? nextUserIndex
: undefined; : undefined;
if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1; if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
@@ -112,11 +112,6 @@ export function ThreadMessages({
) : ( ) : (
<MessageBubble <MessageBubble
message={unit.message} message={unit.message}
showCopyAction={
unit.message.role === "assistant"
? copyFlags[index]
: true
}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={slashCommands}
+15 -8
View File
@@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
assistantCopyFlags, assistantForkFlags,
buildDisplayUnits, buildDisplayUnits,
ThreadMessages, ThreadMessages,
unitKeysForDisplay, unitKeysForDisplay,
@@ -747,7 +747,7 @@ describe("ThreadMessages", () => {
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument(); expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
}); });
it("shows copy only on the last assistant slice before the next user turn", () => { it("shows copy on every assistant slice while keeping fork on the last slice", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
id: "early", id: "early",
@@ -771,19 +771,26 @@ describe("ThreadMessages", () => {
}, },
]; ];
render(<ThreadMessages messages={messages} isStreaming={false} />); render(
<ThreadMessages
messages={messages}
isStreaming={false}
onForkFromMessage={vi.fn()}
/>,
);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1); expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "Fork" })).toHaveLength(1);
expect(screen.getByText("final reply")).toBeInTheDocument(); expect(screen.getByText("final reply")).toBeInTheDocument();
}); });
it("shows copy only on the second assistant when two text slices appear before user", () => { it("shows copy on adjacent assistant text slices", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 }, { id: "a1", role: "assistant", content: "part one", createdAt: 1 },
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 }, { id: "a2", role: "assistant", content: "part two", createdAt: 2 },
]; ];
render(<ThreadMessages messages={messages} isStreaming={false} />); render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1); expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
}); });
it("uses turn ids as activity grouping boundaries when available", () => { it("uses turn ids as activity grouping boundaries when available", () => {
@@ -810,7 +817,7 @@ describe("ThreadMessages", () => {
]); ]);
}); });
it("computes final assistant copy flags with user-boundary semantics", () => { it("computes final assistant fork flags with user-boundary semantics", () => {
const units = buildDisplayUnits([ const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", createdAt: 1 }, { id: "u1", role: "user", content: "one", createdAt: 1 },
{ id: "a1", role: "assistant", content: "draft", createdAt: 2 }, { id: "a1", role: "assistant", content: "draft", createdAt: 2 },
@@ -827,7 +834,7 @@ describe("ThreadMessages", () => {
{ id: "a3", role: "assistant", content: "next", createdAt: 6 }, { id: "a3", role: "assistant", content: "next", createdAt: 6 },
]); ]);
const flags = assistantCopyFlags(units); const flags = assistantForkFlags(units);
const assistantFlags = units const assistantFlags = units
.map((unit, index) => .map((unit, index) =>
unit.type === "message" && unit.message.role === "assistant" unit.type === "message" && unit.message.role === "assistant"