fix: simplify conversation composer options

Consolidate composer settings into a compact popover while preserving runtime, mode, sending, and queue controls. Place the settings icon after voice input, limit the panel to 300px, and show knowledge expansion state.

Include the navigation demo, design guidance, localized labels, and composer regression coverage.

Release note: Simplify the conversation input toolbar with compact settings, clearer knowledge expansion, and visible active selections.
This commit is contained in:
mesalogo
2026-09-13 00:05:05 +08:00
parent 719c9fd6cd
commit 032ebc313f
11 changed files with 871 additions and 189 deletions
+46 -10
View File
@@ -940,11 +940,17 @@ const api: DesktopApi = {
function composerMenuTrigger(
label: "专家角色" | "工作模式",
): HTMLButtonElement {
if (label === "专家角色") openComposerOptions();
return screen.getByRole("button", {
name: new RegExp(`^${label}:`, "u"),
});
}
function openComposerOptions(): void {
const trigger = screen.getByRole("button", { name: "选项" });
if (trigger.getAttribute("aria-expanded") !== "true") fireEvent.click(trigger);
}
function openComposerMenu(label: "专家角色" | "工作模式"): HTMLElement {
fireEvent.click(composerMenuTrigger(label));
return screen.getByRole("menu", { name: label });
@@ -4726,6 +4732,7 @@ describe("App", () => {
evidence: [],
});
render(<App />);
openComposerOptions();
const knowledgeScopeTrigger = await screen.findByRole("button", {
name: "选择知识库,本次已启用 0 个",
});
@@ -4899,18 +4906,22 @@ describe("App", () => {
render(<App />);
await screen.findByRole("button", { name: /^带知识范围/u });
openComposerOptions();
expect(
await screen.findByRole("button", {
name: "选择知识库,本次已启用 1 个",
}),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^空知识范围/u }));
openComposerOptions();
expect(
screen.getByRole("button", {
name: "选择知识库,本次已启用 0 个",
}),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^带知识范围/u }));
openComposerOptions();
expect(
screen.getByRole("button", {
name: "选择知识库,本次已启用 1 个",
@@ -4941,6 +4952,7 @@ describe("App", () => {
evidence: [],
});
render(<App />);
openComposerOptions();
const knowledgeScope = await screen.findByRole("button", {
name: "选择知识库,本次已启用 0 个",
});
@@ -6204,6 +6216,7 @@ describe("App", () => {
it("matches expert and work mode keyboard menus to the model picker", async () => {
render(<App />);
openComposerOptions();
const expertTrigger = await screen.findByRole("button", {
name: "专家角色:通用助手",
@@ -6230,11 +6243,20 @@ describe("App", () => {
screen.queryByRole("menu", { name: "专家角色" }),
).not.toBeInTheDocument();
fireEvent.keyDown(expertTrigger, { key: "Escape" });
const optionsTrigger = screen.getByRole("button", { name: "选项" });
expect(optionsTrigger).toHaveFocus();
expect(optionsTrigger).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("dialog", { name: "对话设置" })).not.toBeInTheDocument();
const modeTrigger = composerMenuTrigger("工作模式");
fireEvent.click(modeTrigger);
const modeMenu = screen.getByRole("menu", { name: "工作模式" });
expect(modeMenu).toHaveClass("runtime-picker__menu");
fireEvent.click(optionsTrigger);
expect(screen.queryByRole("menu", { name: "工作模式" })).not.toBeInTheDocument();
await waitFor(() => expect(expertTrigger).toHaveFocus());
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
expect(optionsTrigger).toHaveAttribute("aria-expanded", "false");
expect(
screen.queryByRole("menu", { name: "工作模式" }),
).not.toBeInTheDocument();
@@ -6247,6 +6269,8 @@ describe("App", () => {
await screen.findByLabelText("向 GoodBuddy 提问")
).closest<HTMLElement>(".composer");
expect(composer).not.toBeNull();
expect(screen.queryByRole("button", { name: "专家角色:通用助手" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "选项" })).toHaveAttribute("aria-expanded", "false");
const contentTools = within(composer!).getByRole("group", {
name: "添加内容",
@@ -6261,8 +6285,9 @@ describe("App", () => {
const conversationSettings = within(composer!).getByRole("group", {
name: "对话设置",
});
openComposerOptions();
expect(
within(conversationSettings).getByRole("button", {
within(screen.getByRole("dialog", { name: "对话设置" })).getByRole("button", {
name: "专家角色:通用助手",
}),
).toBeInTheDocument();
@@ -7750,21 +7775,17 @@ describe("App", () => {
render(<App />);
openComposerOptions();
const agentPicker = await screen.findByRole("button", {
name: /OpenCode Runtime Agent/u,
});
const runtimeToolbar = screen.getByRole("group", {
name: "OpenCode 专属功能",
});
const universalSettings = screen.getByRole("group", {
name: "对话设置",
});
const options = screen.getByRole("dialog", { name: "对话设置" });
expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar");
expect(runtimeToolbar).toContainElement(agentPicker);
expect(universalSettings).not.toContainElement(agentPicker);
expect(universalSettings.closest(".composer__toolbar")).toHaveClass(
"composer__toolbar--with-runtime-controls",
);
expect(options).toContainElement(agentPicker);
fireEvent.click(agentPicker);
fireEvent.click(
within(
@@ -7788,6 +7809,10 @@ describe("App", () => {
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "src/main" },
});
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
expect(screen.queryByRole("dialog", { name: "对话设置" })).not.toBeInTheDocument();
expect(document.querySelector(".composer__option-summary")).toHaveTextContent("Planner");
expect(document.querySelector(".composer__option-summary")).toHaveTextContent("/review");
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() =>
@@ -7906,6 +7931,8 @@ describe("App", () => {
render(<App />);
await screen.findByText("已就绪");
openComposerOptions();
const runtimeToolbar = await screen.findByRole("group", {
name: "OpenCode 专属功能",
});
@@ -7975,6 +8002,7 @@ describe("App", () => {
render(<App />);
openComposerOptions();
fireEvent.click(await screen.findByRole("button", { name: /OpenCode Runtime Agent/u }));
expect(screen.getByRole("menuitemradio", { name: /Planner/u })).toBeVisible();
fireEvent.keyDown(screen.getByRole("menu", { name: "OpenCode Runtime Agent" }), { key: "Escape" });
@@ -8008,6 +8036,7 @@ describe("App", () => {
.mockResolvedValueOnce(healthy)
.mockResolvedValueOnce(healthy);
render(<App />);
openComposerOptions();
await screen.findByRole("button", { name: /OpenCode Runtime Agent/u });
selectProjectOption(secondProject.name);
await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenLastCalledWith(
@@ -8020,6 +8049,7 @@ describe("App", () => {
.mockResolvedValueOnce(failed);
const callsBefore = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length;
selectProjectOption(firstProject.name);
openComposerOptions();
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
await act(async () => {
pending.resolve(failed);
@@ -8071,6 +8101,8 @@ describe("App", () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce(rows);
render(<App />);
await screen.findByRole("button", { name: new RegExp(`^${rows[0]!.title}`, "u") });
openComposerOptions();
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u }));
fireEvent.click(screen.getByRole("menuitemradio", { name: /Planner/u }));
@@ -8078,6 +8110,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("menuitemradio", { name: /\/review/u }));
const inventoryCalls = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length;
fireEvent.click(screen.getByText(rows[1]!.title).closest("button")!);
openComposerOptions();
expect(screen.getByRole("group", { name: "OpenCode 专属功能" })).toBe(toolbar);
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(inventoryCalls);
await waitFor(() => expect(screen.getByRole("button", { name: /OpenCode Runtime Agent/u })).not.toHaveTextContent("Planner"));
@@ -8161,6 +8194,7 @@ describe("App", () => {
render(<App />);
openComposerOptions();
const presetPicker = await screen.findByRole("button", {
name: /Continue 配置预设.*使用设置默认预设/u,
});
@@ -8170,8 +8204,8 @@ describe("App", () => {
expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar");
expect(runtimeToolbar).toContainElement(presetPicker);
expect(
screen.getByRole("group", { name: "对话设置" }),
).not.toContainElement(presetPicker);
screen.getByRole("dialog", { name: "对话设置" }),
).toContainElement(presetPicker);
fireEvent.click(presetPicker);
fireEvent.click(
within(
@@ -8891,6 +8925,8 @@ describe("App", () => {
}));
render(<App />);
await screen.findByText("现有消息不应重写");
openComposerOptions();
const knowledgeScope = await screen.findByRole("button", {
name: "选择知识库,本次已启用 1 个",
});
+192 -129
View File
@@ -31,6 +31,7 @@ import {
Settings,
RefreshCw,
ShieldCheck,
SlidersHorizontal,
PanelRightClose,
PanelRightOpen,
Sparkles,
@@ -2134,6 +2135,10 @@ function App(): React.JSX.Element {
const [runtimeStatusKey, setRuntimeStatusKey] = useState("");
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>();
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false);
const [composerOptionsOpen, setComposerOptionsOpen] = useState(false);
const composerOptionsRef = useRef<HTMLDivElement>(null);
const composerOptionsTriggerRef = useRef<HTMLButtonElement>(null);
const [composerMenuOpen, setComposerMenuOpen] = useState<
| "expert"
| "mode"
@@ -2191,30 +2196,36 @@ function App(): React.JSX.Element {
setComposerMenuOpen(open ? "expert" : undefined);
if (open) {
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}
}, []);
const setModeMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? "mode" : undefined);
if (open) {
setRuntimeMenuOpen(false);
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
}
}, []);
const setRuntimeAgentMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? "runtime-agent" : undefined);
if (open) {
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}
}, []);
const setRuntimeActionMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? "runtime-action" : undefined);
if (open) {
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}
}, []);
const setRuntimePresetMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? "runtime-preset" : undefined);
if (open) {
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}
}, []);
const assistantExpertOptions = useMemo<ComposerMenuOption<string>[]>(
@@ -2542,7 +2553,6 @@ function App(): React.JSX.Element {
const knowledgeOperationCountRef = useRef(knowledgeOperationCount);
const knowledgeLoadRequestRef = useRef(0);
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(undefined);
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false);
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null);
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null);
const [legacyActivityHistory] = useState(loadLegacyActivityHistory);
@@ -2876,7 +2886,7 @@ function App(): React.JSX.Element {
}
};
const closeOnEscape = (event: KeyboardEvent): void => {
if (event.key !== "Escape") {
if (event.key !== "Escape" || event.defaultPrevented) {
return;
}
event.preventDefault();
@@ -2892,6 +2902,32 @@ function App(): React.JSX.Element {
};
}, [knowledgeScopeOpen]);
useEffect(() => {
if (!composerOptionsOpen) {
return;
}
const isOptionsTarget = (target: EventTarget | null): boolean =>
target instanceof Node &&
(composerOptionsRef.current?.contains(target) === true ||
composerOptionsTriggerRef.current?.contains(target) === true);
const dismissOutside = (event: Event): void => {
if (!isOptionsTarget(event.target)) {
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
}
};
const frame = requestAnimationFrame(() => {
composerOptionsRef.current?.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus();
});
document.addEventListener("pointerdown", dismissOutside);
document.addEventListener("focusin", dismissOutside);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener("pointerdown", dismissOutside);
document.removeEventListener("focusin", dismissOutside);
};
}, [composerOptionsOpen]);
useLayoutEffect(() => {
conversationsRef.current = conversations;
}, [conversations]);
@@ -7943,6 +7979,14 @@ function App(): React.JSX.Element {
activeConversation?.messages.some(
(message) => message.state === "streaming",
) ?? false;
const [composerContext, setComposerContext] = useState({ activeId, isRunning, view });
if (composerContext.activeId !== activeId || composerContext.isRunning !== isRunning || composerContext.view !== view) {
setComposerContext({ activeId, isRunning, view });
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
}
const activeConversationQueueItems = useMemo(
() =>
conversationQueueItems.filter((item) => item.conversationId === activeId),
@@ -7999,6 +8043,26 @@ function App(): React.JSX.Element {
runtime: runtimeControlsProvider,
})
: "";
const composerOptionSummary = [
selectedExpertId && runtime?.capability !== "image-generation"
? assistantExpertOptions.find((option) => option.value === selectedExpertId)?.label
: undefined,
enabledKnowledgeLibraryIds.length > 0
? t("composer.knowledge.select", { count: enabledKnowledgeLibraryIds.length })
: undefined,
activeConversation?.knowledgeRetrievalMode === "always"
? t("composer.knowledge.always")
: undefined,
runtimeAgentControlAvailable && selectedRuntimeAgent
? runtimeAgentOptions.find((option) => option.value === selectedRuntimeAgent)?.label
: undefined,
runtimePresetControlAvailable && selectedContinuePreset
? runtimePresetOptions.find((option) => option.value === selectedContinuePreset)?.label
: undefined,
runtimeActionControlAvailable && selectedRuntimeCommand
? runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.label
: undefined,
].filter(Boolean).join(" · ");
const runtimeContextCompactAvailable =
(activeRuntimeSelection?.provider === "opencode" ||
activeRuntimeSelection?.provider === "continue") &&
@@ -9132,6 +9196,11 @@ function App(): React.JSX.Element {
running={conversationExecutionRunning}
/>
<div className="composer">
{composerOptionSummary && (
<div className="composer__option-summary" aria-label={t("composer.settings")}>
{composerOptionSummary}
</div>
)}
{(attachments.length > 0 ||
selectingContextFiles) && (
<div
@@ -9303,13 +9372,7 @@ function App(): React.JSX.Element {
}}
/>
</div>
<div
className={`composer__toolbar${
runtimeControlsProvider
? " composer__toolbar--with-runtime-controls"
: ""
}`}
>
<div className="composer__toolbar">
<div className="composer__controls">
<div
aria-label={t("composer.addContent")}
@@ -9367,6 +9430,46 @@ function App(): React.JSX.Element {
<Mic aria-hidden="true" size={18} />
</button>
</div>
<button
aria-controls="composer-options"
aria-expanded={composerOptionsOpen}
aria-haspopup="dialog"
aria-label={t("composer.options")}
className="composer__options-trigger"
onClick={() => {
setComposerOptionsOpen(!composerOptionsOpen);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}}
ref={composerOptionsTriggerRef}
title={t("composer.settings")}
type="button"
>
<SlidersHorizontal aria-hidden="true" size={17} />
</button>
<div
aria-label={t("composer.settings")}
className="composer__options"
id="composer-options"
hidden={!composerOptionsOpen}
ref={composerOptionsRef}
role="dialog"
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
if (knowledgeScopeOpen) {
setKnowledgeScopeOpen(false);
knowledgeScopeTriggerRef.current?.focus();
} else {
setComposerOptionsOpen(false);
setComposerMenuOpen(undefined);
composerOptionsTriggerRef.current?.focus();
}
}}
>
<strong>{t("composer.settings")}</strong>
{knowledgeSnapshot.libraries.length > 0 && (
<div
className="knowledge-scope"
@@ -9388,11 +9491,13 @@ function App(): React.JSX.Element {
count: enabledKnowledgeLibraryIds.length,
})}
aria-expanded={knowledgeScopeOpen}
onClick={() =>
onClick={() => {
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(
(current) => !current,
)
}
);
}}
ref={knowledgeScopeTriggerRef}
title={t("composer.knowledge.title")}
type="button"
@@ -9404,6 +9509,11 @@ function App(): React.JSX.Element {
{enabledKnowledgeLibraryIds.length}
</strong>
</span>
<ChevronDown
aria-hidden="true"
className="knowledge-scope__chevron"
size={14}
/>
</button>
{knowledgeScopeOpen && (
<div
@@ -9509,11 +9619,7 @@ function App(): React.JSX.Element {
)}
</div>
)}
<div
aria-label={t("composer.settings")}
className="composer__configuration"
role="group"
>
<div className="composer__configuration">
<ComposerMenuSelect
ariaLabel={t("composer.expertLabel")}
className="composer-picker--expert"
@@ -9528,34 +9634,55 @@ function App(): React.JSX.Element {
options={assistantExpertOptions}
value={selectedExpertId}
/>
<ComposerMenuSelect
ariaLabel={t("composer.modeLabel")}
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
disabled={isRunning}
icon={
effectiveWorkMode === "execute" ? (
<ShieldCheck
aria-hidden="true"
size={15}
</div>
{runtimeControlsProvider && (
<div aria-label={runtimeControlsLabel} className="composer__runtime-toolbar" role="group">
<strong className="composer__runtime-toolbar-label">{runtimeControlsLabel}</strong>
<div className="composer__runtime-controls">
{runtimeAgentControlAvailable && (
<ComposerMenuSelect
ariaLabel={t("composer.runtimeControls.agentLabel")}
className="composer-picker--runtime"
disabled={isRunning}
icon={<TerminalSquare aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "runtime-agent"}
onChange={setSelectedRuntimeAgent}
onOpenChange={setRuntimeAgentMenuOpen}
options={runtimeAgentOptions}
value={selectedRuntimeAgent}
/>
) : (
<CircleHelp
aria-hidden="true"
size={15}
)}
{runtimePresetControlAvailable && (
<ComposerMenuSelect
ariaLabel={t("composer.runtimeControls.presetLabel")}
className="composer-picker--runtime"
disabled={isRunning}
icon={<TerminalSquare aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "runtime-preset"}
onChange={setSelectedContinuePreset}
onOpenChange={setRuntimePresetMenuOpen}
options={runtimePresetOptions}
value={selectedContinuePreset}
/>
)
}
menuOpen={composerMenuOpen === "mode"}
onChange={setWorkMode}
onOpenChange={setModeMenuOpen}
options={workModeOptions}
triggerLabel={
effectiveWorkMode === "execute"
? "Execute"
: "Ask"
}
value={effectiveWorkMode}
/>
)}
{runtimeActionControlAvailable && (
<ComposerMenuSelect
ariaLabel={t("composer.runtimeControls.actionLabel")}
className="composer-picker--runtime-action"
disabled={isRunning}
icon={<TerminalSquare aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "runtime-action"}
onChange={selectRuntimeAction}
onOpenChange={setRuntimeActionMenuOpen}
options={runtimeActionOptions}
value={runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.value ?? ""}
/>
)}
</div>
</div>
)}
</div>
<div className="composer__configuration" role="group" aria-label={t("composer.settings")}>
<div className="runtime-picker">
<button
aria-expanded={runtimeMenuOpen}
@@ -9563,6 +9690,8 @@ function App(): React.JSX.Element {
className="model-button"
disabled={isRunning || runtimeSwitching}
onClick={() => {
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(!runtimeMenuOpen);
}}
@@ -9574,6 +9703,8 @@ function App(): React.JSX.Element {
event.key === " ")
) {
event.preventDefault();
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(true);
}
@@ -9847,6 +9978,24 @@ function App(): React.JSX.Element {
</div>
)}
</div>
<ComposerMenuSelect
ariaLabel={t("composer.modeLabel")}
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
disabled={isRunning}
icon={
effectiveWorkMode === "execute" ? (
<ShieldCheck aria-hidden="true" size={15} />
) : (
<CircleHelp aria-hidden="true" size={15} />
)
}
menuOpen={composerMenuOpen === "mode"}
onChange={setWorkMode}
onOpenChange={setModeMenuOpen}
options={workModeOptions}
triggerLabel={effectiveWorkMode === "execute" ? "Execute" : "Ask"}
value={effectiveWorkMode}
/>
</div>
</div>
<div className="composer__submit-actions">
@@ -9905,92 +10054,6 @@ function App(): React.JSX.Element {
</button>
</div>
</div>
{runtimeControlsProvider && (
<div
aria-label={runtimeControlsLabel}
className="composer__runtime-toolbar"
role="group"
>
<strong className="composer__runtime-toolbar-label">
{runtimeControlsLabel}
</strong>
<div className="composer__runtime-controls">
{runtimeAgentControlAvailable && (
<ComposerMenuSelect
ariaLabel={t(
"composer.runtimeControls.agentLabel",
)}
className="composer-picker--runtime"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === "runtime-agent"
}
onChange={setSelectedRuntimeAgent}
onOpenChange={setRuntimeAgentMenuOpen}
options={runtimeAgentOptions}
value={selectedRuntimeAgent}
/>
)}
{runtimePresetControlAvailable && (
<ComposerMenuSelect
ariaLabel={t(
"composer.runtimeControls.presetLabel",
)}
className="composer-picker--runtime"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === "runtime-preset"
}
onChange={setSelectedContinuePreset}
onOpenChange={setRuntimePresetMenuOpen}
options={runtimePresetOptions}
value={selectedContinuePreset}
/>
)}
{runtimeActionControlAvailable && (
<ComposerMenuSelect
ariaLabel={t(
"composer.runtimeControls.actionLabel",
)}
className="composer-picker--runtime-action"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === "runtime-action"
}
onChange={selectRuntimeAction}
onOpenChange={setRuntimeActionMenuOpen}
options={runtimeActionOptions}
value={
runtimeActionOptions.find(
(option) =>
option.action?.type === "command" &&
option.action.id ===
selectedRuntimeCommand,
)?.value ?? ""
}
/>
)}
</div>
</div>
)}
</div>
<div
className={`composer-meta${
@@ -403,12 +403,12 @@ describe('WorkspacePrimitives', () => {
)
})
it('separates Runtime-specific controls from the main composer toolbar', () => {
it('keeps composer options bounded with inline nested menus', () => {
expect(stylesheet).toMatch(
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
/\.composer__options\s*\{[^}]*width:\s*min\(100%, 300px\);[^}]*max-height:\s*min\(480px, 60vh\);[^}]*overflow-y:\s*auto;/u
)
expect(stylesheet).toMatch(
/\.composer__runtime-toolbar\s*\{[^}]*display:\s*flex;[^}]*min-height:\s*52px;[^}]*border-top:\s*1px solid var\(--border-default\);[^}]*background:\s*var\(--surface-muted\);/u
/\.composer__options :is\(\.runtime-picker__menu, \.knowledge-scope__popover\)\s*\{[^}]*position:\s*static;[^}]*width:\s*100%;/u
)
expect(stylesheet).toMatch(
/\.composer__runtime-controls\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;/u
@@ -417,7 +417,7 @@ describe('WorkspacePrimitives', () => {
/\.composer-picker--runtime > \.model-button,\s*\.composer-picker--runtime-action > \.model-button\s*\{[^}]*width:\s*220px;/u
)
expect(stylesheet).toMatch(
/\.composer__runtime-controls \.composer-picker\s*\{[^}]*flex-basis:\s*220px;/u
/\.composer__options\[hidden\]\s*\{[^}]*display:\s*none;/u
)
})
@@ -450,6 +450,7 @@ export const app = {
},
removeAttachment: 'Remove {{name}}',
settings: 'Conversation settings',
options: 'Options',
expertLabel: 'Expert role',
modeLabel: 'Work mode',
runtimeControls: {
@@ -430,6 +430,7 @@ export const app = {
},
removeAttachment: '移除 {{name}}',
settings: '对话设置',
options: '选项',
expertLabel: '专家角色',
modeLabel: '工作模式',
runtimeControls: {
+121 -44
View File
@@ -7187,41 +7187,131 @@ button > svg {
}
.composer__toolbar {
position: relative;
display: flex;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 56px;
padding: 10px var(--space-3);
min-height: 48px;
padding: var(--space-2) var(--space-3);
align-items: center;
border-top: 1px solid var(--border-default);
border-radius: 0 0 calc(var(--radius-card) - 1px)
calc(var(--radius-card) - 1px);
background: var(--surface-subtle);
gap: var(--space-3);
background: transparent;
flex-wrap: wrap;
gap: var(--space-2);
}
.composer__toolbar--with-runtime-controls {
border-radius: 0;
.composer__option-summary {
padding: var(--space-3) var(--space-4) 0;
color: var(--text-secondary);
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
.composer__options-trigger {
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
flex: 0 0 auto;
}
.composer__options-trigger:hover,
.composer__options-trigger[aria-expanded="true"] {
background: var(--accent-subtle);
color: var(--accent);
}
.composer__options {
position: absolute;
z-index: 25;
left: 0;
bottom: calc(100% + var(--space-2));
display: grid;
width: min(100%, 300px);
max-height: min(480px, 60vh);
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
overflow-y: auto;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-2);
}
.composer__options > strong {
padding: var(--space-1) var(--space-2);
color: var(--text-secondary);
font-size: var(--font-caption);
}
.composer__options[hidden] {
display: none;
}
.composer__options :is(.runtime-picker__menu, .knowledge-scope__popover) {
position: static;
width: 100%;
max-height: none;
margin-top: var(--space-2);
box-shadow: none;
}
.composer__options .composer-picker,
.composer__options .composer-picker > .model-button {
width: 100%;
}
.composer__options :is(.composer-picker > .model-button, .knowledge-scope > button) {
width: 100%;
min-height: 36px;
padding: 0 var(--space-2);
border-color: transparent;
background: transparent;
font-size: var(--font-body);
font-weight: 400;
gap: var(--space-2);
}
.composer__options :is(.composer-picker > .model-button, .knowledge-scope > button):hover:not(:disabled),
.composer__options :is(.composer-picker > .model-button, .knowledge-scope > button)[aria-expanded="true"] {
border-color: transparent;
background: var(--accent-subtle);
color: var(--accent);
}
.composer__options .composer__runtime-toolbar-label {
padding: var(--space-1) var(--space-2);
font-weight: 500;
}
.knowledge-scope__chevron {
margin-left: auto;
}
.knowledge-scope > button[aria-expanded="true"] > .knowledge-scope__chevron {
transform: rotate(180deg);
}
.composer__runtime-toolbar {
display: flex;
display: grid;
min-width: 0;
min-height: 52px;
padding: var(--space-2) var(--space-3);
padding-top: var(--space-2);
align-items: center;
border-top: 1px solid var(--border-default);
border-radius: 0 0 calc(var(--radius-card) - 1px)
calc(var(--radius-card) - 1px);
background: var(--surface-muted);
gap: var(--space-3);
border-top: 1px solid var(--border-subtle);
gap: var(--space-2);
}
.composer__runtime-toolbar-label {
min-width: 0;
padding-right: var(--space-3);
border-right: 1px solid var(--border-default);
color: var(--text-secondary);
font-size: var(--font-caption);
font-weight: 650;
@@ -7242,8 +7332,8 @@ button > svg {
min-width: 0;
flex: 1;
align-items: center;
flex-wrap: nowrap;
gap: var(--space-3);
flex-wrap: wrap;
gap: var(--space-2);
}
.composer__tool-group,
@@ -7319,8 +7409,7 @@ button > svg {
.composer__configuration {
min-width: 0;
padding-left: var(--space-3);
border-left: 1px solid var(--border-default);
flex-wrap: wrap;
}
.composer-picker--expert > .model-button {
@@ -7654,38 +7743,26 @@ button > svg {
flex-wrap: wrap;
}
.composer__controls,
.composer__configuration {
width: 100%;
padding-left: 0;
padding-top: 10px;
border-top: 1px solid var(--border-default);
border-left: 0;
display: contents;
}
.composer-picker,
.runtime-picker {
flex: 1 1 150px;
.composer__submit-actions {
margin-left: auto;
}
.composer__runtime-toolbar {
align-items: stretch;
flex-direction: column;
.composer__toolbar > .composer__controls .runtime-picker__menu {
max-width: 100%;
}
.composer__runtime-toolbar-label {
padding-right: 0;
padding-bottom: var(--space-2);
border-right: 0;
border-bottom: 1px solid var(--border-default);
.composer__configuration > .runtime-picker {
position: static;
}
.composer__runtime-controls .composer-picker {
flex-basis: 220px;
}
.composer-picker > .model-button,
.model-button {
width: 100%;
.composer__configuration > .composer-picker > .composer-picker__menu {
right: 0;
left: auto;
}
.composer-context-meter {