Skip to content

Commit a242b3d

Browse files
authored
Keep streaming reasoning collapsed with live previews (#293)
* fix(codex): preserve boundaries between reasoning summary parts * fix(chat): keep streaming reasoning collapsed with live previews
1 parent 2427ddf commit a242b3d

5 files changed

Lines changed: 132 additions & 74 deletions

File tree

src/renderer/components/thread/ChatPane/parts/items/Reasoning.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,22 @@ describe("Reasoning", () => {
5555
MockResizeObserver.reset();
5656
});
5757

58+
it("shows the last streamed line as the collapsed Thinking preview", () => {
59+
const { container } = renderReasoning(
60+
makeReasoningItem("Inspecting logs\nChecking the tail output now"),
61+
);
62+
63+
const toggle = container.querySelector("button");
64+
if (!toggle) throw new Error("missing Thinking toggle");
65+
expect(toggle.textContent).toContain("Thinking");
66+
expect(toggle.textContent).toContain("Checking the tail output now");
67+
// Collapsed: no live viewport mounted until the row is expanded.
68+
expect(container.querySelector(".overflow-y-auto")).toBeNull();
69+
});
70+
5871
it("keeps live reasoning pinned to the bottom while new content streams in", async () => {
5972
const { container, rerender } = renderReasoning(makeReasoningItem("Inspecting logs"));
73+
expandReasoning(container);
6074
const viewport = getReasoningViewport(container);
6175
const content = getReasoningContent(viewport);
6276
const metrics = installScrollMetrics(viewport, {
@@ -102,6 +116,7 @@ describe("Reasoning", () => {
102116

103117
it("stops auto-scrolling once the user scrolls up inside the live reasoning block", async () => {
104118
const { container, rerender } = renderReasoning(makeReasoningItem("Inspecting logs"));
119+
expandReasoning(container);
105120
const viewport = getReasoningViewport(container);
106121
const content = getReasoningContent(viewport);
107122
const metrics = installScrollMetrics(viewport, {
@@ -158,6 +173,12 @@ function makeReasoningItem(text: string): RuntimeChatItem {
158173
};
159174
}
160175

176+
function expandReasoning(container: HTMLElement) {
177+
const toggle = container.querySelector("button");
178+
if (!toggle) throw new Error("missing reasoning toggle");
179+
fireEvent.click(toggle);
180+
}
181+
161182
function getReasoningViewport(container: HTMLElement): HTMLDivElement {
162183
const element = container.querySelector(".overflow-y-auto");
163184
if (!(element instanceof HTMLDivElement)) {
Lines changed: 49 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { memo, useState } from "react";
2-
import { Surface } from "@heroui/react";
32
import { Trans, useLingui } from "@lingui/react/macro";
43
import { Brain, ChevronDown } from "lucide-react";
54
import type { RuntimeChatItem } from "@/renderer/state/slices/runtimeEventSlice";
65
import { useChatPaneActions } from "../../chatPaneActionsContext";
76
import { useBrainThinking, useShimmer } from "@/renderer/thinkingAnimator";
8-
import { chatMessageSurfaceClass } from "./chatMessageSurface";
9-
import { getReasoningPreview } from "./reasoningPreview";
7+
import { getReasoningLastLine, getReasoningPreview } from "./reasoningPreview";
108
import { ReasoningExpandedBody, ReasoningStreamViewport } from "./ReasoningStreamViewport";
119

1210
interface ReasoningProps {
@@ -24,55 +22,55 @@ export const Reasoning = memo(function Reasoning({ item }: ReasoningProps) {
2422
const thinkingTextRef = useShimmer<HTMLSpanElement>(isStreaming);
2523
const brainRef = useBrainThinking(isStreaming);
2624

27-
if (!isStreaming) {
28-
const preview = isOpen ? "" : getReasoningPreview(rawText);
29-
// Compact toggle — visually distinct from tool-call accordions: no border
30-
// tile, dotted left rule when expanded, italic body. Equal vertical
31-
// padding so it doesn't visually bias toward the message above or below.
32-
return (
33-
<div className="flex w-full flex-col items-stretch justify-center py-2 pl-6 pr-3 text-[length:var(--lc-chat-font-size-meta)] text-foreground-muted">
34-
<button
35-
type="button"
36-
onClick={() => {
37-
setIsOpen((v) => !v);
38-
actions?.onContentHeightChange();
39-
}}
40-
aria-expanded={isOpen}
41-
className="group inline-flex min-w-0 max-w-full items-center gap-1.5 self-start leading-none italic opacity-80 hover:text-foreground hover:opacity-100"
42-
>
43-
<Brain className="size-3 shrink-0" />
44-
<span className="shrink-0">
45-
<Trans>Thought</Trans>
46-
</span>
47-
{preview ? <span className="min-w-0 truncate opacity-70">{preview}</span> : null}
48-
<ChevronDown
49-
className={`size-3 shrink-0 opacity-100 transition-[transform,opacity] [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-visible:opacity-100 ${isOpen ? "rotate-180" : ""}`}
50-
/>
51-
</button>
52-
{isOpen ? <ReasoningExpandedBody text={rawText} className="mt-2" /> : null}
53-
</div>
54-
);
55-
}
25+
// Collapsed row is identical while thinking and after: only the label, the
26+
// brain/shimmer animation, and the preview source differ. While streaming the
27+
// trailing meta tracks the model's current line (streamed in line by line);
28+
// on completion it settles into the flattened whole-block preview.
29+
const preview = isOpen
30+
? ""
31+
: isStreaming
32+
? getReasoningLastLine(rawText)
33+
: getReasoningPreview(rawText);
5634

35+
// Compact toggle — visually distinct from tool-call accordions: no border
36+
// tile, dotted left rule when expanded, italic body. Equal vertical padding so
37+
// it doesn't visually bias toward the message above or below. Expanding while
38+
// streaming reveals the live pinned viewport; after completion, the static body.
5739
return (
58-
<Surface variant="transparent" className={`${chatMessageSurfaceClass} pl-6`}>
59-
<div className="flex min-w-0 flex-col gap-1.5 text-[length:var(--lc-chat-font-size-meta)] text-foreground-muted">
60-
<div className="inline-flex items-center gap-1.5">
61-
<Brain
62-
ref={brainRef}
63-
className="poracode-brain-thinking size-3 shrink-0"
64-
aria-label={t`Thinking`}
65-
/>
66-
<span
67-
ref={thinkingTextRef}
68-
className="poracode-thinking-text"
69-
data-poracode-shimmer-text={t`Thinking`}
70-
>
71-
<Trans>Thinking</Trans>
72-
</span>
73-
</div>
74-
{hasText ? <ReasoningStreamViewport text={rawText} className="pl-4" /> : null}
75-
</div>
76-
</Surface>
40+
<div className="flex w-full flex-col items-stretch justify-center py-2 pl-6 pr-3 text-[length:var(--lc-chat-font-size-meta)] text-foreground-muted">
41+
<button
42+
type="button"
43+
onClick={() => {
44+
setIsOpen((v) => !v);
45+
actions?.onContentHeightChange();
46+
}}
47+
aria-expanded={isOpen}
48+
className="group inline-flex min-w-0 max-w-full items-center gap-1.5 self-start leading-none italic opacity-80 hover:text-foreground hover:opacity-100"
49+
>
50+
<Brain
51+
ref={brainRef}
52+
className={`size-3 shrink-0 ${isStreaming ? "poracode-brain-thinking" : ""}`}
53+
{...(isStreaming ? { "aria-label": t`Thinking` } : {})}
54+
/>
55+
<span
56+
ref={thinkingTextRef}
57+
className={`shrink-0 ${isStreaming ? "poracode-thinking-text" : ""}`}
58+
{...(isStreaming ? { "data-poracode-shimmer-text": t`Thinking` } : {})}
59+
>
60+
{isStreaming ? <Trans>Thinking</Trans> : <Trans>Thought</Trans>}
61+
</span>
62+
{preview ? <span className="min-w-0 truncate opacity-70">{preview}</span> : null}
63+
<ChevronDown
64+
className={`size-3 shrink-0 opacity-100 transition-[transform,opacity] [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-visible:opacity-100 ${isOpen ? "rotate-180" : ""}`}
65+
/>
66+
</button>
67+
{isOpen && hasText ? (
68+
isStreaming ? (
69+
<ReasoningStreamViewport text={rawText} className="mt-2" />
70+
) : (
71+
<ReasoningExpandedBody text={rawText} className="mt-2" />
72+
)
73+
) : null}
74+
</div>
7775
);
7876
});

src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,32 @@ import type { RuntimeChatItem } from "@/renderer/state/slices/runtimeEventSlice"
66
import { useBrainThinking, useShimmer } from "@/renderer/thinkingAnimator";
77
import { useChatPaneActions } from "../../chatPaneActionsContext";
88
import { ChatRowMetaSeparator, chatRowIndicatorClass, inlineRowTriggerClass } from "./chatRow";
9-
import { getReasoningPreview } from "./reasoningPreview";
9+
import { getReasoningLastLine, getReasoningPreview } from "./reasoningPreview";
1010
import { ReasoningExpandedBody, ReasoningStreamViewport } from "./ReasoningStreamViewport";
1111

1212
interface ReasoningInlineProps {
1313
item: RuntimeChatItem;
1414
}
1515

1616
/**
17-
* Reasoning rendered as a row inside a tool-call group. While the model is
18-
* thinking the row auto-expands and streams the live reasoning text; on
19-
* completion it auto-collapses into a "Thought" row with a one-line preview of
20-
* the reasoning as trailing meta. Manual toggles override the automatic state.
17+
* Reasoning rendered as a row inside a tool-call group. The row stays collapsed
18+
* by default: while the model is thinking the trailing meta tracks its current
19+
* line (streamed in line by line); on completion it settles into a "Thought"
20+
* row with a one-line preview of the whole block. Expanding reveals the live
21+
* pinned viewport while streaming, or the static body after.
2122
*/
2223
export const ReasoningInline = memo(function ReasoningInline({ item }: ReasoningInlineProps) {
2324
const { t } = useLingui();
2425
const actions = useChatPaneActions();
2526
const isStreaming = item.state !== "completed";
26-
// null = follow the automatic state (open while streaming, closed after).
27-
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
28-
const isExpanded = manualExpanded ?? isStreaming;
27+
const [isExpanded, setIsExpanded] = useState(false);
2928
const rawText = item.streams.reasoning_text ?? "";
3029
const hasText = rawText.trim().length > 0;
31-
const preview = !isStreaming && !isExpanded ? getReasoningPreview(rawText) : "";
30+
const preview = isExpanded
31+
? ""
32+
: isStreaming
33+
? getReasoningLastLine(rawText)
34+
: getReasoningPreview(rawText);
3235
const brainRef = useBrainThinking(isStreaming);
3336
const shimmerRef = useShimmer<HTMLElement>(isStreaming);
3437
const title = isStreaming ? t`Thinking` : t`Thought`;
@@ -50,7 +53,7 @@ export const ReasoningInline = memo(function ReasoningInline({ item }: Reasoning
5053
className="text-[length:var(--lc-chat-font-size-command)] leading-tight"
5154
isExpanded={isExpanded}
5255
onExpandedChange={(next) => {
53-
setManualExpanded(next);
56+
setIsExpanded(next);
5457
actions?.onContentHeightChange();
5558
}}
5659
>

src/renderer/components/thread/ChatPane/parts/items/ToolCallGroup.test.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ describe("ToolCallGroup", () => {
473473
);
474474
});
475475

476-
it("auto-expands streaming reasoning inside the group and collapses it on completion", async () => {
476+
it("keeps streaming reasoning collapsed with a live last-line preview, then settles on completion", async () => {
477477
const threadId = "thread-1";
478478
const items: RuntimeChatItem[] = [
479479
{ ...makeReasoningItem("reasoning-1", "Considering the edge cases"), state: "updated" },
@@ -486,8 +486,11 @@ describe("ToolCallGroup", () => {
486486
items.map((item) => item.id),
487487
);
488488

489-
// Streaming: shimmering "Thinking" title with the live text expanded below.
489+
// Streaming: shimmering "Thinking" title, collapsed, with the current line
490+
// as trailing meta — not an expanded viewport.
490491
expect(screen.getByText("Thinking")).toBeInTheDocument();
492+
const thinkingTrigger = screen.getByText("Thinking").closest("button");
493+
expect(thinkingTrigger).toHaveAttribute("aria-expanded", "false");
491494
expect(await screen.findByText("Considering the edge cases")).toBeInTheDocument();
492495

493496
act(() => {
@@ -497,7 +500,7 @@ describe("ToolCallGroup", () => {
497500
]);
498501
});
499502

500-
// Completion: auto-collapses into a "Thought" row with the preview as meta.
503+
// Completion: stays collapsed as a "Thought" row with the preview as meta.
501504
await waitFor(() => expect(screen.getByText("Thought")).toBeInTheDocument());
502505
const trigger = screen.getByText("Thought").closest("button");
503506
expect(trigger).not.toBeNull();
Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
const REASONING_PREVIEW_MAX_LENGTH = 120;
22

3+
function stripMarkdownMarkers(text: string): string {
4+
return text
5+
.replace(/^[\s>#+*-]+/gm, "")
6+
.replace(/[*_]+/g, " ")
7+
.replace(/`+/g, "")
8+
.replace(/\s+/g, " ")
9+
.trim();
10+
}
11+
12+
function truncateOneLine(text: string, maxLength: number): string {
13+
if (text.length <= maxLength) return text;
14+
return `${text.slice(0, maxLength - 1).trimEnd()}…`;
15+
}
16+
317
/**
418
* One-line sneak peek of a reasoning block for collapsed "Thought" rows.
519
* Strips markdown structure (fences, list/heading markers, emphasis) so the
@@ -9,17 +23,36 @@ export function getReasoningPreview(
923
text: string,
1024
maxLength: number = REASONING_PREVIEW_MAX_LENGTH,
1125
): string {
12-
const flattened = text
26+
const flattened = stripMarkdownMarkers(
1327
// Fences must be stripped over the full text (an early fence can swallow
1428
// kilobytes); afterwards only a bounded prefix can survive truncation, so
1529
// cap the remaining passes instead of scanning the whole block.
16-
.replace(/```[\s\S]*?(?:```|$)/g, " ")
17-
.slice(0, maxLength * 8)
18-
.replace(/^[\s>#+*-]+/gm, "")
19-
.replace(/[*_]+/g, " ")
20-
.replace(/`+/g, "")
21-
.replace(/\s+/g, " ")
22-
.trim();
23-
if (flattened.length <= maxLength) return flattened;
24-
return `${flattened.slice(0, maxLength - 1).trimEnd()}…`;
30+
text.replace(/```[\s\S]*?(?:```|$)/g, " ").slice(0, maxLength * 8),
31+
);
32+
return truncateOneLine(flattened, maxLength);
33+
}
34+
35+
/**
36+
* The last non-empty line of a still-streaming reasoning block, flattened to
37+
* plain prose and truncated. Feeds the live "Thinking …" row so the trailing
38+
* meta tracks the model's current line as text streams in, matching the
39+
* collapsed "Thought" preview it settles into on completion.
40+
*/
41+
export function getReasoningLastLine(
42+
text: string,
43+
maxLength: number = REASONING_PREVIEW_MAX_LENGTH,
44+
): string {
45+
let lineEnd = text.length;
46+
while (lineEnd >= 0) {
47+
const newlineIndex = lineEnd > 0 ? text.lastIndexOf("\n", lineEnd - 1) : -1;
48+
const line = text.slice(newlineIndex + 1, lineEnd);
49+
// Skip fence delimiter lines; their content still surfaces line by line.
50+
if (!/^\s*```/.test(line)) {
51+
const flattened = stripMarkdownMarkers(line);
52+
if (flattened) return truncateOneLine(flattened, maxLength);
53+
}
54+
if (newlineIndex < 0) break;
55+
lineEnd = newlineIndex;
56+
}
57+
return "";
2558
}

0 commit comments

Comments
 (0)