diff --git a/coworker/server/app.py b/coworker/server/app.py index c2e60e159..f7865511e 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1931,6 +1931,11 @@ def settings_set_context_bar(body: dict) -> dict[str, Any]: # Composer: show the context-window fill bar, or just the popover (owner ask). return manager.set_context_bar((body or {}).get("context_bar", True)) + @app.post("/v1/settings/hide-live-thinking") + def settings_set_hide_live_thinking(body: dict) -> dict[str, Any]: + # Hide live thinking steps while running (#611). + return manager.set_hide_live_thinking((body or {}).get("hide_live_thinking", False)) + @app.post("/v1/settings/auto-approve") def settings_set_auto_approve(body: dict) -> dict[str, Any]: # Auto-Approve feature flag (spec §1.5): when on, Mode.AUTO_APPROVE gets an LLM diff --git a/coworker/server/manager.py b/coworker/server/manager.py index cf9b740fa..77be052b2 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -3442,6 +3442,7 @@ def _selectable(m: str) -> bool: "nav_layout": self._nav_layout(), "sessions_peek": self.sessions_peek(), "context_bar": self.context_bar(), + "hide_live_thinking": self.hide_live_thinking(), # Auto-Approve feature flag + its shadow-eval sibling (spec §1.5). Drive the # Settings toggles and gate the composer's Auto-Approve mode entry. "auto_approve": self.auto_approve(), @@ -3515,6 +3516,15 @@ def set_context_bar(self, shown: Any) -> dict[str, Any]: self._save_prefs() return {"ok": True, "context_bar": self.context_bar()} + def hide_live_thinking(self) -> bool: + """Hide/collapse live reasoning steps while running (#611).""" + return bool(self._prefs.get("hide_live_thinking", False)) + + def set_hide_live_thinking(self, hide: Any) -> dict[str, Any]: + self._prefs["hide_live_thinking"] = bool(hide) + self._save_prefs() + return {"ok": True, "hide_live_thinking": self.hide_live_thinking()} + # -- Auto-Approve (spec §1.5, Part 6 step 3) -------------------------------- # The feature flag and its shadow-eval sibling live in prefs (GUI-writable), falling # back to the config.toml value a power user may have hand-set. Prefs is user-global, diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 67cff675c..985fad247 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -21,6 +21,7 @@ import { deleteMemory, updateMemory, getSettings, + setHideLiveThinking as apiSetHideLiveThinking, getPersonas, getInbox, getUnattended, @@ -210,6 +211,8 @@ export function App() { // Settings: show the composer's context-window fill bar. OFF by default (owner ask), // so an older backend without the field also shows the session total. const [contextBar, setContextBar] = useState(false); + // Option to hide/collapse live thinking/reasoning steps while running (#611). + const [hideLiveThinking, setHideLiveThinkingState] = useState(false); // Per-session token usage (OPE-42): rebuilt from the transcript on session load, // accumulated live from assistant_message events, reset with the transcript. const [usage, setUsage] = useState(emptyUsage()); @@ -616,6 +619,7 @@ export function App() { setModelLabels(s.model_labels || {}); setModelContextWindows(s.model_context_windows || {}); setContextBar(s.context_bar === true); + setHideLiveThinkingState(s.hide_live_thinking === true); setModelReady(s.model_ready); if (s.surfaces) setSurfaces(s.surfaces); }) @@ -1979,7 +1983,16 @@ export function App() { the message finalizes. */} {running && reasoningStream && !streaming && (
- +
)} {/* Compaction runs between provider turns (nothing streams during it), so diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index a69e0ceca..565a3c392 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -919,6 +919,8 @@ export interface ModelSettings { // Composer: show the context-window fill bar (default FALSE; absent → the chip shows // the session total). The usage popover keeps both numbers regardless. context_bar?: boolean; + // Option to hide/collapse live thinking/reasoning steps while running (#611). + hide_live_thinking?: boolean; // Auto-Approve mode (spec §1.5): the feature flag that offers the reviewer mode, and its // shadow-eval sibling. Both default FALSE and are absent on older backends — the composer // hides the Auto-Approve mode entry unless auto_approve is explicitly true. @@ -1075,6 +1077,18 @@ export async function setNavLayout( return res.json(); } +/** Persist preference to hide live thinking steps while a task is running (#611). */ +export async function setHideLiveThinking( + hide: boolean, +): Promise<{ ok: boolean; hide_live_thinking?: boolean; error?: string }> { + const res = await fetch(`${httpBase()}/v1/settings/hide-live-thinking`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hide_live_thinking: hide }), + }); + return res.json(); +} + // Fired after a cloud sign-in/out completes so the account row (§26) refreshes without // waiting for the next window focus. export const CLOUD_CHANGED = "coworker:cloud-changed"; diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index 4c65970a5..e350a4211 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -8,6 +8,7 @@ import { setAutoApproveShadow, setCompactionSettings, setContextBar, + setHideLiveThinking, setOnboarded, setPdfSettings, setScratchBase, @@ -473,6 +474,8 @@ function AppearanceSection() { + + @@ -875,6 +878,44 @@ function ContextBarCard() { ); } +// Live Thinking steps toggle (#611): allows collapsing or hiding reasoning text while +// running so long agent turns do not produce a fast-scrolling wall of thinking text. +function LiveThinkingCard() { + const { t } = useTranslation(); + const [hide, setHide] = useState(null); + + useEffect(() => { + getSettings() + .then((s) => setHide(s.hide_live_thinking === true)) + .catch(() => setHide(false)); + }, []); + + const save = async (next: boolean) => { + setHide(next); + await setHideLiveThinking(next); + }; + + if (hide === null) return null; + return ( +
+
{t("settings.thinking_section")}
+ +
+ ); +} + // Auto-Approve (spec §1.5): the experimental feature flag that adds the "Auto-Approve" mode // to the composer's mode picker, plus its shadow-evaluation sibling. Both default off and are // user-global (a cloned repo can't turn either on). Shadow is nested under the main flag — it diff --git a/surfaces/gui/src/components/Transcript.tsx b/surfaces/gui/src/components/Transcript.tsx index 34213cee5..d97edfa5d 100644 --- a/surfaces/gui/src/components/Transcript.tsx +++ b/surfaces/gui/src/components/Transcript.tsx @@ -79,21 +79,64 @@ function BubbleMeta({ text, ts, align }: { text: string; ts?: number; align: "le // Reasoning-model thinking text (model-layer roadmap item 4): a quiet disclosure — // collapsed by default, the trace one click away. `live` = still streaming (pulsing label); // App renders that variant above the transcript, this one rides a finalized assistant item. -export function ThinkingBlock({ text, live }: { text: string; live?: boolean }) { +export function ThinkingBlock({ + text, + live, + hidden = false, + onToggleHidden, +}: { + text: string; + live?: boolean; + hidden?: boolean; + onToggleHidden?: () => void; +}) { const { t } = useTranslation(); const [open, setOpen] = useState(false); + + if (live && hidden) { + return ( +
+ {t("transcript.thinking_live")} + {onToggleHidden && ( + + )} +
+ ); + } + return (
- +
+ + {live && onToggleHidden && ( + + )} +
{open && (
{text} diff --git a/surfaces/gui/src/locales/en.json b/surfaces/gui/src/locales/en.json index 869ea7397..ce9d7a335 100644 --- a/surfaces/gui/src/locales/en.json +++ b/surfaces/gui/src/locales/en.json @@ -159,6 +159,9 @@ "copied": "Copied", "thinking_live": "Thinking…", "thinking_process": "Thought process", + "show_thinking": "Show thinking", + "hide_thinking": "Hide thinking", + "hide_live_thinking": "Hide live thinking", "who_assistant": "assistant", "retry": "Retry", "approval": { @@ -393,7 +396,10 @@ "personas_desc": "Coworkers are agents specialized for a particular role or task. They come equipped with the tools and skills to be successful in that role. Enabling a coworker lets you pick it when starting a conversation.", "composer_section": "Composer", "context_bar_title": "Show the context window bar", - "context_bar_desc": "A small meter showing how full the model’s context window is. Turn it off to show the same thing as a number instead." + "context_bar_desc": "A small meter showing how full the model’s context window is. Turn it off to show the same thing as a number instead.", + "thinking_section": "Reasoning & Thinking", + "hide_live_thinking_title": "Hide live thinking steps while a task is running", + "hide_live_thinking_desc": "Suppress streaming reasoning text from the live transcript during execution. The full thought process remains available once the turn completes." }, "cloud": { "check_browser": "Check your browser…", diff --git a/surfaces/gui/src/locales/zh.json b/surfaces/gui/src/locales/zh.json index 6ac0f33c3..52605f3c2 100644 --- a/surfaces/gui/src/locales/zh.json +++ b/surfaces/gui/src/locales/zh.json @@ -157,6 +157,9 @@ "copied": "已复制", "thinking_live": "思考中…", "thinking_process": "思考过程", + "show_thinking": "显示思考", + "hide_thinking": "隐藏思考", + "hide_live_thinking": "隐藏实时思考", "who_assistant": "助手", "retry": "重试", "approval": { @@ -385,7 +388,10 @@ "personas_desc": "同事是专精于特定角色或任务的 agent,自带胜任该角色所需的工具与技能。启用同事后,即可在开始会话时选择它。", "composer_section": "输入框", "context_bar_title": "显示上下文窗口占用条", - "context_bar_desc": "一个小指示条,显示模型上下文窗口的占用程度。关闭后将改为以数字形式显示。" + "context_bar_desc": "一个小指示条,显示模型上下文窗口的占用程度。关闭后将改为以数字形式显示。", + "thinking_section": "推理与思考", + "hide_live_thinking_title": "在任务运行时隐藏实时思考步骤", + "hide_live_thinking_desc": "在执行期间从实时记录中抑制流式传输的思考文本。回合完成后,完整的思考过程仍可查看。" }, "cloud": { "check_browser": "请查看你的浏览器…", diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index db0f5fcea..cf3eda3cb 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -1726,6 +1726,15 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: border-left: 2px solid var(--line); padding: 4px 0 4px 10px; margin: 4px 0 6px 4px; max-height: 280px; overflow-y: auto; } +.thinking-quiet { + display: flex; align-items: center; gap: 8px; + font-size: 12px; color: var(--faint); padding: 2px 0; +} +.thinking-toggle-btn { + background: none; border: none; font-size: 11px; + color: var(--faint); cursor: pointer; padding: 0 4px; +} +.thinking-toggle-btn:hover { color: var(--ink); text-decoration: underline; } /* -- BETA tag (quiet gray chip beside the wordmark; presentation-only, never in bundle/artifact names — those feed the updater) --------------------------------- */ diff --git a/tests/test_hide_live_thinking.py b/tests/test_hide_live_thinking.py new file mode 100644 index 000000000..c513bcd97 --- /dev/null +++ b/tests/test_hide_live_thinking.py @@ -0,0 +1,66 @@ +"""Tests for option to hide live thinking steps (#611).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from coworker.providers import ModelCapabilities, ProviderClient +from coworker.server import SessionManager, create_app + + +class EmptyProvider(ProviderClient): + def complete(self, *, model, messages, tools=None, **settings): + raise NotImplementedError + + def capabilities(self, model): + return ModelCapabilities() + + +def test_hide_live_thinking_preference_default_and_toggle(tmp_path): + manager = SessionManager(workspace=tmp_path, provider=EmptyProvider()) + # Default is False + assert manager.hide_live_thinking() is False + assert manager.get_settings()["hide_live_thinking"] is False + + # Toggle to True + res = manager.set_hide_live_thinking(True) + assert res["ok"] is True + assert res["hide_live_thinking"] is True + assert manager.hide_live_thinking() is True + assert manager.get_settings()["hide_live_thinking"] is True + + # Rebuilding manager on same workspace / prefs persists preference + manager2 = SessionManager(workspace=tmp_path, provider=EmptyProvider()) + assert manager2.hide_live_thinking() is True + + # Toggle back to False + res2 = manager2.set_hide_live_thinking(False) + assert res2["ok"] is True + assert res2["hide_live_thinking"] is False + assert manager2.hide_live_thinking() is False + + +def test_hide_live_thinking_rest_endpoints(tmp_path): + manager = SessionManager(workspace=tmp_path, provider=EmptyProvider()) + client = TestClient(create_app(manager)) + + # Initial settings + resp = client.get("/v1/settings") + assert resp.status_code == 200 + assert resp.json()["hide_live_thinking"] is False + + # POST /v1/settings/hide-live-thinking + resp = client.post("/v1/settings/hide-live-thinking", json={"hide_live_thinking": True}) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + assert resp.json()["hide_live_thinking"] is True + + # Check updated settings + resp = client.get("/v1/settings") + assert resp.status_code == 200 + assert resp.json()["hide_live_thinking"] is True + + # Turn off + resp = client.post("/v1/settings/hide-live-thinking", json={"hide_live_thinking": False}) + assert resp.status_code == 200 + assert resp.json()["hide_live_thinking"] is False