From 87bf89d6887bd1d91d2afef3f423020a6ff7aab3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:19:00 +0800 Subject: [PATCH 1/7] feat(debugger): add paused editor evaluation entry points (#676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a debug session is paused, Alt/Option+click on a variable or expression runs Quick Evaluate at the clicked position (the selection when the click falls inside it) and shows the value next to it; outside a paused session Alt+click keeps its editor behaviour. The editor context menu offers Evaluate Expression… seeded with the selection or the expression at the caret while paused. Mouse shortcuts now pass the clicked editor and position to the action, and the binding is only consumed when its action is enabled. Fixes #664 --- .../debugger/__tests__/quickEvaluate.test.ts | 158 ++++++++++++++++++ src/features/debugger/actions.ts | 29 ++-- src/features/debugger/editor/quickEvaluate.ts | 34 ++++ src/features/debugger/index.ts | 18 +- src/platform/keymap.ts | 11 +- src/platform/keymapMouse.test.ts | 19 +++ src/platform/keymaps/ideaParity.test.ts | 2 +- src/platform/keymaps/intellij-macos.ts | 2 +- src/platform/keymaps/intellij-windows.ts | 2 +- 9 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 src/features/debugger/__tests__/quickEvaluate.test.ts create mode 100644 src/features/debugger/editor/quickEvaluate.ts diff --git a/src/features/debugger/__tests__/quickEvaluate.test.ts b/src/features/debugger/__tests__/quickEvaluate.test.ts new file mode 100644 index 000000000..4de18562d --- /dev/null +++ b/src/features/debugger/__tests__/quickEvaluate.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { executeAction, registerAction } from "@/platform/actions"; +import * as hints from "@/editor/lsp/hint"; +import { setContext } from "@/platform/context"; +import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; +import { usePlatform } from "@/platform/platformInfo"; +import { menuGroupItems } from "@/workbench/contributions"; +import { navigationExtension } from "@/editor/lsp/navigation"; +import { activate } from "../index"; +import { adoptSession, publishContext, useDebug } from "../state"; +import { quickEvaluateMouse, quickEvaluateMouseDown } from "../editor/quickEvaluate"; +import { useOverlays } from "../ui/popoverStore"; + +const ipc = vi.hoisted(() => ({ + debugEvaluateIn: vi.fn(async () => ({ + status: "ok", + data: { + name: "result", + value: "42", + typeName: "int", + variablesReference: 0, + evaluateName: null, + }, + })), +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { ...real, commands: { ...real.commands, ...ipc } }; +}); +const editor = vi.hoisted(() => ({ current: null as unknown })); +vi.mock("@/editor/api", async (original) => ({ + ...(await original()), + activeEditor: () => editor.current, +})); + +let view: EditorView; +let dispose: () => void; +let uninstall: () => void; +const doc = "total + order.items"; +function session(adapter = "debugpy", status: "paused" | "running" | "terminated" = "paused") { + adoptSession(7, { adapter }); + useDebug.setState((s) => ({ sessions: { 7: { ...s.sessions[7]!, status, selectedFrame: 10 } }, active: 7 })); + publishContext(); +} +function click() { + const e = new MouseEvent("mousedown", { button: 0, altKey: true, bubbles: true, cancelable: true }); + view.contentDOM.dispatchEvent(e); + return e; +} +beforeEach(() => { + vi.clearAllMocks(); + useDebug.setState({ sessions: {}, active: null }); + setContext("textEditorActive", true); + usePlatform.setState({ os: "macos" }); + useKeymap.setState({ preset: "intellij-macos", overrides: {} }); + dispose = activate(); + uninstall = installKeyboardDispatcher(window); + view = new EditorView({ + state: EditorState.create({ doc, extensions: [navigationExtension(), quickEvaluateMouse()] }), + parent: document.body, + }); + editor.current = { view, ctx: { path: "/p/app.py" } }; + vi.spyOn(view, "posAtCoords").mockReturnValue(16); +}); +afterEach(() => { + uninstall(); + dispose(); + view.destroy(); + editor.current = null; + document.body.replaceChildren(); + useDebug.setState({ sessions: {}, active: null }); + publishContext(); + useKeymap.setState({ overrides: {} }); + vi.restoreAllMocks(); +}); + +describe("paused editor evaluation", () => { + it.each(["java-debug", "js-debug", "debugpy"])("evaluates the clicked member through %s", async (adapter) => { + session(adapter); + view.dispatch({ selection: { anchor: 0, head: 5 } }); + expect(click().defaultPrevented).toBe(true); + await vi.waitFor(() => expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("order.items = 42")); + expect(ipc.debugEvaluateIn).toHaveBeenCalledExactlyOnceWith(7, 10, "order.items", "hover"); + expect(view.state.selection.main.head).toBe(16); + }); + + it.each(["intellij-macos", "intellij-windows"])("preserves a clicked selection with %s", async (preset) => { + const showHint = vi.spyOn(hints, "showHint"); + useKeymap.setState({ preset }); + session(); + view.dispatch({ selection: { anchor: 0, head: doc.length } }); + click(); + await vi.waitFor(() => expect(ipc.debugEvaluateIn).toHaveBeenCalledWith(7, 10, doc, "hover")); + expect(view.state.selection.main.from).toBe(0); + expect(view.state.selection.main.to).toBe(doc.length); + await vi.waitFor(() => + expect(showHint).toHaveBeenCalledWith(view, expect.any(Function), { pos: 16, timeout: 60_000 }), + ); + }); + + it("uses a selected expression spanning several lines", async () => { + session(); + const text = "(total +\n order.items)"; + view.dispatch({ changes: { from: 0, to: doc.length, insert: text }, selection: { anchor: 0, head: text.length } }); + click(); + await vi.waitFor(() => expect(ipc.debugEvaluateIn).toHaveBeenCalledWith(7, 10, text, "hover")); + }); + + it("lets a higher priority custom mouse action win the shared gesture", () => { + session(); + const run = vi.fn(); + const remove = registerAction({ id: "test.mouseOverride", title: "Custom", run }); + useKeymap.setState({ overrides: { "test.mouseOverride": ["Alt+Button1"] } }); + try { + click(); + expect(run).toHaveBeenCalledOnce(); + expect(ipc.debugEvaluateIn).not.toHaveBeenCalled(); + } finally { + remove(); + } + }); + + it.each(["none", "running", "terminated"] as const)("leaves Option+click to the editor when %s", async (status) => { + if (status !== "none") session("debugpy", status); + view.dispatch({ selection: { anchor: 0, head: 5 } }); + const dispatch = vi.spyOn(view, "dispatch"); + const e = new MouseEvent("mousedown", { button: 0, altKey: true, cancelable: true }); + expect(quickEvaluateMouseDown(e, view)).toBe(false); + expect(e.defaultPrevented).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + expect(ipc.debugEvaluateIn).not.toHaveBeenCalled(); + }); + + it("honours removal of the mouse binding", () => { + session(); + useKeymap.setState({ overrides: { "debug.quickEvaluate": ["Meta+Alt+F8"] } }); + click(); + expect(ipc.debugEvaluateIn).not.toHaveBeenCalled(); + }); + + it.each(["none", "running", "paused", "terminated"] as const)( + "shows the contributed menu only while paused: %s", + async (status) => { + if (status !== "none") session("debugpy", status); + const items = menuGroupItems("editor", { paths: ["/p/app.py"], text: doc }); + const evaluate = items.find((item) => item.kind === "action" && item.action === "debug.evaluate"); + expect(!!evaluate).toBe(status === "paused"); + if (evaluate) { + view.dispatch({ selection: { anchor: 16 } }); + await executeAction("debug.evaluate"); + expect(useOverlays.getState().evaluate?.expression).toBe("order.items"); + } + expect(menuGroupItems("editorTab", { paths: ["/p/app.py"] })).toEqual([]); + }, + ); +}); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 3112affc7..235b86beb 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -4,6 +4,7 @@ import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, ty import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { activeEditor } from "@/editor/api"; +import type { EditorView } from "@codemirror/view"; import { maybeEditorService, notifications, showStatusInfo, toolWindowService } from "@/workbench/contributions"; import { classFileBreakpointReason } from "@/platform/classFiles"; import { canPlaceBreakpoint } from "./breakpoints/languages"; @@ -39,6 +40,7 @@ import { import { focusedDebugView } from "./ui/focusedView"; import { browseFocusedConsoleHistory } from "./ui/expressionHistory"; import { expressionAt, valueHint } from "./editor/valueTooltip"; +import { mouseEvaluateTarget } from "./editor/quickEvaluate"; import { showHint } from "@/editor/lsp/hint"; import { pausedLineTargets, startInlineSmartStep } from "./editor/smartStep"; import { @@ -157,28 +159,29 @@ function cursor(): { path: string; line: number; selection: string } | null { } /** Evaluate's initial text, as in IDEA: the selection, else the expression at the caret. */ -function evaluateSeed(): string { - const ed = activeEditor(); - if (!ed) return ""; - const { state } = ed.view; +function evaluateSeed(view: EditorView | undefined = activeEditor()?.view, pos?: number): string { + if (!view) return ""; + const { state } = view; const sel = state.selection.main; - if (!sel.empty) return state.sliceDoc(sel.from, sel.to); - return expressionAt(ed.view, sel.head)?.text ?? ""; + if (!sel.empty && (pos == null || (sel.from <= pos && pos <= sel.to))) return state.sliceDoc(sel.from, sel.to); + return expressionAt(view, pos ?? sel.head)?.text ?? ""; } -async function quickEvaluate() { - const view = activeEditor()?.view; +async function quickEvaluate(arg?: unknown) { + const target = mouseEvaluateTarget(arg); + const view = target?.view ?? activeEditor()?.view; if (!view) return; const sel = view.state.selection.main; - const expression = evaluateSeed().trim(); + const pos = target?.pos ?? sel.from; + const expression = evaluateSeed(view, target?.pos).trim(); if (!expression) return showHint(view, "Nothing to evaluate at the caret"); const session = activeSession(); try { const value = await evaluate(expression, "hover", session); if (!session) return; - showHint(view, () => valueHint(expression, value, session.id), { pos: sel.from, timeout: 60_000 }); + showHint(view, () => valueHint(expression, value, session.id), { pos, timeout: 60_000 }); } catch (e) { - showHint(view, e instanceof Error ? e.message : String(e), { pos: sel.from, tone: "error" }); + showHint(view, e instanceof Error ? e.message : String(e), { pos, tone: "error" }); } } @@ -677,8 +680,8 @@ export function registerDebugActions(): () => void { id: "debug.quickEvaluate", title: "Quick Evaluate Expression", category: "Debug", - when: "debugSessionActive && textEditorActive", - run: () => quickEvaluate(), + when: "debugSessionPaused && textEditorActive", + run: ({ arg }) => quickEvaluate(arg), }, { id: "debug.showToolWindow", diff --git a/src/features/debugger/editor/quickEvaluate.ts b/src/features/debugger/editor/quickEvaluate.ts new file mode 100644 index 000000000..76f94df14 --- /dev/null +++ b/src/features/debugger/editor/quickEvaluate.ts @@ -0,0 +1,34 @@ +import { EditorSelection, Prec, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { dispatchMouseShortcut } from "@/platform/keymap"; + +export function mouseEvaluateTarget(arg: unknown): { view: EditorView; pos: number } | null { + if (!arg || typeof arg !== "object" || !("view" in arg) || !("pos" in arg)) return null; + return arg.view instanceof EditorView && typeof arg.pos === "number" ? { view: arg.view, pos: arg.pos } : null; +} + +/** Preserve a clicked selection and anchor the value hint at the mouse position. */ +export function quickEvaluateMouse(): Extension { + return Prec.highest(EditorView.domEventHandlers({ mousedown: quickEvaluateMouseDown })); +} + +export function quickEvaluateMouseDown(e: MouseEvent, view: EditorView): boolean { + if (e.button !== 0) return false; + const over = e.target instanceof Element ? e.target : null; + if (over?.closest(".cm-tooltip, .cm-lsp-inlay") || (over && !over.closest(".cm-content"))) return false; + const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); + if (pos == null) return false; + const handled = dispatchMouseShortcut( + e, + (id) => { + if (id !== "debug.quickEvaluate") return false; + const sel = view.state.selection.main; + if (sel.empty || pos < sel.from || pos > sel.to) view.dispatch({ selection: EditorSelection.cursor(pos) }); + view.focus(); + return true; + }, + { view, pos }, + ); + if (handled) e.preventDefault(); + return handled; +} diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index fa5db45fa..5c606dfa6 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -7,6 +7,7 @@ import { lazyComponent } from "@/ui"; import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { selectionValueTooltip, valueTooltip } from "./editor/valueTooltip"; +import { quickEvaluateMouse } from "./editor/quickEvaluate"; import { registerEditorContribution } from "@/editor/api"; import { registerMenuGroup, @@ -24,7 +25,16 @@ import { executionPoint } from "./editor/execution"; import { runToCursorInlay } from "./editor/runToCursor"; import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep"; import { onHotCodeReplace } from "./hotswap"; -import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state"; +import { + activeSession, + handleDebugEvent, + isLive, + loadWatches, + publishContext, + resetSessions, + stopSession, + useDebug, +} from "./state"; import { DebuggerOverlays } from "./ui/Overlays"; import { REMOTE_TYPES } from "./remote"; @@ -53,6 +63,11 @@ export function activate(): () => void { headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)), }), registerOverlay({ id: "debugger", component: DebuggerOverlays }), + registerMenuGroup({ + id: "debugger.evaluate", + menus: ["editor"], + items: () => (activeSession()?.status === "paused" ? [{ kind: "action", action: "debug.evaluate" }] : []), + }), // Stop (⌘F2) offers the running debug sessions beside run processes. registerMenuGroup({ id: "debugger.stop", @@ -71,6 +86,7 @@ export function activate(): () => void { runToCursorInlay(ctx), valueTooltip(), selectionValueTooltip(), + quickEvaluateMouse(), smartStepTargets(ctx), ], }), diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index d236c023f..a99bd83c9 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -407,15 +407,20 @@ function runIfEnabled(id: string): boolean { } /** Run a configured mouse binding; editors prepare the clicked caret and focus first. */ -export function dispatchMouseShortcut(e: MouseEvent, prepare?: () => boolean): boolean { +export function dispatchMouseShortcut(e: MouseEvent, prepare?: (id: string) => boolean, arg?: unknown): boolean { if (suspended > 0) return false; const stroke = mouseStrokeOf(e); if (!stroke) return false; const ids = effectiveBindings() .filter(([b]) => b === stroke) .map(([, id]) => id); - if (!ids.length || (prepare && !prepare())) return false; - for (const id of ids.reverse()) if (runIfEnabled(id)) return true; + for (const id of ids.reverse()) { + const action = getAction(id); + if (!action || !isActionKeyEnabled(action)) continue; + if (prepare && !prepare(id)) return false; + void executeAction(id, arg); + return true; + } return false; } diff --git a/src/platform/keymapMouse.test.ts b/src/platform/keymapMouse.test.ts index 38d4e6598..ddb4b080f 100644 --- a/src/platform/keymapMouse.test.ts +++ b/src/platform/keymapMouse.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatBinding, + dispatchMouseShortcut, installKeyboardDispatcher, isMouseBinding, isValidBinding, @@ -13,6 +14,7 @@ import { useKeymap, } from "./keymap"; import { registerAction } from "./actions"; +import { setContext } from "./context"; const noMods = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; @@ -84,6 +86,23 @@ describe("mouse shortcuts", () => { return events; }; + it("does not prepare the editor for a disabled mouse action", () => { + const run = vi.fn(); + const prepare = vi.fn(() => true); + const remove = registerAction({ id: "test.pausedMouse", title: "Evaluate", when: "debugSessionPaused", run }); + useKeymap.setState({ overrides: { "test.pausedMouse": ["Alt+Button1"] } }); + setContext("debugSessionPaused", false); + const e = new MouseEvent("mousedown", { button: 0, altKey: true, cancelable: true }); + try { + expect(dispatchMouseShortcut(e, prepare)).toBe(false); + expect(prepare).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + } finally { + remove(); + } + }); + it.each([ ["the editor", () => editor], ["another component", () => document.getElementById("tree")!], diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index 9cff980b6..31a77539b 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -69,7 +69,7 @@ describe("IDEA keymap parity", () => { ["navigate.nextHighlightedUsage", ["Ctrl+Alt+Down"], ["Ctrl+Alt+Down"]], ["analyze.inspectFile", ["Alt+Shift+I"], ["Alt+Shift+I"]], ["project.copyElement", ["F5"], ["F5"]], - ["debug.quickEvaluate", ["Meta+Alt+F8"], ["Ctrl+Alt+F8"]], + ["debug.quickEvaluate", ["Meta+Alt+F8", "Alt+Button1"], ["Ctrl+Alt+F8", "Alt+Button1"]], ["run.chooseDebugConfig", ["Ctrl+Alt+D"], ["Alt+Shift+F9"]], ["editor.nextSplitter", ["Alt+Tab"], ["Alt+Tab"]], ["toolwindow.resizeLeft", ["Ctrl+Alt+Left"], ["Ctrl+Alt+Shift+Left"]], diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 8df2664a0..2351a26b8 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -284,7 +284,7 @@ export const intellijMacos: Record = { "debug.setValue": ["F2"], "debug.jumpToSource": ["F4", "Meta+Down"], "debug.evaluate": ["Alt+F8"], - "debug.quickEvaluate": ["Meta+Alt+F8"], + "debug.quickEvaluate": ["Meta+Alt+F8", "Alt+Button1"], "debug.browseConsoleHistory": ["Meta+Alt+E"], // VCS diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index 64bb4ffb7..951641d27 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -278,7 +278,7 @@ export const intellijWindows: Record = { "debug.setValue": ["F2"], "debug.jumpToSource": ["F4"], "debug.evaluate": ["Alt+F8"], - "debug.quickEvaluate": ["Ctrl+Alt+F8"], + "debug.quickEvaluate": ["Ctrl+Alt+F8", "Alt+Button1"], "debug.browseConsoleHistory": ["Ctrl+Alt+E"], // VCS From 3a87c697b2b860c9e48ff6ae7a40561af428f708 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 17:48:38 +0800 Subject: [PATCH 2/7] fix(ide-tools): move run_input above the tests module (#677) clippy::items_after_test_module failed the workspace clippy run because run_input was defined after the first test module in crates/ide-tools/src/process.rs. Move it above; no code changes. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-tools/src/process.rs | 166 ++++++++++++++++---------------- 1 file changed, 83 insertions(+), 83 deletions(-) diff --git a/crates/ide-tools/src/process.rs b/crates/ide-tools/src/process.rs index fad1f755d..11bc37b45 100644 --- a/crates/ide-tools/src/process.rs +++ b/crates/ide-tools/src/process.rs @@ -348,6 +348,89 @@ pub fn progress_event(bytes: u64, size: Option) -> InstallEvent { } } +/// Runs a formatter with separate pipes so diagnostics can never become document text. +pub(crate) fn run_input( + cmd: &mut Command, + input: &str, + timeout: Duration, + cancel: &CancellationToken, +) -> IdeResult { + use std::io::Write; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + let mut stdin = child.stdin.take().unwrap(); + let input = input.to_owned(); + // All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout. + enum Pipe { + Written(std::io::Result<()>), + Read(bool, std::io::Result>), + } + let (tx, rx) = mpsc::channel(); + let writer = tx.clone(); + std::thread::spawn(move || { + let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes()))); + }); + for (is_stderr, mut pipe) in [ + (false, Box::new(child.stdout.take().unwrap()) as Box), + (true, Box::new(child.stderr.take().unwrap()) as Box), + ] { + let tx = tx.clone(); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes))); + }); + } + drop(tx); + let started = Instant::now(); + let (mut status, mut output, mut errors, mut written) = (None, None, None, None); + let result = loop { + if cancel.is_cancelled() { + break Err(IdeError::cancelled()); + } + if started.elapsed() >= timeout { + break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out")); + } + if status.is_none() { + match child.try_wait() { + Ok(s) => status = s, + Err(e) => break Err(e.into()), + } + } + if status.is_some() && output.is_some() && errors.is_some() && written.is_some() { + break Ok(()); + } + match rx.recv_timeout(Duration::from_millis(10)) { + Ok(Pipe::Written(result)) => written = Some(result), + Ok(Pipe::Read(true, result)) => errors = Some(result), + Ok(Pipe::Read(false, result)) => output = Some(result), + Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")), + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + }; + if result.is_err() { + kill(&mut child); + } + result?; + let output = output.unwrap()?; + let errors = errors.unwrap()?; + let status = status.unwrap(); + if !status.success() { + let diagnostics: String = String::from_utf8_lossy(&errors).into_owned(); + return Err(IdeError::invalid(if diagnostics.trim().is_empty() { + format!("Formatter exited with {status}") + } else { + diagnostics + })); + } + written.unwrap()?; + String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string())) +} + #[cfg(test)] mod tests { use super::*; @@ -465,89 +548,6 @@ mod tests { } } -/// Runs a formatter with separate pipes so diagnostics can never become document text. -pub(crate) fn run_input( - cmd: &mut Command, - input: &str, - timeout: Duration, - cancel: &CancellationToken, -) -> IdeResult { - use std::io::Write; - if cancel.is_cancelled() { - return Err(IdeError::cancelled()); - } - let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; - let mut stdin = child.stdin.take().unwrap(); - let input = input.to_owned(); - // All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout. - enum Pipe { - Written(std::io::Result<()>), - Read(bool, std::io::Result>), - } - let (tx, rx) = mpsc::channel(); - let writer = tx.clone(); - std::thread::spawn(move || { - let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes()))); - }); - for (is_stderr, mut pipe) in [ - (false, Box::new(child.stdout.take().unwrap()) as Box), - (true, Box::new(child.stderr.take().unwrap()) as Box), - ] { - let tx = tx.clone(); - std::thread::spawn(move || { - let mut bytes = Vec::new(); - let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes))); - }); - } - drop(tx); - let started = Instant::now(); - let (mut status, mut output, mut errors, mut written) = (None, None, None, None); - let result = loop { - if cancel.is_cancelled() { - break Err(IdeError::cancelled()); - } - if started.elapsed() >= timeout { - break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out")); - } - if status.is_none() { - match child.try_wait() { - Ok(s) => status = s, - Err(e) => break Err(e.into()), - } - } - if status.is_some() && output.is_some() && errors.is_some() && written.is_some() { - break Ok(()); - } - match rx.recv_timeout(Duration::from_millis(10)) { - Ok(Pipe::Written(result)) => written = Some(result), - Ok(Pipe::Read(true, result)) => errors = Some(result), - Ok(Pipe::Read(false, result)) => output = Some(result), - Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => { - std::thread::sleep(Duration::from_millis(10)); - } - Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")), - Err(mpsc::RecvTimeoutError::Timeout) => {} - } - }; - if result.is_err() { - kill(&mut child); - } - result?; - let output = output.unwrap()?; - let errors = errors.unwrap()?; - let status = status.unwrap(); - if !status.success() { - let diagnostics: String = String::from_utf8_lossy(&errors).into_owned(); - return Err(IdeError::invalid(if diagnostics.trim().is_empty() { - format!("Formatter exited with {status}") - } else { - diagnostics - })); - } - written.unwrap()?; - String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string())) -} - #[cfg(all(test, unix))] mod input_tests { use super::*; From aa2c24c64889cc184b1d295051b8dd2a433d11af Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:00:43 +0800 Subject: [PATCH 3/7] fix(agents): make tool window sessions usable in narrow layouts --- src/features/agents/Agents.module.css | 44 ++-- src/features/agents/AgentsView.test.tsx | 77 +++--- src/features/agents/AgentsView.tsx | 259 ++++++++++++--------- src/features/agents/Composer.module.css | 78 +++++++ src/features/agents/Composer.tsx | 55 +++-- src/features/agents/ConfigPickers.test.tsx | 27 ++- src/features/agents/ConfigPickers.tsx | 133 +++++++---- src/features/agents/Home.module.css | 44 ++++ src/features/agents/HomeEditor.tsx | 48 ++-- src/features/agents/Session.module.css | 26 ++- src/features/agents/SessionEditor.tsx | 44 ++-- src/features/agents/index.ts | 26 +-- src/features/agents/sessionState.test.tsx | 56 ++++- src/features/agents/store.ts | 85 ++++++- src/features/agents/taskActions.ts | 46 +++- src/features/agents/unread.test.tsx | 69 +++++- src/platform/locales/text/zh.json | 9 +- 17 files changed, 819 insertions(+), 307 deletions(-) diff --git a/src/features/agents/Agents.module.css b/src/features/agents/Agents.module.css index ff72619d1..a9b31d3f2 100644 --- a/src/features/agents/Agents.module.css +++ b/src/features/agents/Agents.module.css @@ -3,7 +3,6 @@ min-height: 0; display: flex; flex-direction: column; - container-type: inline-size; } .content { flex: 1; @@ -137,7 +136,7 @@ gap: 5px; } -/* A conversation docked in the tool window, above the task list. */ +/* A docked conversation owns the tool window; history opens on demand. */ .docked { flex: 1 1 0; min-height: 0; @@ -168,30 +167,31 @@ flex-direction: column; } .tasks { - flex: 2 1 0; + flex: 1; min-height: 0; min-width: 0; display: flex; flex-direction: column; - border-top: 1px solid var(--border); +} +.history { + display: flex; + flex-direction: column; + width: min(400px, calc(100vw - 24px)); + height: min(420px, 65vh); + overflow: hidden; +} +.historyTitle { + flex: none; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + font-weight: 600; } -/* Keep the conversation usable in a short bottom tool window; the tree scrolls. */ -.docked + .tasks { - flex: 0 0 20%; - max-height: 160px; -} - -/* A wide bottom window has room beside the conversation, preserving message height. */ -@container (min-width: 600px) { - .content { - flex-direction: row; - } - .docked + .tasks { - flex: 0 0 220px; - max-width: 30%; - max-height: none; - border-top: 0; - border-left: 1px solid var(--border); - } +.location { + display: flex; + align-items: center; + gap: 2px; + padding: 1px; + border: 1px solid var(--border); + border-radius: var(--radius-small); } diff --git a/src/features/agents/AgentsView.test.tsx b/src/features/agents/AgentsView.test.tsx index a8ec9d370..a154ef438 100644 --- a/src/features/agents/AgentsView.test.tsx +++ b/src/features/agents/AgentsView.test.tsx @@ -6,9 +6,9 @@ import { registerActions } from "@/platform/actions"; import { EDITOR_TAB_MIME, type EditorInput } from "@/workbench/contributions"; import { confirmChecked, showError } from "@/workbench/messages"; import { ContextMenuHost, dragSession, hideContextMenu } from "@/ui"; -import { AgentsView } from "./AgentsView"; +import { AgentsHeaderActions, AgentsLocationActions, AgentsView } from "./AgentsView"; import { SESSION_KIND } from "./model"; -import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, undockTask, useAgents } from "./store"; +import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, setSessionLocation, useAgents } from "./store"; import { taskActions, taskMenu } from "./taskActions"; import styles from "./Agents.module.css"; @@ -39,7 +39,7 @@ vi.mock("./store", async (original) => ({ unarchiveTask: vi.fn(async () => {}), deleteTask: vi.fn(async () => {}), dockTask: vi.fn(), - undockTask: vi.fn(), + setSessionLocation: vi.fn(), })); vi.mock("./SessionEditor", () => ({ SessionEditor: () =>
})); @@ -103,6 +103,8 @@ beforeEach(() => { error: null, tasks: new Map([task("one"), task("two"), task("three"), task("four")].map((t) => [t.id, t])), docked: null, + homeOpen: false, + sessionLocation: "editor", }); dispose = registerActions(taskActions); }); @@ -115,6 +117,8 @@ afterEach(() => { function setup() { render( <> + + , @@ -158,24 +162,44 @@ describe("Agents selection", () => { }); describe("Agents docked view", () => { - it("shows the docked conversation above the list and switches it by selecting a task", () => { + it("opens session history on demand and closes it after switching tasks", () => { useAgents.setState({ docked: "one" }); setup(); expect(screen.getByTestId("docked-session")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Open in Editor" })).toBeTruthy(); - expect(openTask).not.toHaveBeenCalled(); - select("two"); - expect(actions.executeAction).toHaveBeenCalledWith("agents.dockTask", "two"); - expect(dockTask).toHaveBeenCalledWith("two"); + expect(screen.getByRole("button", { name: "Chat in Editor" })).toBeTruthy(); expect(openTask).not.toHaveBeenCalled(); + expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Agent sessions" })); + fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("two")); + expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull(); + expect(actions.executeAction).toHaveBeenCalledWith("agents.openTask", "two"); + expect(openTask).toHaveBeenCalledWith("two"); }); - it("hides the docked task from the list below", () => { + it("lets keyboard users browse history before confirming a session", () => { useAgents.setState({ docked: "one" }); setup(); + fireEvent.click(screen.getByRole("button", { name: "Agent sessions" })); + const tree = screen.getByRole("tree", { name: "Agent tasks" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByRole("dialog", { name: "Agent sessions" })).toBeTruthy(); + expect(openTask).not.toHaveBeenCalled(); + fireEvent.keyDown(tree, { key: "Enter" }); + expect(openTask).toHaveBeenCalledWith("two"); + expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull(); + }); + + it("includes the current session in history and keeps the conversation mounted", () => { + useAgents.setState({ docked: "one" }); + setup(); + const session = screen.getByTestId("docked-session"); + fireEvent.click(screen.getByRole("button", { name: "Agent sessions" })); const tree = within(screen.getByRole("tree", { name: "Agent tasks" })); - expect(tree.queryByText("one")).toBeNull(); + expect(tree.getByText("one")).toBeTruthy(); expect(tree.getByText("two")).toBeTruthy(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull(); + expect(screen.getByTestId("docked-session")).toBe(session); }); it("gives the whole tool window to the conversation when the docked task is the only one", () => { @@ -185,11 +209,15 @@ describe("Agents docked view", () => { expect(screen.getByTestId("docked-session")).toBeTruthy(); }); - it("undocks from the docked conversation's bar", () => { - useAgents.setState({ docked: "one" }); + it("switches placement from the global Agents header", () => { + useAgents.setState({ docked: "one", sessionLocation: "toolWindow" }); setup(); - fireEvent.click(screen.getByRole("button", { name: "Open in Editor" })); - expect(undockTask).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: "Open in Editor" })).toBeNull(); + expect(screen.getByRole("button", { name: "Chat in Tool Window" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Chat in Editor" })); + expect(setSessionLocation).toHaveBeenCalledWith("editor"); + fireEvent.click(screen.getByRole("button", { name: "Chat in Tool Window" })); + expect(setSessionLocation).toHaveBeenCalledWith("toolWindow"); }); it("opens tabs as usual without a docked conversation", () => { @@ -282,21 +310,12 @@ describe("Agents bulk actions", () => { }); describe("Agents task menu", () => { - it("offers to dock a task into the tool window", async () => { - setup(); - context("one"); - fireEvent.click(screen.getByRole("menuitem", { name: "Open in Tool Window" })); - await waitFor(() => expect(dockTask).toHaveBeenCalledWith("one")); - }); - - it("offers to move the docked task back to an editor", async () => { - useAgents.setState({ docked: "one" }); - setup(); - // The docked task is not listed; its menu lives on the docked conversation's header. + it.each([null, "one"])("keeps placement out of session menus (docked: %s)", (docked) => { + useAgents.setState({ docked }); const menu = taskMenu([useAgents.getState().tasks.get("one")!]); - expect(menu).toContainEqual(expect.objectContaining({ action: "agents.undockTask", label: "Open in Editor" })); - await taskActions.find((action) => action.id === "agents.undockTask")!.run({ ctx: {}, arg: "one" }); - await waitFor(() => expect(undockTask).toHaveBeenCalledTimes(1)); + expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.dockTask" })); + expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.undockTask" })); + expect(menu).toContainEqual(expect.objectContaining({ action: "agents.openTask" })); }); }); diff --git a/src/features/agents/AgentsView.tsx b/src/features/agents/AgentsView.tsx index 7dafe617a..30651b9d6 100644 --- a/src/features/agents/AgentsView.tsx +++ b/src/features/agents/AgentsView.tsx @@ -7,6 +7,7 @@ import { EmptyState, Icon, IconButton, + Popup, Spinner, T, Tree, @@ -18,16 +19,29 @@ import { SESSION_KIND, TASK_DRAG, age, branchLabel, groupTasks, sessionKey, type import { openHome, useAgents } from "./store"; import { taskMenu } from "./taskActions"; import { SessionEditor } from "./SessionEditor"; +import { HomeEditor } from "./HomeEditor"; import styles from "./Agents.module.css"; import { basename, useProject } from "@/platform/project"; import { useTx } from "@/platform/i18n"; import { executeAction } from "@/platform/actions"; export function AgentsHeaderActions() { + return ; +} + +export function AgentsLocationActions() { + const location = useAgents((s) => s.sessionLocation); + const tx = useTx(); return ( - <> - - +
+ + +
); } @@ -77,6 +91,10 @@ export function AgentsView() { const unread = useAgents((s) => s.unread); const providers = useAgents((s) => s.providers); const docked = useAgents((s) => s.docked); + const homeOpen = useAgents((s) => s.homeOpen); + const inline = docked !== null || homeOpen; + const historyRef = useRef(null); + const [historyOpen, setHistoryOpen] = useState(false); const [collapsed, setCollapsed] = useState>(() => new Set(["g:archived"])); const [selected, setSelected] = useState>(() => { const task = sessionTask(maybeEditorService()?.activeInput?.()); @@ -95,11 +113,7 @@ export function AgentsView() { }), [], ); - // The docked conversation is already shown above; listing it again is noise. - const roots = useMemo( - () => groupTasks([...tasks.values()].filter((t) => t.id !== docked)).map((group) => ({ kind: "group", group })), - [tasks, docked], - ); + const roots = useMemo(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]); const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]); // Dropping a session editor tab anywhere on the tool window docks it. const viewRef = useRef(null); @@ -126,123 +140,156 @@ export function AgentsView() { ); if (error) return {error}; const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id; - // While a conversation is docked, clicking a task switches it instead of opening a tab. - const showTask = (task: string) => void executeAction(docked ? "agents.dockTask" : "agents.openTask", task); + // Opening any session follows the global placement preference. + const showTask = (task: string) => { + setHistoryOpen(false); + void executeAction("agents.openTask", task); + }; const dockedTitle = docked ? (tasks.get(docked)?.title ?? docked) : null; const dragTask = (e: ReactPointerEvent, task: string) => startPointerDrag(e, e.currentTarget, { type: TASK_DRAG, payload: task }); + const taskList = ( +
+ + aria-label="Agent tasks" + roots={roots} + getKey={nodeKey} + getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)} + getChildren={(n) => + n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined + } + isExpandable={(n) => n.kind === "group"} + expanded={expanded} + onExpandedChange={(_next, node, open) => + setCollapsed((c) => { + const s = new Set(c); + if (open) s.delete(nodeKey(node)); + else s.add(nodeKey(node)); + return s; + }) + } + selected={selected} + onSelectionChange={(sel, lead) => { + setSelected(sel); + if (!inline && sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id); + }} + onActivate={(n) => n.kind === "task" && showTask(n.task.id)} + onContextMenu={(n, sel, e) => { + if (n?.kind !== "task") return; + const selection = [...sel].flatMap((id) => { + const task = tasks.get(id); + return task ? [task] : []; + }); + showContextMenu(e, taskMenu(selection)); + }} + renderNode={(n) => + n.kind === "group" ? ( + + + {n.group.label} + + {n.group.tasks.length} + + ) : ( + dragTask(e, n.task.id)} + onClick={(e) => { + if (inline && !e.metaKey && !e.ctrlKey && !e.shiftKey) showTask(n.task.id); + }} + > + + + {n.task.title} + + {unread.has(n.task.id) && ( + + )} + {project?.workspace && ( + + {basename(n.task.projectRoot ?? project.root)} + + )} + + {providerName(n.task.provider)} + {n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""} + + {age(n.task.updatedMs ?? 0)} + + ) + } + empty={ +
+ No agent tasks yet + +
+ } + /> +
+ ); + return (
- {docked && ( + {inline && (
+ { + setSelected(new Set(docked ? [docked] : [])); + setHistoryOpen((open) => !open); + }} + /> dragTask(e, docked)} + onPointerDown={(e) => docked && dragTask(e, docked)} > - {dockedTitle} + {homeOpen ? New Agent Task : dockedTitle} -
- + {homeOpen ? ( + + ) : ( + docked && ( + + ) + )}
)} - {/* Docked with nothing else to list: give the whole window to the conversation. */} - {(roots.length > 0 || !docked) && ( -
- - aria-label="Agent tasks" - roots={roots} - getKey={nodeKey} - getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)} - getChildren={(n) => - n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined - } - isExpandable={(n) => n.kind === "group"} - expanded={expanded} - onExpandedChange={(_next, node, open) => - setCollapsed((c) => { - const s = new Set(c); - if (open) s.delete(nodeKey(node)); - else s.add(nodeKey(node)); - return s; - }) - } - selected={selected} - onSelectionChange={(sel, lead) => { - setSelected(sel); - if (sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id); - }} - onActivate={(n) => n.kind === "task" && showTask(n.task.id)} - onContextMenu={(n, sel, e) => { - if (n?.kind !== "task") return; - const selection = [...sel].flatMap((id) => { - const task = tasks.get(id); - return task ? [task] : []; - }); - showContextMenu(e, taskMenu(selection)); - }} - renderNode={(n) => - n.kind === "group" ? ( - - - {n.group.label} - - {n.group.tasks.length} - - ) : ( - dragTask(e, n.task.id)} - > - - - {n.task.title} - - {unread.has(n.task.id) && ( - - )} - {project?.workspace && ( - - {basename(n.task.projectRoot ?? project.root)} - - )} - - {providerName(n.task.provider)} - {n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""} - - {age(n.task.updatedMs ?? 0)} - - ) - } - empty={ -
- No agent tasks yet - -
- } - /> -
+ {!inline && taskList} + {inline && ( + setHistoryOpen(false)} + aria-label={tx("Agent sessions")} + className={styles.history} + > +
+ Agent sessions +
+ {taskList} +
)}
diff --git a/src/features/agents/Composer.module.css b/src/features/agents/Composer.module.css index f803502f0..e6f095e35 100644 --- a/src/features/agents/Composer.module.css +++ b/src/features/agents/Composer.module.css @@ -1,5 +1,7 @@ /* A framed text area (IDEA text field frame), with the pickers in its bottom row. */ .composer { + container: agent-composer / inline-size; + min-width: 0; position: relative; display: flex; flex-direction: column; @@ -44,9 +46,85 @@ padding: 4px 6px 6px; cursor: default; } +.toolbar { + display: flex; + flex: 1; + flex-wrap: wrap; + align-items: center; + min-width: 0; + gap: 2px; +} +.actions { + display: flex; + flex: none; + align-items: center; + gap: 2px; + margin-left: auto; +} .spacer { flex: 1; } +.configPicker { + display: flex; + min-width: 0; +} +.configPicker > button { + max-width: 100%; +} +.settingsMenu { + display: none; +} +.settingsPopup { + width: min(320px, calc(100vw - 24px)); + padding: 12px; +} +.settingsTitle { + margin-bottom: 12px; + font-weight: 600; +} +.settingsRow { + display: grid; + grid-template-columns: minmax(0, 80px) minmax(0, 1fr); + align-items: center; + gap: 12px; + margin-top: 8px; +} +.settingsLabel { + min-width: 0; + color: var(--fg-muted); + overflow-wrap: anywhere; +} +@container agent-composer (max-width: 600px) { + .bar { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + .toolbar { + flex-wrap: nowrap; + } + .toolbar > button, + .toolbar > .configPicker { + flex: 0 1 auto; + min-width: 0; + max-width: calc((100% - 28px) / 2); + } + .configPicker > button { + width: 100%; + } + .inlineSetting { + display: none; + } + .settingsMenu { + display: flex; + flex: none; + margin-left: auto; + } + .actions { + width: 100%; + margin-left: 0; + } +} .pickerValue { display: inline-flex; align-items: baseline; diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index 21535d108..15ca6b5cb 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -403,30 +403,37 @@ export function Composer(props: ComposerProps) { }} />
e.stopPropagation()}> - {props.toolbar} - - { - const glue = text && !/\s$/.test(text) ? " " : ""; - setText(`${text}${glue}@`, text.length + glue.length + 1); - }} - /> - imageInput.current?.click()} /> - { - readImageFiles(e.target.files ?? []); - e.target.value = ""; - }} - /> - {props.actions} +
{props.toolbar}
+
+ { + const glue = text && !/\s$/.test(text) ? " " : ""; + setText(`${text}${glue}@`, text.length + glue.length + 1); + }} + /> + imageInput.current?.click()} + /> + { + readImageFiles(e.target.files ?? []); + e.target.value = ""; + }} + /> + + {props.actions} +
); diff --git a/src/features/agents/ConfigPickers.test.tsx b/src/features/agents/ConfigPickers.test.tsx index 2c7c737de..e0c0a828c 100644 --- a/src/features/agents/ConfigPickers.test.tsx +++ b/src/features/agents/ConfigPickers.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { AgentConfigOption } from "@/bindings"; import { ConfigPickers, configPickerOptions } from "./ConfigPickers"; @@ -85,3 +85,28 @@ it("sends the complete nested model id through ConfigPickers", () => { fireEvent.click(screen.getByRole("option", { name: "Beamcmc/Beam" })); expect(onChange).toHaveBeenCalledExactlyOnceWith("model", "9router/cmc/Beam"); }); + +it("edits secondary settings from the compact popup and restores focus when dismissed", () => { + const approval: AgentConfigOption = { + id: "approvals", + name: "Approvals", + category: "approval", + description: null, + current: "ask", + via: "option", + values: [value("ask", "Ask first"), value("bypass", "Bypass approvals")], + }; + const onChange = vi.fn(); + render(); + const trigger = screen.getByRole("button", { name: "Agent settings" }); + trigger.focus(); + fireEvent.click(trigger); + const dialog = screen.getByRole("dialog", { name: "Agent settings" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Approvals" })); + fireEvent.click(screen.getByRole("option", { name: "Ask first" })); + expect(onChange).toHaveBeenCalledExactlyOnceWith("approvals", "ask"); + expect(dialog).toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "Agent settings" })).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); +}); diff --git a/src/features/agents/ConfigPickers.tsx b/src/features/agents/ConfigPickers.tsx index 08a0e8af8..dbb6f2331 100644 --- a/src/features/agents/ConfigPickers.tsx +++ b/src/features/agents/ConfigPickers.tsx @@ -2,9 +2,11 @@ * Model, mode, approvals and thinking-effort dropdowns for a composer's bottom row. * Modes and approvals use icons; levels are plain text. */ +import { useRef, useState } from "react"; +import clsx from "clsx"; import type { AgentConfigOption } from "@/bindings"; import { useTx } from "@/platform/i18n"; -import { Select } from "@/ui"; +import { IconButton, Popup, Select, T } from "@/ui"; import type { SelectOption } from "@/ui/Select"; import { mainOptions, modeIcon } from "./composerModel"; import styles from "./Composer.module.css"; @@ -43,47 +45,100 @@ export function ConfigPickers({ onChange: (option: string, value: string) => void; }) { const tx = useTx(); + const [open, setOpen] = useState(false); + const anchor = useRef(null); + const options = mainOptions(config); + const secondary = options.filter((o) => o.category !== "model"); + const approval = secondary.find((o) => o.category === "approval" || o.category === "mode"); + const summary = secondary + .map((o) => { + const value = values?.[o.id] ?? o.current; + return `${tx(o.name)}: ${o.values.find((v) => v.value === value)?.name ?? tx("Default")}`; + }) + .join(" · "); return ( <> - {mainOptions(config).map((o) => { - const isMode = o.category === "mode"; - const value = values?.[o.id] ?? o.current ?? null; - const options = configPickerOptions(o); - - return ( - ({ + ...opt, + icon: + option.category === "approval" + ? opt.value === "bypass" + ? "unlock" + : "lock" + : option.category === "mode" + ? modeIcon(opt.value) + : undefined, + }))} + searchable={option.category === "model"} + onChange={(v) => onChange(option.id, v)} + renderValue={ + option.category === "thought_level" && !framed + ? (current) => ( + + {option.name} + {current?.label ?? tx("Default")} + + ) + : undefined + } + /> + ); +} diff --git a/src/features/agents/Home.module.css b/src/features/agents/Home.module.css index f86221152..67cad0ade 100644 --- a/src/features/agents/Home.module.css +++ b/src/features/agents/Home.module.css @@ -60,3 +60,47 @@ .warning { color: var(--danger); } + +.docked { + justify-content: flex-end; + padding: 16px 12px 12px; +} +.docked .inner { + gap: 8px; +} +.docked .where { + order: -1; + flex-wrap: nowrap; + min-width: 0; + gap: 6px; +} +.docked .project, +.docked .projectPicker { + flex: 0 1 auto; + min-width: 0; + max-width: 30%; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.docked .branch, +.docked .basePicker { + order: 1; + flex: 1 1 0; + min-width: 0; + padding: 0; +} +.docked .isolation { + order: 2; + flex: 0 1 auto; + min-width: 0; + max-width: 45%; + margin-left: auto; +} +.branchName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index d7dfa29e9..3ff76b005 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -1,4 +1,5 @@ /** New Task page: one prompt box with the agent and its settings; where it works under it. */ +import clsx from "clsx"; import { useEffect, useState } from "react"; import type { AgentProbe, Branch } from "@/bindings"; import { executeAction } from "@/platform/actions"; @@ -7,7 +8,7 @@ import { useTx, useTxTemplate } from "@/platform/i18n"; import { basename, useProject } from "@/platform/project"; import { Icon, Select, T, type SelectOption } from "@/ui"; import { defaultTaskProject } from "./project"; -import { maybeEditorService } from "@/workbench/contributions"; +import { maybeEditorService, type EditorInput } from "@/workbench/contributions"; import { Composer } from "./Composer"; import { SubmitButton } from "./SubmitButton"; import { canSend, emptyDraft } from "./composerModel"; @@ -17,7 +18,7 @@ import { ADD_AGENT, providerOptions as agentOptions } from "./providerOptions"; import { HOME, NO_COMMANDS, refreshProviders, startTask, updateDraft, useAgentCommands, useAgents } from "./store"; import styles from "./Home.module.css"; -export function HomeEditor() { +export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) { const tx = useTx(); const txTemplate = useTxTemplate(); const draft = useAgents((s) => s.draft); @@ -86,15 +87,19 @@ export function HomeEditor() { const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } }); return ( -
+
-

- What should the agent do? -

+ {!docked && ( +

+ What should the agent do? +

+ )} ({ value: path, label: basename(path), detail: path }))} onChange={(path) => { @@ -139,12 +145,15 @@ export function HomeEditor() { }} /> ) : ( - {members.length ? basename(root) : project?.name} + + {members.length ? basename(root) : project?.name} + )} {isGit && ( ({ value: b.name, label: b.name }))} onChange={(b) => updateDraft({ base: b === current ? null : b })} @@ -184,7 +198,7 @@ export function HomeEditor() { )}
- {probe && ( + {probe && (!docked || probe.error) && (

{probe.error ? txTemplate(`${provider?.name ?? probe.provider} didn't start: ${probe.error}`) diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index 5b104f185..a28570ef3 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -16,6 +16,20 @@ border-right: 1px solid var(--border); } +.conversation[hidden] { + display: none; +} +.docked { + grid-template-columns: minmax(0, 1fr); + overflow: hidden; +} +.docked .conversation { + border-right: 0; +} +.docked .changes { + width: auto; +} + /* The conversation hugs its content; spare space stays below the composer instead of inside the chat. */ .streamArea, @@ -568,6 +582,7 @@ width: 280px; } .changesHeader { + min-width: 0; display: flex; align-items: center; gap: 6px; @@ -577,11 +592,17 @@ border-bottom: 1px solid var(--border); } .changesTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-weight: 600; color: var(--fg-strong); } .changesSummary { flex: none; + overflow: hidden; + text-overflow: ellipsis; padding: 4px 10px; color: var(--fg-subtle); white-space: pre; @@ -608,8 +629,11 @@ gap: 4px; } .changeName { - flex: none; + flex: 0 1 auto; + min-width: 0; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .changeDir { flex: 1 1 0; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 00d2ae510..deddc4c4c 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -68,15 +68,13 @@ import { setFocusedSession, focusedSession, type FocusedSession } from "./sessio import { taskMenu } from "./taskActions"; import styles from "./Session.module.css"; -export function SessionEditor({ input }: { input: EditorInput }) { +export function SessionEditor({ input, docked = false }: { input: EditorInput; docked?: boolean }) { const id = (input.data as { task: string }).task; - return ; + return ; } -function TaskSession({ id }: { id: string }) { +function TaskSession({ id, docked }: { id: string; docked: boolean }) { const task = useAgents((s) => s.tasks.get(id)); - // The tool window bar above already shows the title of a docked conversation. - const docked = useAgents((s) => s.docked === id); const entries = useAgents((s) => s.entries.get(id)); const changes = useAgents((s) => s.changes.get(id)); const project = useProject((s) => s.project); @@ -84,7 +82,7 @@ function TaskSession({ id }: { id: string }) { task?.isolation === "local" ? task.status : null, project?.workspace ? (task?.projectRoot ?? project.root) : null, ); - const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null); + const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null, docked); // The subagent conversation shown, with the subagents that lead to it (empty: the task's own). const [path, setPath] = useState([]); const view = path.at(-1) ?? null; @@ -96,8 +94,10 @@ function TaskSession({ id }: { id: string }) { useEffect(() => { void loadEntries(id).catch(() => undefined); void refreshChanges(id); - return markVisible(id); }, [id]); + useEffect(() => { + if (!docked || !changesOpen) return markVisible(id); + }, [id, docked, changesOpen]); useEffect(() => { if (view) void loadEntries(id, view).catch(() => undefined); }, [id, view]); @@ -127,7 +127,7 @@ function TaskSession({ id }: { id: string }) { } }} > -

+ ); @@ -270,6 +276,7 @@ function Header({ className={styles.changesToggle} aria-pressed={changesOpen} disabled={count === 0 && !changesOpen} + aria-label={changesOpen ? tx("Hide changes") : tx("Show changes")} title={changesOpen ? tx("Hide changes") : tx("Show changes")} onClick={onToggleChanges} > @@ -1292,16 +1299,22 @@ function loadChangesOpen(): Record { /** * Whether the Changes pane is shown for `task`: the user's choice once made, else - * only while the task has changes (so it opens when the agent first edits a file). + * only while the task has changes. Docked sessions stay in the conversation until + * the user explicitly opens changes; navigation there does not change editor preferences. */ -function useChangesOpen(task: string, changes: AgentChange[] | null): [boolean, (open: boolean) => void] { +function useChangesOpen( + task: string, + changes: AgentChange[] | null, + docked: boolean, +): [boolean, (open: boolean) => void] { const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({ task, - open: loadChangesOpen()[task], + open: docked ? false : loadChangesOpen()[task], })); - const chosen = state.task === task ? state.open : loadChangesOpen()[task]; + const chosen = state.task === task ? state.open : docked ? false : loadChangesOpen()[task]; const set = (open: boolean) => { setState({ task, open }); + if (docked) return; try { globalThis.localStorage?.setItem(CHANGES_OPEN_KEY, JSON.stringify({ ...loadChangesOpen(), [task]: open })); } catch { @@ -1315,11 +1328,13 @@ function ChangesPane({ task, changes, head, + docked, onHide, }: { task: AgentTask; changes: AgentChange[] | null; head: string | null; + docked: boolean; onHide: () => void; }) { const [selected, setSelected] = useState>(() => new Set()); @@ -1328,6 +1343,7 @@ function ChangesPane({ return (