[eric] composer: collapse the bar actions into one + menu (attach + web + select), active toggles surface as removable pills

This commit is contained in:
ciregenz
2026-07-21 19:37:27 -07:00
parent 63eef8b416
commit 56e3db3445
2 changed files with 185 additions and 86 deletions
@@ -0,0 +1,139 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
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 CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
export interface PlusMenuItem {
key: string;
label: string;
icon: React.ReactNode;
// A toggle shows a check when active and stays open-able; an action fires and closes.
toggle?: boolean;
active?: boolean;
onSelect: () => void;
}
// One "+" that holds every composer action so the bar reads empty at rest but has room to grow
// (the ChatGPT/Claude/Open WebUI grammar). Active toggles ALSO surface as a pill on the bar (see
// 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);
if (items.length === 0) return null;
return (
<>
<Tooltip title="Add">
<IconButton
size="small"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => setAnchor(e.currentTarget)}
data-onboarding="composer-plus"
sx={{
p: 0.5,
color: anchor ? c.text.secondary : c.text.tertiary,
bgcolor: anchor ? 'rgba(0,0,0,0.04)' : 'transparent',
'&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' },
transition: 'color 0.12s, background-color 0.12s',
}}
>
<AddRoundedIcon sx={{ fontSize: 19 }} />
</IconButton>
</Tooltip>
<Menu
anchorEl={anchor}
open={!!anchor}
onClose={() => setAnchor(null)}
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',
},
},
}}
>
{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.82rem', fontWeight: 500, color: 'inherit', flex: 1 }}>{it.label}</Typography>
{it.toggle && it.active && <CheckRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />}
</MenuItem>
))}
</Menu>
</>
);
};
// The active toggles as compact pills on the bar, so the mode is visible + one-click-off without
// reopening the menu. Empty when nothing is active, so the resting bar stays minimal.
export const ActiveTogglePills: React.FC<{ c: ClaudeTokens; items: PlusMenuItem[] }> = ({ c, items }) => {
const active = items.filter((it) => it.toggle && it.active);
if (active.length === 0) return null;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 1, minWidth: 0 }}>
{active.map((it) => (
<Tooltip key={it.key} title={`${it.label}: on. Click to turn off.`}>
<Box
role="button"
onClick={it.onSelect}
onMouseDown={(e) => e.preventDefault()}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
height: 24,
pl: 0.75,
pr: 0.9,
borderRadius: 999,
cursor: 'pointer',
bgcolor: `${c.accent.primary}1f`,
color: c.accent.primary,
border: `1px solid ${c.accent.primary}33`,
'&:hover': { bgcolor: `${c.accent.primary}2e` },
transition: 'background-color 0.12s',
'& svg': { fontSize: 14 },
// Icon at rest, X on hover: the remove affordance without a permanent close button.
'& .osw-pill-x': { display: 'none' },
'&:hover .osw-pill-icon': { display: 'none' },
'&:hover .osw-pill-x': { display: 'inline-flex' },
}}
>
<Box component="span" className="osw-pill-icon" sx={{ display: 'inline-flex' }}>{it.icon}</Box>
<Box component="span" className="osw-pill-x" sx={{ alignItems: 'center' }}><CloseRoundedIcon /></Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: 'inherit', whiteSpace: 'nowrap' }}>
{it.label}
</Typography>
</Box>
</Tooltip>
))}
</Box>
);
};
@@ -9,6 +9,7 @@ import AdsClickIcon from '@mui/icons-material/AdsClick';
import LanguageIcon from '@mui/icons-material/Language';
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { ComposerPlusMenu, ActiveTogglePills, PlusMenuItem } from './ComposerPlusMenu';
interface Props {
c: ClaudeTokens;
@@ -33,81 +34,51 @@ export const ToolbarActions: React.FC<Props> = ({
addImageFiles, uploadAndAttachFiles, hasContent, disabled, isRunning, onStop, handleSend,
webSearchOn, onToggleWebSearch,
}) => {
// 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.
const isSelecting = !!elementSelection && elementSelection.selectMode && elementSelection.activeOwnerId === ownerId;
const toggleSelect = (): void => {
if (!elementSelection) return;
if (isSelecting) {
elementSelection.setSelectMode(false);
} else {
if (elementSelection.activeOwnerId !== ownerId) elementSelection.clearOwnerElements(ownerId);
elementSelection.setActiveOwnerId(ownerId);
elementSelection.setExcludeSelectId(sessionId ?? null);
elementSelection.setSelectMode(true);
}
};
const plusItems: PlusMenuItem[] = [];
plusItems.push({
key: 'attach',
label: 'Attach file',
icon: <AttachFileIcon sx={{ fontSize: 17 }} />,
onSelect: () => generalFileInputRef.current?.click(),
});
if (onToggleWebSearch) {
plusItems.push({
key: 'web',
label: 'Web search',
icon: <LanguageIcon sx={{ fontSize: 17 }} />,
toggle: true,
active: !!webSearchOn,
onSelect: onToggleWebSearch,
});
}
if (elementSelection) {
plusItems.push({
key: 'select',
label: 'Select an element',
icon: <AdsClickIcon sx={{ fontSize: 17 }} />,
toggle: true,
active: isSelecting,
onSelect: toggleSelect,
});
}
return (
<>
{onToggleWebSearch && !autoRunMode && (
<Tooltip title={webSearchOn ? 'Web search on: replies search the web' : 'Search the web'}>
<IconButton
size="small"
onMouseDown={(e) => e.preventDefault()}
onClick={onToggleWebSearch}
sx={{
p: 0.5,
...(webSearchOn
? {
bgcolor: '#3b82f6',
color: '#fff',
'&:hover': { bgcolor: '#2563eb' },
boxShadow: '0 0 0 3px rgba(59,130,246,0.18)',
}
: {
color: c.text.tertiary,
'&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' },
}),
transition: 'background-color 0.15s, color 0.15s',
}}
>
<LanguageIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
)}
{elementSelection && !autoRunMode && (() => {
const isMySelectMode = elementSelection.selectMode && elementSelection.activeOwnerId === ownerId;
return (
<Tooltip title={isMySelectMode ? 'Exit select mode' : 'Select UI element'}>
<IconButton
size="small"
onMouseDown={(e) => e.preventDefault()}
data-onboarding="element-selection-toggle"
onClick={() => {
if (isMySelectMode) {
elementSelection.setSelectMode(false);
} else {
if (elementSelection.activeOwnerId !== ownerId) {
elementSelection.clearOwnerElements(ownerId);
}
elementSelection.setActiveOwnerId(ownerId);
if (sessionId) {
elementSelection.setExcludeSelectId(sessionId);
} else {
elementSelection.setExcludeSelectId(null);
}
elementSelection.setSelectMode(true);
}
}}
sx={{
p: 0.5,
...(isMySelectMode
? {
bgcolor: '#3b82f6',
color: '#fff',
'&:hover': { bgcolor: '#2563eb' },
boxShadow: '0 0 0 3px rgba(59,130,246,0.18)',
}
: {
color: c.text.tertiary,
'&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' },
}),
transition: 'background-color 0.15s, color 0.15s',
}}
>
<AdsClickIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
);
})()}
<input
ref={generalFileInputRef}
type="file"
@@ -123,19 +94,8 @@ export const ToolbarActions: React.FC<Props> = ({
e.target.value = '';
}}
/>
<Tooltip title="Attach file">
<IconButton
size="small"
onClick={() => generalFileInputRef.current?.click()}
sx={{
color: c.text.tertiary,
p: 0.5,
'&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' },
}}
>
<AttachFileIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
{!autoRunMode && <ActiveTogglePills c={c} items={plusItems} />}
{!autoRunMode && <ComposerPlusMenu c={c} items={plusItems} />}
{!autoRunMode && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{hasContent && (