feat: surface expert and runtime controls in composer toolbar

This commit is contained in:
mesalogo
2026-10-08 15:55:27 +08:00
parent 4266ff5bea
commit 13b58627c0
7 changed files with 460 additions and 323 deletions
+37 -33
View File
@@ -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(
+4 -3
View File
@@ -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();
+369 -284
View File
@@ -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,207 +807,214 @@ export const Composer = memo(function Composer({
<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={18} />
</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>
{supervisorEnabled && (
<label className="toggle-row">
<span className="composer__story-graph-label">
<Network aria-hidden="true" size={16} />
{t("composer.storyGraph.label")}
</span>
<input
type="checkbox"
role="switch"
checked={view?.storyGraphEnabled !== false}
disabled={storyGraphSaving}
onChange={(event) => {
setStoryGraphSaving(true);
void actions.setStoryGraphEnabled(conversationId, event.target.checked)
.catch((error: unknown) => actions.notify({ tone: "error", message: error instanceof Error ? error.message : String(error) }))
.finally(() => setStoryGraphSaving(false));
<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.contextSources")}
className="composer__options-trigger"
onClick={() => {
setMenus({
optionsOpen: !composerOptionsOpen,
runtimeOptionsOpen: false,
menu: undefined,
runtimeMenuOpen: false,
knowledgeScopeOpen: false,
});
}}
/>
</label>
)}
{knowledgeLibraries.length > 0 && (
<div
className="knowledge-scope"
onBlurCapture={(event) => {
if (
event.relatedTarget instanceof Node &&
!event.currentTarget.contains(
event.relatedTarget,
)
) {
setKnowledgeScopeOpen(false);
}
}}
>
<button
aria-controls="knowledge-scope-popover"
aria-haspopup="dialog"
aria-label={t("composer.knowledge.select", {
count: enabledKnowledgeLibraryIds.length,
})}
aria-expanded={knowledgeScopeOpen}
onClick={() => {
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(
(current) => !current,
);
}}
ref={knowledgeScopeTriggerRef}
title={t("composer.knowledge.title")}
type="button"
>
<Library aria-hidden="true" size={16} />
<span>
{t("navigation.knowledge")}
<strong>
{enabledKnowledgeLibraryIds.length}
</strong>
</span>
<ChevronDown
aria-hidden="true"
className="knowledge-scope__chevron"
size={14}
/>
</button>
{knowledgeScopeOpen && (
<div
aria-label={t("composer.knowledge.scope")}
className="knowledge-scope__popover"
id="knowledge-scope-popover"
ref={knowledgeScopePopoverRef}
role="dialog"
ref={composerOptionsTriggerRef}
title={t("composer.contextSources")}
type="button"
>
<strong>
{t("composer.knowledge.scope")}
</strong>
{knowledgeLibraries.map(
(library) => {
const instance = externalInstances.find(item => item.id === library.external?.instanceId);
const externalStatus = !instance ? 'temporarily-unavailable' : !instance.enabled ? 'instance-disabled' : instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed' ? 'credential-error' : ['failed', 'unreachable'].includes(instance.probeStatus) ? 'temporarily-unavailable' : 'ready';
return (
<label key={library.id}>
<input
disabled={!!library.external && externalStatus !== 'ready'}
checked={enabledKnowledgeLibraryIds.includes(
library.id,
)}
onChange={(event) =>
actions.setEnabledKnowledgeLibraryIds(
(current) =>
event.target.checked
? [
...new Set([
...current,
library.id,
]),
]
: current.filter(
(id) =>
id !== library.id,
),
)
}
type="checkbox"
/>
<span>{library.name}</span>
<small>
{library.external ? `${({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[library.external.provider]} · ${instance?.name ?? library.external.instanceId} · ${t(`external.states.${externalStatus}`, { ns: 'knowledge' })}` : t(
"composer.knowledge.documents",
{
count: library.documentCount,
},
)}
</small>
</label>
)},
<Library aria-hidden="true" size={18} />
</button>
<div
aria-label={t("composer.contextSources")}
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 {
closeComposerOptions();
composerOptionsTriggerRef.current?.focus();
}
}}
>
<strong>{t("composer.contextSources")}</strong>
{supervisorEnabled && (
<label className="toggle-row composer__context-row">
<span className="composer__story-graph-label">
<Network aria-hidden="true" size={16} />
{t("composer.storyGraph.label")}
</span>
<input
type="checkbox"
role="switch"
checked={view?.storyGraphEnabled !== false}
disabled={storyGraphSaving}
onChange={(event) => {
setStoryGraphSaving(true);
void actions.setStoryGraphEnabled(conversationId, event.target.checked)
.catch((error: unknown) => actions.notify({ tone: "error", message: error instanceof Error ? error.message : String(error) }))
.finally(() => setStoryGraphSaving(false));
}}
/>
</label>
)}
<div className="knowledge-scope__retrieval-mode">
<strong>
{t("composer.knowledge.modeLabel")}
</strong>
<SegmentedControl
ariaLabel={t(
"composer.knowledge.modeLabel",
)}
onChange={actions.setKnowledgeRetrievalMode}
options={[
{
value: "auto",
label: t(
"composer.knowledge.auto",
),
},
{
value: "always",
label: t(
"composer.knowledge.always",
),
},
]}
value={
view?.knowledgeRetrievalMode ??
"auto"
}
/>
<small>
{view?.knowledgeRetrievalMode ===
"always"
? t(
"composer.knowledge.alwaysDescription",
{knowledgeLibraries.length > 0 && (
<div
className="knowledge-scope"
onBlurCapture={(event) => {
if (
event.relatedTarget instanceof Node &&
!event.currentTarget.contains(
event.relatedTarget,
)
: t(
"composer.knowledge.autoDescription",
) {
setKnowledgeScopeOpen(false);
}
}}
>
<button
aria-controls="knowledge-scope-popover"
aria-haspopup="dialog"
aria-label={t("composer.knowledge.select", {
count: enabledKnowledgeLibraryIds.length,
})}
aria-expanded={knowledgeScopeOpen}
onClick={() => {
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(
(current) => !current,
);
}}
ref={knowledgeScopeTriggerRef}
title={t("composer.knowledge.title")}
type="button"
>
<Library aria-hidden="true" size={16} />
<span>
{t("navigation.knowledge")}
<strong>
{enabledKnowledgeLibraryIds.length}
</strong>
</span>
<ChevronDown
aria-hidden="true"
className="knowledge-scope__chevron"
size={14}
/>
</button>
{knowledgeScopeOpen && (
<div
aria-label={t("composer.knowledge.scope")}
className="knowledge-scope__popover"
id="knowledge-scope-popover"
ref={knowledgeScopePopoverRef}
role="dialog"
>
<strong>
{t("composer.knowledge.scope")}
</strong>
{knowledgeLibraries.map(
(library) => {
const instance = externalInstances.find(item => item.id === library.external?.instanceId);
const externalStatus = !instance ? 'temporarily-unavailable' : !instance.enabled ? 'instance-disabled' : instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed' ? 'credential-error' : ['failed', 'unreachable'].includes(instance.probeStatus) ? 'temporarily-unavailable' : 'ready';
return (
<label key={library.id}>
<input
disabled={!!library.external && externalStatus !== 'ready'}
checked={enabledKnowledgeLibraryIds.includes(
library.id,
)}
onChange={(event) =>
actions.setEnabledKnowledgeLibraryIds(
(current) =>
event.target.checked
? [
...new Set([
...current,
library.id,
]),
]
: current.filter(
(id) =>
id !== library.id,
),
)
}
type="checkbox"
/>
<span>{library.name}</span>
<small>
{library.external ? `${({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[library.external.provider]} · ${instance?.name ?? library.external.instanceId} · ${t(`external.states.${externalStatus}`, { ns: 'knowledge' })}` : t(
"composer.knowledge.documents",
{
count: library.documentCount,
},
)}
</small>
</label>
)},
)}
</small>
</div>
<div className="knowledge-scope__retrieval-mode">
<strong>
{t("composer.knowledge.modeLabel")}
</strong>
<SegmentedControl
ariaLabel={t(
"composer.knowledge.modeLabel",
)}
onChange={actions.setKnowledgeRetrievalMode}
options={[
{
value: "auto",
label: t(
"composer.knowledge.auto",
),
},
{
value: "always",
label: t(
"composer.knowledge.always",
),
},
]}
value={
view?.knowledgeRetrievalMode ??
"auto"
}
/>
<small>
{view?.knowledgeRetrievalMode ===
"always"
? t(
"composer.knowledge.alwaysDescription",
)
: t(
"composer.knowledge.autoDescription",
)}
</small>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
<div className="composer__configuration">
</>
)}
<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]);
}
+6 -3
View File
@@ -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: {
+42
View File
@@ -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;
}