mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-25 22:12:22 +02:00
[eric] composer: collapse the bar actions into one + menu (attach + web + select), active toggles surface as removable pills
This commit is contained in:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user