From 8fbfbeca6845091b8138855deb4431417fb87cf2 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 15 Nov 2025 19:49:09 +0000 Subject: [PATCH 1/9] feat: inline single-line reasoning trace --- src/browser/App.stories.tsx | 34 +++++++++++++++++-- .../components/Messages/ReasoningMessage.tsx | 30 ++++++++++------ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/browser/App.stories.tsx b/src/browser/App.stories.tsx index b03c5f51c6..a734844ce2 100644 --- a/src/browser/App.stories.tsx +++ b/src/browser/App.stories.tsx @@ -871,6 +871,34 @@ export const ActiveWorkspaceWithChat: Story = { }, }); + // Assistant quick update with a single-line reasoning trace to exercise inline display + callback({ + id: "msg-9a", + role: "assistant", + parts: [ + { + type: "reasoning", + text: "Cache is warm already; rerunning the full suite would be redundant.", + }, + { + type: "text", + text: "Cache is warm from the last test run, so I'll shift focus to documentation next.", + }, + ], + metadata: { + historySequence: 10, + timestamp: STABLE_TIMESTAMP - 165000, + model: "anthropic:claude-sonnet-4-5", + usage: { + inputTokens: 1200, + outputTokens: 180, + totalTokens: 1380, + reasoningTokens: 20, + }, + duration: 900, + }, + }); + // Assistant message with status_set tool to show agent status callback({ id: "msg-10", @@ -899,7 +927,7 @@ export const ActiveWorkspaceWithChat: Story = { }, ], metadata: { - historySequence: 10, + historySequence: 11, timestamp: STABLE_TIMESTAMP - 160000, model: "anthropic:claude-sonnet-4-5", usage: { @@ -922,7 +950,7 @@ export const ActiveWorkspaceWithChat: Story = { }, ], metadata: { - historySequence: 11, + historySequence: 12, timestamp: STABLE_TIMESTAMP - 150000, }, }); @@ -936,7 +964,7 @@ export const ActiveWorkspaceWithChat: Story = { workspaceId: workspaceId, messageId: "msg-12", model: "anthropic:claude-sonnet-4-5", - historySequence: 12, + historySequence: 13, }); // Send reasoning delta diff --git a/src/browser/components/Messages/ReasoningMessage.tsx b/src/browser/components/Messages/ReasoningMessage.tsx index 7c7d624978..a9d9879c08 100644 --- a/src/browser/components/Messages/ReasoningMessage.tsx +++ b/src/browser/components/Messages/ReasoningMessage.tsx @@ -16,6 +16,11 @@ export const ReasoningMessage: React.FC = ({ message, cla const content = message.content; const isStreaming = message.isStreaming; + const trimmedContent = content?.trim() ?? ""; + const hasContent = trimmedContent.length > 0; + // OpenAI models often emit terse, single-line traces; surface them inline instead of hiding behind the label. + const isSingleLineTrace = !isStreaming && hasContent && !/[\r\n]/.test(trimmedContent); + const isCollapsible = !isStreaming && hasContent && !isSingleLineTrace; // Auto-collapse when streaming ends useEffect(() => { @@ -25,9 +30,11 @@ export const ReasoningMessage: React.FC = ({ message, cla }, [isStreaming]); const toggleExpanded = () => { - if (!isStreaming) { - setIsExpanded(!isExpanded); + if (!isCollapsible) { + return; } + + setIsExpanded(!isExpanded); }; // Render appropriate content based on state @@ -55,24 +62,27 @@ export const ReasoningMessage: React.FC = ({ message, cla >
-
+
- + {isStreaming ? ( Thinking... + ) : isSingleLineTrace ? ( + trimmedContent ) : ( - "Thought..." + "Thought" )}
- {!isStreaming && ( + {isCollapsible && ( = ({ message, cla )}
- {isExpanded && ( + {isExpanded && !isSingleLineTrace && (
{renderContent()}
From 66fe77005f01136645117a7c75c91abe8e800b14 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 15 Nov 2025 19:52:25 +0000 Subject: [PATCH 2/9] fix: render markdown for single-line reasoning --- src/browser/components/Messages/ReasoningMessage.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/browser/components/Messages/ReasoningMessage.tsx b/src/browser/components/Messages/ReasoningMessage.tsx index a9d9879c08..e28ac85c65 100644 --- a/src/browser/components/Messages/ReasoningMessage.tsx +++ b/src/browser/components/Messages/ReasoningMessage.tsx @@ -72,15 +72,18 @@ export const ReasoningMessage: React.FC = ({ message, cla - +
{isStreaming ? ( Thinking... ) : isSingleLineTrace ? ( - trimmedContent + ) : ( "Thought" )} - +
{isCollapsible && ( Date: Sat, 15 Nov 2025 19:54:45 +0000 Subject: [PATCH 3/9] chore: set single-line reasoning font size --- src/browser/components/Messages/ReasoningMessage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/components/Messages/ReasoningMessage.tsx b/src/browser/components/Messages/ReasoningMessage.tsx index e28ac85c65..cd3db17fd8 100644 --- a/src/browser/components/Messages/ReasoningMessage.tsx +++ b/src/browser/components/Messages/ReasoningMessage.tsx @@ -78,7 +78,7 @@ export const ReasoningMessage: React.FC = ({ message, cla ) : isSingleLineTrace ? ( ) : ( "Thought" From f86dd0cb0522fce8387bfedadae233aa6b373055 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 15 Nov 2025 19:57:12 +0000 Subject: [PATCH 4/9] fix: allow markdown renderer style overrides --- src/browser/components/Messages/MarkdownRenderer.tsx | 5 +++-- src/browser/components/Messages/ReasoningMessage.tsx | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/browser/components/Messages/MarkdownRenderer.tsx b/src/browser/components/Messages/MarkdownRenderer.tsx index 9eef6e3ef5..7659808c6b 100644 --- a/src/browser/components/Messages/MarkdownRenderer.tsx +++ b/src/browser/components/Messages/MarkdownRenderer.tsx @@ -5,11 +5,12 @@ import { cn } from "@/common/lib/utils"; interface MarkdownRendererProps { content: string; className?: string; + style?: React.CSSProperties; } -export const MarkdownRenderer: React.FC = ({ content, className }) => { +export const MarkdownRenderer: React.FC = ({ content, className, style }) => { return ( -
+
); diff --git a/src/browser/components/Messages/ReasoningMessage.tsx b/src/browser/components/Messages/ReasoningMessage.tsx index cd3db17fd8..8b29b27d46 100644 --- a/src/browser/components/Messages/ReasoningMessage.tsx +++ b/src/browser/components/Messages/ReasoningMessage.tsx @@ -78,7 +78,8 @@ export const ReasoningMessage: React.FC = ({ message, cla ) : isSingleLineTrace ? ( ) : ( "Thought" From 853208012ec1d999d989d33b2b85f10b024c38f4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 15 Nov 2025 20:11:30 +0000 Subject: [PATCH 5/9] test: update reasoning toolflow expectations --- src/browser/components/Messages/ReasoningMessage.tsx | 9 +++++++-- tests/e2e/scenarios/toolFlows.spec.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/browser/components/Messages/ReasoningMessage.tsx b/src/browser/components/Messages/ReasoningMessage.tsx index 8b29b27d46..a5603d59cf 100644 --- a/src/browser/components/Messages/ReasoningMessage.tsx +++ b/src/browser/components/Messages/ReasoningMessage.tsx @@ -68,7 +68,12 @@ export const ReasoningMessage: React.FC = ({ message, cla )} onClick={isCollapsible ? toggleExpanded : undefined} > -
+
@@ -78,7 +83,7 @@ export const ReasoningMessage: React.FC = ({ message, cla ) : isSingleLineTrace ? ( ) : ( diff --git a/tests/e2e/scenarios/toolFlows.spec.ts b/tests/e2e/scenarios/toolFlows.spec.ts index 42ae704f3a..d488566f75 100644 --- a/tests/e2e/scenarios/toolFlows.spec.ts +++ b/tests/e2e/scenarios/toolFlows.spec.ts @@ -141,7 +141,7 @@ test.describe("tool and reasoning flows", () => { } const transcript = page.getByRole("log", { name: "Conversation transcript" }); - const thinkingHeader = transcript.getByText("Thought..."); + const thinkingHeader = transcript.getByText("Thought"); await expect(thinkingHeader).toBeVisible(); await thinkingHeader.click(); await expect( From 96a48989a7ff116666604538daef0743474bd3b5 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 15 Nov 2025 20:32:56 +0000 Subject: [PATCH 6/9] fix: inline label only for single reasoning segment --- src/browser/components/Messages/MarkdownRenderer.tsx | 6 +++++- src/browser/components/Messages/ReasoningMessage.tsx | 4 +++- .../utils/messages/StreamingMessageAggregator.ts | 11 +++++++++-- src/common/types/message.ts | 2 ++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/browser/components/Messages/MarkdownRenderer.tsx b/src/browser/components/Messages/MarkdownRenderer.tsx index 7659808c6b..1eb4f75431 100644 --- a/src/browser/components/Messages/MarkdownRenderer.tsx +++ b/src/browser/components/Messages/MarkdownRenderer.tsx @@ -8,7 +8,11 @@ interface MarkdownRendererProps { style?: React.CSSProperties; } -export const MarkdownRenderer: React.FC = ({ content, className, style }) => { +export const MarkdownRenderer: React.FC = ({ + content, + className, + style, +}) => { return (
diff --git a/src/browser/components/Messages/ReasoningMessage.tsx b/src/browser/components/Messages/ReasoningMessage.tsx index a5603d59cf..2e51fc96bb 100644 --- a/src/browser/components/Messages/ReasoningMessage.tsx +++ b/src/browser/components/Messages/ReasoningMessage.tsx @@ -18,8 +18,10 @@ export const ReasoningMessage: React.FC = ({ message, cla const isStreaming = message.isStreaming; const trimmedContent = content?.trim() ?? ""; const hasContent = trimmedContent.length > 0; + const reasoningSegments = message.reasoningSegmentCount ?? 1; // OpenAI models often emit terse, single-line traces; surface them inline instead of hiding behind the label. - const isSingleLineTrace = !isStreaming && hasContent && !/[\r\n]/.test(trimmedContent); + const isSingleLineTrace = + !isStreaming && hasContent && reasoningSegments === 1 && !/[\r\n]/.test(trimmedContent); const isCollapsible = !isStreaming && hasContent && !isSingleLineTrace; // Auto-collapse when streaming ends diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 2c0e8610bb..751536b746 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -613,6 +613,7 @@ export class StreamingMessageAggregator { type: "reasoning", text: data.delta, timestamp: data.timestamp, + segmentCount: 1, }); // Track delta for token counting and TPS calculation @@ -798,15 +799,20 @@ export class StreamingMessageAggregator { timestamp: lastMerged.timestamp ?? part.timestamp, }; } else if (lastMerged?.type === "reasoning" && part.type === "reasoning") { - // Merge reasoning parts, preserving the first timestamp + // Merge reasoning parts, preserving the first timestamp and accumulating segment count mergedParts[mergedParts.length - 1] = { type: "reasoning", text: lastMerged.text + part.text, timestamp: lastMerged.timestamp ?? part.timestamp, + segmentCount: (lastMerged.segmentCount ?? 1) + (part.segmentCount ?? 1), }; } else { // Different type or tool part - add new part - mergedParts.push(part); + if (part.type === "reasoning") { + mergedParts.push({ ...part, segmentCount: part.segmentCount ?? 1 }); + } else { + mergedParts.push(part); + } } } @@ -843,6 +849,7 @@ export class StreamingMessageAggregator { isPartial: message.metadata?.partial ?? false, isLastPartOfMessage: isLastPart, timestamp: part.timestamp ?? baseTimestamp, + reasoningSegmentCount: part.segmentCount ?? 1, }); } else if (part.type === "text" && part.text) { // Skip empty text parts diff --git a/src/common/types/message.ts b/src/common/types/message.ts index c388b57e16..d5b4b6a225 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -72,6 +72,7 @@ export interface MuxReasoningPart { type: "reasoning"; text: string; timestamp?: number; + segmentCount?: number; // Number of merged reasoning deltas represented by this part } // File/Image part type for multimodal messages (matches AI SDK FileUIPart) @@ -151,6 +152,7 @@ export type DisplayedMessage = isLastPartOfMessage?: boolean; // True if this is the last part of a multi-part message timestamp?: number; tokens?: number; // Reasoning tokens if available + reasoningSegmentCount?: number; // Number of merged reasoning deltas represented by this block } | { type: "stream-error"; From 6055fe4439dff7c2808a5fe486ceb7fb093db8d8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 15 Nov 2025 23:18:17 +0000 Subject: [PATCH 7/9] refactor: rely on content for single-line reasoning --- src/browser/components/Messages/ReasoningMessage.tsx | 4 +--- .../utils/messages/StreamingMessageAggregator.ts | 11 ++--------- src/common/types/message.ts | 2 -- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/browser/components/Messages/ReasoningMessage.tsx b/src/browser/components/Messages/ReasoningMessage.tsx index 2e51fc96bb..a5603d59cf 100644 --- a/src/browser/components/Messages/ReasoningMessage.tsx +++ b/src/browser/components/Messages/ReasoningMessage.tsx @@ -18,10 +18,8 @@ export const ReasoningMessage: React.FC = ({ message, cla const isStreaming = message.isStreaming; const trimmedContent = content?.trim() ?? ""; const hasContent = trimmedContent.length > 0; - const reasoningSegments = message.reasoningSegmentCount ?? 1; // OpenAI models often emit terse, single-line traces; surface them inline instead of hiding behind the label. - const isSingleLineTrace = - !isStreaming && hasContent && reasoningSegments === 1 && !/[\r\n]/.test(trimmedContent); + const isSingleLineTrace = !isStreaming && hasContent && !/[\r\n]/.test(trimmedContent); const isCollapsible = !isStreaming && hasContent && !isSingleLineTrace; // Auto-collapse when streaming ends diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 751536b746..2c0e8610bb 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -613,7 +613,6 @@ export class StreamingMessageAggregator { type: "reasoning", text: data.delta, timestamp: data.timestamp, - segmentCount: 1, }); // Track delta for token counting and TPS calculation @@ -799,20 +798,15 @@ export class StreamingMessageAggregator { timestamp: lastMerged.timestamp ?? part.timestamp, }; } else if (lastMerged?.type === "reasoning" && part.type === "reasoning") { - // Merge reasoning parts, preserving the first timestamp and accumulating segment count + // Merge reasoning parts, preserving the first timestamp mergedParts[mergedParts.length - 1] = { type: "reasoning", text: lastMerged.text + part.text, timestamp: lastMerged.timestamp ?? part.timestamp, - segmentCount: (lastMerged.segmentCount ?? 1) + (part.segmentCount ?? 1), }; } else { // Different type or tool part - add new part - if (part.type === "reasoning") { - mergedParts.push({ ...part, segmentCount: part.segmentCount ?? 1 }); - } else { - mergedParts.push(part); - } + mergedParts.push(part); } } @@ -849,7 +843,6 @@ export class StreamingMessageAggregator { isPartial: message.metadata?.partial ?? false, isLastPartOfMessage: isLastPart, timestamp: part.timestamp ?? baseTimestamp, - reasoningSegmentCount: part.segmentCount ?? 1, }); } else if (part.type === "text" && part.text) { // Skip empty text parts diff --git a/src/common/types/message.ts b/src/common/types/message.ts index d5b4b6a225..c388b57e16 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -72,7 +72,6 @@ export interface MuxReasoningPart { type: "reasoning"; text: string; timestamp?: number; - segmentCount?: number; // Number of merged reasoning deltas represented by this part } // File/Image part type for multimodal messages (matches AI SDK FileUIPart) @@ -152,7 +151,6 @@ export type DisplayedMessage = isLastPartOfMessage?: boolean; // True if this is the last part of a multi-part message timestamp?: number; tokens?: number; // Reasoning tokens if available - reasoningSegmentCount?: number; // Number of merged reasoning deltas represented by this block } | { type: "stream-error"; From c68344e27b24f74f6a0ba02d3ae47584fa834b9f Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 15 Nov 2025 23:24:54 +0000 Subject: [PATCH 8/9] test: support inline reasoning traces --- tests/e2e/scenarios/toolFlows.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/e2e/scenarios/toolFlows.spec.ts b/tests/e2e/scenarios/toolFlows.spec.ts index d488566f75..44156de7c5 100644 --- a/tests/e2e/scenarios/toolFlows.spec.ts +++ b/tests/e2e/scenarios/toolFlows.spec.ts @@ -142,8 +142,13 @@ test.describe("tool and reasoning flows", () => { const transcript = page.getByRole("log", { name: "Conversation transcript" }); const thinkingHeader = transcript.getByText("Thought"); - await expect(thinkingHeader).toBeVisible(); - await thinkingHeader.click(); + const hasThoughtLabel = (await thinkingHeader.count()) > 0; + + if (hasThoughtLabel) { + await expect(thinkingHeader.first()).toBeVisible(); + await thinkingHeader.first().click(); + } + await expect( transcript.getByText("Assessing quicksort mechanics and choosing example array...") ).toBeVisible(); From 137e663d05f14577b9119624e093852bfe0cd09b Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 16 Nov 2025 00:10:26 +0000 Subject: [PATCH 9/9] fix: show first reasoning line inline --- .../Messages/ReasoningMessage.stories.tsx | 10 +++++++++ .../components/Messages/ReasoningMessage.tsx | 21 ++++++++++++++----- src/node/services/mock/scenarios/toolFlows.ts | 2 +- tests/e2e/scenarios/toolFlows.spec.ts | 16 +++++++------- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/browser/components/Messages/ReasoningMessage.stories.tsx b/src/browser/components/Messages/ReasoningMessage.stories.tsx index 19253b7fbc..0c7d50c6af 100644 --- a/src/browser/components/Messages/ReasoningMessage.stories.tsx +++ b/src/browser/components/Messages/ReasoningMessage.stories.tsx @@ -135,3 +135,13 @@ export const EmptyContent: Story = { message: createReasoningMessage(""), }, }; +export const ExpandablePreview: Story = { + args: { + message: createReasoningMessage( + "Assessing quicksort mechanics and choosing example array...\n" + + "Plan: explain pivot selection, partitioning, recursion, base case.\n" + + "Next, I'll outline best practices for implementing the partition step.", + { isStreaming: false } + ), + }, +}; diff --git a/src/browser/components/Messages/ReasoningMessage.tsx b/src/browser/components/Messages/ReasoningMessage.tsx index a5603d59cf..5b96d42daa 100644 --- a/src/browser/components/Messages/ReasoningMessage.tsx +++ b/src/browser/components/Messages/ReasoningMessage.tsx @@ -18,9 +18,12 @@ export const ReasoningMessage: React.FC = ({ message, cla const isStreaming = message.isStreaming; const trimmedContent = content?.trim() ?? ""; const hasContent = trimmedContent.length > 0; + const summaryLine = hasContent ? (trimmedContent.split(/\r?\n/)[0] ?? "") : ""; + const hasAdditionalLines = hasContent && /[\r\n]/.test(trimmedContent); // OpenAI models often emit terse, single-line traces; surface them inline instead of hiding behind the label. - const isSingleLineTrace = !isStreaming && hasContent && !/[\r\n]/.test(trimmedContent); - const isCollapsible = !isStreaming && hasContent && !isSingleLineTrace; + const isSingleLineTrace = !isStreaming && hasContent && !hasAdditionalLines; + const isCollapsible = !isStreaming && hasContent && hasAdditionalLines; + const showEllipsis = isCollapsible && !isExpanded; // Auto-collapse when streaming ends useEffect(() => { @@ -77,18 +80,26 @@ export const ReasoningMessage: React.FC = ({ message, cla -
+
{isStreaming ? ( Thinking... - ) : isSingleLineTrace ? ( + ) : hasContent ? ( ) : ( "Thought" )} + {showEllipsis && ( + + ... + + )}
{isCollapsible && ( diff --git a/src/node/services/mock/scenarios/toolFlows.ts b/src/node/services/mock/scenarios/toolFlows.ts index 9056053955..6b96e2c579 100644 --- a/src/node/services/mock/scenarios/toolFlows.ts +++ b/src/node/services/mock/scenarios/toolFlows.ts @@ -317,7 +317,7 @@ const reasoningQuicksortTurn: ScenarioTurn = { { kind: "reasoning-delta", delay: STREAM_BASE_DELAY, - text: "Assessing quicksort mechanics and choosing example array...", + text: "Assessing quicksort mechanics and choosing example array...\n", }, { kind: "reasoning-delta", diff --git a/tests/e2e/scenarios/toolFlows.spec.ts b/tests/e2e/scenarios/toolFlows.spec.ts index 44156de7c5..5e1ed07bca 100644 --- a/tests/e2e/scenarios/toolFlows.spec.ts +++ b/tests/e2e/scenarios/toolFlows.spec.ts @@ -141,16 +141,18 @@ test.describe("tool and reasoning flows", () => { } const transcript = page.getByRole("log", { name: "Conversation transcript" }); - const thinkingHeader = transcript.getByText("Thought"); - const hasThoughtLabel = (await thinkingHeader.count()) > 0; + const reasoningPreview = transcript + .getByText("Assessing quicksort mechanics and choosing example array...") + .first(); + await expect(reasoningPreview).toBeVisible(); - if (hasThoughtLabel) { - await expect(thinkingHeader.first()).toBeVisible(); - await thinkingHeader.first().click(); - } + const ellipsisIndicator = transcript.getByTestId("reasoning-ellipsis").first(); + await expect(ellipsisIndicator).toBeVisible(); + + await reasoningPreview.click(); await expect( - transcript.getByText("Assessing quicksort mechanics and choosing example array...") + transcript.getByText("Plan: explain pivot selection, partitioning, recursion, base case.") ).toBeVisible(); await ui.chat.expectTranscriptContains("Quicksort works by picking a pivot"); });