From 2d489f5c5f9806aa5ad7366376498a6494b458b5 Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 8 Jul 2026 00:01:19 +0800 Subject: [PATCH 1/9] Dock run state into the composer card Replace the floating run-status pill with a run strip inside the composer card, so starting or finishing a turn no longer shifts the surrounding layout, and give stop a fixed home next to the send button while a turn runs (send switches to a queue-guidance icon). The strip is a small state machine: retry > waiting-approval > waiting-ask > streaming. While a tool approval or ask question is blocked on the user, the strip says so (with the pending tool label) instead of ticking whimsical "working" seconds, and the card hands the running accent off to the prompt card above it. Accessibility: the per-second ticker was inside an aria-live region and re-announced every second; it is now aria-hidden, with a separate sr-only status region that announces only stable state transitions. The approval card also gains a hint when the approval mode is relaxed (ask -> auto/yolo) while a request is pending, since switching modes does not auto-resolve the pending prompt. Verified: tsc --noEmit, tsc -p tsconfig.test.json, check:css, new composer-run-strip test (16 assertions), and the full frontend test chain all pass. --- desktop/frontend/package.json | 4 +- desktop/frontend/src/App.tsx | 5 +- .../__tests__/composer-goal-toggle.test.tsx | 4 +- .../src/__tests__/composer-run-strip.test.tsx | 214 ++++++++++++++++ .../frontend/src/components/ApprovalModal.tsx | 38 ++- desktop/frontend/src/components/Composer.tsx | 79 ++++-- desktop/frontend/src/locales/en.ts | 6 +- desktop/frontend/src/locales/zh-TW.ts | 6 +- desktop/frontend/src/locales/zh.ts | 6 +- desktop/frontend/src/styles.css | 235 +++++++----------- 10 files changed, 403 insertions(+), 194 deletions(-) create mode 100644 desktop/frontend/src/__tests__/composer-run-strip.test.tsx diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 2e98d726fd..8d3cafadc7 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -10,9 +10,9 @@ "check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css", "test:todo-visibility": "node scripts/test-todo-visibility.mjs", "typecheck": "tsc --noEmit", - "test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts", + "test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts", "test:typecheck": "tsc --noEmit -p tsconfig.test.json", - "test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx" + "test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx" }, "dependencies": { "@gsap/react": "^2.1.2", diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 02369820a6..042ad3b85c 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -45,7 +45,7 @@ import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPl import { Transcript } from "./components/Transcript"; import { Composer } from "./components/Composer"; import { TodoPanel } from "./components/TodoPanel"; -import { ApprovalModal } from "./components/ApprovalModal"; +import { ApprovalModal, approvalToolLabel } from "./components/ApprovalModal"; import { AskCard } from "./components/AskCard"; import { UndoRewindBanner } from "./components/UndoRewindBanner"; import { ClearContextCard } from "./components/ClearContextCard"; @@ -3516,6 +3516,7 @@ export default function App() { onStop={() => { cancel(); }} + toolApprovalMode={toolApprovalMode} /> )} {state.ask && ( @@ -3567,6 +3568,8 @@ export default function App() { turnStartAt={state.turnStartAt} turnTokens={state.turnTokens} retry={state.retry} + pendingApprovalLabel={state.approval ? approvalToolLabel(state.approval.tool, t) : null} + pendingAsk={state.ask != null} transientDismissSignal={transientOverlayDismissSignal} sessionKey={composerSessionKey} fileRefRefreshKey={composerFileRefRefreshKey} diff --git a/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx b/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx index 5c5ea918c1..fae2806641 100644 --- a/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx +++ b/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx @@ -257,7 +257,7 @@ console.log("\ncomposer goal toggle"); turnStartAt: Date.now(), }); - const stopButton = document.querySelector(".composer-runstatus__stop") as HTMLButtonElement | null; + const stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null; if (!stopButton) throw new Error("composer stop button did not render"); await act(async () => { @@ -643,7 +643,7 @@ console.log("\ncomposer goal toggle"); const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null; if (!sendButton) throw new Error("running composer send button did not render"); - eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input"); + eq(textarea.placeholder, "Running — type guidance, Enter adds it to the queue", "running composer explains queued guidance input"); ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer"); ok(sendButton.disabled === true, "running steer button stays disabled without input"); diff --git a/desktop/frontend/src/__tests__/composer-run-strip.test.tsx b/desktop/frontend/src/__tests__/composer-run-strip.test.tsx new file mode 100644 index 0000000000..609dc18acb --- /dev/null +++ b/desktop/frontend/src/__tests__/composer-run-strip.test.tsx @@ -0,0 +1,214 @@ +// Run: tsx src/__tests__/composer-run-strip.test.tsx +// +// The run state lives inside the composer card (no floating pill, no layout +// jump), stop has a fixed home next to send, and a pending approval/ask shifts +// the strip into a waiting state instead of a ticking "working" spinner. + +import { JSDOM } from "jsdom"; +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { Composer } from "../components/Composer"; +import { LocaleProvider } from "../lib/i18n"; +import { ToastProvider } from "../lib/toast"; +import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types"; + +let passed = 0; +let failed = 0; + +function ok(value: boolean, label: string) { + if (value) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}\n`); + failed += 1; + } +} + +function eq(actual: unknown, expected: unknown, label: string) { + if (actual === expected) ok(true, label); + else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} + +function flushTimers(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +function installDom() { + const dom = new JSDOM("
", { + pretendToBeVisual: true, + url: "http://localhost/", + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + globalThis.window = dom.window as unknown as Window & typeof globalThis; + globalThis.document = dom.window.document; + Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); + globalThis.Node = dom.window.Node; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement; + globalThis.Event = dom.window.Event; + globalThis.CustomEvent = dom.window.CustomEvent; + globalThis.KeyboardEvent = dom.window.KeyboardEvent; + globalThis.InputEvent = dom.window.InputEvent; + globalThis.MouseEvent = dom.window.MouseEvent; + globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent; + globalThis.MutationObserver = dom.window.MutationObserver; + globalThis.File = dom.window.File; + globalThis.FileReader = dom.window.FileReader; + globalThis.localStorage = dom.window.localStorage; + globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); + globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); + globalThis.ResizeObserver = TestResizeObserver; + Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} }); + Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: () => ({ + matches: true, + media: "(prefers-reduced-motion: reduce)", + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + }), + }); + return dom; +} + +async function renderComposer(props: Partial[0]> = {}) { + const rootEl = document.getElementById("root"); + if (!rootEl) throw new Error("missing root"); + const root = createRoot(rootEl); + const calls = { cancel: 0 }; + let currentProps: Parameters[0] = { + running: false, + collaborationMode: "normal" as CollaborationMode, + toolApprovalMode: "ask" as ToolApprovalMode, + tokenMode: "full" as TokenMode, + goal: "", + cwd: "/repo", + modelLabel: "DeepSeek-R1", + onSend: () => {}, + onCancel: () => { + calls.cancel += 1; + return undefined; + }, + onCycleMode: () => {}, + onSetMode: () => {}, + onSetCollaborationMode: () => {}, + onSetToolApprovalMode: () => {}, + onToggleYoloApprovalMode: () => {}, + onClearGoal: () => {}, + onSwitchModel: () => {}, + onSetEffort: () => {}, + onSetTokenMode: () => {}, + ready: true, + ...props, + }; + const paint = async (nextProps: Partial[0]> = {}) => { + currentProps = { ...currentProps, ...nextProps }; + await act(async () => { + root.render( + + + + + , + ); + await flushTimers(); + }); + }; + await paint(); + return { root, calls, rerender: paint }; +} + +console.log("\ncomposer run strip"); + +// Idle: no strip, no stop button, plain send arrow. +{ + const dom = installDom(); + const { root } = await renderComposer(); + + eq(document.querySelector(".composer-run-strip"), null, "idle composer renders no run strip"); + eq(document.querySelector(".composer__btn--stop"), null, "idle composer renders no stop button"); + ok(document.querySelector(".composer__btn--send") !== null, "idle composer keeps the send button"); + eq(document.querySelector(".composer-toolbar--status-only"), null, "floating status pill is gone"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + +// Running: strip lives inside the card, ticker is aria-hidden, stop cancels. +{ + const dom = installDom(); + const { root, calls } = await renderComposer({ running: true, turnStartAt: Date.now() }); + + const strip = document.querySelector(".composer-card .composer-run-strip"); + ok(strip !== null, "running strip renders inside the composer card"); + const ticker = strip?.querySelector(".composer-run-strip__text"); + eq(ticker?.getAttribute("aria-hidden"), "true", "ticking spinner text stays out of the accessibility tree"); + const live = strip?.querySelector(".sr-only[role=\"status\"]"); + eq(live?.textContent, "Reasonix is working", "live region announces the stable state text only"); + ok(document.querySelector(".composer-card--running") !== null, "running card keeps its running modifier"); + + const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null; + if (!stop) throw new Error("running composer stop button did not render"); + await act(async () => { + stop.click(); + await flushTimers(); + }); + eq(calls.cancel, 1, "stop button next to send cancels the turn"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + +// Waiting on approval: strip switches to the waiting state and stops ticking. +{ + const dom = installDom(); + const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() }); + + await rerender({ pendingApprovalLabel: "Run command" }); + + const strip = document.querySelector(".composer-run-strip"); + ok(strip?.classList.contains("composer-run-strip--waiting") === true, "pending approval shifts the strip into waiting"); + const text = strip?.querySelector(".composer-run-strip__text"); + eq(text?.textContent, "Waiting for your approval — Run command", "waiting strip names the tool awaiting approval"); + eq(text?.getAttribute("aria-hidden"), null, "waiting text is static and stays accessible"); + eq(document.querySelector(".composer-card--running"), null, "waiting card hands the running accent off to the prompt card"); + ok(document.querySelector(".composer-card--waiting") !== null, "waiting card takes the waiting modifier"); + + await rerender({ pendingApprovalLabel: null, pendingAsk: true }); + eq( + document.querySelector(".composer-run-strip__text")?.textContent, + "Waiting for your answer", + "pending ask question shows the ask waiting state", + ); + + await rerender({ pendingAsk: false }); + ok( + document.querySelector(".composer-run-strip__text")?.getAttribute("aria-hidden") === "true", + "resolving the prompt returns the strip to the ticking spinner", + ); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/components/ApprovalModal.tsx b/desktop/frontend/src/components/ApprovalModal.tsx index 029c516cfc..9fc2cd0a0c 100644 --- a/desktop/frontend/src/components/ApprovalModal.tsx +++ b/desktop/frontend/src/components/ApprovalModal.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import gsap from "gsap"; import { useT, type Translator } from "../lib/i18n"; -import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types"; +import type { ComposerInsertRequest, DirEntry, ToolApprovalMode, WireApproval } from "../lib/types"; import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf"; import { DUR_FAST } from "../lib/gsapAnimations"; import { @@ -30,7 +30,9 @@ function requiresFreshHumanApproval(tool: string): boolean { return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape"; } -function approvalToolLabel(tool: string, t: Translator): string { +const APPROVAL_MODE_RANK: Record = { ask: 0, auto: 1, yolo: 2 }; + +export function approvalToolLabel(tool: string, t: Translator): string { switch (tool) { case "bash": return t("approval.toolLabelBash"); @@ -121,6 +123,7 @@ export function ApprovalModal({ cwd, insertRequest, onRevisionActiveChange, + toolApprovalMode, }: { approval: WireApproval; onAnswer: (allow: boolean, session: boolean, persist: boolean) => void; @@ -130,12 +133,22 @@ export function ApprovalModal({ cwd?: string; insertRequest?: ComposerInsertRequest | null; onRevisionActiveChange?: (active: boolean) => void; + toolApprovalMode?: ToolApprovalMode; }) { const t = useT(); const isPlanApproval = approval.tool === "exit_plan_mode"; const toolLabel = approvalToolLabel(approval.tool, t); const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool); const hasFreshSessionGrant = approval.tool === "sandbox_escape"; + // Switching the approval segmented control to a more permissive mode does not + // resolve an already-pending request; say so on the card instead of leaving + // the user to wonder why the switch "did nothing". + const initialToolApprovalModeRef = useRef(toolApprovalMode); + const approvalModeRelaxed = + !isPlanApproval && + toolApprovalMode !== undefined && + initialToolApprovalModeRef.current !== undefined && + APPROVAL_MODE_RANK[toolApprovalMode] > APPROVAL_MODE_RANK[initialToolApprovalModeRef.current]; const subject = localizeApprovalSubject(approval.tool, approval.subject, t); const reason = localizePlanModeApprovalReason(approval.tool, localizeApprovalReason(approval.tool, approval.reason, t), t); const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? ""; @@ -425,13 +438,22 @@ export function ApprovalModal({ } > - {detailsOpen && ( -
- {reason &&
{reason}
} - {subject && ( -
{subject}
+ {/* Guard the whole block: PromptShelf only renders its body when children + are truthy, and a fragment of two false branches would still count. */} + {(approvalModeRelaxed || detailsOpen) && ( + <> + {approvalModeRelaxed && ( +
{t("approval.modeSwitchPendingHint")}
)} -
+ {detailsOpen && ( +
+ {reason &&
{reason}
} + {subject && ( +
{subject}
+ )} +
+ )} + )} diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index ac0070e914..1752ced24b 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -449,6 +449,8 @@ export function Composer({ turnStartAt, turnTokens, retry, + pendingApprovalLabel, + pendingAsk = false, transientDismissSignal, sessionKey, fileRefRefreshKey, @@ -492,6 +494,11 @@ export function Composer({ turnStartAt?: number; turnTokens?: number; retry?: { attempt: number; max: number }; + // Resolved label of the tool waiting for approval (null/undefined when none); + // shifts the run strip into its waiting state so the ticking spinner does not + // claim the model is working while a prompt is blocked on the user. + pendingApprovalLabel?: string | null; + pendingAsk?: boolean; transientDismissSignal?: number; sessionKey?: string; fileRefRefreshKey?: number | string; @@ -2054,17 +2061,28 @@ export function Composer({ requestAnimationFrame(() => taRef.current?.focus()); }); }; - const runActivity = retry + // Run-strip state machine: retry > waiting-approval > waiting-ask > streaming. + // The waiting states replace the whimsical ticker — while a prompt is blocked + // on the user, the strip must say so instead of counting "working" seconds. + const waitingPrompt = pendingApprovalLabel ? "approval" : pendingAsk ? "ask" : null; + const runStateText = retry ? t("status.retrying", { attempt: retry.attempt, max: retry.max }) - : running && turnStartAt - ? (() => { - const elapsedMs = Math.max(0, now - turnStartAt); - const words = SPINNER_WORDS[locale]; - const word = words[Math.floor(elapsedMs / 3000) % words.length]; - const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : ""; - return `${word}… ${fmtElapsed(elapsedMs)}${tok}`; - })() - : null; + : waitingPrompt === "approval" + ? t("composer.runWaitingApproval", { tool: pendingApprovalLabel ?? "" }) + : waitingPrompt === "ask" + ? t("composer.runWaitingAsk") + : running + ? t("composer.runAnnounceRunning") + : null; + const runTicker = !retry && !waitingPrompt && running && turnStartAt + ? (() => { + const elapsedMs = Math.max(0, now - turnStartAt); + const words = SPINNER_WORDS[locale]; + const word = words[Math.floor(elapsedMs / 3000) % words.length]; + const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : ""; + return `${word}… ${fmtElapsed(elapsedMs)}${tok}`; + })() + : null; const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0; const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly; const submitTooltip = running ? t("composer.queueGuidance") : t("composer.send"); @@ -2322,20 +2340,6 @@ export function Composer({ /> ) )} - {runActivity && ( -
-
- - {runActivity} - - - -
-
- )} {pendingGuidance.length > 0 && (
@@ -2484,7 +2488,7 @@ export function Composer({
)}
@@ -2502,6 +2506,17 @@ export function Composer({ onKeyDown={onComposerResizeKeyDown} onDoubleClick={resetComposerHeight} /> + {runStateText && ( +
+
+ )}
)} + {running && ( + + + + )}
diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 173b4be18b..9059082618 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -471,7 +471,10 @@ export const en = { "composer.send": "Send (Enter)", "composer.queueGuidance": "Add to guidance queue (Enter)", "composer.steer": "Add guidance to current turn (Enter)", - "composer.steerPlaceholder": "Add guidance to the queue...", + "composer.steerPlaceholder": "Running — type guidance, Enter adds it to the queue", + "composer.runWaitingApproval": "Waiting for your approval — {tool}", + "composer.runWaitingAsk": "Waiting for your answer", + "composer.runAnnounceRunning": "Reasonix is working", "composer.guidanceQueue": "Queued guidance", "composer.guidanceCount": "Queued guidance {n}", "composer.guidanceRemaining": "{n} more queued", @@ -673,6 +676,7 @@ export const en = { "approval.sendRevision": "Send update", "approval.exitPlan": "Exit plan", "approval.toolPending": "Allow tool call", + "approval.modeSwitchPendingHint": "Approval mode was relaxed — this pending request still needs your decision.", "approval.details": "Details", "approval.hideDetails": "Hide", "approval.deny": "Deny", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 66bed251a2..45ebc2e920 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -350,7 +350,10 @@ export const zhTW: Record = { "composer.send": "傳送(Enter)", "composer.queueGuidance": "加入引導佇列(Enter)", "composer.steer": "追加到目前任務(Enter)", - "composer.steerPlaceholder": "輸入要加入佇列的引導…", + "composer.steerPlaceholder": "正在執行——輸入補充指示,Enter 加入佇列", + "composer.runWaitingApproval": "等待你核准 — {tool}", + "composer.runWaitingAsk": "等待你回答", + "composer.runAnnounceRunning": "正在執行", "composer.guidanceQueue": "待處理引導", "composer.guidanceCount": "待處理引導 {n}", "composer.guidanceRemaining": "還有 {n} 條", @@ -520,6 +523,7 @@ export const zhTW: Record = { "approval.sendRevision": "傳送修改意見", "approval.exitPlan": "退出計畫", "approval.toolPending": "允許工具呼叫", + "approval.modeSwitchPendingHint": "核准檔位已放寬——這筆擱置的請求仍需你確認。", "approval.details": "詳情", "approval.hideDetails": "收起", "approval.deny": "拒絕", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index ddac9eaf72..1453babe96 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -472,7 +472,10 @@ export const zh: Record = { "composer.send": "发送(Enter)", "composer.queueGuidance": "加入引导队列(Enter)", "composer.steer": "追加到当前任务(Enter)", - "composer.steerPlaceholder": "输入要加入队列的引导…", + "composer.steerPlaceholder": "正在运行——输入补充指示,回车加入队列", + "composer.runWaitingApproval": "等待你批准 — {tool}", + "composer.runWaitingAsk": "等待你回答", + "composer.runAnnounceRunning": "正在运行", "composer.guidanceQueue": "待处理引导", "composer.guidanceCount": "待处理引导 {n}", "composer.guidanceRemaining": "还有 {n} 条", @@ -674,6 +677,7 @@ export const zh: Record = { "approval.sendRevision": "发送修改意见", "approval.exitPlan": "退出计划", "approval.toolPending": "允许工具调用", + "approval.modeSwitchPendingHint": "审批档位已放宽——本次挂起的请求仍需你确认。", "approval.details": "详情", "approval.hideDetails": "收起", "approval.deny": "拒绝", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 8a4eb0abe0..51f5d06987 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -5457,6 +5457,45 @@ body > .mermaid-diagram--fullscreen { .composer-card:focus-within { border-color: var(--fg-faint); } +/* Run strip: the running/waiting state lives inside the card so starting or + finishing a turn never shifts the surrounding layout. */ +.composer-run-strip { + --wails-draggable: no-drag; + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; + padding: 6px 14px 0; + color: var(--accent); + font-family: var(--sans); + font-size: 12px; +} +.composer-run-strip__dot { + flex: 0 0 auto; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + animation: pulse 1.2s ease-in-out infinite; +} +.composer-run-strip__text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.composer-run-strip--waiting { + color: color-mix(in srgb, var(--warn) 74%, var(--fg)); +} +.composer-run-strip--waiting .composer-run-strip__dot { + animation: none; +} +/* Attention handoff: while a prompt card is waiting on the user, the card + drops the accent running tint and takes a calm warn edge instead. */ +.composer-card--waiting { + border-color: color-mix(in srgb, var(--warn) 30%, var(--border)); +} .composer-resize-handle { --wails-draggable: no-drag; appearance: none; @@ -5710,6 +5749,22 @@ body > .mermaid-diagram--fullscreen { cursor: default; transform: none; } +.composer__btn--stop { + margin-right: 6px; + border: 1px solid color-mix(in srgb, var(--err) 42%, transparent); + background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2)); + color: var(--err); +} +.composer__btn--stop:hover, +.composer__btn--stop:focus-visible { + color: var(--fg); + background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2)); + border-color: color-mix(in srgb, var(--err) 70%, var(--border)); +} +.composer__btn--stop:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} .composer-meta { display: flex; align-items: center; @@ -7227,6 +7282,16 @@ body > .mermaid-diagram--fullscreen { margin-top: 8px; flex-wrap: wrap; } +.approval-mode-hint { + margin: 0; + padding: 6px 10px; + border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 30%, var(--border-soft)); + border-radius: 8px; + background: color-mix(in srgb, var(--warning, #f59e0b) 8%, var(--bg-soft)); + color: color-mix(in srgb, var(--warning, #f59e0b) 62%, var(--fg)); + font-size: 12px; + line-height: 1.45; +} .approval-reason { margin: 0; padding: 8px 10px; @@ -17310,21 +17375,6 @@ body > .mermaid-diagram--fullscreen { padding-left: 18px; } -/* ── composer run status ──────────────────────────────────────────────────── */ -.composer-toolbar { - --wails-draggable: no-drag; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - max-width: var(--maxw); - margin: 0 auto 8px; -} - -.composer-toolbar--status-only { - justify-content: flex-end; -} - .composer-guidance-shelf { --wails-draggable: no-drag; display: grid; @@ -17481,79 +17531,6 @@ body > .mermaid-diagram--fullscreen { } } -.composer-runstatus { - --wails-draggable: no-drag; - flex: 0 1 auto; - min-width: 0; - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - height: 34px; - padding: 0 5px 0 11px; - border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); - border-radius: 10px; - background: color-mix(in srgb, var(--accent) 9%, var(--bg-elev)); - color: var(--accent); - font-family: var(--sans); - font-size: 12px; - box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 6%, transparent); -} - -.composer-runstatus__dot { - flex: 0 0 auto; - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - animation: pulse 1.2s ease-in-out infinite; -} - -.composer-runstatus__text { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - -.composer-runstatus__stop { - --wails-draggable: no-drag; - flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - height: 26px; - padding: 0 9px; - border: 1px solid color-mix(in srgb, var(--err) 42%, transparent); - border-radius: 7px; - background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2)); - color: var(--err); - font: inherit; - font-weight: 620; -} - -.composer-runstatus__stop:hover, -.composer-runstatus__stop:focus-visible { - color: var(--fg); - background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2)); - border-color: color-mix(in srgb, var(--err) 70%, var(--border)); -} -.composer-runstatus__stop:disabled { - cursor: not-allowed; - opacity: 0.48; - color: var(--fg-faint); - background: var(--button-bg); - border-color: var(--border-soft); -} -.composer-runstatus__stop:disabled:hover, -.composer-runstatus__stop:disabled:focus-visible { - color: var(--fg-faint); - background: var(--button-bg); - border-color: var(--border-soft); -} - .composer-context { display: flex; flex-wrap: wrap; @@ -22786,10 +22763,6 @@ body > .mermaid-diagram--fullscreen { padding: 5px 10px 10px 30px; } -.composer-toolbar { - margin-bottom: 8px; -} - .composer-modebar { border-radius: 8px; background: color-mix(in srgb, var(--bg-elev-2) 72%, transparent); @@ -26864,41 +26837,23 @@ body > .mermaid-diagram--fullscreen { margin: 0 auto; } -.app--creation .composer-toolbar, -:root[data-theme-style] .app--creation .composer-toolbar { - max-width: 880px; - margin: 0 auto 8px; -} - -.app--creation .composer-toolbar--status-only, -:root[data-theme-style] .app--creation .composer-toolbar--status-only { - justify-content: center; -} - -.app--creation .composer-runstatus, -:root[data-theme-style] .app--creation .composer-runstatus { - max-width: min(100%, 340px); - height: 28px; - gap: 7px; - padding: 0 4px 0 10px; - border-color: color-mix(in srgb, var(--accent) 22%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--bg-elev) 24%, transparent); - color: color-mix(in srgb, var(--accent) 88%, var(--fg-muted)); - font-size: 11.5px; - box-shadow: none; -} - -.app--creation .composer-runstatus__dot, -:root[data-theme-style] .app--creation .composer-runstatus__dot { +.app--creation .composer-run-strip__dot, +:root[data-theme-style] .app--creation .composer-run-strip__dot { width: 5px; height: 5px; background: var(--accent); box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 48%, transparent); - animation: creation-runstatus-dot-breathe 1.65s ease-in-out infinite; + animation: creation-run-strip-dot-breathe 1.65s ease-in-out infinite; +} + +.app--creation .composer-run-strip--waiting .composer-run-strip__dot, +:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__dot { + animation: none; + background: currentColor; + box-shadow: none; } -@keyframes creation-runstatus-dot-breathe { +@keyframes creation-run-strip-dot-breathe { 0%, 100% { opacity: 0.42; box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent); @@ -26910,46 +26865,22 @@ body > .mermaid-diagram--fullscreen { } @media (prefers-reduced-motion: reduce) { - .app--creation .composer-runstatus__dot, - :root[data-theme-style] .app--creation .composer-runstatus__dot { + .app--creation .composer-run-strip__dot, + :root[data-theme-style] .app--creation .composer-run-strip__dot { animation: none; opacity: 0.78; } } -.app--creation .composer-runstatus__text, -:root[data-theme-style] .app--creation .composer-runstatus__text { +.app--creation .composer-run-strip__text, +:root[data-theme-style] .app--creation .composer-run-strip__text { color: color-mix(in srgb, var(--fg-muted) 72%, var(--accent)); font-weight: 540; } -.app--creation .composer-runstatus__stop, -:root[data-theme-style] .app--creation .composer-runstatus__stop { - height: 22px; - gap: 4px; - padding: 0 8px; - border-color: color-mix(in srgb, var(--err) 24%, transparent); - border-radius: 999px; - background: transparent; - color: color-mix(in srgb, var(--err) 76%, var(--fg-muted)); - font-size: 11.5px; - font-weight: 560; -} - -.app--creation .composer-runstatus__stop svg, -:root[data-theme-style] .app--creation .composer-runstatus__stop svg { - width: 8px; - height: 8px; - opacity: 0.78; -} - -.app--creation .composer-runstatus__stop:hover, -.app--creation .composer-runstatus__stop:focus-visible, -:root[data-theme-style] .app--creation .composer-runstatus__stop:hover, -:root[data-theme-style] .app--creation .composer-runstatus__stop:focus-visible { - border-color: color-mix(in srgb, var(--err) 42%, transparent); - background: color-mix(in srgb, var(--err) 9%, transparent); - color: color-mix(in srgb, var(--err) 90%, var(--fg)); +.app--creation .composer-run-strip--waiting .composer-run-strip__text, +:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__text { + color: color-mix(in srgb, var(--warn) 64%, var(--fg-muted)); } .app--creation .composer-card, From fa646b75c9d61d17a013a4bcc653b76879e37fa8 Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 8 Jul 2026 00:35:50 +0800 Subject: [PATCH 2/9] Anchor run strip as zero-height tab on the composer card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-flow strip added ~30px to the card's content height, which a user-resized card (fixed --composer-height, minimum 104px) could not absorb: the meta row overflowed the card bottom and was clipped behind the app status bar. Position the strip absolutely on the card's top edge instead — it no longer participates in height math (resized cards keep their exact height) and turn start/finish causes zero layout shift. The strip is non-interactive (pointer-events: none) so the resize handle underneath keeps working. --- desktop/frontend/src/styles.css | 36 +++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 51f5d06987..b3e4d21830 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -5457,18 +5457,32 @@ body > .mermaid-diagram--fullscreen { .composer-card:focus-within { border-color: var(--fg-faint); } -/* Run strip: the running/waiting state lives inside the card so starting or - finishing a turn never shifts the surrounding layout. */ +/* Run strip: a zero-height tab anchored to the card's top edge. It is + absolutely positioned so it never participates in the card's height math — + a user-resized card keeps its fixed height (no clipped meta row) and turn + start/finish causes no layout shift. Non-interactive by design: the resize + handle underneath keeps working through it. */ .composer-run-strip { - --wails-draggable: no-drag; - display: flex; + position: absolute; + bottom: calc(100% - 1px); + left: 12px; + max-width: calc(100% - 24px); + display: inline-flex; align-items: center; - gap: 8px; - min-height: 24px; - padding: 6px 14px 0; + gap: 7px; + height: 24px; + padding: 0 11px; + border: 1px solid var(--border); + border-bottom: none; + border-radius: 9px 9px 0 0; + background: var(--bg-elev); color: var(--accent); font-family: var(--sans); - font-size: 12px; + font-size: 11.5px; + pointer-events: none; +} +.composer-card--waiting .composer-run-strip { + border-color: color-mix(in srgb, var(--warn) 30%, var(--border)); } .composer-run-strip__dot { flex: 0 0 auto; @@ -26837,6 +26851,12 @@ body > .mermaid-diagram--fullscreen { margin: 0 auto; } +.app--creation .composer-run-strip, +:root[data-theme-style] .app--creation .composer-run-strip { + border-color: color-mix(in srgb, var(--fg-faint) 14%, transparent); + background: color-mix(in srgb, var(--bg-elev) 92%, transparent); +} + .app--creation .composer-run-strip__dot, :root[data-theme-style] .app--creation .composer-run-strip__dot { width: 5px; From a665c88192529f1908624be78407aaac551807e0 Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 8 Jul 2026 00:54:42 +0800 Subject: [PATCH 3/9] Keep the approval bar usable while its own prompt is pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer is disabled while a tool approval is pending, which also disabled the ask/auto/yolo bar — making the controller's designed mode-switch semantics unreachable from the UI: SetToolApprovalMode drains pending approvals the relaxed posture allows (fresh-human tools are never drained, approval.go drainLocked). Scope the disable so the approval bar stays active exactly when the pending approval itself is the reason: relaxing the mode now resolves drainable requests in place, and for surviving fresh-human requests the approval card's mode-switch hint explains that an explicit decision is still needed. Verified live against the dev instance: a pending bash approval auto-resolves when switching ask -> auto, the run strip returns from waiting to streaming, and the composer stays disabled for non-approval reasons (covered by composer-run-strip assertions). --- .../src/__tests__/composer-run-strip.test.tsx | 11 +++++++++-- desktop/frontend/src/components/Composer.tsx | 13 ++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/desktop/frontend/src/__tests__/composer-run-strip.test.tsx b/desktop/frontend/src/__tests__/composer-run-strip.test.tsx index 609dc18acb..f9c0a9cc6c 100644 --- a/desktop/frontend/src/__tests__/composer-run-strip.test.tsx +++ b/desktop/frontend/src/__tests__/composer-run-strip.test.tsx @@ -181,7 +181,7 @@ console.log("\ncomposer run strip"); const dom = installDom(); const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() }); - await rerender({ pendingApprovalLabel: "Run command" }); + await rerender({ pendingApprovalLabel: "Run command", disabled: true }); const strip = document.querySelector(".composer-run-strip"); ok(strip?.classList.contains("composer-run-strip--waiting") === true, "pending approval shifts the strip into waiting"); @@ -191,14 +191,21 @@ console.log("\ncomposer run strip"); eq(document.querySelector(".composer-card--running"), null, "waiting card hands the running accent off to the prompt card"); ok(document.querySelector(".composer-card--waiting") !== null, "waiting card takes the waiting modifier"); + const modeButtons = [...document.querySelectorAll(".composer-modebar--approval .composer-modebar__item")] as HTMLButtonElement[]; + ok(modeButtons.length === 3 && modeButtons.every((b) => !b.disabled), "approval bar stays usable while its own prompt disables the composer"); + await rerender({ pendingApprovalLabel: null, pendingAsk: true }); eq( document.querySelector(".composer-run-strip__text")?.textContent, "Waiting for your answer", "pending ask question shows the ask waiting state", ); + ok( + modeButtons.every((b) => b.disabled), + "approval bar stays disabled for non-approval reasons", + ); - await rerender({ pendingAsk: false }); + await rerender({ pendingAsk: false, disabled: false }); ok( document.querySelector(".composer-run-strip__text")?.getAttribute("aria-hidden") === "true", "resolving the prompt returns the strip to the ticking spinner", diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 1752ced24b..b471b27df2 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -2065,6 +2065,9 @@ export function Composer({ // The waiting states replace the whimsical ticker — while a prompt is blocked // on the user, the strip must say so instead of counting "working" seconds. const waitingPrompt = pendingApprovalLabel ? "approval" : pendingAsk ? "ask" : null; + // The pending approval itself disables the composer; keep the approval bar + // usable in exactly that case so the mode can still be changed mid-prompt. + const approvalBarDisabled = Boolean(disabled) && !pendingApprovalLabel; const runStateText = retry ? t("status.retrying", { attempt: retry.attempt, max: retry.max }) : waitingPrompt === "approval" @@ -2661,13 +2664,17 @@ export function Composer({ )}
+ {/* A pending tool approval disables the composer, but the approval + bar stays usable so mode changes remain possible mid-prompt; + the approval card explains that the pending request still needs + an explicit decision. */}
@@ -92,6 +97,8 @@ export function PromptAction({ description, onClick, ariaLabel, + title, + onHoverChange, primary = false, selected = false, quiet = false, @@ -102,6 +109,11 @@ export function PromptAction({ description?: ReactNode; onClick: () => void; ariaLabel?: string; + // Native tooltip fallback for truncated descriptions. + title?: string; + // Fires on mouse enter/focus (true) and mouse leave/blur (false) so the + // parent can drive a focus-following detail preview. + onHoverChange?: (hovering: boolean) => void; primary?: boolean; selected?: boolean; quiet?: boolean; @@ -121,6 +133,11 @@ export function PromptAction({ onClick={onClick} disabled={disabled} aria-label={ariaLabel} + title={title} + onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined} + onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined} + onFocus={onHoverChange ? () => onHoverChange(true) : undefined} + onBlur={onHoverChange ? () => onHoverChange(false) : undefined} > {keyLabel && {keyLabel}} {hasCopy && ( diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 0fa97dd6c9..12e0d5dd58 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -7513,6 +7513,31 @@ body > .mermaid-diagram--fullscreen { padding: 4px 12px; border-radius: 999px; } +.prompt-shelf__footnote { + min-width: 0; +} +.ask-shelf__detail { + display: flex; + align-items: baseline; + gap: 8px; + padding: 0 2px; + color: var(--fg-dim); + font-size: 11.5px; + line-height: 1.45; +} +.ask-shelf__detail-label { + flex: 0 0 auto; + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg); + font-weight: 620; +} +.ask-shelf__detail-text { + min-width: 0; + overflow-wrap: anywhere; +} .prompt-shelf--ask .ask-shelf__custom-row { margin-top: 0; } From fb1840f22d6e8bc5e1bc76f453efe8e4cd0f0ef3 Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 8 Jul 2026 02:54:07 +0800 Subject: [PATCH 7/9] Preview each approval action's consequence on the tool approval card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The allow-once / allow-session / always-allow / deny pills carry real semantic differences (scope, persistence) that the labels alone do not explain. Reuse the ask card's quick-pick pattern: a consequence row under the pills previews what the hovered or arrow-selected action will do, with a native title fallback on each pill. The subject block stays default-visible and untouched — what you approve must remain fully inspectable. The tool actions are now a descriptor list driving both the buttons and the preview, replacing the hand-rolled branch JSX. Also map exit_plan_mode to a friendly label ("Plan approval") — the composer run strip previously showed the raw tool name while a plan was waiting. --- .../approval-modal-file-reference.test.tsx | 19 ++++++ .../frontend/src/components/ApprovalModal.tsx | 64 ++++++++++++++----- desktop/frontend/src/locales/en.ts | 6 ++ desktop/frontend/src/locales/zh-TW.ts | 6 ++ desktop/frontend/src/locales/zh.ts | 6 ++ desktop/frontend/src/styles.css | 25 ++++++++ 6 files changed, 109 insertions(+), 17 deletions(-) diff --git a/desktop/frontend/src/__tests__/approval-modal-file-reference.test.tsx b/desktop/frontend/src/__tests__/approval-modal-file-reference.test.tsx index 354455bc2c..c144c9ce23 100644 --- a/desktop/frontend/src/__tests__/approval-modal-file-reference.test.tsx +++ b/desktop/frontend/src/__tests__/approval-modal-file-reference.test.tsx @@ -312,6 +312,25 @@ console.log("\napproval modal file references"); ); ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded"); + // Consequence preview row: follows the keyboard-selected action by default, + // arrow navigation, and hover; each pill carries a native title fallback. + const consequence = () => document.querySelector(".approval-consequence__text")?.textContent ?? null; + eq(consequence(), "Allow this call only; the next one asks again.", "consequence row previews the selected action by default"); + + await act(async () => { + document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + await flushTimers(); + }); + eq(consequence(), "Allow matching calls until this session ends; resets on restart.", "arrow navigation moves the consequence preview"); + + const pills = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[]; + eq(pills[2]?.getAttribute("title"), "Save a persistent allow rule; future sessions stop asking too.", "action pills carry a native title fallback"); + await act(async () => { + pills[3].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true })); + await flushTimers(); + }); + eq(consequence(), "Reject this call; the model sees the refusal and continues.", "hover overrides the consequence preview"); + await act(async () => { root.unmount(); }); diff --git a/desktop/frontend/src/components/ApprovalModal.tsx b/desktop/frontend/src/components/ApprovalModal.tsx index 9fc2cd0a0c..f50e33619f 100644 --- a/desktop/frontend/src/components/ApprovalModal.tsx +++ b/desktop/frontend/src/components/ApprovalModal.tsx @@ -56,6 +56,8 @@ export function approvalToolLabel(tool: string, t: Translator): string { return t("approval.toolLabelSandboxEscape"); case "plan_mode_read_only_command": return t("approval.toolLabelPlanModeReadOnly"); + case "exit_plan_mode": + return t("approval.toolLabelExitPlan"); default: return tool; } @@ -159,6 +161,9 @@ export function ApprovalModal({ const [revisionText, setRevisionText] = useState(""); const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault); const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0)); + // Action index currently hovered/focused; the consequence preview row + // prefers it over the keyboard-selected action. + const [hoverIndex, setHoverIndex] = useState(null); const cardRef = useRef(null); const shelfRef = useRef(null); const inputRef = useRef(null); @@ -217,6 +222,7 @@ export function ApprovalModal({ setRevisionText(""); setDetailsOpen(showToolDetailsByDefault); setSelectedIndex(isPlanApproval ? 1 : 0); + setHoverIndex(null); }, [approval.id, isPlanApproval, showToolDetailsByDefault]); const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4; @@ -395,6 +401,26 @@ export function ApprovalModal({ ); } + // Descriptor list mirrors the action buttons below; the consequence row + // previews what the hovered (or keyboard-selected) action will actually do, + // in the quick-pick style the ask card uses for option descriptions. + const toolActions: { key: string; label: string; desc: string; run: () => void }[] = [ + { key: "1", label: t("approval.allowOnce"), desc: t("approval.allowOnceDesc"), run: () => onAnswer(true, false, false) }, + ...(isFreshHumanApproval + ? hasFreshSessionGrant + ? [ + { key: "2", label: t("approval.allowSandboxEscapeSession"), desc: t("approval.allowSandboxEscapeSessionDesc"), run: () => onAnswer(true, true, false) }, + { key: "3", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) }, + ] + : [{ key: "2", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) }] + : [ + { key: "2", label: t("approval.allowRuleSession"), desc: t("approval.allowRuleSessionDesc"), run: () => onAnswer(true, true, false) }, + { key: "3", label: t("approval.allowRulePersistent"), desc: t("approval.allowRulePersistentDesc"), run: () => onAnswer(true, true, true) }, + { key: "4", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) }, + ]), + ]; + const previewAction = toolActions[hoverIndex ?? selectedIndex] ?? null; + return (
- answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} /> - {isFreshHumanApproval ? ( - hasFreshSessionGrant ? ( - <> - answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} /> - answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 2} /> - - ) : ( - answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} /> - ) - ) : ( - <> - answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} /> - answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} /> - answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} /> - - )} + {toolActions.map((action, index) => ( + answerWithExit(action.run)} + selected={selectedIndex === index} + title={action.desc} + onHoverChange={(hovering) => + setHoverIndex((current) => (hovering ? index : current === index ? null : current)) + } + /> + ))} } + note={ + previewAction && ( +
+ {previewAction.label} + {previewAction.desc} +
+ ) + } > {/* Guard the whole block: PromptShelf only renders its body when children are truthy, and a fragment of two false branches would still count. */} diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 9059082618..d3af31361d 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -677,6 +677,12 @@ export const en = { "approval.exitPlan": "Exit plan", "approval.toolPending": "Allow tool call", "approval.modeSwitchPendingHint": "Approval mode was relaxed — this pending request still needs your decision.", + "approval.toolLabelExitPlan": "Plan approval", + "approval.allowOnceDesc": "Allow this call only; the next one asks again.", + "approval.allowRuleSessionDesc": "Allow matching calls until this session ends; resets on restart.", + "approval.allowRulePersistentDesc": "Save a persistent allow rule; future sessions stop asking too.", + "approval.denyDesc": "Reject this call; the model sees the refusal and continues.", + "approval.allowSandboxEscapeSessionDesc": "Run follow-up commands in the real environment for the rest of this session.", "approval.details": "Details", "approval.hideDetails": "Hide", "approval.deny": "Deny", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 45ebc2e920..86c3bbd817 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -524,6 +524,12 @@ export const zhTW: Record = { "approval.exitPlan": "退出計畫", "approval.toolPending": "允許工具呼叫", "approval.modeSwitchPendingHint": "核准檔位已放寬——這筆擱置的請求仍需你確認。", + "approval.toolLabelExitPlan": "計畫確認", + "approval.allowOnceDesc": "僅允許本次呼叫,下次仍會詢問。", + "approval.allowRuleSessionDesc": "本會話內符合的呼叫不再詢問,重啟後失效。", + "approval.allowRulePersistentDesc": "儲存為持久允許規則,之後的會話也不再詢問。", + "approval.denyDesc": "拒絕本次呼叫,模型會收到拒絕結果並繼續。", + "approval.allowSandboxEscapeSessionDesc": "本會話後續命令改在真實環境執行,跳過沙箱。", "approval.details": "詳情", "approval.hideDetails": "收起", "approval.deny": "拒絕", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 1453babe96..85df113b26 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -678,6 +678,12 @@ export const zh: Record = { "approval.exitPlan": "退出计划", "approval.toolPending": "允许工具调用", "approval.modeSwitchPendingHint": "审批档位已放宽——本次挂起的请求仍需你确认。", + "approval.toolLabelExitPlan": "计划确认", + "approval.allowOnceDesc": "仅允许本次调用,下次仍会询问。", + "approval.allowRuleSessionDesc": "本会话内匹配的调用不再询问,重启后失效。", + "approval.allowRulePersistentDesc": "保存为持久允许规则,之后的会话也不再询问。", + "approval.denyDesc": "拒绝本次调用,模型会收到拒绝结果并继续。", + "approval.allowSandboxEscapeSessionDesc": "本会话后续命令改在真实环境运行,跳过沙箱。", "approval.details": "详情", "approval.hideDetails": "收起", "approval.deny": "拒绝", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 12e0d5dd58..b20d405489 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -7538,6 +7538,31 @@ body > .mermaid-diagram--fullscreen { min-width: 0; overflow-wrap: anywhere; } +/* Tool-approval consequence preview: same quick-pick pattern as the ask + detail row — pill buttons stay compact, the row spells out what the + hovered/selected action will do. */ +.approval-consequence { + display: flex; + align-items: baseline; + gap: 8px; + padding: 0 2px; + color: var(--fg-dim); + font-size: 11.5px; + line-height: 1.45; +} +.approval-consequence__label { + flex: 0 0 auto; + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg); + font-weight: 620; +} +.approval-consequence__text { + min-width: 0; + overflow-wrap: anywhere; +} .prompt-shelf--ask .ask-shelf__custom-row { margin-top: 0; } From 2f4a6995f184c66aa30951a257392b267f8716fd Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 8 Jul 2026 03:54:57 +0800 Subject: [PATCH 8/9] Keep --composer-height in logical space across all resize writers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the strip compensation baked into the React render path only: composerCardStyle wrote height+30px while the live drag updater and drag-start measurement wrote raw logical heights to the same CSS variable. Dragging a resized composer during a running turn briefly shrank the card by the strip height (re-clipping the meta row) until release re-added the compensation — a visible jump. Make the variable single-meaning: --composer-height always carries the user's logical height from every writer (render, live drag, keyboard), and the strip reservation moves into a separate --composer-run-strip-reserved variable consumed by a CSS calc on .composer-card--resized. DOM-based fallback measurements subtract the strip so auto-height cards convert to the same space. Regression covered in composer-run-strip: drag start/release during a running turn stays in logical-height space, the reservation survives the drag, and the idle card releases it. --- .../src/__tests__/composer-run-strip.test.tsx | 54 +++++++++++++++++++ desktop/frontend/src/components/Composer.tsx | 29 +++++++--- desktop/frontend/src/styles.css | 5 +- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/desktop/frontend/src/__tests__/composer-run-strip.test.tsx b/desktop/frontend/src/__tests__/composer-run-strip.test.tsx index 06220c94fa..84ba183e22 100644 --- a/desktop/frontend/src/__tests__/composer-run-strip.test.tsx +++ b/desktop/frontend/src/__tests__/composer-run-strip.test.tsx @@ -4,6 +4,9 @@ // jump), stop has a fixed home next to send, and a pending approval/ask shifts // the strip into a waiting state instead of a ticking "working" spinner. +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { JSDOM } from "jsdom"; import React from "react"; import { act } from "react"; @@ -269,5 +272,56 @@ console.log("\ncomposer run strip"); dom.window.close(); } +// Resize consistency: --composer-height always carries the logical height in +// every writer (React render, live drag, keyboard), with the run strip's +// reservation isolated in a CSS calc — so dragging a resized composer during a +// running turn cannot flash-shrink the card. +{ + const stylesSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../styles.css"), "utf8"); + ok( + stylesSource.includes("calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px))"), + "resized card height combines logical height and strip reservation in CSS", + ); + + const dom = installDom(); + const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() }); + + const handle = document.querySelector(".composer-resize-handle") as HTMLButtonElement; + await act(async () => { + handle.focus(); + handle.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true })); + await flushTimers(); + }); + + const card = document.querySelector(".composer-card") as HTMLElement; + eq(card.style.getPropertyValue("--composer-height"), "104px", "render path writes the logical height, not a compensated one"); + eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "running card reserves the strip height via its own variable"); + + // Drag while running: the live writer stays in logical-height space. + await act(async () => { + handle.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, clientY: 300 })); + await flushTimers(); + }); + eq(card.style.getPropertyValue("--composer-height"), "104px", "drag start does not flash-shrink the running card"); + + await act(async () => { + document.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientY: 280 })); + document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientY: 280 })); + await flushTimers(); + }); + eq(card.style.getPropertyValue("--composer-height"), "124px", "drag release keeps the same logical-height space as the render path"); + eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "strip reservation survives the drag"); + eq(handle.getAttribute("aria-valuenow"), "124", "separator reports the logical height"); + + await rerender({ running: false, turnStartAt: undefined }); + eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "idle card releases the strip reservation"); + eq(card.style.getPropertyValue("--composer-height"), "124px", "idle card keeps the user's logical height"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index bf26737f56..ac1874cf62 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -51,6 +51,9 @@ const LONG_PASTE_MIN_CHARS = 2000; const LONG_PASTE_MIN_LINES = 20; const COMPOSER_MIN_HEIGHT = 104; const COMPOSER_MAX_HEIGHT = 360; +// Height reserved for the in-card run strip while a turn runs; applied via a +// CSS calc so --composer-height always stays in "logical height" space. +const COMPOSER_RUN_STRIP_RESERVED = 30; const COMPOSER_MAX_VIEWPORT_RATIO = 0.4; const COMPOSER_AUTO_RESERVED_HEIGHT = 58; const PROMPT_HISTORY_PREFETCH_REMAINING = 3; @@ -255,6 +258,14 @@ function composerMaxHeight(): number { return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO))); } +// The rendered card includes the run strip while a turn runs; subtract it to +// recover the user's logical height when measuring from the DOM. +function composerLogicalHeight(card: HTMLElement): number { + const strip = card.querySelector(".composer-run-strip"); + const stripHeight = strip ? strip.getBoundingClientRect().height : 0; + return card.getBoundingClientRect().height - stripHeight; +} + function clampComposerHeight(height: number): number { return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight()); } @@ -1656,7 +1667,7 @@ export function Composer({ e.preventDefault(); const startY = e.clientY; - const startHeight = composerHeight ?? card.getBoundingClientRect().height; + const startHeight = composerHeight ?? composerLogicalHeight(card); let nextHeight = clampComposerHeight(startHeight); let moved = false; card.style.setProperty("--composer-height", `${nextHeight}px`); @@ -1694,7 +1705,7 @@ export function Composer({ const onComposerResizeKeyDown = (e: KeyboardEvent) => { const card = composerCardRef.current; - const current = composerHeight ?? card?.getBoundingClientRect().height ?? COMPOSER_MIN_HEIGHT; + const current = composerHeight ?? (card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT); const step = e.shiftKey ? 32 : 16; let next: number | null = null; if (e.key === "ArrowUp" || e.key === "PageUp") next = current + step; @@ -2025,14 +2036,18 @@ export function Composer({ } }; - // When the run strip is visible inside a user-resized card (fixed - // --composer-height), grow the card by the strip's reserved height so the - // meta row stays fully visible instead of being clipped. - const COMPOSER_RUN_STRIP_RESERVED = 30; + // When the run strip is visible inside a user-resized card, the card grows + // by the strip's reserved height so the meta row stays fully visible. + // --composer-height carries only the user's logical height; the reservation + // is a separate variable consumed by the CSS calc, so the live resize drag + // (which writes raw logical heights) stays consistent with this render path. const showRunStrip = Boolean(retry || running); const composerCardStyle = composerHeight === null ? undefined - : ({ "--composer-height": `${composerHeight + (showRunStrip ? COMPOSER_RUN_STRIP_RESERVED : 0)}px` } as CSSProperties); + : ({ + "--composer-height": `${composerHeight}px`, + "--composer-run-strip-reserved": `${showRunStrip ? COMPOSER_RUN_STRIP_RESERVED : 0}px`, + } as CSSProperties); const textareaStyle = composerHeight === null && textareaAutoHeight !== null ? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties) : undefined; diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index b20d405489..9eddf3a93d 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -5450,7 +5450,10 @@ body > .mermaid-diagram--fullscreen { transition: border-color 0.12s; } .composer-card--resized { - height: var(--composer-height); + /* --composer-height is always the user's logical height; the run strip's + reservation lives in this calc so the live resize drag (which writes the + raw logical height) and the React render path can never disagree. */ + height: calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px)); display: flex; flex-direction: column; } From 80cf5274bf456677bdf30c7778d8e1ff9a762a05 Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 8 Jul 2026 09:45:59 +0800 Subject: [PATCH 9/9] test(frontend): mock canvas in settings snapshot test --- .../__tests__/settings-refresh-snapshot.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx index 9970b4fdc7..6e23aa2c5d 100644 --- a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx +++ b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx @@ -42,6 +42,19 @@ function flushPromises(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } +function installCanvasMock(win: Window) { + Object.defineProperty(win.HTMLCanvasElement.prototype, "getContext", { + configurable: true, + value(type: string) { + if (type !== "2d") return null; + return { + font: "", + measureText: () => ({ width: 0 }), + } as unknown as CanvasRenderingContext2D; + }, + }); +} + async function waitFor(label: string, predicate: () => boolean) { for (let attempt = 0; attempt < 20; attempt += 1) { await act(async () => { @@ -172,6 +185,7 @@ const dom = new JSDOM("
pretendToBeVisual: true, url: "http://localhost/", }); +installCanvasMock(dom.window as unknown as Window); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; globalThis.window = dom.window as unknown as Window & typeof globalThis; globalThis.document = dom.window.document;