From f672cd6fc73edbdf47ac3ba7590db8d4f0e029ac Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Mon, 13 Jul 2026 11:02:28 -0700 Subject: [PATCH 1/2] fix(codex): preserve boundaries between reasoning summary parts --- .../agents/codex/canonicalMapping.test.ts | 33 ++++++++++++++++++- .../agents/codex/canonicalMapping/dispatch.ts | 14 +++++++- .../agents/codex/canonicalMappingState.ts | 3 ++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/supervisor/agents/codex/canonicalMapping.test.ts b/src/supervisor/agents/codex/canonicalMapping.test.ts index 5e7173cf2..823cdbdc2 100644 --- a/src/supervisor/agents/codex/canonicalMapping.test.ts +++ b/src/supervisor/agents/codex/canonicalMapping.test.ts @@ -1343,12 +1343,43 @@ describe("mapCodexNotification — streaming deltas", () => { expect(text[0]).toMatchObject({ type: "content.delta", stream: "reasoning_text" }); const summary = mapCodexNotification( "item/reasoning/summaryTextDelta", - { threadId: "x", itemId: "rs-1", delta: "summary" }, + { threadId: "x", itemId: "rs-1", delta: "summary", summaryIndex: 0 }, state, ); expect(summary[0]).toMatchObject({ type: "content.delta", stream: "reasoning_text" }); }); + it("preserves boundaries between indexed reasoning summary parts", () => { + const state = createCodexMapperState("t-codex"); + mapCodexNotification( + "item/started", + { threadId: "x", itemId: "rs-1", item: { id: "rs-1", type: "reasoning" } }, + state, + ); + + const events = [ + ...mapCodexNotification( + "item/reasoning/summaryTextDelta", + { threadId: "x", itemId: "rs-1", delta: "**Planning sidebar**", summaryIndex: 0 }, + state, + ), + ...mapCodexNotification( + "item/reasoning/summaryTextDelta", + { threadId: "x", itemId: "rs-1", delta: "**Refining", summaryIndex: 1 }, + state, + ), + ...mapCodexNotification( + "item/reasoning/summaryTextDelta", + { threadId: "x", itemId: "rs-1", delta: " removal**", summaryIndex: 1 }, + state, + ), + ]; + + expect( + events.flatMap((event) => (event.type === "content.delta" ? [event.delta] : [])).join(""), + ).toBe("**Planning sidebar**\n\n**Refining removal**"); + }); + it("maps MCP tool progress into the existing tool payload", () => { const state = createCodexMapperState("t-codex"); mapCodexNotification( diff --git a/src/supervisor/agents/codex/canonicalMapping/dispatch.ts b/src/supervisor/agents/codex/canonicalMapping/dispatch.ts index 8405bfb5b..c43451d56 100644 --- a/src/supervisor/agents/codex/canonicalMapping/dispatch.ts +++ b/src/supervisor/agents/codex/canonicalMapping/dispatch.ts @@ -91,6 +91,7 @@ export function mapCodexNotification( state.commandOutputSeenSet.clear(); state.fileChangeOutputMap.clear(); state.fileChangePathMap.clear(); + state.reasoningSummaryIndexMap.clear(); return events; } @@ -260,6 +261,7 @@ export function mapCodexNotification( state.commandOutputSeenSet.delete(codexItemId); state.fileChangeOutputMap.delete(codexItemId); state.fileChangePathMap.delete(codexItemId); + state.reasoningSummaryIndexMap.delete(codexItemId); return events; } const itemType = state.itemTypeMap.get(codexItemId) ?? canonicalTypeFor(item.type ?? item.kind); @@ -300,6 +302,7 @@ export function mapCodexNotification( state.commandOutputSeenSet.delete(codexItemId); state.fileChangeOutputMap.delete(codexItemId); state.fileChangePathMap.delete(codexItemId); + state.reasoningSummaryIndexMap.delete(codexItemId); return events; } @@ -310,6 +313,15 @@ export function mapCodexNotification( if (!delta) return []; const codexItemId = readItemId(params); if (!codexItemId) return []; + let contentDelta = delta; + const summaryIndex = params?.summaryIndex; + if (method === "item/reasoning/summaryTextDelta" && typeof summaryIndex === "number") { + const previousIndex = state.reasoningSummaryIndexMap.get(codexItemId); + if (previousIndex !== summaryIndex) { + if (previousIndex !== undefined) contentDelta = `\n\n${delta}`; + state.reasoningSummaryIndexMap.set(codexItemId, summaryIndex); + } + } let internalId = state.itemIdMap.get(codexItemId); const opened: RuntimeEvent[] = []; if (!internalId) { @@ -349,7 +361,7 @@ export function mapCodexNotification( threadId, itemId: internalId, stream, - delta, + delta: contentDelta, }, ]; } diff --git a/src/supervisor/agents/codex/canonicalMappingState.ts b/src/supervisor/agents/codex/canonicalMappingState.ts index eecd4bcea..d0d750316 100644 --- a/src/supervisor/agents/codex/canonicalMappingState.ts +++ b/src/supervisor/agents/codex/canonicalMappingState.ts @@ -16,6 +16,8 @@ export interface CodexMapperState { fileChangeOutputMap: Map; /** Last path emitted for a file-change item, to avoid duplicate updates. */ fileChangePathMap: Map; + /** Last streamed reasoning summary index, used to preserve summary-part boundaries. */ + reasoningSummaryIndexMap: Map; /** Current chat item that mirrors the provider's active goal state. */ goalItemId?: string; /** Provider-created timestamp for the current goal, when reported. */ @@ -34,6 +36,7 @@ export function createCodexMapperState(threadId: string): CodexMapperState { commandOutputSeenSet: new Set(), fileChangeOutputMap: new Map(), fileChangePathMap: new Map(), + reasoningSummaryIndexMap: new Map(), }; } From c75e3b33a4a800948e3b318e8aac53fa875a29d6 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Mon, 13 Jul 2026 14:18:29 -0700 Subject: [PATCH 2/2] fix(chat): keep streaming reasoning collapsed with live previews --- .../ChatPane/parts/items/Reasoning.test.tsx | 21 ++++ .../thread/ChatPane/parts/items/Reasoning.tsx | 100 +++++++++--------- .../ChatPane/parts/items/ReasoningInline.tsx | 23 ++-- .../parts/items/ToolCallGroup.test.tsx | 9 +- .../ChatPane/parts/items/reasoningPreview.ts | 53 ++++++++-- 5 files changed, 132 insertions(+), 74 deletions(-) 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 90532f1c2..63456b718 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 9b67fa3ef..6b765fa06 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 3b6130f0f..89af90918 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 47f7fc0d2..b06765960 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 ed2389b71..60557d8a8 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 ""; }