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:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user