From 2b86dd2d9f08664d08f3bbd1f5e5b4ad1028bb27 Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Wed, 4 Nov 2026 22:20:26 -0800 Subject: [PATCH] feat(agents): combine model and effort, and mode and Allow All, in the composer (#958) - One composer button shows the model and its effort; its popup has the searchable model list and a stepped effort slider over the levels the agent advertises. - With an on/off permissions option (Allow All), the mode menu carries it as a switch, and new tasks start with it on like the other bypass settings. - Recognise ACP session-mode URIs when picking mode icons. - The Stop action in a running session is an icon button. --- src/features/agents/Composer.module.css | 140 +++++++++++ src/features/agents/ConfigPickers.test.tsx | 134 ++++++++++ src/features/agents/ConfigPickers.tsx | 264 ++++++++++++++++++-- src/features/agents/SessionEditor.tsx | 6 +- src/features/agents/agents.test.ts | 27 +- src/features/agents/composerModel.ts | 16 +- src/features/agents/messageEditing.test.tsx | 4 +- src/platform/locales/text/zh.json | 3 +- 8 files changed, 564 insertions(+), 30 deletions(-) diff --git a/src/features/agents/Composer.module.css b/src/features/agents/Composer.module.css index e6f095e35..62f1c1e21 100644 --- a/src/features/agents/Composer.module.css +++ b/src/features/agents/Composer.module.css @@ -94,6 +94,146 @@ color: var(--fg-muted); overflow-wrap: anywhere; } +/* A ghost button showing a value and its detail, like a ghost Select. */ +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: var(--h-control); + min-width: 0; + padding: 0 4px 0 6px; + border-radius: var(--radius-control); + color: var(--fg-secondary); + white-space: nowrap; + transition: background var(--dur-fast) var(--ease); +} +.chip:hover, +.chip[aria-expanded="true"] { + background: var(--bg-toolbar-hover); + color: var(--fg-strong); +} +.chipValue { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.chipDetail { + display: inline-grid; + color: var(--fg-subtle); +} +.chipDetail > span { + grid-area: 1 / 1; +} +.chipSizer { + visibility: hidden; +} +.chipChevron { + flex: none; + color: var(--fg-muted); +} +.modelEffortPopup { + width: min(300px, calc(100vw - 24px)); + padding: 4px 12px 12px; +} +.effortHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-top: 12px; +} +.effortValue { + color: var(--fg-strong); +} +.effortSlider { + --thumb: 14px; + margin-top: 8px; +} +.effortSlider input { + display: block; + width: 100%; + height: var(--thumb); + margin: 0; + appearance: none; + background: transparent; +} +.effortSlider input::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background: linear-gradient(to right, var(--accent) var(--fill), var(--control-off) var(--fill)); +} +.effortSlider input::-webkit-slider-thumb { + width: var(--thumb); + height: var(--thumb); + margin-top: calc((4px - var(--thumb)) / 2); + border-radius: 50%; + appearance: none; + background: var(--fg-on-accent); + box-shadow: + 0 0 0 1px var(--border-button), + var(--shadow-tooltip); +} +.effortSlider input:focus-visible { + outline: none; +} +.effortSlider input:focus-visible::-webkit-slider-thumb { + box-shadow: 0 0 0 2px var(--focus-ring); +} +/* One dot per level, centred under the thumb's positions. */ +.effortTicks { + display: flex; + justify-content: space-between; + padding: 0 calc(var(--thumb) / 2 - 2px); + margin-top: 4px; +} +.effortTicks > span { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--control-off); +} +.effortTicks > .effortTickActive { + background: var(--accent); +} +.modePopup { + min-width: 200px; + padding: 6px 0; +} +.modeList { + display: flex; + flex-direction: column; +} +.modeOption { + display: flex; + align-items: center; + gap: 6px; + height: 24px; + margin: 0 6px; + padding: 0 8px; + border-radius: var(--radius-row); + color: var(--fg-strong); + text-align: left; + white-space: nowrap; +} +.modeOption:hover, +.modeOption:focus-visible { + outline: none; + background: var(--bg-list-selected); +} +.modeCheck { + width: 12px; + flex: none; + color: var(--fg-muted); +} +.modeFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 6px; + padding: 8px 14px 2px; + border-top: 1px solid var(--border); +} @container agent-composer (max-width: 600px) { .bar { flex-direction: column; diff --git a/src/features/agents/ConfigPickers.test.tsx b/src/features/agents/ConfigPickers.test.tsx index e0c0a828c..d2a347924 100644 --- a/src/features/agents/ConfigPickers.test.tsx +++ b/src/features/agents/ConfigPickers.test.tsx @@ -78,6 +78,63 @@ it("shows Cursor approvals separately from its execution mode and sends the choi expect(onChange).toHaveBeenCalledWith("openidea.cursor.approval", "bypass"); }); +it("shows Allow All as a switch and sends on/off values", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("switch", { name: "Allow All" })); + expect(onChange).toHaveBeenCalledExactlyOnceWith("allow_all", "on"); +}); + +it("shows Allow All inside the mode popup when both options exist", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Mode" })); + fireEvent.click(screen.getByRole("option", { name: "Plan" })); + expect(onChange).toHaveBeenNthCalledWith(1, "mode", "plan"); + fireEvent.click(screen.getByRole("button", { name: "Mode" })); + fireEvent.click(screen.getByRole("switch", { name: "Allow All" })); + expect(onChange).toHaveBeenNthCalledWith(2, "allow_all", "on"); +}); + it("sends the complete nested model id through ConfigPickers", () => { const onChange = vi.fn(); render(); @@ -110,3 +167,80 @@ it("edits secondary settings from the compact popup and restores focus when dism expect(screen.queryByRole("dialog", { name: "Agent settings" })).not.toBeInTheDocument(); expect(trigger).toHaveFocus(); }); + +describe("model and effort button", () => { + const copilotConfig = (effortLevels: string[] | null = ["low", "medium", "high", "xhigh"]): AgentConfigOption[] => [ + { + id: "model", + name: "Model", + category: "model", + description: null, + current: "claude-sonnet-5.5", + via: "option", + values: [value("claude-sonnet-5.5", "Claude Sonnet 5.5", "1x"), value("gpt-6-sol", "GPT-6 Sol", "1x")], + }, + ...(effortLevels + ? [ + { + id: "reasoning_effort", + name: "Reasoning Effort", + category: "thought_level", + description: null, + current: "medium", + via: "option" as const, + values: effortLevels.map((lvl) => value(lvl, lvl === "xhigh" ? "XHigh" : lvl)), + }, + ] + : []), + { + id: "mode", + name: "Mode", + category: "mode", + description: null, + current: "https://agentclientprotocol.com/protocol/session-modes#agent", + via: "option", + values: [ + value("https://agentclientprotocol.com/protocol/session-modes#agent", "Agent"), + value("https://agentclientprotocol.com/protocol/session-modes#plan", "Plan"), + ], + }, + { + id: "allow_all", + name: "Allow All", + category: "permissions", + description: null, + current: "on", + via: "option", + values: [value("on", "On"), value("off", "Off")], + }, + ]; + + it("shows the model with the agent's effort name and the mode with Allow All", () => { + render(); + const model = screen.getByRole("button", { name: "Model" }); + expect(model).toHaveTextContent("Claude Sonnet 5.5medium"); + expect(screen.getByRole("button", { name: "Mode" })).toHaveTextContent("AgentAllow All"); + expect(screen.queryByRole("button", { name: "Reasoning Effort" })).not.toBeInTheDocument(); + }); + + it("steps through the advertised effort levels and picks a model from the popup", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Model" })); + const dialog = screen.getByRole("dialog", { name: "Model & effort settings" }); + const slider = within(dialog).getByRole("slider", { name: "Reasoning Effort" }); + expect(slider).toHaveAttribute("max", "3"); + expect(slider).toHaveValue("2"); + expect(slider).toHaveAttribute("aria-valuetext", "high"); + fireEvent.change(slider, { target: { value: "3" } }); + expect(onChange).toHaveBeenLastCalledWith("reasoning_effort", "xhigh"); + fireEvent.click(within(dialog).getByRole("button", { name: "Model" })); + fireEvent.click(screen.getByRole("option", { name: /GPT-6 Sol/ })); + expect(onChange).toHaveBeenLastCalledWith("model", "gpt-6-sol"); + }); + + it("keeps a plain model picker when the agent has no effort option", () => { + render(); + expect(screen.getByRole("button", { name: "Model" })).toHaveAttribute("aria-haspopup", "listbox"); + }); +}); diff --git a/src/features/agents/ConfigPickers.tsx b/src/features/agents/ConfigPickers.tsx index dbb6f2331..9118b2202 100644 --- a/src/features/agents/ConfigPickers.tsx +++ b/src/features/agents/ConfigPickers.tsx @@ -1,12 +1,14 @@ /** - * Model, mode, approvals and thinking-effort dropdowns for a composer's bottom row. - * Modes and approvals use icons; levels are plain text. + * Model, mode, approvals and thinking-effort controls for a composer's bottom row. + * With both a model and an effort option, one button shows both and opens the model + * list and an effort slider; with a mode and an on/off permissions option (Allow All), + * the mode menu carries the switch. Names and descriptions are the agent's own. */ -import { useRef, useState } from "react"; +import { useRef, useState, type CSSProperties } from "react"; import clsx from "clsx"; import type { AgentConfigOption } from "@/bindings"; import { useTx } from "@/platform/i18n"; -import { IconButton, Popup, Select, T } from "@/ui"; +import { Icon, IconButton, Popup, Select, T, Toggle } from "@/ui"; import type { SelectOption } from "@/ui/Select"; import { mainOptions, modeIcon } from "./composerModel"; import styles from "./Composer.module.css"; @@ -34,6 +36,17 @@ export function configPickerOptions(option: AgentConfigOption): SelectOption[] { ]; } +/** The on and off values of a two-state permissions option, or null when it has other values. */ +function switchValues(option: AgentConfigOption): { on: string; off: string } | null { + if (option.category !== "permissions") return null; + const on = option.values.find((v) => /^(on|true)$/i.test(v.value))?.value; + const off = option.values.find((v) => /^(off|false)$/i.test(v.value))?.value; + return on && off ? { on, off } : null; +} + +const selected = (option: AgentConfigOption, value: string | null) => + option.values.find((v) => v.value === value) ?? null; + export function ConfigPickers({ config, values, @@ -48,26 +61,46 @@ export function ConfigPickers({ const [open, setOpen] = useState(false); const anchor = useRef(null); const options = mainOptions(config); - const secondary = options.filter((o) => o.category !== "model"); + const valueOf = (o: AgentConfigOption) => values?.[o.id] ?? o.current ?? null; + const model = options.find((o) => o.category === "model"); + const effort = model && options.find((o) => o.category === "thought_level" && o.values.length > 0); + const mode = options.find((o) => o.category === "mode"); + const allowAll = mode && options.find((o) => switchValues(o)); + const inline = options.filter((o) => o !== effort && o !== allowAll); + // The compact menu holds everything but the model button (which carries the effort). + const secondary = options.filter((o) => o.category !== "model" && o !== effort); 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(" · "); + const summary = secondary.map((o) => `${tx(o.name)}: ${selected(o, valueOf(o))?.name ?? tx("Default")}`).join(" · "); return ( <> - {options.map((o) => ( + {inline.map((o) => ( - + {o === model && effort ? ( + + ) : o === mode && allowAll ? ( + + ) : ( + + )} ))} {secondary.length > 0 && (
(
{tx(o.name)} - +
))} @@ -110,6 +143,17 @@ function ConfigPicker({ framed?: boolean; }) { const tx = useTx(); + const toggle = switchValues(option); + if (toggle) { + return ( + onChange(option.id, checked ? toggle.on : toggle.off)} + /> + ); + } return ( { + const next = steps[Number(e.target.value)]; + if (next && next.value !== value) onChange(next.value); + }} + /> +
+ {steps.map((v) => ( + + ))} +
+
+ ); +} + +/** The mode menu, with the agent's on/off permissions option (Allow All) as a switch under it. */ +function ModePicker({ + mode, + modeValue, + allowAll, + allowAllValue, + onChange, +}: { + mode: AgentConfigOption; + modeValue: string | null; + allowAll: AgentConfigOption; + allowAllValue: string | null; + onChange: (option: string, value: string) => void; +}) { + const tx = useTx(); + const [open, setOpen] = useState(false); + const anchor = useRef(null); + const toggle = switchValues(allowAll)!; + const current = selected(mode, modeValue); + const on = allowAllValue === toggle.on; + const summary = `${tx(mode.name)}: ${current?.name ?? tx("Default")} · ${tx(allowAll.name)}: ${on ? tx("On") : tx("Off")}`; + return ( + <> + + setOpen(false)} + aria-label={tx(mode.name)} + className={styles.modePopup} + > +
+ {mode.values.map((v) => ( + + ))} +
+
+ {tx(allowAll.name)} + onChange(allowAll.id, checked ? toggle.on : toggle.off)} + /> +
+
+ + ); +} diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 02f91fa1d..f1c777a13 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -1295,11 +1295,7 @@ function SessionComposer({ toolbar={} actions={ <> - {busy && ( - - )} + {busy && void cancelTask(task.id)} />} { const config = [option("fast", ["on"], null), option("mode", ["ask", "code"], "code"), option("model", ["m"], "m")]; expect(mainOptions(config).map((o) => o.id)).toEqual(["model", "mode"]); expect(nextValue(config[1]!)).toBe("ask"); + const permissions = option("allow_all", ["off", "on"], "off"); + permissions.category = "permissions"; + expect(mainOptions([...config, permissions]).map((o) => o.id)).toEqual(["model", "mode", permissions.id]); const approvals = { ...option("openidea.cursor.approval", ["ask", "bypass"], "ask"), category: "approval" }; - expect(mainOptions([...config, approvals]).map((o) => o.id)).toEqual(["model", "mode", approvals.id]); + expect(mainOptions([...config, permissions, approvals]).map((o) => o.id)).toEqual([ + "model", + "mode", + permissions.id, + approvals.id, + ]); expect( - ["default", "acceptEdits", "plan", "auto", "bypassPermissions", "auto_edit", "read-only", "x"].map(modeIcon), - ).toEqual(["lock", "edit", "todo", "run", "unlock", "edit", "eye", "lock"]); + [ + "default", + "acceptEdits", + "plan", + "auto", + "bypassPermissions", + "auto_edit", + "read-only", + "x", + "https://agentclientprotocol.com/protocol/session-modes#agent", + "https://agentclientprotocol.com/protocol/session-modes#autopilot", + ].map(modeIcon), + ).toEqual(["lock", "edit", "todo", "run", "unlock", "edit", "eye", "lock", "run", "run"]); + expect(bypassDefaults([...config, permissions])).toEqual({ allow_all: "on" }); expect(usageLabel({ used: 24_000, size: 200_000 })).toEqual({ short: "12%", detail: "24k / 200k tokens", diff --git a/src/features/agents/composerModel.ts b/src/features/agents/composerModel.ts index a0aafd410..dcb51e3aa 100644 --- a/src/features/agents/composerModel.ts +++ b/src/features/agents/composerModel.ts @@ -152,9 +152,9 @@ export function atEdgeLine(text: string, caret: number, dir: -1 | 1): boolean { return dir < 0 ? !text.slice(0, caret).includes("\n") : !text.slice(caret).includes("\n"); } -/** The settings shown as dropdowns: model, mode, approvals, thinking effort, in that order. */ +/** The settings shown in the composer: model, mode, permissions, approvals, effort. */ export function mainOptions(config: readonly AgentConfigOption[]): AgentConfigOption[] { - const order = ["model", "mode", "approval", "thought_level"]; + const order = ["model", "mode", "permissions", "approval", "thought_level"]; return order.flatMap((c) => config.filter((o) => o.category === c).slice(0, 1)); } @@ -165,12 +165,17 @@ export const modeOption = (config: readonly AgentConfigOption[]) => config.find( * Claude `acceptEdits`, Gemini `auto_edit`, Codex `full-access`…). Unknown modes ask. */ export function modeIcon(mode: string): string { - const m = mode.toLowerCase().replace(/[^a-z]/g, ""); + const m = + mode + .toLowerCase() + .split(/[#/]/) + .at(-1) + ?.replace(/[^a-z]/g, "") ?? ""; if (m.includes("plan") || m === "architect") return "todo"; if (m.includes("bypass") || m === "yolo" || m.includes("fullaccess") || m === "dangerous") return "unlock"; if (m.includes("readonly") || m === "ask" || m === "chat") return "eye"; if (m.includes("edit") || m === "code") return "edit"; - if (m === "auto" || m === "agent") return "run"; + if (m === "auto" || m === "agent" || m.includes("autopilot")) return "run"; return "lock"; } @@ -180,6 +185,9 @@ export function bypassDefaults(config: readonly AgentConfigOption[]): Record modeIcon(v.value) === "unlock"); if (mode && unlock) out[mode.id] = unlock.value; + const permissions = config.find((o) => o.category === "permissions"); + const allowAll = permissions?.values.find((v) => /^(on|true)$/i.test(v.value)); + if (permissions && allowAll) out[permissions.id] = allowAll.value; const approval = config.find((o) => o.category === "approval"); if (approval?.values.some((v) => v.value === "bypass")) out[approval.id] = "bypass"; return out; diff --git a/src/features/agents/messageEditing.test.tsx b/src/features/agents/messageEditing.test.tsx index 4542fa063..81a2096ef 100644 --- a/src/features/agents/messageEditing.test.tsx +++ b/src/features/agents/messageEditing.test.tsx @@ -256,14 +256,14 @@ describe("session composer submission", () => { expect(box).toHaveValue(""); }); - it("keeps Queue named and leaves Stop unchanged while the agent works", async () => { + it("keeps Queue named and shows icon-only Stop actions while the agent works", async () => { useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) }); render(); const queue = screen.getByRole("button", { name: "Queue" }); expect(queue.textContent).toBe(""); expect(queue).toHaveAttribute("data-tooltip", "Queue"); expect(queue).toBeDisabled(); - expect(screen.getAllByRole("button", { name: "Stop" }).some((button) => button.textContent === "Stop")).toBe(true); + expect(screen.getAllByRole("button", { name: "Stop" }).every((button) => button.textContent === "")).toBe(true); await screen.findByText("Original message"); }); }); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d67238c05..5fb66be34 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6219,5 +6219,6 @@ "First Page": "第一页", "Count rows": "统计行数", "Last Page": "最后一页", - "{first}-{last} of {total}": "{first}-{last},共 {total}" + "{first}-{last} of {total}": "{first}-{last},共 {total}", + "Model & effort settings": "模型与推理强度" }