From bf761384025e71f5521888cc8d3710cca3da7436 Mon Sep 17 00:00:00 2001 From: "wayne.luo" Date: Tue, 8 Sep 2026 17:11:24 +0800 Subject: [PATCH 1/2] Show the reasoning level of the running turn The reasoning control is hidden while a turn streams because the level cannot be changed mid-turn. That also hides the level itself, exactly when it decides how long the turn runs and how much budget it spends. Render the active level as a read-only label in the same slot while streaming. It is not a button: nothing can change until the turn ends, so a clickable control would only invite clicks that do nothing. The label reuses the existing display label, icon, padding, height, and mobile collapse rule, so the control row keeps its spacing next to Stop. --- .../ChatInput.streaming-thinking.test.mjs | 26 +++++++++++++++++++ components/ChatInput.tsx | 20 ++++++++++++++ lib/i18n/messages/en.ts | 1 + lib/i18n/messages/zh-CN.ts | 1 + lib/i18n/messages/zh-TW.ts | 1 + 5 files changed, 49 insertions(+) create mode 100644 components/ChatInput.streaming-thinking.test.mjs diff --git a/components/ChatInput.streaming-thinking.test.mjs b/components/ChatInput.streaming-thinking.test.mjs new file mode 100644 index 000000000..5d31cf22c --- /dev/null +++ b/components/ChatInput.streaming-thinking.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const source = await readFile(new URL("./ChatInput.tsx", import.meta.url), "utf8"); +const controls = source.slice(source.indexOf("{isStreaming && onThinkingLevelChange &&")); + +test("shows the reasoning level of the running turn without offering a control", () => { + const readOnly = controls.slice(0, controls.indexOf("{!isStreaming && onThinkingLevelChange &&")); + assert.match(readOnly, //); + assert.doesNotMatch(readOnly, / { + const readOnly = controls.slice(0, controls.indexOf("{!isStreaming && onThinkingLevelChange &&")); + const dropdown = controls.slice(controls.indexOf("{!isStreaming && onThinkingLevelChange &&")); + const padding = /padding: isMobile \? "0 6px" : "8px 12px"/; + const height = /height: 32/; + for (const [label, markup] of [["read-only", readOnly], ["dropdown", dropdown]]) { + assert.match(markup, padding, `${label} horizontal padding`); + assert.match(markup, height, `${label} height`); + } + // The label collapses to the icon on mobile exactly like the editable control. + assert.match(readOnly, /\(!isMobile \|\| controlsMenuOpen\) &&/); +}); diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 1f91913cb..e30ab7b03 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -26,6 +26,7 @@ import { FolderIcon, getFileIcon } from "./FileIcons"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useI18n } from "@/hooks/useI18n"; import { useChatAppearance } from "@/hooks/useChatAppearance"; +import { ThinkingIcon } from "./ThinkingIcon"; import type { ToolPreset } from "@/lib/tool-presets"; import { ModelSelector, type ModelSelectorOption } from "./ModelSelector"; @@ -2268,6 +2269,25 @@ export const ChatInput = forwardRef(function ChatInput({ backdropFilter: "blur(10px)", } : null), }}> + {isStreaming && onThinkingLevelChange && ( + // The level cannot change mid-turn, so this is read-only: a button here + // would invite clicks that do nothing. It still answers the question + // that matters while a turn runs, which budget is this one spending. + + + {(!isMobile || controlsMenuOpen) && {thinkingDisplayLabel}} + + )} {!isStreaming && onThinkingLevelChange && (