[eric] composer: Claude-style two-level plus menu (Skills attach pills, Tools & connectors flyout)

This commit is contained in:
ciregenz
2026-07-24 12:27:23 -07:00
parent 8c0340c360
commit f62bfbf71c
4 changed files with 128 additions and 37 deletions
@@ -52,6 +52,7 @@ interface Props {
handleSend: () => void;
webSearchOn?: boolean;
onToggleWebSearch?: () => void;
onAttachSkill?: (skillId: string) => void;
}
export const ChatInputToolbar: React.FC<Props> = (p) => {
@@ -176,6 +177,7 @@ export const ChatInputToolbar: React.FC<Props> = (p) => {
handleSend={handleSend}
webSearchOn={webSearchOn}
onToggleWebSearch={onToggleWebSearch}
onAttachSkill={p.onAttachSkill}
/>
</Box>
);
@@ -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<HTMLElement | null>(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 => (
<MenuItem
key={it.key}
onClick={(e) => {
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 },
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', color: 'inherit', flexShrink: 0 }}>{it.icon}</Box>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: 'inherit', flex: 1 }}>{it.label}</Typography>
{it.hint && (
<Typography sx={{ fontSize: '0.6875rem', color: c.text.ghost, flexShrink: 0 }}>{it.hint}</Typography>
)}
{it.toggle && it.active && <CheckRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />}
{it.children && <ChevronRightRoundedIcon sx={{ fontSize: 16, color: c.text.ghost, flexShrink: 0 }} />}
</MenuItem>
);
const openSub = subFor ? items.find((it) => it.key === subFor.key) : null;
return (
<>
<Tooltip title="Add">
@@ -48,45 +101,27 @@ export const ComposerPlusMenu: React.FC<{ c: ClaudeTokens; items: PlusMenuItem[]
<Menu
anchorEl={anchor}
open={!!anchor}
onClose={() => 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) => (
<MenuItem
key={it.key}
onClick={() => {
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 },
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', color: 'inherit', flexShrink: 0 }}>{it.icon}</Box>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: 'inherit', flex: 1 }}>{it.label}</Typography>
{it.toggle && it.active && <CheckRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />}
</MenuItem>
))}
{items.map((it) => row(it, false))}
</Menu>
<Menu
anchorEl={subFor?.el ?? null}
open={!!subFor && !!openSub?.children?.length}
onClose={() => 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))}
</Menu>
</>
);
@@ -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<Props> = ({
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<Props> = ({
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: <AutoAwesomeOutlinedIcon sx={{ fontSize: 17 }} />,
onSelect: () => {},
children: skillList.slice(0, 12).map((sk) => ({
key: `skill-${sk.id}`,
label: sk.name,
icon: <DescriptionOutlinedIcon sx={{ fontSize: 15 }} />,
onSelect: () => onAttachSkill(sk.id),
})),
});
}
plusItems.push({
key: 'tools',
label: 'Tools & connectors',
icon: <ExtensionOutlinedIcon sx={{ fontSize: 17 }} />,
hint: activeMcps.length > 0 ? `${activeMcps.length} active` : undefined,
onSelect: () => {},
children: [
...activeMcps.map((name) => ({
key: `mcp-${name}`,
label: name,
icon: <ExtensionOutlinedIcon sx={{ fontSize: 15 }} />,
hint: 'active',
onSelect: () => dispatch(openSettingsModal('tools')),
})),
{
key: 'manage-tools',
label: 'Manage tools',
icon: <TuneRoundedIcon sx={{ fontSize: 15 }} />,
onSelect: () => dispatch(openSettingsModal('tools')),
},
],
});
return (
<>
@@ -285,6 +285,7 @@ export const ChatInputView: React.FC<Props> = (p) => {
handleSend={p.handleSend}
webSearchOn={p.webSearchOn}
onToggleWebSearch={p.onToggleWebSearch}
onAttachSkill={(skillId) => p.handlePickerSelect({ id: skillId, type: 'skill', category: '', name: '', description: '', command: '', icon: null })}
/>
<ChatInputOverlays