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 1/3] 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 2/3] 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 (