From 22303382eb08d3c7ee659719c224da0c79e68111 Mon Sep 17 00:00:00 2001 From: bigstyle <1349292139@qq.com> Date: Fri, 28 Aug 2026 10:25:57 +0800 Subject: [PATCH 1/2] feat: add setting to expand thinking blocks by default Thinking blocks always rendered collapsed, forcing users who review model reasoning to click each block on every response (#411). Add a General settings toggle persisted in localStorage (pi-thinking-expanded). ThinkingBlock reads it as its initial state, already-mounted blocks sync via a custom window event, and deferred history content loads automatically through the existing promise cache when blocks start expanded. Closes #411 --- components/MessageView.tsx | 53 ++++++++++++++------ components/SettingsPanel.tsx | 25 ++++++++++ lib/i18n/messages/en.ts | 3 ++ lib/i18n/messages/zh-CN.ts | 3 ++ lib/i18n/messages/zh-TW.ts | 3 ++ lib/thinking-expansion-preference.test.mjs | 57 ++++++++++++++++++++++ lib/thinking-expansion-preference.ts | 15 ++++++ 7 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 lib/thinking-expansion-preference.test.mjs create mode 100644 lib/thinking-expansion-preference.ts diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 60e9844722..4925de5583 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -9,6 +9,7 @@ import { parseCompactionSummary } from "@/lib/compaction-summary"; import { getAssistantErrorMessage, isEmptyThinkingBlock } from "@/lib/message-display"; import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch"; import { isEditToolName } from "@/lib/tool-names"; +import { isThinkingExpandedByDefault, THINKING_EXPANDED_EVENT } from "@/lib/thinking-expansion-preference"; import { TurnWrittenFiles } from "./TurnWrittenFiles"; import type { WrittenFile } from "@/lib/turn-written-files"; import { skillExpansionToCommand } from "@/lib/slash-display"; @@ -878,30 +879,50 @@ function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: { blockIndex: number; }) { const { t } = useI18n(); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(isThinkingExpandedByDefault); const [content, setContent] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const tRef = useRef(t); + tRef.current = t; - const toggle = async () => { - const nextExpanded = !expanded; - setExpanded(nextExpanded); - if (!nextExpanded || !block.deferred || content !== null) return; + // Keep already-mounted blocks in sync when the preference changes. + useEffect(() => { + const onChange = () => setExpanded(isThinkingExpandedByDefault()); + window.addEventListener(THINKING_EXPANDED_EVENT, onChange); + return () => window.removeEventListener(THINKING_EXPANDED_EVENT, onChange); + }, []); + + // Load deferred history content whenever the block is expanded. + // loadThinkingContent() memoizes in-flight promises and drops failed ones + // from its cache, so re-running this effect is cheap and a failed load can + // be retried by collapsing and expanding the block again. + useEffect(() => { + if (!expanded || !block.deferred || content !== null) return; if (!sessionId || !entryId) { - setError(t("i18n.thinkingUnavailable")); + setError(tRef.current("i18n.thinkingUnavailable")); return; } - + let cancelled = false; setLoading(true); setError(null); - try { - setContent(await loadThinkingContent(sessionId, entryId, blockIndex)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }; + loadThinkingContent(sessionId, entryId, blockIndex) + .then((value) => { + if (!cancelled) { + setContent(value); + setLoading(false); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [expanded, block.deferred, content, sessionId, entryId, blockIndex]); return (
); } diff --git a/lib/message-display.test.mjs b/lib/message-display.test.mjs index fa7573b3e0..6407364edd 100644 --- a/lib/message-display.test.mjs +++ b/lib/message-display.test.mjs @@ -101,6 +101,39 @@ test("keeps deferred historical thinking placeholders", async () => { ); }); +test("keeps thinking outside contiguous process segments in original order", async () => { + const { getDisplayableAssistantBlocks, splitThinkingBlocks, splitFinalAssistantBlocks } = await loadSubject(); + const message = assistant([ + { type: "thinking", thinking: "" }, + { type: "text", text: "Inspecting" }, + { type: "thinking", thinking: "", deferred: true }, + { type: "thinking", thinking: "Check the result" }, + { type: "toolCall", toolCallId: "call-1", toolName: "bash", input: {} }, + { type: "text", text: "Tool commentary" }, + { type: "thinking", thinking: "Conclude" }, + { type: "text", text: "Final answer" }, + ]); + const { processBlocks, answerBlocks } = splitFinalAssistantBlocks(message); + const groups = splitThinkingBlocks(processBlocks); + assert.deepEqual(groups.map(({ thinking, blocks }) => ({ + thinking, + indices: blocks.map((block) => message.content.indexOf(block)), + })), [ + { thinking: false, indices: [1] }, + { thinking: true, indices: [2, 3] }, + { thinking: false, indices: [4, 5] }, + { thinking: true, indices: [6] }, + ]); + assert.deepEqual([...groups.flatMap(({ blocks }) => blocks), ...answerBlocks], getDisplayableAssistantBlocks(message)); + + const simple = splitFinalAssistantBlocks(assistant([ + { type: "thinking", thinking: "Reasoning" }, + { type: "text", text: "Answer" }, + ])); + assert.equal(splitThinkingBlocks(simple.processBlocks).some(({ thinking }) => !thinking), false); + assert.deepEqual(splitThinkingBlocks([]), []); +}); + test("returns completed provider errors even when the message has no content", async () => { const { getAssistantErrorMessage } = await loadSubject(); const message = { diff --git a/lib/message-display.ts b/lib/message-display.ts index 2bd9d7450b..304451526b 100644 --- a/lib/message-display.ts +++ b/lib/message-display.ts @@ -45,3 +45,14 @@ export function splitFinalAssistantBlocks( export function countToolCallBlocks(blocks: AssistantContentBlock[]): number { return blocks.filter((block): block is ToolCallContent => block.type === "toolCall").length; } + +export function splitThinkingBlocks(blocks: AssistantContentBlock[]): { thinking: boolean; blocks: AssistantContentBlock[] }[] { + const groups: { thinking: boolean; blocks: AssistantContentBlock[] }[] = []; + for (const block of blocks) { + const thinking = block.type === "thinking"; + const previous = groups.at(-1); + if (previous?.thinking === thinking) previous.blocks.push(block); + else groups.push({ thinking, blocks: [block] }); + } + return groups; +}