mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 22:42:23 +02:00
[eric] composer: Claude-style two-level plus menu (Skills attach pills, Tools & connectors flyout)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user