diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 2e98d726f..8d3cafadc 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 02369820a..042ad3b85 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__/approval-modal-file-reference.test.tsx b/desktop/frontend/src/__tests__/approval-modal-file-reference.test.tsx index 354455bc2..c144c9ce2 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/__tests__/ask-card-layout.test.ts b/desktop/frontend/src/__tests__/ask-card-layout.test.ts index af6232730..304f22bb3 100644 --- a/desktop/frontend/src/__tests__/ask-card-layout.test.ts +++ b/desktop/frontend/src/__tests__/ask-card-layout.test.ts @@ -111,6 +111,29 @@ console.log("\nask card layout"); eq(computed.overflowWrap, "anywhere", "long unspaced ask questions can break within the shelf"); ok(card.getAttribute("role") === "dialog", "ask prompt shelf keeps dialog semantics"); + // Detail preview row: shows the first described option by default, follows + // hover, and each option carries a native-title fallback for truncation. + const detail = document.querySelector(".ask-shelf__detail") as HTMLElement | null; + if (!detail) throw new Error("ask detail preview row did not render"); + eq( + detail.querySelector(".ask-shelf__detail-text")?.textContent, + ask.questions[0].options[0].description, + "detail row previews the first described option by default", + ); + + const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[]; + eq(optionButtons[1]?.getAttribute("title"), ask.questions[0].options[1].description, "options carry a native title fallback"); + + await act(async () => { + optionButtons[1].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true })); + await flushTimers(); + }); + eq( + document.querySelector(".ask-shelf__detail-text")?.textContent, + ask.questions[0].options[1].description, + "detail row follows the hovered option", + ); + await act(async () => { root.unmount(); }); diff --git a/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx b/desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx index 5c5ea918c..fae280664 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 000000000..84ba183e2 --- /dev/null +++ b/desktop/frontend/src/__tests__/composer-run-strip.test.tsx @@ -0,0 +1,327 @@ +// 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 { 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"; +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", 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"); + 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"); + + 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, disabled: 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(); +} + +// Cancel restores queued guidance: stop means "stop acting", never "discard +// what I typed". +{ + const dom = installDom(); + const { root, calls } = await renderComposer({ + running: true, + turnStartAt: Date.now(), + guidanceQueuePreviewItems: ["数到一半改用英文", "最后给出一句总结"], + }); + + ok(document.querySelector(".composer-guidance-shelf") !== null, "queued guidance renders in the shelf"); + + const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null; + if (!stop) throw new Error("stop button did not render"); + await act(async () => { + stop.click(); + await flushTimers(); + }); + + eq(calls.cancel, 1, "stop cancels the running turn"); + const ta = document.querySelector("textarea") as HTMLTextAreaElement; + eq(ta.value, "数到一半改用英文\n最后给出一句总结", "stop folds unconsumed queued guidance back into the draft"); + eq(document.querySelector(".composer-guidance-shelf"), null, "restored queue clears the shelf"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + +// Waiting on the user pauses the ticker clock: elapsed time means model time. +{ + const dom = installDom(); + const start = Date.now() - 30000; + const { root, rerender } = await renderComposer({ running: true, turnStartAt: start }); + + await rerender({ pendingApprovalLabel: "Run command", disabled: true }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2400)); + }); + await rerender({ pendingApprovalLabel: null, disabled: false }); + + const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? ""; + ok(/ 30s| 31s/.test(ticker), `ticker excludes the time spent waiting for approval (got "${ticker}")`); + ok(!/ 32s| 33s/.test(ticker), "ticker does not count the ~2.4s approval wait as model time"); + + await act(async () => { + root.unmount(); + }); + 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/__tests__/settings-refresh-snapshot.test.tsx b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx index 9970b4fdc..6e23aa2c5 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; diff --git a/desktop/frontend/src/components/ApprovalModal.tsx b/desktop/frontend/src/components/ApprovalModal.tsx index 029c516cf..f50e33619 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"); @@ -54,6 +56,8 @@ 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; } @@ -121,6 +125,7 @@ export function ApprovalModal({ cwd, insertRequest, onRevisionActiveChange, + toolApprovalMode, }: { approval: WireApproval; onAnswer: (allow: boolean, session: boolean, persist: boolean) => void; @@ -130,12 +135,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() ?? ""; @@ -146,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); @@ -204,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; @@ -382,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} +
+ ) + } > - {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/AskCard.tsx b/desktop/frontend/src/components/AskCard.tsx index bd2aa0fa5..66f41943b 100644 --- a/desktop/frontend/src/components/AskCard.tsx +++ b/desktop/frontend/src/components/AskCard.tsx @@ -23,6 +23,8 @@ export function AskCard({ const [custom, setCustom] = useState>({}); const [customOpen, setCustomOpen] = useState(false); const [active, setActive] = useState(0); + // Option label currently hovered/focused; drives the detail preview row. + const [hoverLabel, setHoverLabel] = useState(null); const shelfRef = useRef(null); const customInputRef = useRef(null); const advanceTimer = useRef(null); @@ -39,6 +41,7 @@ export function AskCard({ setCustom({}); setCustomOpen(false); setActive(0); + setHoverLabel(null); if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current); // The attention chime plays from the global runtime event stream (App.tsx), // keyed by prompt id — not here. A mount-time chime would double-fire for @@ -74,6 +77,7 @@ export function AskCard({ useEffect(() => { setCustomOpen(false); + setHoverLabel(null); }, [active]); useEffect(() => { @@ -157,6 +161,23 @@ export function AskCard({ [active, custom, questions, sel], ); + // Grid cells truncate descriptions to one line; this row previews the full + // description of the hovered/focused option, falling back to the latest + // selected option and then the first described option so it never blanks. + const detailOption = useMemo(() => { + if (!q) return null; + const withDescription = (label?: string | null) => + label ? q.options.find((option) => option.label === label && option.description) : undefined; + const hovered = withDescription(hoverLabel); + if (hovered) return hovered; + const selectedLabels = sel[q.id] ?? []; + for (let i = selectedLabels.length - 1; i >= 0; i -= 1) { + const chosen = withDescription(selectedLabels[i]); + if (chosen) return chosen; + } + return q.options.find((option) => option.description) ?? null; + }, [hoverLabel, q, sel]); + if (!q) return null; return ( @@ -197,11 +218,23 @@ export function AskCard({ description={o.description} onClick={() => toggle(q, o.label)} selected={on} + title={o.description || undefined} + onHoverChange={(hovering) => + setHoverLabel((current) => (hovering ? o.label : current === o.label ? null : current)) + } /> ); })} } + note={ + detailOption && ( +
+ {detailOption.label} + {detailOption.description} +
+ ) + } quickActions={ <> {active > 0 && ( diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index ac0070e91..ac1874cf6 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()); } @@ -449,6 +460,8 @@ export function Composer({ turnStartAt, turnTokens, retry, + pendingApprovalLabel, + pendingAsk = false, transientDismissSignal, sessionKey, fileRefRefreshKey, @@ -492,6 +505,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; @@ -1536,7 +1554,19 @@ export function Composer({ const handleCancel = () => { const restored = onCancel(); if (goalModeOn && activeGoal) onClearGoal(); - if (typeof restored === "string") setTextCaretEnd(restored); + // Queued guidance the model never consumed would otherwise vanish when the + // cancelled turn ends (the running→false effect clears the shelf). Fold it + // back into the draft: cancelling means "stop acting", not "discard what I + // typed" — the same contract onCancel already honors for un-sent text. + const queued = pendingGuidance.map((item) => item.text).filter((part) => part.trim() !== ""); + if (queued.length === 0) { + if (typeof restored === "string") setTextCaretEnd(restored); + return; + } + setPendingGuidance([]); + setGuidanceExpanded(false); + const base = typeof restored === "string" ? restored : text; + setTextCaretEnd([base, ...queued].filter((part) => part.trim() !== "").join("\n")); }; const pickCommand = (c: CommandInfo) => setTextCaretEnd("/" + c.name + " "); @@ -1637,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`); @@ -1675,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; @@ -2006,7 +2036,18 @@ export function Composer({ } }; - const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties); + // 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}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; @@ -2054,17 +2095,45 @@ 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; + // 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; + // Waiting on the user (approval/ask) is not model work: pause the ticker's + // clock while a prompt is blocked so the elapsed time means "model time", + // matching what the waiting strip promises. Retries keep counting — they are + // system time, not user time. Accumulated in state via the effect cleanup so + // leaving the waiting state re-renders with the corrected time immediately. + const [waitAccumMs, setWaitAccumMs] = useState(0); + useEffect(() => { + setWaitAccumMs(0); + }, [turnStartAt]); + useEffect(() => { + if (!waitingPrompt) return; + const since = Date.now(); + return () => setWaitAccumMs((total) => total + (Date.now() - since)); + }, [waitingPrompt]); + 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 - waitAccumMs); + 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 +2391,6 @@ export function Composer({ /> ) )} - {runActivity && ( -
-
- - {runActivity} - - - -
-
- )} {pendingGuidance.length > 0 && (
@@ -2484,7 +2539,7 @@ export function Composer({
)}
@@ -2502,6 +2557,17 @@ export function Composer({ onKeyDown={onComposerResizeKeyDown} onDoubleClick={resetComposerHeight} /> + {runStateText && ( +
+
+ )}
)} + {running && ( + + + + )}
@@ -2634,13 +2712,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/locales/en.ts b/desktop/frontend/src/locales/en.ts index 173b4be18..d3af31361 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,13 @@ 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.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 66bed251a..86c3bbd81 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,13 @@ export const zhTW: Record = { "approval.sendRevision": "傳送修改意見", "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 ddac9eaf7..85df113b2 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,13 @@ export const zh: Record = { "approval.sendRevision": "发送修改意见", "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 8a4eb0abe..9eddf3a93 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -5450,13 +5450,59 @@ 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; } .composer-card:focus-within { border-color: var(--fg-faint); } +/* Run strip: an in-flow bar at the top of the card. For user-resized cards + (fixed --composer-height), the JS adds the strip's reserved height so the + meta row stays fully visible. For auto-height cards the card grows + naturally. */ +.composer-run-strip { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; + padding: 4px 14px 0; + color: var(--accent); + font-family: var(--sans); + font-size: 12px; +} +.composer-card--waiting .composer-run-strip { + /* inherited from the card border tint; no extra rule needed */ +} +.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 +5756,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 +7289,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; @@ -7408,14 +7480,14 @@ body > .mermaid-diagram--fullscreen { } .prompt-shelf--ask .prompt-shelf__actions { display: grid; - grid-template-columns: 1fr; - max-height: 154px; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + max-height: min(220px, 30vh); overflow: auto; padding-right: 2px; } .prompt-shelf--ask .prompt-action { width: 100%; - min-height: 42px; + min-height: 36px; align-items: center; border-radius: 8px; padding: 5px 8px; @@ -7431,6 +7503,69 @@ body > .mermaid-diagram--fullscreen { text-overflow: ellipsis; white-space: nowrap; } +.prompt-shelf--ask .prompt-shelf__quick-actions { + justify-content: flex-end; + gap: 6px; + border-top: 1px solid var(--border-soft); + padding-top: 6px; + margin-top: 2px; +} +.prompt-shelf--ask .prompt-shelf__quick-actions > .prompt-action { + width: auto; + min-height: 28px; + 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; +} +/* 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; } @@ -17310,21 +17445,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 +17601,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 +22833,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 +26907,29 @@ 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); +.app--creation .composer-run-strip, +:root[data-theme-style] .app--creation .composer-run-strip { 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; } -@keyframes creation-runstatus-dot-breathe { +.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-run-strip-dot-breathe { 0%, 100% { opacity: 0.42; box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent); @@ -26910,46 +26941,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,