дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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.
Цей коміт міститься в:
1 джерело
566190e94b
коміт
2b86dd2d9f
8 files changed
+564
-30
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
<ConfigPickers
|
||||
config={[
|
||||
{
|
||||
id: "allow_all",
|
||||
name: "Allow All",
|
||||
category: "permissions",
|
||||
description: null,
|
||||
current: "off",
|
||||
via: "option",
|
||||
values: [value("on", "On"), value("off", "Off")],
|
||||
},
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ConfigPickers
|
||||
config={[
|
||||
{
|
||||
id: "mode",
|
||||
name: "Mode",
|
||||
category: "mode",
|
||||
description: null,
|
||||
current: "agent",
|
||||
via: "option",
|
||||
values: [value("agent", "Agent"), value("plan", "Plan"), value("autopilot", "Autopilot")],
|
||||
},
|
||||
{
|
||||
id: "allow_all",
|
||||
name: "Allow All",
|
||||
category: "permissions",
|
||||
description: null,
|
||||
current: "off",
|
||||
via: "option",
|
||||
values: [value("on", "On"), value("off", "Off")],
|
||||
},
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
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(<ConfigPickers config={[config([value("9router/cmc/Beam")])]} onChange={onChange} />);
|
||||
@@ -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(<ConfigPickers config={copilotConfig()} onChange={vi.fn()} />);
|
||||
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(<ConfigPickers config={copilotConfig()} values={{ reasoning_effort: "high" }} onChange={onChange} />);
|
||||
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(<ConfigPickers config={copilotConfig(null)} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveAttribute("aria-haspopup", "listbox");
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLButtonElement>(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) => (
|
||||
<span key={o.id} className={clsx(styles.configPicker, o.category !== "model" && styles.inlineSetting)}>
|
||||
<ConfigPicker option={o} value={values?.[o.id] ?? o.current ?? null} onChange={onChange} />
|
||||
{o === model && effort ? (
|
||||
<ModelEffortPicker
|
||||
model={o}
|
||||
modelValue={valueOf(o)}
|
||||
effort={effort}
|
||||
effortValue={valueOf(effort)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : o === mode && allowAll ? (
|
||||
<ModePicker
|
||||
mode={o}
|
||||
modeValue={valueOf(o)}
|
||||
allowAll={allowAll}
|
||||
allowAllValue={valueOf(allowAll)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : (
|
||||
<ConfigPicker option={o} value={valueOf(o)} onChange={onChange} />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{secondary.length > 0 && (
|
||||
<div className={styles.settingsMenu}>
|
||||
<IconButton
|
||||
ref={anchor}
|
||||
icon={approval ? modeIcon(values?.[approval.id] ?? approval.current ?? "") : "settings"}
|
||||
icon={approval ? modeIcon(valueOf(approval) ?? "") : "settings"}
|
||||
title={`${tx("Agent settings")}: ${summary}`}
|
||||
aria-label={tx("Agent settings")}
|
||||
aria-haspopup="dialog"
|
||||
@@ -88,7 +121,7 @@ export function ConfigPickers({
|
||||
{secondary.map((o) => (
|
||||
<div key={o.id} className={styles.settingsRow}>
|
||||
<span className={styles.settingsLabel}>{tx(o.name)}</span>
|
||||
<ConfigPicker option={o} value={values?.[o.id] ?? o.current ?? null} onChange={onChange} framed />
|
||||
<ConfigPicker option={o} value={valueOf(o)} onChange={onChange} framed />
|
||||
</div>
|
||||
))}
|
||||
</Popup>
|
||||
@@ -110,6 +143,17 @@ function ConfigPicker({
|
||||
framed?: boolean;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const toggle = switchValues(option);
|
||||
if (toggle) {
|
||||
return (
|
||||
<Toggle
|
||||
checked={value === toggle.on}
|
||||
aria-label={tx(option.name)}
|
||||
label={framed ? undefined : tx(option.name)}
|
||||
onChange={(checked) => onChange(option.id, checked ? toggle.on : toggle.off)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
variant={framed ? "framed" : "ghost"}
|
||||
@@ -142,3 +186,193 @@ function ConfigPicker({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** One button for the model and its effort; the popup has the model list and an effort slider. */
|
||||
function ModelEffortPicker({
|
||||
model,
|
||||
modelValue,
|
||||
effort,
|
||||
effortValue,
|
||||
onChange,
|
||||
}: {
|
||||
model: AgentConfigOption;
|
||||
modelValue: string | null;
|
||||
effort: AgentConfigOption;
|
||||
effortValue: string | null;
|
||||
onChange: (option: string, value: string) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchor = useRef<HTMLButtonElement>(null);
|
||||
const modelName = selected(model, modelValue)?.name ?? tx("Default");
|
||||
const effortName = selected(effort, effortValue)?.name ?? tx("Default");
|
||||
const summary = `${tx(model.name)}: ${modelName} · ${tx(effort.name)}: ${effortName}`;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchor}
|
||||
type="button"
|
||||
className={styles.chip}
|
||||
aria-label={tx(model.name)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={summary}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className={styles.chipValue}>{modelName}</span>
|
||||
{/* Every level's name in one cell, so the button keeps the widest one's width. */}
|
||||
<span className={styles.chipDetail}>
|
||||
{[...new Set([effortName, ...effort.values.map((v) => v.name)])].map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className={name === effortName ? undefined : styles.chipSizer}
|
||||
aria-hidden={name !== effortName}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<Icon name="chevron-down" size={14} className={styles.chipChevron} />
|
||||
</button>
|
||||
<Popup
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
onClose={() => setOpen(false)}
|
||||
aria-label={tx("Model & effort settings")}
|
||||
className={styles.modelEffortPopup}
|
||||
>
|
||||
<div className={styles.settingsRow}>
|
||||
<span className={styles.settingsLabel}>{tx(model.name)}</span>
|
||||
<ConfigPicker option={model} value={modelValue} onChange={onChange} framed />
|
||||
</div>
|
||||
<div className={styles.effortHeader}>
|
||||
<span className={styles.settingsLabel}>{tx(effort.name)}</span>
|
||||
<span className={styles.effortValue}>{effortName}</span>
|
||||
</div>
|
||||
<EffortSlider option={effort} value={effortValue} onChange={(v) => onChange(effort.id, v)} />
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stepped slider over the agent's effort levels, in the order the agent lists them. */
|
||||
function EffortSlider({
|
||||
option,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
option: AgentConfigOption;
|
||||
value: string | null;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const steps = option.values;
|
||||
const index = Math.max(
|
||||
0,
|
||||
steps.findIndex((v) => v.value === value),
|
||||
);
|
||||
const last = steps.length - 1;
|
||||
const fill = last > 0 ? (index / last) * 100 : 100;
|
||||
return (
|
||||
<div className={styles.effortSlider}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={last}
|
||||
step={1}
|
||||
value={index}
|
||||
aria-label={tx(option.name)}
|
||||
aria-valuetext={steps[index]?.name}
|
||||
title={steps[index]?.description ?? undefined}
|
||||
style={{ "--fill": `${fill}%` } as CSSProperties}
|
||||
onChange={(e) => {
|
||||
const next = steps[Number(e.target.value)];
|
||||
if (next && next.value !== value) onChange(next.value);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.effortTicks} aria-hidden>
|
||||
{steps.map((v) => (
|
||||
<span key={v.value} className={clsx(v.value === steps[index]?.value && styles.effortTickActive)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<HTMLButtonElement>(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 (
|
||||
<>
|
||||
<button
|
||||
ref={anchor}
|
||||
type="button"
|
||||
className={styles.chip}
|
||||
aria-label={tx(mode.name)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={summary}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Icon name={on ? "unlock" : modeIcon(current?.value ?? "")} size={14} />
|
||||
<span className={styles.chipValue}>{current?.name ?? tx("Default")}</span>
|
||||
{on && <span className={styles.chipDetail}>{tx(allowAll.name)}</span>}
|
||||
<Icon name="chevron-down" size={14} className={styles.chipChevron} />
|
||||
</button>
|
||||
<Popup
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
onClose={() => setOpen(false)}
|
||||
aria-label={tx(mode.name)}
|
||||
className={styles.modePopup}
|
||||
>
|
||||
<div role="listbox" aria-label={tx(mode.name)} className={styles.modeList}>
|
||||
{mode.values.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={v.value === current?.value}
|
||||
title={v.description ?? undefined}
|
||||
className={styles.modeOption}
|
||||
onClick={() => {
|
||||
onChange(mode.id, v.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className={styles.modeCheck}>{v.value === current?.value && <Icon name="check" size={12} />}</span>
|
||||
<Icon name={modeIcon(v.value)} size={14} />
|
||||
<span className={styles.chipValue}>{v.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.modeFooter}>
|
||||
<span title={allowAll.description ?? undefined}>{tx(allowAll.name)}</span>
|
||||
<Toggle
|
||||
checked={on}
|
||||
aria-label={tx(allowAll.name)}
|
||||
onChange={(checked) => onChange(allowAll.id, checked ? toggle.on : toggle.off)}
|
||||
/>
|
||||
</div>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1295,11 +1295,7 @@ function SessionComposer({
|
||||
toolbar={<ConfigPickers config={task.config} onChange={setOption} />}
|
||||
actions={
|
||||
<>
|
||||
{busy && (
|
||||
<Button size="small" icon="stop" onClick={() => void cancelTask(task.id)}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
{busy && <IconButton icon="stop" title="Stop" size="small" onClick={() => void cancelTask(task.id)} />}
|
||||
<SubmitButton
|
||||
label={busy ? "Queue" : "Send"}
|
||||
pending={sending}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentConfigOption, AgentEntry, AgentStatus, AgentTask } from "@/bi
|
||||
import {
|
||||
applyCompletion,
|
||||
atEdgeLine,
|
||||
bypassDefaults,
|
||||
canSend,
|
||||
completionAt,
|
||||
isPathQuery,
|
||||
@@ -157,11 +158,31 @@ describe("agent composer", () => {
|
||||
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",
|
||||
|
||||
@@ -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<str
|
||||
const mode = modeOption(config);
|
||||
const unlock = mode?.values.find((v) => 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;
|
||||
|
||||
@@ -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(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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": "模型与推理强度"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача