import type { FirstPartyMcpToolName } from "@opengeni/contracts"; import { AudioLinesIcon, ChevronLeftIcon, ChevronRightIcon, EyeIcon, GaugeIcon, GitBranchIcon, LockKeyholeIcon, PaperclipIcon, PlugIcon, ServerIcon, SettingsIcon, SlidersHorizontalIcon, } from "lucide-react"; import { useEffect, useRef, Suspense, cloneElement, isValidElement, type ReactElement, type ReactNode, type CSSProperties, } from "react"; import { SessionToolsMenuBody, type SessionToolSelection } from "./pickers"; import { COMPOSER_MENU_PANEL_CLASS, ComposerMenuHeader, ComposerMenuRowsSkeleton, lazyComposerPanel, } from "../../ui/composer-menu"; import { MENU_BACK_BUTTON_CLASS, MENU_CHEVRON_CLASS } from "../../ui/menu-styles"; const loadAgentLearning = () => import("./knowledge/agent-learning-settings"); const AgentLearningDraftEditor = lazyComposerPanel(() => loadAgentLearning().then((module) => module.AgentLearningDraftEditor), ); import { DialogContent, DialogTitle } from "../../ui/dialog"; import { useOptionalAppContext } from "../app-context"; import { DropdownMenuContent, DropdownMenuItem, DropdownMenuMeta, DropdownMenuSeparator, } from "../../ui/dropdown-menu"; import { isComposerConnector, type McpServerOption } from "../lib/session-tools"; import type { SessionConnectorsMenuProps } from "./session-connectors-menu-body"; import { ConnectorAction } from "../../ui/composer-menu-action"; import { ComposerCapabilitiesMenuBody, type ComposerAgentCapabilities, } from "./composer-capabilities-menu-body"; import { capabilitySummary } from "../lib/agent-capabilities"; export type Panel = | "root" | "capabilities" | "tools" | "repos" | "voice" | "secrets" | "settings" | "runs-on" | "visibility" | "usage-limit"; /** A per-session setting shown as a "+" row with its current value, opening a drill-in. */ export type ComposerSettingPanel = { /** The current value, right-aligned on the row. */ summary: string; disabled?: boolean | undefined; /** * Panel element; receives `leading` (back control) and `presentation` * (inside the dropdown, or in a dialog) via clone. */ panel: ReactElement<{ leading?: ReactNode | undefined; presentation?: "menu" | "dialog" | undefined; }>; }; /** * Shared composer actions at every width; model and voice stay in the bar. */ export type ComposerPlusProps = { connectorActions?: | Pick< SessionConnectorsMenuProps, "onReconnect" | "loading" | "error" | "busyId" | "accountControls" > | undefined; onOpenConnectors?: (() => void) | undefined; /** Centered composers need viewport-sized panels rather than trigger-side space. */ expandedPanelPresentation?: "menu" | "dialog" | undefined; /** Anchor below a centered new-chat composer and above a docked composer. */ menuSide?: "top" | "bottom" | undefined; draftChatSettings?: | { workspaceId: string; scope: "workspace" | "personal"; value: import("@opengeni/sdk").AgentLearningOverrides; onChange: (value: import("@opengeni/sdk").AgentLearningOverrides) => void; } | undefined; /** * An existing chat: "Chat settings" opens the chat's Agent tab, the one * place for its identity, capabilities and Agent learning. */ chatSettings?: | { workspaceId: string; sessionId: string; scope: "workspace" | "personal"; canEdit: boolean; onOpen: () => void; } | undefined; /** * Agent settings are on for this server: "+" shows Capabilities (with the * connectors nested under Workspace connectors) instead of Connectors. */ agentCapabilities?: ComposerAgentCapabilities | undefined; disabled?: boolean | undefined; fileUploadsEnabled: boolean; servers: McpServerOption[]; firstPartyTools: ReadonlyArray<{ id: FirstPartyMcpToolName; name: string }>; selection: SessionToolSelection; toolsDisabled?: boolean | undefined; toolsSaving?: boolean | undefined; connectorCustomizing?: boolean | undefined; onConnectorCustomizingChange?: ((customizing: boolean) => void) | undefined; onToolSelectionChange: (selection: SessionToolSelection) => void; /** When set, Repositories appears under + and opens a drill-in panel. */ repositories?: | { selectedCount: number; disabled?: boolean | undefined; /** Panel element; receives `leading` (back control) via clone. */ panel: ReactElement<{ leading?: ReactNode | undefined }>; } | undefined; /** Which vaults agents in this chat may use, and the task's own items. */ secrets?: ComposerSettingPanel | undefined; /** Where the chat runs: managed sandbox and environment, or a connected machine. */ runsOn?: ComposerSettingPanel | undefined; /** Who can see the chat, when the organization offers a choice. */ visibility?: ComposerSettingPanel | undefined; /** A new chat's usage limit, when the server offers limits. */ usageLimit?: ComposerSettingPanel | undefined; /** When set, Voice model appears under + (bar keeps a start-only control). */ voiceModel?: | { selectedLabel: string; disabled?: boolean | undefined; /** Panel element; receives `leading` (back control) via clone. */ panel: ReactElement<{ leading?: ReactNode | undefined }>; } | undefined; }; export function ComposerMobilePlusPanel( props: ComposerPlusProps & { triggerRef: { current: HTMLButtonElement | null }; panel: Panel; setPanel: (panel: Panel) => void; setOpen: (open: boolean) => void; dialogOpen: boolean; dialogFocusOwnerRef: { current: boolean }; }, ) { const { triggerRef, panel, setPanel, setOpen, dialogOpen } = props; const returnFocusTo = useRef(null); useEffect(() => { const previous = returnFocusTo.current; if (!previous || previous === panel) return; returnFocusTo.current = null; const frame = requestAnimationFrame(() => { document .querySelector(`[role="menu"] [data-composer-panel="${previous}"]`) ?.focus(); }); return () => cancelAnimationFrame(frame); }, [panel]); // Chat settings opens without a load: fetch the editor (and, for an existing // chat, its settings for the Agent tab) while the menu is open. A composer // rendered outside the app (a preview harness) has no client. const client = useOptionalAppContext()?.client ?? null; const chatSettings = props.chatSettings; const draftChatSettings = Boolean(props.draftChatSettings); useEffect(() => { if (!chatSettings && !draftChatSettings) return; void loadAgentLearning() .then((module) => { if (chatSettings && client) { module.prefetchAgentLearningSettings(client, { workspaceId: chatSettings.workspaceId, scope: chatSettings.scope, source: { kind: "chat", id: chatSettings.sessionId }, canEdit: chatSettings.canEdit, }); } }) .catch(() => undefined); }, [client, chatSettings, draftChatSettings]); const connectors = props.servers.filter(isComposerConnector); const toolsSelected = connectors.filter((server) => props.selection.mcpServerIds.has(server.id), ).length; const repositories = props.repositories; const voiceModel = props.voiceModel; // In a menu, Back is a menu item: arrow keys reach it and it is announced as // part of the menu. In a dialog it is a plain button. Going back returns // focus to the item that opened the panel. const backAction = (label: string, target: Panel) => ( { returnFocusTo.current = panel; setPanel(target); }} > ); const backButton = backAction("Back", "root"); const backToCapabilities = backAction("Back to capabilities", "capabilities"); const agentCapabilities = props.agentCapabilities; return ( {panel === "root" ? ( <> {props.fileUploadsEnabled ? ( { event.preventDefault(); setOpen(false); const root = triggerRef.current?.closest("[data-og-composer-id]"); root?.querySelector("[data-og-composer-attach]")?.click(); }} > Add photos & files ) : null} {agentCapabilities ? ( { event.preventDefault(); setPanel("capabilities"); props.onOpenConnectors?.(); }} > Capabilities {agentCapabilities.customized ? capabilitySummary( agentCapabilities.draft.values, agentCapabilities.availability, ).replace(" capabilities", "") : "Default"} ) : ( { event.preventDefault(); setPanel("tools"); props.onOpenConnectors?.(); }} > Connectors {props.toolsSaving ? "Saving…" : toolsSelected || ""} )} {repositories ? ( { event.preventDefault(); setPanel("repos"); }} > Repositories {repositories.selectedCount || ""} ) : null} {props.secrets ? ( } label="Secrets" setting={props.secrets} disabled={props.disabled} onOpen={() => setPanel("secrets")} /> ) : null} {props.runsOn || props.visibility || props.usageLimit ? : null} {props.runsOn ? ( } label="Runs on" setting={props.runsOn} disabled={props.disabled} onOpen={() => setPanel("runs-on")} /> ) : null} {props.visibility ? ( } label="Visibility" setting={props.visibility} disabled={props.disabled} onOpen={() => setPanel("visibility")} /> ) : null} {props.usageLimit ? ( } label="Usage limit" setting={props.usageLimit} disabled={props.disabled} onOpen={() => setPanel("usage-limit")} /> ) : null} {voiceModel ? ( { event.preventDefault(); setPanel("voice"); }} > Voice model {voiceModel.selectedLabel} ) : null} {props.chatSettings ? ( // One place per chat: its Agent tab, not a second copy here. { setOpen(false); props.chatSettings?.onOpen(); }} > Chat settings Agent tab ) : props.draftChatSettings ? ( { event.preventDefault(); setPanel("settings"); }} > Chat settings ) : null} ) : panel === "capabilities" && agentCapabilities ? ( setPanel("tools")} /> ) : panel === "tools" ? ( ) : panel === "repos" && repositories ? ( withLeading(repositories.panel, backButton) ) : panel === "secrets" && props.secrets ? ( cloneElement(props.secrets.panel, { leading: backButton, presentation: dialogOpen ? "dialog" : "menu", }) ) : panel === "runs-on" && props.runsOn ? ( cloneElement(props.runsOn.panel, { leading: backButton, presentation: dialogOpen ? "dialog" : "menu", }) ) : panel === "visibility" && props.visibility ? ( cloneElement(props.visibility.panel, { leading: backButton, presentation: dialogOpen ? "dialog" : "menu", }) ) : panel === "usage-limit" && props.usageLimit ? ( cloneElement(props.usageLimit.panel, { leading: backButton, presentation: dialogOpen ? "dialog" : "menu", }) ) : panel === "voice" && voiceModel ? ( withLeading(voiceModel.panel, backButton) ) : panel === "settings" && props.draftChatSettings ? ( <>

Agent learning for this chat: whether agents' changes to knowledge, instructions and skills apply right away or wait for your OK.

} >
) : null}
); } /** The dialog's accessible name for each drill-in (the root is never a dialog). */ export const PANEL_DIALOG_TITLE: Record = { root: "Composer actions", capabilities: "Capabilities", tools: "Connectors", repos: "Repositories", voice: "Voice model", secrets: "Secrets", settings: "Chat settings", "runs-on": "Runs on", visibility: "Who can see this chat", "usage-limit": "Usage limit", }; function ComposerPanelContent(props: { dialog: boolean; dialogFocusOwnerRef: { current: boolean }; side: "top" | "bottom"; panel: Panel; triggerRef: { current: HTMLButtonElement | null }; className: string; children: ReactNode; }) { if (props.dialog) { return ( { event.preventDefault(); props.triggerRef.current?.focus(); }} > {PANEL_DIALOG_TITLE[props.panel]} {props.children} ); } return ( { if (props.dialogFocusOwnerRef.current) event.preventDefault(); }} align="start" side={props.side} sideOffset={8} collisionPadding={12} className={props.className} > {props.children} ); } function SettingRowItem(props: { /** Focus returns to this row when its drill-in goes back. */ panel?: Panel | undefined; icon: ReactNode; label: string; setting: ComposerSettingPanel; disabled?: boolean | undefined; onOpen: () => void; }) { return ( { event.preventDefault(); props.onOpen(); }} > {props.icon} {props.label} {props.setting.summary} ); } function withLeading( panel: ReactElement<{ leading?: ReactNode | undefined }>, leading: ReactNode, ): ReactElement { if (!isValidElement(panel)) { throw new Error("ComposerMobilePlus panel must be a valid React element"); } return cloneElement(panel, { leading }); }