Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,22 @@ describe("Reasoning", () => {
MockResizeObserver.reset();
});

it("shows the last streamed line as the collapsed Thinking preview", () => {
const { container } = renderReasoning(
makeReasoningItem("Inspecting logs\nChecking the tail output now"),
);

const toggle = container.querySelector("button");
if (!toggle) throw new Error("missing Thinking toggle");
expect(toggle.textContent).toContain("Thinking");
expect(toggle.textContent).toContain("Checking the tail output now");
// Collapsed: no live viewport mounted until the row is expanded.
expect(container.querySelector(".overflow-y-auto")).toBeNull();
});

it("keeps live reasoning pinned to the bottom while new content streams in", async () => {
const { container, rerender } = renderReasoning(makeReasoningItem("Inspecting logs"));
expandReasoning(container);
const viewport = getReasoningViewport(container);
const content = getReasoningContent(viewport);
const metrics = installScrollMetrics(viewport, {
Expand Down Expand Up @@ -102,6 +116,7 @@ describe("Reasoning", () => {

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

function expandReasoning(container: HTMLElement) {
const toggle = container.querySelector("button");
if (!toggle) throw new Error("missing reasoning toggle");
fireEvent.click(toggle);
}

function getReasoningViewport(container: HTMLElement): HTMLDivElement {
const element = container.querySelector(".overflow-y-auto");
if (!(element instanceof HTMLDivElement)) {
Expand Down
100 changes: 49 additions & 51 deletions src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { memo, useState } from "react";
import { Surface } from "@heroui/react";
import { Trans, useLingui } from "@lingui/react/macro";
import { Brain, ChevronDown } from "lucide-react";
import type { RuntimeChatItem } from "@/renderer/state/slices/runtimeEventSlice";
import { useChatPaneActions } from "../../chatPaneActionsContext";
import { useBrainThinking, useShimmer } from "@/renderer/thinkingAnimator";
import { chatMessageSurfaceClass } from "./chatMessageSurface";
import { getReasoningPreview } from "./reasoningPreview";
import { getReasoningLastLine, getReasoningPreview } from "./reasoningPreview";
import { ReasoningExpandedBody, ReasoningStreamViewport } from "./ReasoningStreamViewport";

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

if (!isStreaming) {
const preview = isOpen ? "" : getReasoningPreview(rawText);
// Compact toggle — visually distinct from tool-call accordions: no border
// tile, dotted left rule when expanded, italic body. Equal vertical
// padding so it doesn't visually bias toward the message above or below.
return (
<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">
<button
type="button"
onClick={() => {
setIsOpen((v) => !v);
actions?.onContentHeightChange();
}}
aria-expanded={isOpen}
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"
>
<Brain className="size-3 shrink-0" />
<span className="shrink-0">
<Trans>Thought</Trans>
</span>
{preview ? <span className="min-w-0 truncate opacity-70">{preview}</span> : null}
<ChevronDown
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" : ""}`}
/>
</button>
{isOpen ? <ReasoningExpandedBody text={rawText} className="mt-2" /> : null}
</div>
);
}
// Collapsed row is identical while thinking and after: only the label, the
// brain/shimmer animation, and the preview source differ. While streaming the
// trailing meta tracks the model's current line (streamed in line by line);
// on completion it settles into the flattened whole-block preview.
const preview = isOpen
? ""
: isStreaming
? getReasoningLastLine(rawText)
: getReasoningPreview(rawText);

// Compact toggle — visually distinct from tool-call accordions: no border
// tile, dotted left rule when expanded, italic body. Equal vertical padding so
// it doesn't visually bias toward the message above or below. Expanding while
// streaming reveals the live pinned viewport; after completion, the static body.
return (
<Surface variant="transparent" className={`${chatMessageSurfaceClass} pl-6`}>
<div className="flex min-w-0 flex-col gap-1.5 text-[length:var(--lc-chat-font-size-meta)] text-foreground-muted">
<div className="inline-flex items-center gap-1.5">
<Brain
ref={brainRef}
className="poracode-brain-thinking size-3 shrink-0"
aria-label={t`Thinking`}
/>
<span
ref={thinkingTextRef}
className="poracode-thinking-text"
data-poracode-shimmer-text={t`Thinking`}
>
<Trans>Thinking</Trans>
</span>
</div>
{hasText ? <ReasoningStreamViewport text={rawText} className="pl-4" /> : null}
</div>
</Surface>
<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">
<button
type="button"
onClick={() => {
setIsOpen((v) => !v);
actions?.onContentHeightChange();
}}
aria-expanded={isOpen}
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"
>
<Brain
ref={brainRef}
className={`size-3 shrink-0 ${isStreaming ? "poracode-brain-thinking" : ""}`}
{...(isStreaming ? { "aria-label": t`Thinking` } : {})}
/>
<span
ref={thinkingTextRef}
className={`shrink-0 ${isStreaming ? "poracode-thinking-text" : ""}`}
{...(isStreaming ? { "data-poracode-shimmer-text": t`Thinking` } : {})}
>
{isStreaming ? <Trans>Thinking</Trans> : <Trans>Thought</Trans>}
</span>
{preview ? <span className="min-w-0 truncate opacity-70">{preview}</span> : null}
<ChevronDown
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" : ""}`}
/>
</button>
{isOpen && hasText ? (
isStreaming ? (
<ReasoningStreamViewport text={rawText} className="mt-2" />
) : (
<ReasoningExpandedBody text={rawText} className="mt-2" />
)
) : null}
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,32 @@ import type { RuntimeChatItem } from "@/renderer/state/slices/runtimeEventSlice"
import { useBrainThinking, useShimmer } from "@/renderer/thinkingAnimator";
import { useChatPaneActions } from "../../chatPaneActionsContext";
import { ChatRowMetaSeparator, chatRowIndicatorClass, inlineRowTriggerClass } from "./chatRow";
import { getReasoningPreview } from "./reasoningPreview";
import { getReasoningLastLine, getReasoningPreview } from "./reasoningPreview";
import { ReasoningExpandedBody, ReasoningStreamViewport } from "./ReasoningStreamViewport";

interface ReasoningInlineProps {
item: RuntimeChatItem;
}

/**
* Reasoning rendered as a row inside a tool-call group. While the model is
* thinking the row auto-expands and streams the live reasoning text; on
* completion it auto-collapses into a "Thought" row with a one-line preview of
* the reasoning as trailing meta. Manual toggles override the automatic state.
* Reasoning rendered as a row inside a tool-call group. The row stays collapsed
* by default: while the model is thinking the trailing meta tracks its current
* line (streamed in line by line); on completion it settles into a "Thought"
* row with a one-line preview of the whole block. Expanding reveals the live
* pinned viewport while streaming, or the static body after.
*/
export const ReasoningInline = memo(function ReasoningInline({ item }: ReasoningInlineProps) {
const { t } = useLingui();
const actions = useChatPaneActions();
const isStreaming = item.state !== "completed";
// null = follow the automatic state (open while streaming, closed after).
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
const isExpanded = manualExpanded ?? isStreaming;
const [isExpanded, setIsExpanded] = useState(false);
const rawText = item.streams.reasoning_text ?? "";
const hasText = rawText.trim().length > 0;
const preview = !isStreaming && !isExpanded ? getReasoningPreview(rawText) : "";
const preview = isExpanded
? ""
: isStreaming
? getReasoningLastLine(rawText)
: getReasoningPreview(rawText);
const brainRef = useBrainThinking(isStreaming);
const shimmerRef = useShimmer<HTMLElement>(isStreaming);
const title = isStreaming ? t`Thinking` : t`Thought`;
Expand All @@ -50,7 +53,7 @@ export const ReasoningInline = memo(function ReasoningInline({ item }: Reasoning
className="text-[length:var(--lc-chat-font-size-command)] leading-tight"
isExpanded={isExpanded}
onExpandedChange={(next) => {
setManualExpanded(next);
setIsExpanded(next);
actions?.onContentHeightChange();
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ describe("ToolCallGroup", () => {
);
});

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

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

act(() => {
Expand All @@ -497,7 +500,7 @@ describe("ToolCallGroup", () => {
]);
});

// Completion: auto-collapses into a "Thought" row with the preview as meta.
// Completion: stays collapsed as a "Thought" row with the preview as meta.
await waitFor(() => expect(screen.getByText("Thought")).toBeInTheDocument());
const trigger = screen.getByText("Thought").closest("button");
expect(trigger).not.toBeNull();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
const REASONING_PREVIEW_MAX_LENGTH = 120;

function stripMarkdownMarkers(text: string): string {
return text
.replace(/^[\s>#+*-]+/gm, "")
.replace(/[*_]+/g, " ")
.replace(/`+/g, "")
.replace(/\s+/g, " ")
.trim();
}

function truncateOneLine(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 1).trimEnd()}…`;
}

/**
* One-line sneak peek of a reasoning block for collapsed "Thought" rows.
* Strips markdown structure (fences, list/heading markers, emphasis) so the
Expand All @@ -9,17 +23,36 @@ export function getReasoningPreview(
text: string,
maxLength: number = REASONING_PREVIEW_MAX_LENGTH,
): string {
const flattened = text
const flattened = stripMarkdownMarkers(
// Fences must be stripped over the full text (an early fence can swallow
// kilobytes); afterwards only a bounded prefix can survive truncation, so
// cap the remaining passes instead of scanning the whole block.
.replace(/```[\s\S]*?(?:```|$)/g, " ")
.slice(0, maxLength * 8)
.replace(/^[\s>#+*-]+/gm, "")
.replace(/[*_]+/g, " ")
.replace(/`+/g, "")
.replace(/\s+/g, " ")
.trim();
if (flattened.length <= maxLength) return flattened;
return `${flattened.slice(0, maxLength - 1).trimEnd()}…`;
text.replace(/```[\s\S]*?(?:```|$)/g, " ").slice(0, maxLength * 8),
);
return truncateOneLine(flattened, maxLength);
}

/**
* The last non-empty line of a still-streaming reasoning block, flattened to
* plain prose and truncated. Feeds the live "Thinking …" row so the trailing
* meta tracks the model's current line as text streams in, matching the
* collapsed "Thought" preview it settles into on completion.
*/
export function getReasoningLastLine(
text: string,
maxLength: number = REASONING_PREVIEW_MAX_LENGTH,
): string {
let lineEnd = text.length;
while (lineEnd >= 0) {
const newlineIndex = lineEnd > 0 ? text.lastIndexOf("\n", lineEnd - 1) : -1;
const line = text.slice(newlineIndex + 1, lineEnd);
// Skip fence delimiter lines; their content still surfaces line by line.
if (!/^\s*```/.test(line)) {
const flattened = stripMarkdownMarkers(line);
if (flattened) return truncateOneLine(flattened, maxLength);
}
if (newlineIndex < 0) break;
lineEnd = newlineIndex;
}
return "";
}