diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ComposerPlusMenu.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ComposerPlusMenu.tsx new file mode 100644 index 00000000..59eb304d --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ComposerPlusMenu.tsx @@ -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(null); + if (items.length === 0) return null; + return ( + <> + + 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', + }} + > + + + + 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) => ( + { + 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 && } + + ))} + + + ); +}; + +// 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 ( + + {active.map((it) => ( + + 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' }, + }} + > + {it.icon} + + + {it.label} + + + + ))} + + ); +}; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx index 6e1b65bf..7138ae55 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx @@ -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 = ({ 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: , + onSelect: () => generalFileInputRef.current?.click(), + }); + if (onToggleWebSearch) { + plusItems.push({ + key: 'web', + label: 'Web search', + icon: , + toggle: true, + active: !!webSearchOn, + onSelect: onToggleWebSearch, + }); + } + if (elementSelection) { + plusItems.push({ + key: 'select', + label: 'Select an element', + icon: , + toggle: true, + active: isSelecting, + onSelect: toggleSelect, + }); + } + return ( <> - {onToggleWebSearch && !autoRunMode && ( - - 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', - }} - > - - - - )} - - {elementSelection && !autoRunMode && (() => { - const isMySelectMode = elementSelection.selectMode && elementSelection.activeOwnerId === ownerId; - return ( - - 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', - }} - > - - - - ); - })()} - = ({ e.target.value = ''; }} /> - - generalFileInputRef.current?.click()} - sx={{ - color: c.text.tertiary, - p: 0.5, - '&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' }, - }} - > - - - + {!autoRunMode && } + {!autoRunMode && } {!autoRunMode && ( {hasContent && (