feat: surface expert and runtime controls in composer toolbar
This commit is contained in:
@@ -1146,14 +1146,18 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
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: "选项" });
|
||||
const trigger = screen.getByRole("button", { name: "故事图谱与知识库" });
|
||||
if (trigger.getAttribute("aria-expanded") !== "true") fireEvent.click(trigger);
|
||||
}
|
||||
|
||||
async function openRuntimeOptions(runtime = "OpenCode"): Promise<void> {
|
||||
const trigger = await screen.findByRole("button", { name: `${runtime} 专属功能` });
|
||||
if (trigger.getAttribute("aria-expanded") !== "true") fireEvent.click(trigger);
|
||||
}
|
||||
|
||||
@@ -6873,6 +6877,7 @@ describe("App", () => {
|
||||
evidence: [],
|
||||
});
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: "故事图谱与知识库" });
|
||||
openComposerOptions();
|
||||
const knowledgeScopeTrigger = await screen.findByRole("button", {
|
||||
name: "选择知识库,本次已启用 0 个",
|
||||
@@ -7101,6 +7106,7 @@ describe("App", () => {
|
||||
evidence: [],
|
||||
});
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: "故事图谱与知识库" });
|
||||
openComposerOptions();
|
||||
const knowledgeScope = await screen.findByRole("button", {
|
||||
name: "选择知识库,本次已启用 0 个",
|
||||
@@ -8656,7 +8662,6 @@ describe("App", () => {
|
||||
|
||||
it("matches the expert keyboard menu to the model picker", async () => {
|
||||
render(<App />);
|
||||
openComposerOptions();
|
||||
|
||||
const expertTrigger = await screen.findByRole("button", {
|
||||
name: "专家角色:通用助手",
|
||||
@@ -8683,15 +8688,16 @@ 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();
|
||||
fireEvent.click(optionsTrigger);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "专家角色:通用助手" })).toHaveFocus());
|
||||
const contextTrigger = screen.getByRole("button", { name: "故事图谱与知识库" });
|
||||
fireEvent.click(contextTrigger);
|
||||
expect(screen.getByRole("dialog", { name: "故事图谱与知识库" })).toBeVisible();
|
||||
await waitFor(() => expect(screen.getByRole("switch", { name: "使用故事图谱" })).toHaveFocus());
|
||||
fireEvent.keyDown(screen.getByRole("switch", { name: "使用故事图谱" }), { key: "Escape" });
|
||||
expect(contextTrigger).toHaveFocus();
|
||||
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(contextTrigger);
|
||||
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
|
||||
expect(optionsTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(
|
||||
screen.queryByRole("menu", { name: "工作模式" }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -8731,8 +8737,6 @@ 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: "添加内容",
|
||||
@@ -8747,12 +8751,13 @@ describe("App", () => {
|
||||
const conversationSettings = within(composer!).getByRole("group", {
|
||||
name: "对话设置",
|
||||
});
|
||||
openComposerOptions();
|
||||
expect(
|
||||
within(screen.getByRole("dialog", { name: "对话设置" })).getByRole("button", {
|
||||
name: "专家角色:通用助手",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
// Order: context sources, expert, Runtime, then Runtime-specific settings.
|
||||
const contextTrigger = within(conversationSettings).getByRole("button", { name: "故事图谱与知识库" });
|
||||
const expertTrigger = within(conversationSettings).getByRole("button", { name: "专家角色:通用助手" });
|
||||
const runtimeTrigger = within(conversationSettings).getByRole("button", { name: /默认模型/u });
|
||||
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(contextTrigger.compareDocumentPosition(expertTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(expertTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(
|
||||
within(conversationSettings).queryByRole("button", {
|
||||
name: /工作模式/u,
|
||||
@@ -10311,14 +10316,14 @@ describe("App", () => {
|
||||
));
|
||||
// Let startup selection and its focus effects finish before opening these options.
|
||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
const agentPicker = await screen.findByRole("button", {
|
||||
name: /OpenCode Runtime Agent/u,
|
||||
});
|
||||
const runtimeToolbar = screen.getByRole("group", {
|
||||
name: "OpenCode 专属功能",
|
||||
});
|
||||
const options = screen.getByRole("dialog", { name: "对话设置" });
|
||||
const options = screen.getByRole("dialog", { name: "OpenCode 专属功能" });
|
||||
expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar");
|
||||
expect(runtimeToolbar).toContainElement(agentPicker);
|
||||
expect(options).toContainElement(agentPicker);
|
||||
@@ -10346,7 +10351,7 @@ describe("App", () => {
|
||||
target: { value: "src/main" },
|
||||
});
|
||||
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
|
||||
expect(screen.queryByRole("dialog", { name: "对话设置" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "OpenCode 专属功能" })).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".composer__option-summary")).toHaveTextContent("Planner");
|
||||
expect(document.querySelector(".composer__option-summary")).toHaveTextContent("/review");
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
@@ -10468,7 +10473,7 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
await screen.findByText("已就绪");
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
const runtimeToolbar = await screen.findByRole("group", {
|
||||
name: "OpenCode 专属功能",
|
||||
});
|
||||
@@ -10536,7 +10541,7 @@ describe("App", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
openComposerOptions();
|
||||
expect(screen.queryByRole("button", { name: "OpenCode 专属功能" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /OpenCode Runtime Agent/u })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
retry.resolve({
|
||||
@@ -10548,6 +10553,7 @@ describe("App", () => {
|
||||
});
|
||||
await retry.promise;
|
||||
});
|
||||
await openRuntimeOptions();
|
||||
fireEvent.click(screen.getByRole("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" });
|
||||
@@ -10581,7 +10587,7 @@ describe("App", () => {
|
||||
.mockResolvedValueOnce(healthy)
|
||||
.mockResolvedValueOnce(healthy);
|
||||
render(<App />);
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
await screen.findByRole("button", { name: /OpenCode Runtime Agent/u });
|
||||
selectProjectOption(secondProject.name);
|
||||
await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenLastCalledWith(
|
||||
@@ -10596,7 +10602,7 @@ describe("App", () => {
|
||||
selectProjectOption(firstProject.name);
|
||||
// Let the project picker restore focus before opening the dismiss-on-blur options.
|
||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
|
||||
await act(async () => {
|
||||
pending.resolve(failed);
|
||||
@@ -10615,9 +10621,7 @@ describe("App", () => {
|
||||
vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce(empty);
|
||||
selectProjectOption(firstProject.name);
|
||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||
openComposerOptions();
|
||||
expect(screen.getByRole("button", { name: "选项" })).toHaveAttribute("aria-expanded", "true");
|
||||
await waitFor(() => expect(screen.queryByRole("group", { name: "OpenCode 专属功能" })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "OpenCode 专属功能" })).not.toBeInTheDocument());
|
||||
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 4);
|
||||
});
|
||||
|
||||
@@ -10652,7 +10656,7 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole("button", { name: new RegExp(`^${rows[0]!.title}`, "u") });
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions();
|
||||
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 }));
|
||||
@@ -10660,7 +10664,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();
|
||||
await openRuntimeOptions();
|
||||
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"));
|
||||
@@ -10744,7 +10748,7 @@ describe("App", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
openComposerOptions();
|
||||
await openRuntimeOptions("Continue");
|
||||
const presetPicker = await screen.findByRole("button", {
|
||||
name: /Continue 配置预设.*使用设置默认预设/u,
|
||||
});
|
||||
@@ -10754,7 +10758,7 @@ describe("App", () => {
|
||||
expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar");
|
||||
expect(runtimeToolbar).toContainElement(presetPicker);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "对话设置" }),
|
||||
screen.getByRole("dialog", { name: "Continue 专属功能" }),
|
||||
).toContainElement(presetPicker);
|
||||
fireEvent.click(presetPicker);
|
||||
fireEvent.click(
|
||||
|
||||
@@ -98,13 +98,14 @@ afterEach(() => {
|
||||
describe("Composer render boundary", () => {
|
||||
it("hides the story graph switch with Supervisor off and keeps conversation state while saving or failing", async () => {
|
||||
const { props, rerender, store } = setup();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Options" }));
|
||||
expect(screen.queryByRole("switch", { name: "Use story graph" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Story graph and knowledge" })).toBeNull();
|
||||
expect(screen.queryByRole("switch", { name: "Use story graph", hidden: true })).toBeNull();
|
||||
let finish!: () => void;
|
||||
const save = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
|
||||
const notify = vi.fn();
|
||||
const next = { ...props, supervisorEnabled: true, actions: { ...actions, setStoryGraphEnabled: save, notify } };
|
||||
rerender(<Composer {...next} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Story graph and knowledge" }));
|
||||
const toggle = screen.getByRole("switch", { name: "Use story graph" });
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle.closest("label")).toHaveClass("toggle-row");
|
||||
@@ -121,7 +122,7 @@ describe("Composer render boundary", () => {
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => { expect(toggle).toBeEnabled(); expect(toggle).not.toBeChecked(); expect(notify).toHaveBeenCalledWith({ tone: "error", message: "Save failed" }); });
|
||||
rerender(<Composer {...next} conversationId="b" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Options" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Story graph and knowledge" }));
|
||||
expect(screen.getByRole("switch", { name: "Use story graph" })).toBeChecked();
|
||||
rerender(<Composer {...next} supervisorEnabled={false} />);
|
||||
expect(screen.queryByRole("switch", { name: "Use story graph", hidden: true })).toBeNull();
|
||||
|
||||
+187
-102
@@ -208,11 +208,16 @@ export const Composer = memo(function Composer({
|
||||
`${conversationId}\u0000${isRunning}\u0000${workspaceView}`,
|
||||
);
|
||||
const composerOptionsOpen = menus.optionsOpen;
|
||||
const runtimeOptionsOpen = menus.runtimeOptionsOpen;
|
||||
const knowledgeScopeOpen = menus.knowledgeScopeOpen;
|
||||
const composerMenuOpen = menus.menu;
|
||||
const runtimeMenuOpen = menus.runtimeMenuOpen;
|
||||
const setComposerOptionsOpen = useCallback(
|
||||
(open: boolean): void => setMenus({ optionsOpen: open }),
|
||||
const closeComposerOptions = useCallback(
|
||||
(): void => setMenus({ optionsOpen: false, knowledgeScopeOpen: false }),
|
||||
[setMenus],
|
||||
);
|
||||
const closeRuntimeOptions = useCallback(
|
||||
(): void => setMenus({ runtimeOptionsOpen: false, menu: undefined }),
|
||||
[setMenus],
|
||||
);
|
||||
const setKnowledgeScopeOpen = useCallback(
|
||||
@@ -233,7 +238,13 @@ export const Composer = memo(function Composer({
|
||||
);
|
||||
const setExpertMenuOpen = useCallback((open: boolean): void => {
|
||||
setMenus(open
|
||||
? { menu: "expert", runtimeMenuOpen: false, knowledgeScopeOpen: false }
|
||||
? {
|
||||
menu: "expert",
|
||||
runtimeMenuOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
optionsOpen: false,
|
||||
runtimeOptionsOpen: false,
|
||||
}
|
||||
: { menu: undefined });
|
||||
}, [setMenus]);
|
||||
const setRuntimeAgentMenuOpen = useCallback((open: boolean): void => {
|
||||
@@ -253,6 +264,8 @@ export const Composer = memo(function Composer({
|
||||
}, [setMenus]);
|
||||
const composerOptionsRef = useRef<HTMLDivElement>(null);
|
||||
const composerOptionsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const runtimeOptionsRef = useRef<HTMLDivElement>(null);
|
||||
const runtimeOptionsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null);
|
||||
const runtimeMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -308,32 +321,18 @@ export const Composer = memo(function Composer({
|
||||
};
|
||||
}, [knowledgeScopeOpen, setKnowledgeScopeOpen]);
|
||||
|
||||
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)) {
|
||||
setMenus({ optionsOpen: false, knowledgeScopeOpen: false });
|
||||
}
|
||||
};
|
||||
const frame = requestAnimationFrame(() => {
|
||||
// Keyboard users land on the same first control as the model picker
|
||||
// and expert menu; optional switches above stay Tab-reachable.
|
||||
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, setMenus]);
|
||||
useDismissibleComposerDialog(
|
||||
composerOptionsOpen,
|
||||
composerOptionsRef,
|
||||
composerOptionsTriggerRef,
|
||||
closeComposerOptions,
|
||||
);
|
||||
useDismissibleComposerDialog(
|
||||
runtimeOptionsOpen,
|
||||
runtimeOptionsRef,
|
||||
runtimeOptionsTriggerRef,
|
||||
closeRuntimeOptions,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtimeMenuOpen) {
|
||||
@@ -406,10 +405,9 @@ export const Composer = memo(function Composer({
|
||||
runtime: runtimeControlsProvider,
|
||||
})
|
||||
: "";
|
||||
const composerContextAvailable =
|
||||
supervisorEnabled === true || knowledgeLibraries.length > 0;
|
||||
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,
|
||||
@@ -809,26 +807,32 @@ export const Composer = memo(function Composer({
|
||||
<Mic aria-hidden="true" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="composer__configuration" role="group" aria-label={t("composer.settings")}>
|
||||
{composerContextAvailable && (
|
||||
<>
|
||||
<button
|
||||
aria-controls="composer-options"
|
||||
aria-expanded={composerOptionsOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={t("composer.options")}
|
||||
aria-label={t("composer.contextSources")}
|
||||
className="composer__options-trigger"
|
||||
onClick={() => {
|
||||
setComposerOptionsOpen(!composerOptionsOpen);
|
||||
setComposerMenuOpen(undefined);
|
||||
setRuntimeMenuOpen(false);
|
||||
setKnowledgeScopeOpen(false);
|
||||
setMenus({
|
||||
optionsOpen: !composerOptionsOpen,
|
||||
runtimeOptionsOpen: false,
|
||||
menu: undefined,
|
||||
runtimeMenuOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
});
|
||||
}}
|
||||
ref={composerOptionsTriggerRef}
|
||||
title={t("composer.settings")}
|
||||
title={t("composer.contextSources")}
|
||||
type="button"
|
||||
>
|
||||
<SlidersHorizontal aria-hidden="true" size={18} />
|
||||
<Library aria-hidden="true" size={18} />
|
||||
</button>
|
||||
<div
|
||||
aria-label={t("composer.settings")}
|
||||
aria-label={t("composer.contextSources")}
|
||||
className="composer__options"
|
||||
id="composer-options"
|
||||
hidden={!composerOptionsOpen}
|
||||
@@ -842,15 +846,14 @@ export const Composer = memo(function Composer({
|
||||
setKnowledgeScopeOpen(false);
|
||||
knowledgeScopeTriggerRef.current?.focus();
|
||||
} else {
|
||||
setComposerOptionsOpen(false);
|
||||
setComposerMenuOpen(undefined);
|
||||
closeComposerOptions();
|
||||
composerOptionsTriggerRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<strong>{t("composer.settings")}</strong>
|
||||
<strong>{t("composer.contextSources")}</strong>
|
||||
{supervisorEnabled && (
|
||||
<label className="toggle-row">
|
||||
<label className="toggle-row composer__context-row">
|
||||
<span className="composer__story-graph-label">
|
||||
<Network aria-hidden="true" size={16} />
|
||||
{t("composer.storyGraph.label")}
|
||||
@@ -1009,7 +1012,9 @@ export const Composer = memo(function Composer({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="composer__configuration">
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.expertLabel")}
|
||||
className="composer-picker--expert"
|
||||
@@ -1024,55 +1029,6 @@ export const Composer = memo(function Composer({
|
||||
options={assistantExpertOptions}
|
||||
value={selectedExpertId}
|
||||
/>
|
||||
</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={actions.selectRuntimeAgent}
|
||||
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={actions.selectContinuePreset}
|
||||
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={actions.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}
|
||||
@@ -1080,10 +1036,13 @@ export const Composer = memo(function Composer({
|
||||
className="model-button"
|
||||
disabled={isRunning || runtimeSwitching}
|
||||
onClick={() => {
|
||||
setComposerOptionsOpen(false);
|
||||
setKnowledgeScopeOpen(false);
|
||||
setComposerMenuOpen(undefined);
|
||||
setRuntimeMenuOpen(!runtimeMenuOpen);
|
||||
setMenus({
|
||||
optionsOpen: false,
|
||||
runtimeOptionsOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
menu: undefined,
|
||||
runtimeMenuOpen: !runtimeMenuOpen,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
@@ -1093,10 +1052,13 @@ export const Composer = memo(function Composer({
|
||||
event.key === " ")
|
||||
) {
|
||||
event.preventDefault();
|
||||
setComposerOptionsOpen(false);
|
||||
setKnowledgeScopeOpen(false);
|
||||
setComposerMenuOpen(undefined);
|
||||
setRuntimeMenuOpen(true);
|
||||
setMenus({
|
||||
optionsOpen: false,
|
||||
runtimeOptionsOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
menu: undefined,
|
||||
runtimeMenuOpen: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
ref={runtimeMenuButtonRef}
|
||||
@@ -1143,6 +1105,91 @@ export const Composer = memo(function Composer({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{runtimeControlsProvider && (
|
||||
<>
|
||||
<button
|
||||
aria-controls="composer-runtime-options"
|
||||
aria-expanded={runtimeOptionsOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={runtimeControlsLabel}
|
||||
className="composer__options-trigger"
|
||||
onClick={() => {
|
||||
setMenus({
|
||||
runtimeOptionsOpen: !runtimeOptionsOpen,
|
||||
optionsOpen: false,
|
||||
menu: undefined,
|
||||
runtimeMenuOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
});
|
||||
}}
|
||||
ref={runtimeOptionsTriggerRef}
|
||||
title={runtimeControlsLabel}
|
||||
type="button"
|
||||
>
|
||||
<SlidersHorizontal aria-hidden="true" size={18} />
|
||||
</button>
|
||||
<div
|
||||
aria-label={runtimeControlsLabel}
|
||||
className="composer__options composer__options--runtime"
|
||||
id="composer-runtime-options"
|
||||
hidden={!runtimeOptionsOpen}
|
||||
ref={runtimeOptionsRef}
|
||||
role="dialog"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeRuntimeOptions();
|
||||
runtimeOptionsTriggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<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={actions.selectRuntimeAgent}
|
||||
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={actions.selectContinuePreset}
|
||||
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={actions.selectRuntimeAction}
|
||||
onOpenChange={setRuntimeActionMenuOpen}
|
||||
options={runtimeActionOptions}
|
||||
value={runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.value ?? ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="composer__submit-actions">
|
||||
@@ -1378,3 +1425,41 @@ export const Composer = memo(function Composer({
|
||||
});
|
||||
|
||||
const emptyLibraryIds: string[] = [];
|
||||
|
||||
/**
|
||||
* Closes a composer toolbar dialog when pointer or focus leaves it and its
|
||||
* trigger, and moves keyboard focus to its first enabled control on open.
|
||||
*/
|
||||
function useDismissibleComposerDialog(
|
||||
open: boolean,
|
||||
dialogRef: RefObject<HTMLElement | null>,
|
||||
triggerRef: RefObject<HTMLElement | null>,
|
||||
close: () => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const isDialogTarget = (target: EventTarget | null): boolean =>
|
||||
target instanceof Node &&
|
||||
(dialogRef.current?.contains(target) === true ||
|
||||
triggerRef.current?.contains(target) === true);
|
||||
const dismissOutside = (event: Event): void => {
|
||||
if (!isDialogTarget(event.target)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
const frame = requestAnimationFrame(() => {
|
||||
dialogRef.current
|
||||
?.querySelector<HTMLElement>("button:not(:disabled), input:not(:disabled)")
|
||||
?.focus();
|
||||
});
|
||||
document.addEventListener("pointerdown", dismissOutside);
|
||||
document.addEventListener("focusin", dismissOutside);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener("pointerdown", dismissOutside);
|
||||
document.removeEventListener("focusin", dismissOutside);
|
||||
};
|
||||
}, [close, dialogRef, open, triggerRef]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* Open/closed state of the composer's popups (options dialog, knowledge
|
||||
* scope, the pickers and the runtime menu). It lives outside App so opening a
|
||||
* Open/closed state of the composer's popups (context dialog, runtime
|
||||
* controls dialog, knowledge scope, the pickers and the runtime menu). It lives outside App so opening a
|
||||
* menu re-renders only the composer.
|
||||
*
|
||||
* The state belongs to one composer context (conversation, running state and
|
||||
@@ -17,6 +17,7 @@ export type ComposerMenuId =
|
||||
|
||||
export type ComposerMenuState = {
|
||||
readonly optionsOpen: boolean;
|
||||
readonly runtimeOptionsOpen: boolean;
|
||||
readonly knowledgeScopeOpen: boolean;
|
||||
readonly menu: ComposerMenuId | undefined;
|
||||
readonly runtimeMenuOpen: boolean;
|
||||
@@ -24,6 +25,7 @@ export type ComposerMenuState = {
|
||||
|
||||
export const closedComposerMenus: ComposerMenuState = Object.freeze({
|
||||
optionsOpen: false,
|
||||
runtimeOptionsOpen: false,
|
||||
knowledgeScopeOpen: false,
|
||||
menu: undefined,
|
||||
runtimeMenuOpen: false,
|
||||
@@ -38,6 +40,7 @@ export type ComposerMenuStore = ReturnType<typeof createComposerMenuStore>;
|
||||
function sameMenus(left: ComposerMenuState, right: ComposerMenuState): boolean {
|
||||
return (
|
||||
left.optionsOpen === right.optionsOpen &&
|
||||
left.runtimeOptionsOpen === right.runtimeOptionsOpen &&
|
||||
left.knowledgeScopeOpen === right.knowledgeScopeOpen &&
|
||||
left.menu === right.menu &&
|
||||
left.runtimeMenuOpen === right.runtimeMenuOpen
|
||||
@@ -78,7 +81,7 @@ export function createComposerMenuStore() {
|
||||
},
|
||||
/** Closes the pickers and the runtime menu of whatever context is current. */
|
||||
closeMenus(): void {
|
||||
store.update(key, { menu: undefined, runtimeMenuOpen: false });
|
||||
store.update(key, { menu: undefined, runtimeMenuOpen: false, runtimeOptionsOpen: false });
|
||||
},
|
||||
closeRuntimeMenu(): void {
|
||||
store.update(key, { runtimeMenuOpen: false });
|
||||
|
||||
@@ -546,6 +546,7 @@ export const app = {
|
||||
removeAttachment: 'Remove {{name}}',
|
||||
settings: 'Conversation settings',
|
||||
storyGraph: { label: 'Use story graph' },
|
||||
contextSources: 'Story graph and knowledge',
|
||||
options: 'Options',
|
||||
expertLabel: 'Expert role',
|
||||
runtimeControls: {
|
||||
|
||||
@@ -528,6 +528,7 @@ export const app = {
|
||||
removeAttachment: '移除 {{name}}',
|
||||
settings: '对话设置',
|
||||
storyGraph: { label: '使用故事图谱' },
|
||||
contextSources: '故事图谱与知识库',
|
||||
options: '选项',
|
||||
expertLabel: '专家角色',
|
||||
runtimeControls: {
|
||||
|
||||
@@ -8511,6 +8511,42 @@ button > svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Story graph and knowledge rows share one row style in the context dialog. */
|
||||
.composer__options .composer__context-row {
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
font-weight: 400;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.composer__options .composer__context-row:hover:not(:has(input:disabled)) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.composer__options .composer__story-graph-label,
|
||||
.composer__options .knowledge-scope > button > span {
|
||||
gap: var(--space-2);
|
||||
color: inherit;
|
||||
font-size: var(--font-body);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.composer__options .knowledge-scope > button {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.composer__options .knowledge-scope > button > span {
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.composer__options .knowledge-scope__chevron {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.composer__options .composer__runtime-toolbar-label {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-weight: 500;
|
||||
@@ -8637,10 +8673,16 @@ button > svg {
|
||||
}
|
||||
|
||||
.composer__configuration {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.composer__options--runtime {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.composer-picker--expert > .model-button {
|
||||
width: 148px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user