fix(webui): normalize thinking trace row box model

Thinking and Used tools are both auxiliary rows, but Thinking still carried
an internal mb-2 even when it was standalone. That made collapsed Thinking
rows visually taller than tool trace rows despite the shared thread spacing.

Only add the extra bottom margin when a Thinking bubble has answer content
below it in the same assistant message. Standalone Thinking rows now share
the same outer box model as Used tools. Tests lock both standalone and
answer-backed cases.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren 2026-05-13 08:12:44 +00:00
parent 82ba63e148
commit 321c565ec4
2 changed files with 11 additions and 3 deletions

View File

@ -92,7 +92,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
return (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? (
<ReasoningBubble text={reasoning} streaming={reasoningStreaming} />
<ReasoningBubble text={reasoning} streaming={reasoningStreaming} hasBodyBelow={!empty} />
) : null}
{empty && message.isStreaming && !hasReasoning ? (
<TypingDots />
@ -443,6 +443,7 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
interface ReasoningBubbleProps {
text: string;
streaming: boolean;
hasBodyBelow: boolean;
}
/**
@ -456,7 +457,7 @@ interface ReasoningBubbleProps {
* the user can re-expand to inspect the chain of thought. The local
* toggle persists once the user interacts.
*/
function ReasoningBubble({ text, streaming }: ReasoningBubbleProps) {
function ReasoningBubble({ text, streaming, hasBodyBelow }: ReasoningBubbleProps) {
const { t } = useTranslation();
const [userToggled, setUserToggled] = useState(false);
const [openLocal, setOpenLocal] = useState(true);
@ -466,7 +467,12 @@ function ReasoningBubble({ text, streaming }: ReasoningBubbleProps) {
setOpenLocal((v) => (userToggled ? !v : !open));
};
return (
<div className="mb-2 w-full animate-in fade-in-0 slide-in-from-top-1 duration-200">
<div
className={cn(
"w-full animate-in fade-in-0 slide-in-from-top-1 duration-200",
hasBodyBelow && "mb-2",
)}
>
<button
type="button"
onClick={onToggle}

View File

@ -119,6 +119,7 @@ describe("MessageBubble", () => {
expect(screen.getByText("Thinking…")).toBeInTheDocument();
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
expect(container.querySelector(".reasoning-shimmer")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
});
it("collapses the reasoning section by default once streaming ends", () => {
@ -136,6 +137,7 @@ describe("MessageBubble", () => {
expect(screen.getByText("Thinking")).toBeInTheDocument();
expect(screen.getByText("The answer is 42.")).toBeInTheDocument();
expect(screen.queryByText("hidden until expanded")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).toHaveClass("mb-2");
fireEvent.click(screen.getByRole("button", { name: /thinking/i }));
expect(screen.getByText("hidden until expanded")).toBeInTheDocument();