diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx index a48dc8ed..6b492b5d 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx @@ -52,6 +52,7 @@ interface Props { handleSend: () => void; webSearchOn?: boolean; onToggleWebSearch?: () => void; + onAttachSkill?: (skillId: string) => void; } export const ChatInputToolbar: React.FC = (p) => { @@ -176,6 +177,7 @@ export const ChatInputToolbar: React.FC = (p) => { handleSend={handleSend} webSearchOn={webSearchOn} onToggleWebSearch={onToggleWebSearch} + onAttachSkill={p.onAttachSkill} /> ); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ComposerPlusMenu.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ComposerPlusMenu.tsx index 5e013f04..a3804218 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ComposerPlusMenu.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ComposerPlusMenu.tsx @@ -7,6 +7,7 @@ import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import AddRoundedIcon from '@mui/icons-material/AddRounded'; import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import { ClaudeTokens } from '@/shared/styles/claudeTokens'; @@ -17,6 +18,10 @@ export interface PlusMenuItem { // A toggle shows a check when active and stays open-able; an action fires and closes. toggle?: boolean; active?: boolean; + // Quiet right-aligned text: a shortcut hint or a status ("2 active"). + hint?: string; + // Claude-style flyout: a row with children opens a nested menu instead of firing onSelect. + children?: PlusMenuItem[]; onSelect: () => void; } @@ -25,7 +30,55 @@ export interface PlusMenuItem { // ActiveTogglePills) so their state is visible with the menu closed. export const ComposerPlusMenu: React.FC<{ c: ClaudeTokens; items: PlusMenuItem[] }> = ({ c, items }) => { const [anchor, setAnchor] = useState(null); + // One flyout at a time (Claude's grammar): hovering/clicking a parent row opens its submenu + // anchored to the row; moving to a sibling row closes it. + const [subFor, setSubFor] = useState<{ key: string; el: HTMLElement } | null>(null); + const closeAll = (): void => { setSubFor(null); setAnchor(null); }; if (items.length === 0) return null; + + const paperSx = { + bgcolor: c.bg.surface, + border: `1px solid ${c.border.subtle}`, + borderRadius: '12px', + minWidth: 208, + boxShadow: c.shadow.lg, + overflow: 'hidden', + }; + + const row = (it: PlusMenuItem, inSub: boolean): React.ReactElement => ( + { + if (it.children) { setSubFor((prev) => (prev?.key === it.key ? null : { key: it.key, el: e.currentTarget as HTMLElement })); return; } + it.onSelect(); + if (!it.toggle) closeAll(); + }} + onMouseEnter={(e) => { + if (it.children) setSubFor({ key: it.key, el: e.currentTarget as HTMLElement }); + else if (!inSub) setSubFor(null); + }} + sx={{ + py: 0.9, + px: 1.5, + gap: 1.25, + display: 'flex', + alignItems: 'center', + color: it.active ? c.accent.primary : c.text.secondary, + '&:hover': { bgcolor: c.bg.secondary }, + }} + > + {it.icon} + {it.label} + {it.hint && ( + {it.hint} + )} + {it.toggle && it.active && } + {it.children && } + + ); + + const openSub = subFor ? items.find((it) => it.key === subFor.key) : null; + return ( <> @@ -48,45 +101,27 @@ export const ComposerPlusMenu: React.FC<{ c: ClaudeTokens; items: PlusMenuItem[] setAnchor(null)} + onClose={closeAll} anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} - slotProps={{ - paper: { - sx: { - bgcolor: c.bg.surface, - border: `1px solid ${c.border.subtle}`, - borderRadius: '12px', - minWidth: 208, - mb: 0.5, - boxShadow: c.shadow.lg, - overflow: 'hidden', - }, - }, - }} + slotProps={{ paper: { sx: { ...paperSx, mb: 0.5 } } }} > - {items.map((it) => ( - { - it.onSelect(); - if (!it.toggle) setAnchor(null); - }} - sx={{ - py: 0.9, - px: 1.5, - gap: 1.25, - display: 'flex', - alignItems: 'center', - color: it.active ? c.accent.primary : c.text.secondary, - '&:hover': { bgcolor: c.bg.secondary }, - }} - > - {it.icon} - {it.label} - {it.toggle && it.active && } - - ))} + {items.map((it) => row(it, false))} + + setSubFor(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'left' }} + // The parent Menu already owns focus; a modal submenu would steal it and flicker. + disableAutoFocus + disableEnforceFocus + hideBackdrop + sx={{ pointerEvents: 'none' }} + slotProps={{ paper: { sx: { ...paperSx, ml: 0.5, pointerEvents: 'auto', maxHeight: 320, overflowY: 'auto' } } }} + > + {(openSub?.children ?? []).map((child) => row(child, true))} ); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx index fc8be109..e34aef8a 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx @@ -8,6 +8,13 @@ import AttachFileIcon from '@mui/icons-material/AttachFile'; import AdsClickIcon from '@mui/icons-material/AdsClick'; import LanguageIcon from '@mui/icons-material/Language'; import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; +import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined'; +import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; +import TuneRoundedIcon from '@mui/icons-material/TuneRounded'; +import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { fetchSkills } from '@/shared/state/skillsSlice'; +import { openSettingsModal } from '@/shared/state/settingsSlice'; import { useVoice } from '@/shared/voice/VoiceDictationContext'; import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; import { ClaudeTokens } from '@/shared/styles/claudeTokens'; @@ -29,13 +36,20 @@ interface Props { handleSend: () => void; webSearchOn?: boolean; onToggleWebSearch?: () => void; + onAttachSkill?: (skillId: string) => void; } export const ToolbarActions: React.FC = ({ c, elementSelection, autoRunMode, ownerId, sessionId, generalFileInputRef, addImageFiles, uploadAndAttachFiles, hasContent, disabled, isRunning, onStop, handleSend, - webSearchOn, onToggleWebSearch, + webSearchOn, onToggleWebSearch, onAttachSkill, }) => { + const dispatch = useAppDispatch(); + // Lazy-load the skills list the first time the menu could need it; cheap and cached in the slice. + const skills = useAppSelector((s) => s.skills.items); + const skillsLoaded = useAppSelector((s) => s.skills.loaded); + const activeMcps = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId]?.active_mcps : undefined) ?? []); + React.useEffect(() => { if (!skillsLoaded) dispatch(fetchSkills()); }, [skillsLoaded, dispatch]); // Every composer action collapses into one "+" so the bar reads empty at rest; active toggles // (web search, selecting) still surface as a pill so their state stays visible. New capabilities // (skills, MCP tools, voice, image) just push another entry onto this list, no new bar icon. @@ -88,6 +102,45 @@ export const ToolbarActions: React.FC = ({ onSelect: toggleSelect, }); } + // Claude-style flyouts: Skills attaches a real pill (same path as typing /skill); Tools shows the + // session's active connectors and jumps to Settings > Tools for management. + const skillList = Object.values(skills); + if (onAttachSkill && skillList.length > 0) { + plusItems.push({ + key: 'skills', + label: 'Skills', + icon: , + onSelect: () => {}, + children: skillList.slice(0, 12).map((sk) => ({ + key: `skill-${sk.id}`, + label: sk.name, + icon: , + onSelect: () => onAttachSkill(sk.id), + })), + }); + } + plusItems.push({ + key: 'tools', + label: 'Tools & connectors', + icon: , + hint: activeMcps.length > 0 ? `${activeMcps.length} active` : undefined, + onSelect: () => {}, + children: [ + ...activeMcps.map((name) => ({ + key: `mcp-${name}`, + label: name, + icon: , + hint: 'active', + onSelect: () => dispatch(openSettingsModal('tools')), + })), + { + key: 'manage-tools', + label: 'Manage tools', + icon: , + onSelect: () => dispatch(openSettingsModal('tools')), + }, + ], + }); return ( <> diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx index 76048505..44c026d5 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx @@ -285,6 +285,7 @@ export const ChatInputView: React.FC = (p) => { handleSend={p.handleSend} webSearchOn={p.webSearchOn} onToggleWebSearch={p.onToggleWebSearch} + onAttachSkill={(skillId) => p.handlePickerSelect({ id: skillId, type: 'skill', category: '', name: '', description: '', command: '', icon: null })} />