diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index dfffae19d..308171210 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -49,12 +49,47 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] { out.push({ type: "cluster", messages: cluster }); continue; } + const previous = out[out.length - 1]; + if (previous?.type === "cluster" && assistantHasInlineReasoning(m)) { + previous.messages.push(reasoningOnlyMessageFromAnswer(m)); + out.push({ type: "single", message: stripInlineReasoning(m) }); + i += 1; + continue; + } out.push({ type: "single", message: m }); i += 1; } return out; } +function assistantHasInlineReasoning(message: UIMessage): boolean { + return ( + message.role === "assistant" + && message.kind !== "trace" + && message.content.trim().length > 0 + && (!!message.reasoning?.trim() || !!message.reasoningStreaming) + ); +} + +function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage { + return { + id: `${message.id}-reasoning`, + role: "assistant", + content: "", + createdAt: message.createdAt, + reasoning: message.reasoning, + reasoningStreaming: message.reasoningStreaming, + isStreaming: message.reasoningStreaming, + }; +} + +function stripInlineReasoning(message: UIMessage): UIMessage { + const next = { ...message }; + delete next.reasoning; + delete next.reasoningStreaming; + return next; +} + export function assistantCopyFlags(units: DisplayUnit[]): boolean[] { const flags = new Array(units.length).fill(true); let hasLaterUnitBeforeUser = false; diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index f5ecba688..4e7711fa5 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -55,6 +55,59 @@ describe("ThreadMessages", () => { expect(rows[1]).toHaveClass("mt-4"); }); + it("folds final answer reasoning into the preceding activity cluster", () => { + const messages: UIMessage[] = [ + { + id: "r1", + role: "assistant", + content: "", + reasoning: "search plan", + reasoningStreaming: false, + createdAt: 1, + }, + { + id: "t1", + role: "tool", + kind: "trace", + content: "web_search()", + traces: ["web_search()"], + createdAt: 2, + }, + { + id: "a1", + role: "assistant", + content: "final answer", + reasoning: "summarize results", + reasoningStreaming: false, + createdAt: 3, + }, + ]; + + const units = buildDisplayUnits(messages); + + expect(units).toHaveLength(2); + expect(units[0]).toMatchObject({ type: "cluster" }); + expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ + "r1", + "t1", + "a1-reasoning", + ]); + expect(units[1]).toMatchObject({ + type: "single", + message: { + id: "a1", + content: "final answer", + }, + }); + if (units[1].type === "single") { + expect(units[1].message).not.toHaveProperty("reasoning"); + } + + render(); + expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument(); + expect(screen.getByText("final answer")).toBeInTheDocument(); + }); + it("shows copy only on the last assistant slice before the next user turn", () => { const messages: UIMessage[] = [ {