diff --git a/src/renderer/components/thread/ChatPane/parts/items/Reasoning.test.tsx b/src/renderer/components/thread/ChatPane/parts/items/Reasoning.test.tsx index 90532f1c..63456b71 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/Reasoning.test.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/Reasoning.test.tsx @@ -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, { @@ -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, { @@ -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)) { diff --git a/src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx b/src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx index 9b67fa3e..6b765fa0 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx @@ -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 { @@ -24,55 +22,55 @@ export const Reasoning = memo(function Reasoning({ item }: ReasoningProps) { const thinkingTextRef = useShimmer(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 ( -
- - {isOpen ? : null} -
- ); - } + // 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 ( - -
-
- - - Thinking - -
- {hasText ? : null} -
-
+
+ + {isOpen && hasText ? ( + isStreaming ? ( + + ) : ( + + ) + ) : null} +
); }); diff --git a/src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx b/src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx index 3b6130f0..89af9091 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx @@ -6,7 +6,7 @@ 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 { @@ -14,21 +14,24 @@ interface ReasoningInlineProps { } /** - * 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(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(isStreaming); const title = isStreaming ? t`Thinking` : t`Thought`; @@ -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(); }} > diff --git a/src/renderer/components/thread/ChatPane/parts/items/ToolCallGroup.test.tsx b/src/renderer/components/thread/ChatPane/parts/items/ToolCallGroup.test.tsx index 47f7fc0d..b0676596 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/ToolCallGroup.test.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/ToolCallGroup.test.tsx @@ -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" }, @@ -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(() => { @@ -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(); diff --git a/src/renderer/components/thread/ChatPane/parts/items/reasoningPreview.ts b/src/renderer/components/thread/ChatPane/parts/items/reasoningPreview.ts index ed2389b7..60557d8a 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/reasoningPreview.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/reasoningPreview.ts @@ -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 @@ -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 ""; }