Skip to content
34 changes: 31 additions & 3 deletions src/browser/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -899,7 +927,7 @@ export const ActiveWorkspaceWithChat: Story = {
},
],
metadata: {
historySequence: 10,
historySequence: 11,
timestamp: STABLE_TIMESTAMP - 160000,
model: "anthropic:claude-sonnet-4-5",
usage: {
Expand All @@ -922,7 +950,7 @@ export const ActiveWorkspaceWithChat: Story = {
},
],
metadata: {
historySequence: 11,
historySequence: 12,
timestamp: STABLE_TIMESTAMP - 150000,
},
});
Expand All @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/browser/components/Messages/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import { cn } from "@/common/lib/utils";
interface MarkdownRendererProps {
content: string;
className?: string;
style?: React.CSSProperties;
}

export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className }) => {
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
className,
style,
}) => {
return (
<div className={cn("markdown-content", className)}>
<div className={cn("markdown-content", className)} style={style}>
<MarkdownCore content={content} />
</div>
);
Expand Down
10 changes: 10 additions & 0 deletions src/browser/components/Messages/ReasoningMessage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
),
},
};
52 changes: 41 additions & 11 deletions src/browser/components/Messages/ReasoningMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla

const content = message.content;
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 && !hasAdditionalLines;
const isCollapsible = !isStreaming && hasContent && hasAdditionalLines;
const showEllipsis = isCollapsible && !isExpanded;

// Auto-collapse when streaming ends
useEffect(() => {
Expand All @@ -25,9 +33,11 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
}, [isStreaming]);

const toggleExpanded = () => {
if (!isStreaming) {
setIsExpanded(!isExpanded);
if (!isCollapsible) {
return;
}

setIsExpanded(!isExpanded);
};

// Render appropriate content based on state
Expand Down Expand Up @@ -55,24 +65,44 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
>
<div
className={cn(
"flex cursor-pointer items-center justify-between gap-2 select-none",
isExpanded && "mb-1.5"
"flex items-center justify-between gap-2 select-none",
isCollapsible && "cursor-pointer",
isExpanded && !isSingleLineTrace && "mb-1.5"
)}
onClick={toggleExpanded}
onClick={isCollapsible ? toggleExpanded : undefined}
>
<div className="text-thinking-mode flex items-center gap-1 text-xs opacity-80">
<div
className={cn(
"flex flex-1 items-center gap-1 min-w-0 text-xs opacity-80",
"text-thinking-mode"
)}
>
<span className="text-xs">
<Lightbulb className={cn("size-3.5", isStreaming && "animate-pulse")} />
</span>
<span>
<div className="flex min-w-0 items-center gap-1 truncate">
{isStreaming ? (
<Shimmer colorClass="var(--color-thinking-mode)">Thinking...</Shimmer>
) : hasContent ? (
<MarkdownRenderer
content={summaryLine}
className="truncate [&_*]:inline [&_*]:align-baseline [&_*]:whitespace-nowrap"
style={{ fontSize: 12, lineHeight: "18px" }}
/>
) : (
"Thought..."
"Thought"
)}
</span>
{showEllipsis && (
<span
className="text-[11px] tracking-widest text-[color:var(--color-text)] opacity-70"
data-testid="reasoning-ellipsis"
>
...
</span>
)}
</div>
</div>
{!isStreaming && (
{isCollapsible && (
<span
className={cn(
"text-thinking-mode opacity-60 transition-transform duration-200 ease-in-out text-xs",
Expand All @@ -84,7 +114,7 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
)}
</div>

{isExpanded && (
{isExpanded && !isSingleLineTrace && (
<div className="font-primary text-sm leading-6 italic opacity-85 [&_p]:mt-0 [&_p]:mb-1 [&_p:last-child]:mb-0">
{renderContent()}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/node/services/mock/scenarios/toolFlows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 11 additions & 4 deletions tests/e2e/scenarios/toolFlows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,18 @@ 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 reasoningPreview = transcript
.getByText("Assessing quicksort mechanics and choosing example array...")
.first();
await expect(reasoningPreview).toBeVisible();

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");
});
Expand Down