diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index f5bc15e0..118640e1 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useEffect } from 'react'; import { Provider } from 'react-redux'; import { HashRouter, Routes, Route } from 'react-router-dom'; -import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material'; +import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material'; import { store } from '../shared/state/store'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchSettings } from '@/shared/state/settingsSlice'; @@ -28,128 +28,7 @@ import OnboardingModal from './components/OnboardingModal'; import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts'; import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp'; import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { ClaudeTokens } from '@/shared/styles/claudeTokens'; - -function buildMuiTheme(c: ClaudeTokens, mode: 'light' | 'dark') { - return createTheme({ - palette: { - mode, - background: { - default: c.bg.page, - paper: c.bg.surface, - }, - primary: { - main: c.accent.primary, - dark: c.accent.pressed, - light: c.accent.hover, - }, - text: { - primary: c.text.primary, - secondary: c.text.muted, - disabled: c.text.tertiary, - }, - divider: c.border.medium, - error: { main: c.status.error }, - warning: { main: c.status.warning }, - success: { main: c.status.success }, - info: { main: c.status.info }, - }, - typography: { - fontFamily: c.font.sans, - h1: { fontWeight: 600 }, - h2: { fontWeight: 600 }, - h3: { fontWeight: 600 }, - h5: { fontWeight: 600 }, - h6: { fontWeight: 600 }, - button: { textTransform: 'none' as const, fontWeight: 500 }, - }, - shape: { - borderRadius: c.radius.xl, - }, - components: { - MuiCssBaseline: { - styleOverrides: { - body: { - backgroundColor: c.bg.page, - color: c.text.primary, - scrollbarWidth: 'thin', - scrollbarColor: `${c.border.strong} transparent`, - }, - '*': { - scrollbarWidth: 'thin', - scrollbarColor: `${c.border.strong} transparent`, - }, - '*::-webkit-scrollbar': { - width: '6px', - height: '6px', - }, - '*::-webkit-scrollbar-track': { - background: 'transparent', - }, - '*::-webkit-scrollbar-thumb': { - background: c.border.strong, - borderRadius: '3px', - }, - '*::-webkit-scrollbar-thumb:hover': { - background: c.text.ghost, - }, - '*::-webkit-scrollbar-corner': { - background: 'transparent', - }, - }, - }, - MuiButton: { - styleOverrides: { - root: { - borderRadius: c.radius.lg, - transition: c.transition, - textTransform: 'none' as const, - '&:active': { transform: 'scale(0.98)' }, - }, - contained: { - boxShadow: 'none', - '&:hover': { boxShadow: 'none' }, - }, - }, - }, - MuiPaper: { - styleOverrides: { - root: { - boxShadow: c.shadow.md, - border: `1px solid ${c.border.subtle}`, - backgroundImage: 'none', - }, - }, - }, - MuiChip: { - styleOverrides: { - root: { - fontWeight: 500, - borderRadius: c.radius.md, - }, - }, - }, - MuiDialog: { - styleOverrides: { - paper: { - borderRadius: 16, - boxShadow: c.shadow.lg, - border: `1px solid ${c.border.subtle}`, - }, - }, - }, - MuiTooltip: { - styleOverrides: { - tooltip: { - backgroundColor: c.bg.inverse, - color: c.text.inverse, - fontSize: '0.75rem', - }, - }, - }, - }, - }); -} +import { buildMuiTheme } from './buildMuiTheme'; const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { useKeyboardShortcuts(); diff --git a/frontend/src/app/buildMuiTheme.ts b/frontend/src/app/buildMuiTheme.ts new file mode 100644 index 00000000..ccf7f559 --- /dev/null +++ b/frontend/src/app/buildMuiTheme.ts @@ -0,0 +1,123 @@ +import { createTheme } from '@mui/material'; +import { ClaudeTokens } from '@/shared/styles/claudeTokens'; + +export function buildMuiTheme(c: ClaudeTokens, mode: 'light' | 'dark') { + return createTheme({ + palette: { + mode, + background: { + default: c.bg.page, + paper: c.bg.surface, + }, + primary: { + main: c.accent.primary, + dark: c.accent.pressed, + light: c.accent.hover, + }, + text: { + primary: c.text.primary, + secondary: c.text.muted, + disabled: c.text.tertiary, + }, + divider: c.border.medium, + error: { main: c.status.error }, + warning: { main: c.status.warning }, + success: { main: c.status.success }, + info: { main: c.status.info }, + }, + typography: { + fontFamily: c.font.sans, + h1: { fontWeight: 600 }, + h2: { fontWeight: 600 }, + h3: { fontWeight: 600 }, + h5: { fontWeight: 600 }, + h6: { fontWeight: 600 }, + button: { textTransform: 'none' as const, fontWeight: 500 }, + }, + shape: { + borderRadius: c.radius.xl, + }, + components: { + MuiCssBaseline: { + styleOverrides: { + body: { + backgroundColor: c.bg.page, + color: c.text.primary, + scrollbarWidth: 'thin', + scrollbarColor: `${c.border.strong} transparent`, + }, + '*': { + scrollbarWidth: 'thin', + scrollbarColor: `${c.border.strong} transparent`, + }, + '*::-webkit-scrollbar': { + width: '6px', + height: '6px', + }, + '*::-webkit-scrollbar-track': { + background: 'transparent', + }, + '*::-webkit-scrollbar-thumb': { + background: c.border.strong, + borderRadius: '3px', + }, + '*::-webkit-scrollbar-thumb:hover': { + background: c.text.ghost, + }, + '*::-webkit-scrollbar-corner': { + background: 'transparent', + }, + }, + }, + MuiButton: { + styleOverrides: { + root: { + borderRadius: c.radius.lg, + transition: c.transition, + textTransform: 'none' as const, + '&:active': { transform: 'scale(0.98)' }, + }, + contained: { + boxShadow: 'none', + '&:hover': { boxShadow: 'none' }, + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + boxShadow: c.shadow.md, + border: `1px solid ${c.border.subtle}`, + backgroundImage: 'none', + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + fontWeight: 500, + borderRadius: c.radius.md, + }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + borderRadius: 16, + boxShadow: c.shadow.lg, + border: `1px solid ${c.border.subtle}`, + }, + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + backgroundColor: c.bg.inverse, + color: c.text.inverse, + fontSize: '0.75rem', + }, + }, + }, + }, + }); +} diff --git a/frontend/src/app/components/CommandPicker.tsx b/frontend/src/app/components/CommandPicker.tsx index 316d51c1..4ffb576a 100644 --- a/frontend/src/app/components/CommandPicker.tsx +++ b/frontend/src/app/components/CommandPicker.tsx @@ -1,311 +1,19 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Paper from '@mui/material/Paper'; -import DescriptionIcon from '@mui/icons-material/Description'; -import PsychologyIcon from '@mui/icons-material/Psychology'; -import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; -import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined'; -import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; -import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined'; -import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined'; -import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; -import LanguageIcon from '@mui/icons-material/Language'; -import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; -import SvgIcon from '@mui/material/SvgIcon'; -import ViewQuiltOutlinedIcon from '@mui/icons-material/ViewQuiltOutlined'; -import { useAppSelector, useAppDispatch } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice'; -import { fetchOutputs } from '@/shared/state/outputsSlice'; +import { CommandPickerItem, CommandPickerProps, highlightMatch } from './commandPickerTypes'; +import { useCommandPickerItems } from './useCommandPickerItems'; -const XLogoIcon: React.FC<{ sx?: object }> = ({ sx }) => ( - - - -); +export { getToolGroupIcon } from './CommandPickerIcons'; +export type { CommandPickerItem } from './commandPickerTypes'; -const GoogleIcon: React.FC<{ sx?: object }> = ({ sx }) => ( - - - - - - -); - -const RedditIcon: React.FC<{ sx?: object }> = ({ sx }) => ( - - - -); - -const TOOL_GROUP_ICONS: Record> = { - Twitter: XLogoIcon, - Google: GoogleIcon, - Reddit: RedditIcon, - Web: LanguageIcon, - View: ViewQuiltOutlinedIcon, -}; - -export function getToolGroupIcon(groupName: string, size: number = 15): React.ReactNode { - const Icon = TOOL_GROUP_ICONS[groupName]; - if (Icon) return ; - return ; -} - -export interface CommandPickerItem { - id: string; - type: 'template' | 'skill' | 'mode' | 'context'; - category: string; - name: string; - description: string; - command: string; - icon: React.ReactNode; - toolNames?: string[]; - iconKey?: string; -} - -interface Props { - trigger: '/' | '@'; - filter: string; - onSelect: (item: CommandPickerItem) => void; - onClose: () => void; - visible: boolean; -} - -const MODE_ICON_MAP: Record> = { - smart_toy: SmartToyOutlinedIcon, - question_answer: QuestionAnswerOutlinedIcon, - map: MapOutlinedIcon, - category: CategoryOutlinedIcon, - tune: TuneOutlinedIcon, -}; - -function highlightMatch(text: string, query: string, color: string): React.ReactNode { - if (!query) return text; - const idx = text.toLowerCase().indexOf(query.toLowerCase()); - if (idx === -1) return text; - return ( - <> - {text.slice(0, idx)} - {text.slice(idx, idx + query.length)} - {text.slice(idx + query.length)} - - ); -} - -const CommandPicker: React.FC = ({ trigger, filter, onSelect, onClose, visible }) => { +const CommandPicker: React.FC = ({ trigger, filter, onSelect, onClose, visible }) => { const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - const templates = useAppSelector((s) => s.templates.items); - const skills = useAppSelector((s) => s.skills.items); - const modesMap = useAppSelector((s) => s.modes.items); - const builtinTools = useAppSelector((s) => s.tools.builtinTools); - const customTools = useAppSelector((s) => s.tools.items); - const outputItems = useAppSelector((s) => s.outputs.items); const [selectedIndex, setSelectedIndex] = useState(0); const containerRef = useRef(null); - - const toolsLoaded = useAppSelector((s) => s.tools.loaded); - const builtinLoaded = useAppSelector((s) => s.tools.builtinLoaded); - const outputsLoaded = useAppSelector((s) => s.outputs.loaded); - - useEffect(() => { - if (!builtinLoaded) dispatch(fetchBuiltinTools()); - if (!toolsLoaded) dispatch(fetchTools()); - if (!outputsLoaded) dispatch(fetchOutputs()); - }, [dispatch, builtinLoaded, toolsLoaded, outputsLoaded]); - - const items: CommandPickerItem[] = useMemo(() => { - let all: CommandPickerItem[] = []; - - if (trigger === '/') { - const templateItems: CommandPickerItem[] = Object.values(templates).map((t) => ({ - id: t.id, - type: 'template' as const, - category: 'Templates', - name: t.name, - description: t.description || `Template with ${t.fields.length} fields`, - command: t.name.toLowerCase().replace(/\s+/g, '-'), - icon: , - })); - - const skillItems: CommandPickerItem[] = Object.values(skills).map((s) => ({ - id: s.id, - type: 'skill' as const, - category: 'Skills', - name: s.name, - description: s.description || 'Skill', - command: s.command || s.id, - icon: , - })); - - const modeItems: CommandPickerItem[] = Object.values(modesMap).map((m) => { - const IconComp = MODE_ICON_MAP[m.icon] || SmartToyOutlinedIcon; - return { - id: m.id, - type: 'mode' as const, - category: 'Modes', - name: m.name, - description: m.description || 'Switch to this mode', - command: m.name.toLowerCase().replace(/\s+/g, '-'), - icon: , - }; - }); - - all = [...templateItems, ...skillItems, ...modeItems]; - } else { - const atItems: CommandPickerItem[] = [ - { - id: 'file', - type: 'context' as const, - category: 'Context', - name: 'File', - description: 'Attach a file or folder as context', - command: 'file', - icon: , - }, - ]; - - const hasWebSearch = builtinTools.some((t) => t.name === 'WebSearch' && t.deferred); - const hasWebFetch = builtinTools.some((t) => t.name === 'WebFetch' && t.deferred); - if (hasWebSearch || hasWebFetch) { - const webTools = [hasWebSearch && 'WebSearch', hasWebFetch && 'WebFetch'].filter(Boolean) as string[]; - atItems.push({ - id: 'web', - type: 'context' as const, - category: 'Actions', - name: 'Web', - description: 'Search the web and fetch URLs', - command: 'web', - icon: , - toolNames: webTools, - iconKey: 'Web', - }); - } - - for (const tool of Object.values(customTools)) { - if (!tool.mcp_config || Object.keys(tool.mcp_config).length === 0) continue; - const services = tool.tool_permissions?._services as Record | undefined; - if (!services) continue; - const perms = tool.tool_permissions as Record; - const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record; - - const enabledServices: { name: string; tools: string[] }[] = []; - for (const [serviceName, serviceTools] of Object.entries(services)) { - const allToolNames = [...(serviceTools.read || []), ...(serviceTools.write || [])]; - const enabled = allToolNames.filter((name) => perms[name] !== 'deny'); - if (enabled.length > 0) enabledServices.push({ name: serviceName, tools: enabled }); - } - - if (enabledServices.length === 0) continue; - - const groupEntries = Object.entries(serviceGroups); - const emittedServices = new Set(); - - for (const [groupName, groupServiceNames] of groupEntries) { - const groupCmd = groupName.toLowerCase().replace(/\s+/g, '-'); - const groupServices = enabledServices.filter((s) => groupServiceNames.includes(s.name)); - if (groupServices.length === 0) continue; - groupServices.forEach((s) => emittedServices.add(s.name)); - - const groupIcon = getToolGroupIcon(groupName); - if (groupServices.length >= 2) { - const allTools = groupServices.flatMap((s) => s.tools); - atItems.push({ - id: `mcp-${tool.id}-group-${groupName}`, - type: 'context' as const, - category: tool.name, - name: groupName, - description: `Use all ${groupName} actions`, - command: groupCmd, - icon: groupIcon, - toolNames: allTools, - iconKey: groupName, - }); - for (const svc of groupServices) { - atItems.push({ - id: `mcp-${tool.id}-${svc.name}`, - type: 'context' as const, - category: tool.name, - name: svc.name, - description: `Use ${svc.name} actions from ${tool.name}`, - command: `${groupCmd}/${svc.name.toLowerCase().replace(/\s+/g, '-')}`, - icon: groupIcon, - toolNames: svc.tools, - iconKey: groupName, - }); - } - } else { - const svc = groupServices[0]; - atItems.push({ - id: `mcp-${tool.id}-${svc.name}`, - type: 'context' as const, - category: tool.name, - name: svc.name, - description: `Use ${svc.name} actions from ${tool.name}`, - command: svc.name.toLowerCase().replace(/\s+/g, '-'), - icon: groupIcon, - toolNames: svc.tools, - iconKey: groupName, - }); - } - } - - for (const svc of enabledServices) { - if (emittedServices.has(svc.name)) continue; - atItems.push({ - id: `mcp-${tool.id}-${svc.name}`, - type: 'context' as const, - category: tool.name, - name: svc.name, - description: `Use ${svc.name} actions from ${tool.name}`, - command: svc.name.toLowerCase().replace(/\s+/g, '-'), - icon: , - toolNames: svc.tools, - }); - } - } - - for (const out of Object.values(outputItems)) { - if (out.permission === 'deny') continue; - const cmd = out.name.toLowerCase().replace(/\s+/g, '-'); - atItems.push({ - id: `view-${out.id}`, - type: 'context' as const, - category: 'Apps', - name: out.name, - description: out.description || `Render ${out.name} view`, - command: cmd, - icon: , - toolNames: ['RenderOutput'], - iconKey: 'View', - }); - } - - all = atItems; - } - - if (!filter) return all; - const lower = filter.toLowerCase(); - return all.filter( - (item) => - item.name.toLowerCase().includes(lower) || - item.command.toLowerCase().includes(lower) || - item.description.toLowerCase().includes(lower), - ); - }, [trigger, templates, skills, modesMap, builtinTools, customTools, outputItems, filter]); - - const flatItems = useMemo(() => { - const result: { item: CommandPickerItem; isGroupStart: boolean; category: string }[] = []; - let lastCat = ''; - for (const item of items) { - result.push({ item, isGroupStart: item.category !== lastCat, category: item.category }); - lastCat = item.category; - } - return result; - }, [items]); + const { items, flatItems, modesMap } = useCommandPickerItems(trigger, filter); const getIconColor = (item: CommandPickerItem): string => { switch (item.type) { diff --git a/frontend/src/app/components/CommandPickerIcons.tsx b/frontend/src/app/components/CommandPickerIcons.tsx new file mode 100644 index 00000000..be4cb453 --- /dev/null +++ b/frontend/src/app/components/CommandPickerIcons.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import SvgIcon from '@mui/material/SvgIcon'; +import LanguageIcon from '@mui/icons-material/Language'; +import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; +import ViewQuiltOutlinedIcon from '@mui/icons-material/ViewQuiltOutlined'; + +export const XLogoIcon: React.FC<{ sx?: object }> = ({ sx }) => ( + + + +); + +export const GoogleIcon: React.FC<{ sx?: object }> = ({ sx }) => ( + + + + + + +); + +export const RedditIcon: React.FC<{ sx?: object }> = ({ sx }) => ( + + + +); + +const TOOL_GROUP_ICONS: Record> = { + Twitter: XLogoIcon, + Google: GoogleIcon, + Reddit: RedditIcon, + Web: LanguageIcon, + View: ViewQuiltOutlinedIcon, +}; + +export function getToolGroupIcon(groupName: string, size: number = 15): React.ReactNode { + const Icon = TOOL_GROUP_ICONS[groupName]; + if (Icon) return ; + return ; +} diff --git a/frontend/src/app/components/DirectoryBrowser.tsx b/frontend/src/app/components/DirectoryBrowser.tsx index 00294dec..8b64e986 100644 --- a/frontend/src/app/components/DirectoryBrowser.tsx +++ b/frontend/src/app/components/DirectoryBrowser.tsx @@ -7,20 +7,10 @@ import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import TextField from '@mui/material/TextField'; -import List from '@mui/material/List'; -import ListItemButton from '@mui/material/ListItemButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import CircularProgress from '@mui/material/CircularProgress'; -import IconButton from '@mui/material/IconButton'; -import Breadcrumbs from '@mui/material/Breadcrumbs'; -import Link from '@mui/material/Link'; -import FolderIcon from '@mui/icons-material/Folder'; -import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; -import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { BrowseResult } from '@/shared/state/settingsSlice'; import { API_BASE } from '@/shared/config'; +import DirectoryFileList from './DirectoryFileList'; const SETTINGS_API = `${API_BASE}/settings`; @@ -98,9 +88,6 @@ const DirectoryBrowser: React.FC = ({ open, onClose, onSe onClose(); }; - const pathSegments = browseData?.current.split('/').filter(Boolean) ?? []; - const hasEntries = (browseData?.directories.length ?? 0) + (browseData?.files.length ?? 0) > 0; - return ( = ({ open, onClose, onSe - {browseData && ( - - - - - - browse('/')} - sx={{ color: c.text.tertiary, fontSize: '0.78rem' }} - > - / - - {pathSegments.map((seg, i) => { - const fullPath = '/' + pathSegments.slice(0, i + 1).join('/'); - const isLast = i === pathSegments.length - 1; - return isLast ? ( - - {seg} - - ) : ( - browse(fullPath)} - sx={{ color: c.text.tertiary, fontSize: '0.78rem' }} - > - {seg} - - ); - })} - - - )} - - {error && ( - - {error} - - )} - - - {loading ? ( - - - - ) : !hasEntries ? ( - - - Empty directory - - - ) : ( - - {browseData?.directories.map((dir) => ( - handleNavigate(dir)} - onClick={() => - setSelected((prev) => - prev?.name === dir && prev.type === 'directory' ? null : { name: dir, type: 'directory' }, - ) - } - sx={{ - py: 0.75, - '&.Mui-selected': { bgcolor: `${c.accent.primary}0c` }, - '&:hover': { bgcolor: `${c.accent.primary}08` }, - }} - > - - - - - - ))} - {browseData?.files.map((file) => ( - - setSelected((prev) => - prev?.name === file && prev.type === 'file' ? null : { name: file, type: 'file' }, - ) - } - sx={{ - py: 0.75, - '&.Mui-selected': { bgcolor: `${c.accent.primary}0c` }, - '&:hover': { bgcolor: `${c.accent.primary}08` }, - }} - > - - - - - - ))} - - )} - + diff --git a/frontend/src/app/components/DirectoryFileList.tsx b/frontend/src/app/components/DirectoryFileList.tsx new file mode 100644 index 00000000..b0c14964 --- /dev/null +++ b/frontend/src/app/components/DirectoryFileList.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import List from '@mui/material/List'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import CircularProgress from '@mui/material/CircularProgress'; +import IconButton from '@mui/material/IconButton'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import FolderIcon from '@mui/icons-material/Folder'; +import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { BrowseResult } from '@/shared/state/settingsSlice'; + +interface DirectoryFileListProps { + browseData: BrowseResult | null; + selected: { name: string; type: 'file' | 'directory' } | null; + loading: boolean; + error: string | null; + onBrowse: (path: string) => void; + onNavigate: (dir: string) => void; + onGoUp: () => void; + onSelect: (item: { name: string; type: 'file' | 'directory' } | null) => void; +} + +const DirectoryFileList: React.FC = ({ + browseData, selected, loading, error, onBrowse, onNavigate, onGoUp, onSelect, +}) => { + const c = useClaudeTokens(); + const pathSegments = browseData?.current.split('/').filter(Boolean) ?? []; + const hasEntries = (browseData?.directories.length ?? 0) + (browseData?.files.length ?? 0) > 0; + + return ( + <> + {browseData && ( + + + + + + onBrowse('/')} + sx={{ color: c.text.tertiary, fontSize: '0.78rem' }} + > + / + + {pathSegments.map((seg, i) => { + const fullPath = '/' + pathSegments.slice(0, i + 1).join('/'); + const isLast = i === pathSegments.length - 1; + return isLast ? ( + + {seg} + + ) : ( + onBrowse(fullPath)} + sx={{ color: c.text.tertiary, fontSize: '0.78rem' }} + > + {seg} + + ); + })} + + + )} + + {error && ( + + {error} + + )} + + + {loading ? ( + + + + ) : !hasEntries ? ( + + + Empty directory + + + ) : ( + + {browseData?.directories.map((dir) => ( + onNavigate(dir)} + onClick={() => + onSelect( + selected?.name === dir && selected.type === 'directory' ? null : { name: dir, type: 'directory' }, + ) + } + sx={{ + py: 0.75, + '&.Mui-selected': { bgcolor: `${c.accent.primary}0c` }, + '&:hover': { bgcolor: `${c.accent.primary}08` }, + }} + > + + + + + + ))} + {browseData?.files.map((file) => ( + + onSelect( + selected?.name === file && selected.type === 'file' ? null : { name: file, type: 'file' }, + ) + } + sx={{ + py: 0.75, + '&.Mui-selected': { bgcolor: `${c.accent.primary}0c` }, + '&:hover': { bgcolor: `${c.accent.primary}08` }, + }} + > + + + + + + ))} + + )} + + + ); +}; + +export default DirectoryFileList; diff --git a/frontend/src/app/components/DynamicIsland.tsx b/frontend/src/app/components/DynamicIsland.tsx deleted file mode 100644 index 76149127..00000000 --- a/frontend/src/app/components/DynamicIsland.tsx +++ /dev/null @@ -1,993 +0,0 @@ -import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import Collapse from '@mui/material/Collapse'; -import SearchIcon from '@mui/icons-material/Search'; -import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined'; -import CloseIcon from '@mui/icons-material/Close'; -import CheckIcon from '@mui/icons-material/Check'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import { motion, AnimatePresence } from 'framer-motion'; -import { useNavigate } from 'react-router-dom'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { - handleApproval, - stopAgent, - dismissAgentNotification, - dismissAllFinishedNotifications, - ApprovalRequest, - AgentSession, - HistorySession, -} from '@/shared/state/agentsSlice'; -import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; -import ApprovalBar, { BatchApprovalBar, parseMcpToolName, useMcpToolMeta, getToolIcon } from '@/app/pages/AgentChat/ApprovalBar'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type IslandState = 'idle' | 'compact' | 'compact-actionable' | 'expanded'; - -interface SessionApprovalGroup { - sessionId: string; - sessionName: string; - approvals: ApprovalRequest[]; -} - -type TrackedAgent = { - id: string; - name: string; - status: AgentSession['status'] | string; - dashboardId?: string; -}; - -const STATUS_CONFIG: Record = { - running: { label: 'Running', tokenKey: 'success' }, - waiting_approval: { label: 'Waiting', tokenKey: 'warning' }, - completed: { label: 'Done', tokenKey: 'success' }, - error: { label: 'Error', tokenKey: 'error' }, - stopped: { label: 'Stopped', tokenKey: 'info' }, -}; - -// --------------------------------------------------------------------------- -// Spring configs -// --------------------------------------------------------------------------- - -const SPRING_LAYOUT = { type: 'spring' as const, stiffness: 400, damping: 30 }; -const SPRING_BOUNCE = { type: 'spring' as const, stiffness: 500, damping: 25 }; - -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - -const StatusDot: React.FC<{ status: string; c: ReturnType }> = ({ status, c }) => { - const cfg = STATUS_CONFIG[status]; - const color = cfg?.tokenKey ? (c.status as any)[cfg.tokenKey] : c.text.ghost; - const isActive = status === 'running'; - return ( - - ); -}; - -const AgentStatusRow: React.FC<{ - agent: TrackedAgent; - c: ReturnType; - onStop: (id: string) => void; - onDismiss: (id: string) => void; - onNavigate: (dashboardId: string, agentId: string) => void; -}> = ({ agent, c, onStop, onDismiss, onNavigate }) => { - const isActive = agent.status === 'running' || agent.status === 'waiting_approval'; - const cfg = STATUS_CONFIG[agent.status] ?? { label: agent.status }; - - return ( - agent.dashboardId && onNavigate(agent.dashboardId, agent.id)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1, - px: 2, - py: 0.75, - cursor: agent.dashboardId ? 'pointer' : 'default', - '&:hover': { bgcolor: c.border.subtle }, - transition: 'background-color 0.15s', - minHeight: 34, - }} - > - - - {agent.name} - - - {cfg.label} - - {isActive ? ( - - { e.stopPropagation(); onStop(agent.id); }} - sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.border.subtle } }} - > - - - - ) : ( - - { e.stopPropagation(); onDismiss(agent.id); }} - sx={{ p: 0.25, color: c.text.ghost, '&:hover': { bgcolor: c.border.subtle } }} - > - - - - )} - - ); -}; - -// --------------------------------------------------------------------------- -// Compact activity indicator — subtle breathing dot -// --------------------------------------------------------------------------- - -const ActivityIndicator: React.FC<{ c: ReturnType }> = ({ c }) => ( - -); - -// --------------------------------------------------------------------------- -// Main component -// --------------------------------------------------------------------------- - -const DynamicIsland: React.FC = () => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - const navigate = useNavigate(); - const islandRef = useRef(null); - - const sessions = useAppSelector((state) => state.agents.sessions); - const history = useAppSelector((state) => state.agents.history); - const trackedIds = useAppSelector((state) => state.agents.trackedNotificationIds); - - const [userExpanded, setUserExpanded] = useState(false); - - // ---- Derived data ---- - - const groups: SessionApprovalGroup[] = useMemo(() => { - const result: SessionApprovalGroup[] = []; - for (const [sessionId, session] of Object.entries(sessions)) { - if (session.pending_approvals?.length > 0) { - result.push({ - sessionId, - sessionName: session.name || 'Agent', - approvals: session.pending_approvals, - }); - } - } - return result; - }, [sessions]); - - const totalApprovals = useMemo( - () => groups.reduce((sum, g) => sum + g.approvals.length, 0), - [groups], - ); - - const trackedAgents: TrackedAgent[] = useMemo(() => { - const agents = trackedIds - .map((id): TrackedAgent | null => { - const session = sessions[id]; - if (session && session.status !== 'draft') { - return { id, name: session.name, status: session.status, dashboardId: session.dashboard_id }; - } - const hist: HistorySession | undefined = history[id]; - if (hist) { - return { id, name: hist.name, status: hist.status, dashboardId: hist.dashboard_id }; - } - return null; - }) - .filter((a): a is TrackedAgent => a !== null); - - const trackedIdSet = new Set(trackedIds); - for (const g of groups) { - if (!trackedIdSet.has(g.sessionId)) { - const session = sessions[g.sessionId]; - if (session && session.status !== 'draft') { - agents.push({ id: g.sessionId, name: session.name, status: session.status, dashboardId: session.dashboard_id }); - } - } - } - - return agents; - }, [trackedIds, sessions, history, groups]); - - const activeAgents = useMemo( - () => trackedAgents.filter((a) => a.status === 'running' || a.status === 'waiting_approval'), - [trackedAgents], - ); - const finishedAgents = useMemo( - () => trackedAgents.filter((a) => a.status !== 'running' && a.status !== 'waiting_approval'), - [trackedAgents], - ); - - const hasApprovals = totalApprovals > 0; - const hasAgents = trackedAgents.length > 0; - - const hasOnlyQuestionApprovals = useMemo(() => { - if (!hasApprovals) return false; - const allApprovals = groups.flatMap((g) => g.approvals); - return allApprovals.every((a) => a.tool_name === 'AskUserQuestion'); - }, [hasApprovals, groups]); - - const nonQuestionApprovalCount = useMemo( - () => groups.reduce((sum, g) => sum + g.approvals.filter((a) => a.tool_name !== 'AskUserQuestion').length, 0), - [groups], - ); - - const oldestNonQuestionApproval = useMemo(() => { - const all = groups - .flatMap((g) => g.approvals) - .filter((a) => a.tool_name !== 'AskUserQuestion'); - if (all.length === 0) return null; - return all.reduce((oldest, a) => - a.created_at < oldest.created_at ? a : oldest, - ); - }, [groups]); - - // ---- Island state machine ---- - - const islandState: IslandState = useMemo(() => { - if (userExpanded && (hasAgents || hasApprovals)) return 'expanded'; - if (hasApprovals && hasOnlyQuestionApprovals) return 'expanded'; - if (hasApprovals) return 'compact-actionable'; - if (hasAgents) return 'compact'; - return 'idle'; - }, [hasApprovals, hasOnlyQuestionApprovals, userExpanded, hasAgents]); - - useEffect(() => { - if (!hasAgents && !hasApprovals) { - setUserExpanded(false); - } - }, [hasAgents, hasApprovals]); - - // ---- Click outside to collapse ---- - - useEffect(() => { - if (islandState !== 'expanded') return; - const handler = (e: MouseEvent) => { - if (islandRef.current && !islandRef.current.contains(e.target as Node)) { - setUserExpanded(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [islandState]); - - // ---- Callbacks ---- - - const onApprove = useCallback( - (requestId: string, updatedInput?: Record) => { - dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput })); - }, - [dispatch], - ); - - const onDeny = useCallback( - (requestId: string, message?: string) => { - dispatch(handleApproval({ requestId, behavior: 'deny', message })); - }, - [dispatch], - ); - - const onStopAgent = useCallback( - (sessionId: string) => dispatch(stopAgent({ sessionId })), - [dispatch], - ); - - const onDismissAgent = useCallback( - (sessionId: string) => dispatch(dismissAgentNotification(sessionId)), - [dispatch], - ); - - const onNavigateToDashboard = useCallback( - (dashboardId: string, agentId: string) => { - dispatch(setPendingFocusAgentId(agentId)); - navigate(`/dashboard/${dashboardId}`); - }, - [navigate, dispatch], - ); - - const onApproveAllNonQuestion = useCallback(() => { - for (const g of groups) { - for (const req of g.approvals) { - if (req.tool_name !== 'AskUserQuestion') { - dispatch(handleApproval({ requestId: req.id, behavior: 'allow' })); - } - } - } - }, [dispatch, groups]); - - const onDenyAllNonQuestion = useCallback(() => { - for (const g of groups) { - for (const req of g.approvals) { - if (req.tool_name !== 'AskUserQuestion') { - dispatch(handleApproval({ requestId: req.id, behavior: 'deny' })); - } - } - } - }, [dispatch, groups]); - - const onClearAllFinished = useCallback(() => { - dispatch(dismissAllFinishedNotifications()); - }, [dispatch]); - - const handleIslandClick = useCallback(() => { - if (islandState === 'compact' || islandState === 'compact-actionable') { - setUserExpanded(true); - } else if (islandState === 'expanded') { - setUserExpanded(false); - } - }, [islandState]); - - // ---- Styling — uses the same neutral palette as the rest of the UI ---- - - const islandWidth = islandState === 'idle' - ? 200 - : islandState === 'compact' - ? 210 - : islandState === 'compact-actionable' - ? 310 - : 400; - - const islandBorderRadius = islandState === 'expanded' ? 14 : 50; - - const shadow = islandState === 'idle' - ? 'none' - : islandState === 'compact' - ? c.shadow.sm - : c.shadow.md; - - // ---- Compact summary text ---- - - const compactText = useMemo(() => { - const parts: string[] = []; - if (activeAgents.length > 0) { - parts.push(`${activeAgents.length} running`); - } - if (finishedAgents.length > 0) { - parts.push(`${finishedAgents.length} done`); - } - return parts.join(' · ') || 'Agents'; - }, [activeAgents.length, finishedAgents.length]); - - const glowKeyframes = useMemo(() => ` - @keyframes approvalGlow { - 0%, 100% { box-shadow: 0 0 6px 1px ${c.status.warning}30; } - 50% { box-shadow: 0 0 12px 3px ${c.status.warning}60; } - } - `, [c.status.warning]); - - // ---- Render ---- - - return ( - <> - {islandState === 'compact-actionable' && } - - - - {islandState === 'idle' && ( - - )} - {islandState === 'compact' && ( - - )} - {islandState === 'compact-actionable' && oldestNonQuestionApproval && ( - setUserExpanded(true)} - /> - )} - {islandState === 'expanded' && ( - setUserExpanded(false)} - /> - )} - - - - - ); -}; - -// --------------------------------------------------------------------------- -// Idle pill — disabled search bar -// --------------------------------------------------------------------------- - -const IdlePill: React.FC<{ c: ReturnType }> = ({ c }) => ( - - - - - - Search... - - - - -); - -// --------------------------------------------------------------------------- -// Compact pill -// --------------------------------------------------------------------------- - -const CompactPill: React.FC<{ - c: ReturnType; - text: string; - activeCount: number; - hasApprovals: boolean; -}> = ({ c, text, activeCount, hasApprovals }) => ( - - - - - {text} - - {hasApprovals && ( - - )} - - -); - -// --------------------------------------------------------------------------- -// Compact-actionable pill — single approval with icon + name + approve/deny -// --------------------------------------------------------------------------- - -const CompactActionablePill: React.FC<{ - c: ReturnType; - request: ApprovalRequest; - remainingCount: number; - onApprove: (requestId: string) => void; - onDeny: (requestId: string) => void; - onExpand: () => void; -}> = ({ c, request, remainingCount, onApprove, onDeny, onExpand }) => { - const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]); - const meta = useMcpToolMeta(parsed); - - const icon = parsed.isMcp - ? (meta.integration?.icon || null) - : getToolIcon(request.tool_name); - - return ( - - - - {icon} - - - {parsed.displayName} - - {remainingCount > 1 && ( - - +{remainingCount - 1} - - )} - - { e.stopPropagation(); onApprove(request.id); }} - sx={{ - p: 0, - width: 18, - height: 18, - color: '#fff', - bgcolor: c.status.success, - '&:hover': { bgcolor: c.status.success, filter: 'brightness(0.85)' }, - }} - > - - - - - { e.stopPropagation(); onDeny(request.id); }} - sx={{ - p: 0, - width: 18, - height: 18, - color: c.status.error, - border: `1px solid ${c.status.error}`, - '&:hover': { bgcolor: `${c.status.error}0a` }, - }} - > - - - - - { e.stopPropagation(); onExpand(); }} - sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }} - > - - - - - - ); -}; - -// --------------------------------------------------------------------------- -// Expanded card -// --------------------------------------------------------------------------- - -const ExpandedCard: React.FC<{ - c: ReturnType; - groups: SessionApprovalGroup[]; - totalApprovals: number; - activeAgents: TrackedAgent[]; - finishedAgents: TrackedAgent[]; - hasApprovals: boolean; - hasAgents: boolean; - onApprove: (requestId: string, updatedInput?: Record) => void; - onDeny: (requestId: string, message?: string) => void; - onStopAgent: (id: string) => void; - onDismissAgent: (id: string) => void; - onNavigateToDashboard: (dashboardId: string, agentId: string) => void; - onClearAllFinished: () => void; - onCollapse: () => void; -}> = ({ - c, groups, totalApprovals, - activeAgents, finishedAgents, hasApprovals, hasAgents, - onApprove, onDeny, onStopAgent, onDismissAgent, onNavigateToDashboard, onClearAllFinished, onCollapse, -}) => { - const [completedExpanded, setCompletedExpanded] = useState(false); - const headerTitle = hasApprovals && !hasAgents - ? 'Approval Required' - : hasAgents && !hasApprovals - ? 'Agents' - : 'Notifications'; - - const badgeCount = totalApprovals + activeAgents.length; - - return ( - - {/* Header */} - - - {headerTitle} - - {badgeCount > 0 && ( - - {badgeCount} - - )} - {!hasApprovals && ( - { e.stopPropagation(); onCollapse(); }} - sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }} - > - - - )} - - - {/* Content */} - - {hasApprovals && ( - - {hasAgents && ( - - Approvals - - )} - {groups.map((group) => ( - - {groups.length > 1 && ( - - {group.sessionName} - - )} - {group.approvals.length > 1 ? ( - - ) : ( - group.approvals.map((req) => ( - - )) - )} - - ))} - - )} - - {hasApprovals && hasAgents && ( - - )} - - {hasAgents && ( - - {hasApprovals && ( - - Agents - - )} - {activeAgents.map((agent) => ( - - ))} - {finishedAgents.length > 0 && ( - <> - {activeAgents.length > 0 && ( - - )} - setCompletedExpanded((v) => !v)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1, - px: 2, - py: 0.5, - cursor: 'pointer', - userSelect: 'none', - '&:hover': { bgcolor: c.border.subtle }, - transition: 'background-color 0.15s', - }} - > - - Completed ({finishedAgents.length}) - - { e.stopPropagation(); onClearAllFinished(); }} - sx={{ - fontSize: '0.58rem', - fontWeight: 600, - color: c.text.ghost, - cursor: 'pointer', - '&:hover': { color: c.text.secondary }, - transition: 'color 0.15s', - }} - > - Clear all - - - {completedExpanded - ? - : } - - - - {finishedAgents.map((agent) => ( - - ))} - - - )} - - )} - - - ); -}; - -export default DynamicIsland; diff --git a/frontend/src/app/components/DynamicIsland/AgentStatusRow.tsx b/frontend/src/app/components/DynamicIsland/AgentStatusRow.tsx new file mode 100644 index 00000000..510b91fd --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/AgentStatusRow.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined'; +import CloseIcon from '@mui/icons-material/Close'; +import { StatusDot } from './StatusDot'; +import { STATUS_CONFIG } from './islandTypes'; +import type { ClaudeTokens, TrackedAgent } from './islandTypes'; + +export const AgentStatusRow: React.FC<{ + agent: TrackedAgent; + c: ClaudeTokens; + onStop: (id: string) => void; + onDismiss: (id: string) => void; + onNavigate: (dashboardId: string, agentId: string) => void; +}> = ({ agent, c, onStop, onDismiss, onNavigate }) => { + const isActive = agent.status === 'running' || agent.status === 'waiting_approval'; + const cfg = STATUS_CONFIG[agent.status] ?? { label: agent.status }; + + return ( + agent.dashboardId && onNavigate(agent.dashboardId, agent.id)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + px: 2, + py: 0.75, + cursor: agent.dashboardId ? 'pointer' : 'default', + '&:hover': { bgcolor: c.border.subtle }, + transition: 'background-color 0.15s', + minHeight: 34, + }} + > + + + {agent.name} + + + {cfg.label} + + {isActive ? ( + + { e.stopPropagation(); onStop(agent.id); }} + sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.border.subtle } }} + > + + + + ) : ( + + { e.stopPropagation(); onDismiss(agent.id); }} + sx={{ p: 0.25, color: c.text.ghost, '&:hover': { bgcolor: c.border.subtle } }} + > + + + + )} + + ); +}; diff --git a/frontend/src/app/components/DynamicIsland/CompactActionablePill.tsx b/frontend/src/app/components/DynamicIsland/CompactActionablePill.tsx new file mode 100644 index 00000000..688f52c2 --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/CompactActionablePill.tsx @@ -0,0 +1,132 @@ +import React, { useMemo } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import CloseIcon from '@mui/icons-material/Close'; +import CheckIcon from '@mui/icons-material/Check'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { motion } from 'framer-motion'; +import { parseMcpToolName, useMcpToolMeta, getToolIcon } from '@/app/pages/AgentChat/ApprovalBar'; +import { SPRING_BOUNCE } from './islandTypes'; +import type { ClaudeTokens } from './islandTypes'; +import type { ApprovalRequest } from '@/shared/state/agentsSlice'; + +export const CompactActionablePill: React.FC<{ + c: ClaudeTokens; + request: ApprovalRequest; + remainingCount: number; + onApprove: (requestId: string) => void; + onDeny: (requestId: string) => void; + onExpand: () => void; +}> = ({ c, request, remainingCount, onApprove, onDeny, onExpand }) => { + const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]); + const meta = useMcpToolMeta(parsed); + + const icon = parsed.isMcp + ? (meta.integration?.icon || null) + : getToolIcon(request.tool_name); + + return ( + + + + {icon} + + + {parsed.displayName} + + {remainingCount > 1 && ( + + +{remainingCount - 1} + + )} + + { e.stopPropagation(); onApprove(request.id); }} + sx={{ + p: 0, + width: 18, + height: 18, + color: '#fff', + bgcolor: c.status.success, + '&:hover': { bgcolor: c.status.success, filter: 'brightness(0.85)' }, + }} + > + + + + + { e.stopPropagation(); onDeny(request.id); }} + sx={{ + p: 0, + width: 18, + height: 18, + color: c.status.error, + border: `1px solid ${c.status.error}`, + '&:hover': { bgcolor: `${c.status.error}0a` }, + }} + > + + + + + { e.stopPropagation(); onExpand(); }} + sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }} + > + + + + + + ); +}; diff --git a/frontend/src/app/components/DynamicIsland/CompactPill.tsx b/frontend/src/app/components/DynamicIsland/CompactPill.tsx new file mode 100644 index 00000000..5623c774 --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/CompactPill.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { motion } from 'framer-motion'; +import { ActivityIndicator } from './StatusDot'; +import { SPRING_BOUNCE } from './islandTypes'; +import type { ClaudeTokens } from './islandTypes'; + +export const CompactPill: React.FC<{ + c: ClaudeTokens; + text: string; + activeCount: number; + hasApprovals: boolean; +}> = ({ c, text, activeCount, hasApprovals }) => ( + + + + + {text} + + {hasApprovals && ( + + )} + + +); diff --git a/frontend/src/app/components/DynamicIsland/CompletedAgentsList.tsx b/frontend/src/app/components/DynamicIsland/CompletedAgentsList.tsx new file mode 100644 index 00000000..1ef4ee63 --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/CompletedAgentsList.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Collapse from '@mui/material/Collapse'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { AgentStatusRow } from './AgentStatusRow'; +import type { ClaudeTokens, TrackedAgent } from './islandTypes'; + +export const CompletedAgentsList: React.FC<{ + c: ClaudeTokens; + finishedAgents: TrackedAgent[]; + showDivider: boolean; + onStopAgent: (id: string) => void; + onDismissAgent: (id: string) => void; + onNavigateToDashboard: (dashboardId: string, agentId: string) => void; + onClearAllFinished: () => void; +}> = ({ c, finishedAgents, showDivider, onStopAgent, onDismissAgent, onNavigateToDashboard, onClearAllFinished }) => { + const [completedExpanded, setCompletedExpanded] = useState(false); + + if (finishedAgents.length === 0) return null; + + return ( + <> + {showDivider && ( + + )} + setCompletedExpanded((v) => !v)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + px: 2, + py: 0.5, + cursor: 'pointer', + userSelect: 'none', + '&:hover': { bgcolor: c.border.subtle }, + transition: 'background-color 0.15s', + }} + > + + Completed ({finishedAgents.length}) + + { e.stopPropagation(); onClearAllFinished(); }} + sx={{ + fontSize: '0.58rem', + fontWeight: 600, + color: c.text.ghost, + cursor: 'pointer', + '&:hover': { color: c.text.secondary }, + transition: 'color 0.15s', + }} + > + Clear all + + + {completedExpanded + ? + : } + + + + {finishedAgents.map((agent) => ( + + ))} + + + ); +}; diff --git a/frontend/src/app/components/DynamicIsland/DynamicIsland.tsx b/frontend/src/app/components/DynamicIsland/DynamicIsland.tsx new file mode 100644 index 00000000..cd3bfa8d --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/DynamicIsland.tsx @@ -0,0 +1,159 @@ +import React, { useMemo, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { SPRING_LAYOUT, SPRING_BOUNCE } from './islandTypes'; +import { IdlePill } from './IdlePill'; +import { CompactPill } from './CompactPill'; +import { CompactActionablePill } from './CompactActionablePill'; +import { ExpandedCard } from './ExpandedCard'; +import { useDynamicIslandData } from './hooks/useDynamicIslandData'; +import { useDynamicIslandActions } from './hooks/useDynamicIslandActions'; + +const DynamicIsland: React.FC = () => { + const c = useClaudeTokens(); + const islandRef = useRef(null); + + const { + groups, + totalApprovals, + activeAgents, + finishedAgents, + hasApprovals, + hasAgents, + nonQuestionApprovalCount, + oldestNonQuestionApproval, + islandState, + userExpanded, + setUserExpanded, + } = useDynamicIslandData(); + + const { + onApprove, + onDeny, + onStopAgent, + onDismissAgent, + onNavigateToDashboard, + onClearAllFinished, + handleIslandClick, + } = useDynamicIslandActions(groups, islandState, hasAgents, hasApprovals, setUserExpanded, islandRef); + + const islandWidth = islandState === 'idle' + ? 200 + : islandState === 'compact' + ? 210 + : islandState === 'compact-actionable' + ? 310 + : 400; + + const islandBorderRadius = islandState === 'expanded' ? 14 : 50; + + const shadow = islandState === 'idle' + ? 'none' + : islandState === 'compact' + ? c.shadow.sm + : c.shadow.md; + + const compactText = useMemo(() => { + const parts: string[] = []; + if (activeAgents.length > 0) parts.push(`${activeAgents.length} running`); + if (finishedAgents.length > 0) parts.push(`${finishedAgents.length} done`); + return parts.join(' · ') || 'Agents'; + }, [activeAgents.length, finishedAgents.length]); + + const glowKeyframes = useMemo(() => ` + @keyframes approvalGlow { + 0%, 100% { box-shadow: 0 0 6px 1px ${c.status.warning}30; } + 50% { box-shadow: 0 0 12px 3px ${c.status.warning}60; } + } + `, [c.status.warning]); + + return ( + <> + {islandState === 'compact-actionable' && } + + + + {islandState === 'idle' && ( + + )} + {islandState === 'compact' && ( + + )} + {islandState === 'compact-actionable' && oldestNonQuestionApproval && ( + setUserExpanded(true)} + /> + )} + {islandState === 'expanded' && ( + setUserExpanded(false)} + /> + )} + + + + + ); +}; + +export default DynamicIsland; diff --git a/frontend/src/app/components/DynamicIsland/ExpandedCard.tsx b/frontend/src/app/components/DynamicIsland/ExpandedCard.tsx new file mode 100644 index 00000000..80e75dd7 --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/ExpandedCard.tsx @@ -0,0 +1,213 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; +import { motion } from 'framer-motion'; +import ApprovalBar, { BatchApprovalBar } from '@/app/pages/AgentChat/ApprovalBar'; +import { AgentStatusRow } from './AgentStatusRow'; +import { CompletedAgentsList } from './CompletedAgentsList'; +import type { ClaudeTokens, SessionApprovalGroup, TrackedAgent } from './islandTypes'; + +export const ExpandedCard: React.FC<{ + c: ClaudeTokens; + groups: SessionApprovalGroup[]; + totalApprovals: number; + activeAgents: TrackedAgent[]; + finishedAgents: TrackedAgent[]; + hasApprovals: boolean; + hasAgents: boolean; + onApprove: (requestId: string, updatedInput?: Record) => void; + onDeny: (requestId: string, message?: string) => void; + onStopAgent: (id: string) => void; + onDismissAgent: (id: string) => void; + onNavigateToDashboard: (dashboardId: string, agentId: string) => void; + onClearAllFinished: () => void; + onCollapse: () => void; +}> = ({ + c, groups, totalApprovals, + activeAgents, finishedAgents, hasApprovals, hasAgents, + onApprove, onDeny, onStopAgent, onDismissAgent, onNavigateToDashboard, onClearAllFinished, onCollapse, +}) => { + const headerTitle = hasApprovals && !hasAgents + ? 'Approval Required' + : hasAgents && !hasApprovals + ? 'Agents' + : 'Notifications'; + + const badgeCount = totalApprovals + activeAgents.length; + + return ( + + {/* Header */} + + + {headerTitle} + + {badgeCount > 0 && ( + + {badgeCount} + + )} + {!hasApprovals && ( + { e.stopPropagation(); onCollapse(); }} + sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }} + > + + + )} + + + {/* Content */} + + {hasApprovals && ( + + {hasAgents && ( + + Approvals + + )} + {groups.map((group) => ( + + {groups.length > 1 && ( + + {group.sessionName} + + )} + {group.approvals.length > 1 ? ( + + ) : ( + group.approvals.map((req) => ( + + )) + )} + + ))} + + )} + + {hasApprovals && hasAgents && ( + + )} + + {hasAgents && ( + + {hasApprovals && ( + + Agents + + )} + {activeAgents.map((agent) => ( + + ))} + 0} + onStopAgent={onStopAgent} + onDismissAgent={onDismissAgent} + onNavigateToDashboard={onNavigateToDashboard} + onClearAllFinished={onClearAllFinished} + /> + + )} + + + ); +}; diff --git a/frontend/src/app/components/DynamicIsland/IdlePill.tsx b/frontend/src/app/components/DynamicIsland/IdlePill.tsx new file mode 100644 index 00000000..b04d25d5 --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/IdlePill.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import SearchIcon from '@mui/icons-material/Search'; +import { motion } from 'framer-motion'; +import type { ClaudeTokens } from './islandTypes'; + +export const IdlePill: React.FC<{ c: ClaudeTokens }> = ({ c }) => ( + + + + + + Search... + + + + +); diff --git a/frontend/src/app/components/DynamicIsland/StatusDot.tsx b/frontend/src/app/components/DynamicIsland/StatusDot.tsx new file mode 100644 index 00000000..f6625bde --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/StatusDot.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import { STATUS_CONFIG } from './islandTypes'; +import type { ClaudeTokens } from './islandTypes'; + +export const StatusDot: React.FC<{ status: string; c: ClaudeTokens }> = ({ status, c }) => { + const cfg = STATUS_CONFIG[status]; + const color = cfg?.tokenKey ? (c.status as any)[cfg.tokenKey] : c.text.ghost; + const isActive = status === 'running'; + return ( + + ); +}; + +export const ActivityIndicator: React.FC<{ c: ClaudeTokens }> = ({ c }) => ( + +); diff --git a/frontend/src/app/components/DynamicIsland/hooks/useDynamicIslandActions.ts b/frontend/src/app/components/DynamicIsland/hooks/useDynamicIslandActions.ts new file mode 100644 index 00000000..0fdae39f --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/hooks/useDynamicIslandActions.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect } from 'react'; +import type { MutableRefObject } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAppDispatch } from '@/shared/hooks'; +import { + handleApproval, + stopAgent, + dismissAgentNotification, + dismissAllFinishedNotifications, +} from '@/shared/state/agentsSlice'; +import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; +import type { IslandState, SessionApprovalGroup } from '../islandTypes'; + +export function useDynamicIslandActions( + groups: SessionApprovalGroup[], + islandState: IslandState, + hasAgents: boolean, + hasApprovals: boolean, + setUserExpanded: (v: boolean) => void, + islandRef: MutableRefObject, +) { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + useEffect(() => { + if (!hasAgents && !hasApprovals) { + setUserExpanded(false); + } + }, [hasAgents, hasApprovals, setUserExpanded]); + + useEffect(() => { + if (islandState !== 'expanded') return; + const handler = (e: MouseEvent) => { + if (islandRef.current && !islandRef.current.contains(e.target as Node)) { + setUserExpanded(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [islandState, islandRef, setUserExpanded]); + + const onApprove = useCallback( + (requestId: string, updatedInput?: Record) => { + dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput })); + }, + [dispatch], + ); + + const onDeny = useCallback( + (requestId: string, message?: string) => { + dispatch(handleApproval({ requestId, behavior: 'deny', message })); + }, + [dispatch], + ); + + const onStopAgent = useCallback( + (sessionId: string) => dispatch(stopAgent({ sessionId })), + [dispatch], + ); + + const onDismissAgent = useCallback( + (sessionId: string) => dispatch(dismissAgentNotification(sessionId)), + [dispatch], + ); + + const onNavigateToDashboard = useCallback( + (dashboardId: string, agentId: string) => { + dispatch(setPendingFocusAgentId(agentId)); + navigate(`/dashboard/${dashboardId}`); + }, + [navigate, dispatch], + ); + + const onApproveAllNonQuestion = useCallback(() => { + for (const g of groups) { + for (const req of g.approvals) { + if (req.tool_name !== 'AskUserQuestion') { + dispatch(handleApproval({ requestId: req.id, behavior: 'allow' })); + } + } + } + }, [dispatch, groups]); + + const onDenyAllNonQuestion = useCallback(() => { + for (const g of groups) { + for (const req of g.approvals) { + if (req.tool_name !== 'AskUserQuestion') { + dispatch(handleApproval({ requestId: req.id, behavior: 'deny' })); + } + } + } + }, [dispatch, groups]); + + const onClearAllFinished = useCallback(() => { + dispatch(dismissAllFinishedNotifications()); + }, [dispatch]); + + const handleIslandClick = useCallback(() => { + if (islandState === 'compact' || islandState === 'compact-actionable') { + setUserExpanded(true); + } else if (islandState === 'expanded') { + setUserExpanded(false); + } + }, [islandState, setUserExpanded]); + + return { + onApprove, + onDeny, + onStopAgent, + onDismissAgent, + onNavigateToDashboard, + onApproveAllNonQuestion, + onDenyAllNonQuestion, + onClearAllFinished, + handleIslandClick, + }; +} diff --git a/frontend/src/app/components/DynamicIsland/hooks/useDynamicIslandData.ts b/frontend/src/app/components/DynamicIsland/hooks/useDynamicIslandData.ts new file mode 100644 index 00000000..6fbb6e7d --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/hooks/useDynamicIslandData.ts @@ -0,0 +1,114 @@ +import { useMemo, useState } from 'react'; +import { useAppSelector } from '@/shared/hooks'; +import type { HistorySession } from '@/shared/state/agentsSlice'; +import type { IslandState, SessionApprovalGroup, TrackedAgent } from '../islandTypes'; + +export function useDynamicIslandData() { + const sessions = useAppSelector((state) => state.agents.sessions); + const history = useAppSelector((state) => state.agents.history); + const trackedIds = useAppSelector((state) => state.agents.trackedNotificationIds); + + const [userExpanded, setUserExpanded] = useState(false); + + const groups: SessionApprovalGroup[] = useMemo(() => { + const result: SessionApprovalGroup[] = []; + for (const [sessionId, session] of Object.entries(sessions)) { + if (session.pending_approvals?.length > 0) { + result.push({ + sessionId, + sessionName: session.name || 'Agent', + approvals: session.pending_approvals, + }); + } + } + return result; + }, [sessions]); + + const totalApprovals = useMemo( + () => groups.reduce((sum, g) => sum + g.approvals.length, 0), + [groups], + ); + + const trackedAgents: TrackedAgent[] = useMemo(() => { + const agents = trackedIds + .map((id): TrackedAgent | null => { + const session = sessions[id]; + if (session && session.status !== 'draft') { + return { id, name: session.name, status: session.status, dashboardId: session.dashboard_id }; + } + const hist: HistorySession | undefined = history[id]; + if (hist) { + return { id, name: hist.name, status: hist.status, dashboardId: hist.dashboard_id }; + } + return null; + }) + .filter((a): a is TrackedAgent => a !== null); + + const trackedIdSet = new Set(trackedIds); + for (const g of groups) { + if (!trackedIdSet.has(g.sessionId)) { + const session = sessions[g.sessionId]; + if (session && session.status !== 'draft') { + agents.push({ id: g.sessionId, name: session.name, status: session.status, dashboardId: session.dashboard_id }); + } + } + } + + return agents; + }, [trackedIds, sessions, history, groups]); + + const activeAgents = useMemo( + () => trackedAgents.filter((a) => a.status === 'running' || a.status === 'waiting_approval'), + [trackedAgents], + ); + const finishedAgents = useMemo( + () => trackedAgents.filter((a) => a.status !== 'running' && a.status !== 'waiting_approval'), + [trackedAgents], + ); + + const hasApprovals = totalApprovals > 0; + const hasAgents = trackedAgents.length > 0; + + const hasOnlyQuestionApprovals = useMemo(() => { + if (!hasApprovals) return false; + const allApprovals = groups.flatMap((g) => g.approvals); + return allApprovals.every((a) => a.tool_name === 'AskUserQuestion'); + }, [hasApprovals, groups]); + + const nonQuestionApprovalCount = useMemo( + () => groups.reduce((sum, g) => sum + g.approvals.filter((a) => a.tool_name !== 'AskUserQuestion').length, 0), + [groups], + ); + + const oldestNonQuestionApproval = useMemo(() => { + const all = groups + .flatMap((g) => g.approvals) + .filter((a) => a.tool_name !== 'AskUserQuestion'); + if (all.length === 0) return null; + return all.reduce((oldest, a) => + a.created_at < oldest.created_at ? a : oldest, + ); + }, [groups]); + + const islandState: IslandState = useMemo(() => { + if (userExpanded && (hasAgents || hasApprovals)) return 'expanded'; + if (hasApprovals && hasOnlyQuestionApprovals) return 'expanded'; + if (hasApprovals) return 'compact-actionable'; + if (hasAgents) return 'compact'; + return 'idle'; + }, [hasApprovals, hasOnlyQuestionApprovals, userExpanded, hasAgents]); + + return { + groups, + totalApprovals, + activeAgents, + finishedAgents, + hasApprovals, + hasAgents, + nonQuestionApprovalCount, + oldestNonQuestionApproval, + islandState, + userExpanded, + setUserExpanded, + }; +} diff --git a/frontend/src/app/components/DynamicIsland/index.ts b/frontend/src/app/components/DynamicIsland/index.ts new file mode 100644 index 00000000..be56269a --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/index.ts @@ -0,0 +1 @@ +export { default } from './DynamicIsland'; diff --git a/frontend/src/app/components/DynamicIsland/islandTypes.ts b/frontend/src/app/components/DynamicIsland/islandTypes.ts new file mode 100644 index 00000000..c0b46b67 --- /dev/null +++ b/frontend/src/app/components/DynamicIsland/islandTypes.ts @@ -0,0 +1,30 @@ +import type { ApprovalRequest, AgentSession } from '@/shared/state/agentsSlice'; +import type { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +export type ClaudeTokens = ReturnType; + +export type IslandState = 'idle' | 'compact' | 'compact-actionable' | 'expanded'; + +export interface SessionApprovalGroup { + sessionId: string; + sessionName: string; + approvals: ApprovalRequest[]; +} + +export type TrackedAgent = { + id: string; + name: string; + status: AgentSession['status'] | string; + dashboardId?: string; +}; + +export const STATUS_CONFIG: Record = { + running: { label: 'Running', tokenKey: 'success' }, + waiting_approval: { label: 'Waiting', tokenKey: 'warning' }, + completed: { label: 'Done', tokenKey: 'success' }, + error: { label: 'Error', tokenKey: 'error' }, + stopped: { label: 'Stopped', tokenKey: 'info' }, +}; + +export const SPRING_LAYOUT = { type: 'spring' as const, stiffness: 400, damping: 30 }; +export const SPRING_BOUNCE = { type: 'spring' as const, stiffness: 500, damping: 25 }; diff --git a/frontend/src/app/components/GoogleServiceIcon.tsx b/frontend/src/app/components/GoogleServiceIcon.tsx new file mode 100644 index 00000000..45a47c38 --- /dev/null +++ b/frontend/src/app/components/GoogleServiceIcon.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => { + if (service === 'gmail') { + return ( + + + + + + + + + ); + } + if (service === 'calendar') { + return ( + + + + 31 + + ); + } + if (service === 'drive' || service === 'sheets') { + return ( + + + + + + + ); + } + return null; +}; + +export default GoogleServiceIcon; diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 59776b65..bfbadc41 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -1,931 +1,97 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { openSettingsModal } from '@/shared/state/settingsSlice'; +import React, { useState, useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; import Box from '@mui/material/Box'; -import ListItemButton from '@mui/material/ListItemButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import Collapse from '@mui/material/Collapse'; import Button from '@mui/material/Button'; import Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; -import InputBase from '@mui/material/InputBase'; -import DashboardIcon from '@mui/icons-material/Dashboard'; -import DescriptionIcon from '@mui/icons-material/Description'; -import PsychologyIcon from '@mui/icons-material/Psychology'; -import BuildIcon from '@mui/icons-material/Build'; -import TuneIcon from '@mui/icons-material/Tune'; -import ViewQuiltIcon from '@mui/icons-material/ViewQuilt'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import AddIcon from '@mui/icons-material/Add'; -import SettingsIcon from '@mui/icons-material/Settings'; -import ExtensionIcon from '@mui/icons-material/Extension'; -import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined'; -import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined'; -import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; -import CloseIcon from '@mui/icons-material/Close'; -import LinearProgress from '@mui/material/LinearProgress'; import Settings from '@/app/pages/Settings/Settings'; -import DynamicIsland from '@/app/components/DynamicIsland'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice'; -import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice'; -import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice'; +import { fetchDashboards } from '@/shared/state/dashboardsSlice'; import { fetchOutputs } from '@/shared/state/outputsSlice'; -import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; - -const SIDEBAR_MIN = 160; -const SIDEBAR_MAX = 400; -const SIDEBAR_DEFAULT = 220; -const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width'; -const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed'; - -const CUSTOMIZATION_ITEMS = [ - { label: 'Prompts', path: '/templates', icon: }, - { label: 'Skills', path: '/skills', icon: }, - { label: 'Actions', path: '/actions', icon: }, - { label: 'Modes', path: '/modes', icon: }, -]; - -const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path)); - - +import { useUpdateNotification } from './hooks/useUpdateNotification'; +import { useSidebarResize } from './hooks/useSidebarResize'; +import { useUrlInterception } from './hooks/useUrlInterception'; +import TitleBar from './TitleBar'; +import Sidebar from './Sidebar'; +import UpdateBanner from './UpdateBanner'; const AppShell: React.FC = () => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const navigate = useNavigate(); - const location = useLocation(); - const [dashboardsExpanded, setDashboardsExpanded] = useState(true); - const [appsExpanded, setAppsExpanded] = useState(true); - const [customizationExpanded, setCustomizationExpanded] = useState(true); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [renamingDashboardId, setRenamingDashboardId] = useState(null); - const [renameValue, setRenameValue] = useState(''); - const [sidebarWidth, setSidebarWidth] = useState(() => { - try { - const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY); - if (stored) { - const w = Number(stored); - if (w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) return w; - } - } catch {} - return SIDEBAR_DEFAULT; - }); - const isResizing = useRef(false); - const updateStatus = useAppSelector((state) => state.update.status); - const availableVersion = useAppSelector((state) => state.update.availableVersion); - const downloadPercent = useAppSelector((state) => state.update.downloadPercent); + const { + updateStatus, availableVersion, downloadPercent, + snackbarDismissed, setSnackbarDismissed, + showUpdateDot, showUpdateBanner, showUpdateSnackbar, + handleDismissBanner, handleDownloadUpdate, handleInstallUpdate, + } = useUpdateNotification(); - const [dismissedVersion, setDismissedVersion] = useState(() => { - try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; } - }); - const [snackbarDismissed, setSnackbarDismissed] = useState(false); + const { sidebarWidth, handleResizeStart, handleResizeDoubleClick } = useSidebarResize(); - const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion; - const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading'; - - const showUpdateDot = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion; - const showUpdateBanner = isUpdateActionable && !bannerDismissedForVersion; - const showUpdateSnackbar = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion && !snackbarDismissed; - - const handleDismissBanner = useCallback(() => { - if (availableVersion) { - try { localStorage.setItem(UPDATE_DISMISS_KEY, availableVersion); } catch {} - setDismissedVersion(availableVersion); - } - }, [availableVersion]); - - const handleDownloadUpdate = useCallback(async () => { - try { await (window as any).openswarm?.downloadUpdate(); } catch {} - }, []); - - const handleInstallUpdate = useCallback(() => { - (window as any).openswarm?.installUpdate(); - }, []); - - const dashboardItems = useAppSelector((state) => state.dashboards.items); + const dashboardItems = useAppSelector((s) => s.dashboards.items); const dashboardList = Object.values(dashboardItems).sort( (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), ); - const outputItems = useAppSelector((state) => state.outputs.items); - const appsList = Object.values(outputItems).sort( - (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), - ); + useUrlInterception(dashboardList); useEffect(() => { dispatch(fetchDashboards()); dispatch(fetchOutputs()); }, [dispatch]); - const openUrlInBrowser = useCallback((url: string, webContentsId?: number) => { - const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/); - if (dashMatch) { - if (webContentsId != null) { - const browserId = findBrowserByWebContentsId(webContentsId); - if (browserId) { - dispatch(addBrowserTab({ browserId, url, makeActive: true })); - return; - } - } - dispatch(addBrowserCard({ url })); - } else { - dispatch(setPendingBrowserUrl(url)); - const lastId = (window as any).__openswarm_last_dashboard_id as string | undefined; - const firstDashboard = dashboardList[0]; - const targetId = lastId || firstDashboard?.id; - if (targetId) { - navigate(`/dashboard/${targetId}`); - } else { - dispatch(createDashboard('Untitled Dashboard')).then((result: any) => { - if (createDashboard.fulfilled.match(result)) { - navigate(`/dashboard/${result.payload.id}`); - } - }); - } - } - }, [location.pathname, dashboardList, dispatch, navigate]); - - useEffect(() => { - let lastUrl = ''; - let lastTime = 0; - - const handleClick = (e: MouseEvent) => { - const anchor = (e.target as HTMLElement)?.closest?.('a'); - if (!anchor) return; - const href = anchor.getAttribute('href'); - if (!href) return; - if (!/^https?:\/\//i.test(href)) return; - if (href.startsWith('http://localhost:')) return; - - e.preventDefault(); - e.stopPropagation(); - - const now = Date.now(); - if (href === lastUrl && now - lastTime < 1000) return; - lastUrl = href; - lastTime = now; - - openUrlInBrowser(href); - }; - - document.addEventListener('click', handleClick, true); - return () => document.removeEventListener('click', handleClick, true); - }, [openUrlInBrowser]); - - useEffect(() => { - const w = window as any; - if (!w.openswarm?.onWebviewNewWindow) return; - let lastUrl = ''; - let lastTime = 0; - return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number) => { - const now = Date.now(); - if (url === lastUrl && now - lastTime < 1000) return; - lastUrl = url; - lastTime = now; - openUrlInBrowser(url, webContentsId); - }); - }, [openUrlInBrowser]); - - useEffect(() => { - try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {} - }, [sidebarWidth]); - - const handleResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - isResizing.current = true; - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - - const onMouseMove = (ev: MouseEvent) => { - if (!isResizing.current) return; - setSidebarWidth(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, ev.clientX))); - }; - - const onMouseUp = () => { - isResizing.current = false; - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }, []); - - const handleResizeDoubleClick = useCallback(() => { - setSidebarWidth(SIDEBAR_DEFAULT); - }, []); - - const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/'); - const isAppsRoute = location.pathname === '/apps' || location.pathname.startsWith('/apps/'); - const isCustomizationRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname); - const activeDashboardId = location.pathname.startsWith('/dashboard/') - ? location.pathname.split('/dashboard/')[1] - : null; - const activeAppId = location.pathname.startsWith('/apps/') - ? location.pathname.split('/apps/')[1] - : null; - - const handleDashboardsClick = () => { - if (isDashboardRoute && location.pathname === '/') { - setDashboardsExpanded((prev) => !prev); - } else { - navigate('/'); - setDashboardsExpanded(true); - } - }; - - const handleDashboardItemClick = (dashboardId: string) => { - if (renamingDashboardId === dashboardId) return; - navigate(`/dashboard/${dashboardId}`); - }; - - const handleStartDashboardRename = (id: string, currentName: string) => { - setRenamingDashboardId(id); - setRenameValue(currentName); - }; - - const handleDashboardRenameSubmit = (id: string) => { - const trimmed = renameValue.trim(); - if (trimmed && trimmed !== dashboardItems[id]?.name) { - dispatch(renameDashboard({ id, name: trimmed })); - } - setRenamingDashboardId(null); - }; - - const handleCreateDashboard = async (e: React.MouseEvent) => { - e.stopPropagation(); - const result = await dispatch(createDashboard('Untitled Dashboard')); - if (createDashboard.fulfilled.match(result)) { - navigate(`/dashboard/${result.payload.id}`); - } - }; - - const handleAppsClick = () => { - if (isAppsRoute && location.pathname === '/apps') { - setAppsExpanded((prev) => !prev); - } else { - navigate('/apps'); - setAppsExpanded(true); - } - }; - - const handleCreateApp = (e: React.MouseEvent) => { - e.stopPropagation(); - navigate('/apps/new'); - }; - return ( - {/* Draggable title bar */} - - - setSidebarCollapsed((prev) => !prev)} - sx={{ - WebkitAppRegion: 'no-drag', - color: c.text.tertiary, - p: 0.5, - borderRadius: 1, - '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, - }} - > - - - - - navigate(-1)} - sx={{ - WebkitAppRegion: 'no-drag', - color: c.text.tertiary, - p: 0.5, - borderRadius: 1, - '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, - }} - > - - - - - navigate(1)} - sx={{ - WebkitAppRegion: 'no-drag', - color: c.text.tertiary, - p: 0.5, - borderRadius: 1, - '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, - }} - > - - - - - - - - - - - - OpenSwarm - - - + setSidebarCollapsed((p) => !p)} + /> {showUpdateBanner && ( - - - - {updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`} - {updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}…`} - {updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`} - - {updateStatus === 'downloading' && ( - - )} - {updateStatus === 'downloading' && ( - - {Math.round(downloadPercent)}% - - )} - {updateStatus === 'available' && ( - - )} - {updateStatus === 'downloaded' && ( - - )} - - - - + )} - {!sidebarCollapsed && ( - <> - - - {/* Dashboards section */} - - + + + + - - - - - - - - - - {dashboardList.length > 0 && ( - - )} - - - 0} timeout={200}> - - {dashboardList.map((entry) => { - const isActive = activeDashboardId === entry.id; - const isRenaming = renamingDashboardId === entry.id; - return ( - handleDashboardItemClick(entry.id)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 0.75, - pl: 1.25, - pr: 1, - py: isRenaming ? 0.25 : 0.5, - ml: '-0.5px', - cursor: isRenaming ? 'default' : 'pointer', - borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent', - bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent', - '&:hover': { bgcolor: `${c.text.tertiary}0A` }, - transition: 'background-color 0.12s, border-color 0.12s', - }} - > - {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => handleDashboardRenameSubmit(entry.id)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleDashboardRenameSubmit(entry.id); - if (e.key === 'Escape') setRenamingDashboardId(null); - }} - onClick={(e) => e.stopPropagation()} - onFocus={(e) => e.target.select()} - sx={{ - flex: 1, - minWidth: 0, - fontSize: '0.78rem', - fontWeight: isActive ? 500 : 400, - color: isActive ? c.text.secondary : c.text.ghost, - py: 0, - px: 0.5, - borderRadius: 0.75, - border: `1px solid ${c.accent.primary}80`, - bgcolor: `${c.bg.page}`, - '& input': { - padding: '1px 0', - }, - }} - /> - ) : ( - { - e.stopPropagation(); - handleStartDashboardRename(entry.id, entry.name); - }} - sx={{ - color: isActive ? c.text.secondary : c.text.ghost, - fontSize: '0.78rem', - fontWeight: isActive ? 500 : 400, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - flex: 1, - minWidth: 0, - }} - > - {entry.name} - - )} - - ); - })} - - - - - {/* Divider */} - - - {/* Customization section */} - - { - if (isCustomizationRoute) { - setCustomizationExpanded((prev) => !prev); - } else { - navigate('/customization'); - setCustomizationExpanded(true); - } - }} - sx={{ - borderRadius: 1.5, - py: 0.6, - px: 1.25, - bgcolor: isCustomizationRoute ? `${c.accent.primary}12` : 'transparent', - '&:hover': { bgcolor: isCustomizationRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` }, - transition: 'background-color 0.15s', - }} - > - - - - - - - - - - {CUSTOMIZATION_ITEMS.map((item) => ( - - {({ isActive }) => ( - - - {item.label} - - - )} - - ))} - - - - - {/* Divider */} - - - {/* Apps section */} - - - - - - - - - - - - {appsList.length > 0 && ( - - )} - - - 0} timeout={200}> - - {appsList.map((app) => { - const isActive = activeAppId === app.id; - return ( - navigate(`/apps/${app.id}`)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 0.75, - pl: 1.25, - pr: 1, - py: 0.5, - ml: '-0.5px', - cursor: 'pointer', - borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent', - bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent', - '&:hover': { bgcolor: `${c.text.tertiary}0A` }, - transition: 'background-color 0.12s, border-color 0.12s', - }} - > - - {app.name} - - - ); - })} - - - - - - - {/* Settings */} - - dispatch(openSettingsModal())} - sx={{ - borderRadius: 1.5, - py: 0.6, - px: 1.25, - '&:hover': { bgcolor: `${c.text.tertiary}0A` }, - transition: 'background-color 0.15s', - }} - > - - - {showUpdateDot && ( - - )} - - - + + )} + + - - - )} - - - - - @@ -943,54 +109,31 @@ const AppShell: React.FC = () => { } action={ - {updateStatus === 'available' && ( - )} {updateStatus === 'downloaded' && ( - )} } sx={{ - bgcolor: c.bg.surface, - color: c.text.primary, - border: `1px solid ${c.border.medium}`, - boxShadow: c.shadow.md, + bgcolor: c.bg.surface, color: c.text.primary, + border: `1px solid ${c.border.medium}`, boxShadow: c.shadow.md, '& .MuiAlert-icon': { color: c.accent.primary }, }} > diff --git a/frontend/src/app/components/Layout/Sidebar.tsx b/frontend/src/app/components/Layout/Sidebar.tsx new file mode 100644 index 00000000..25c486af --- /dev/null +++ b/frontend/src/app/components/Layout/Sidebar.tsx @@ -0,0 +1,227 @@ +import React, { useState } from 'react'; +import { NavLink, useNavigate, useLocation } from 'react-router-dom'; +import { openSettingsModal } from '@/shared/state/settingsSlice'; +import Box from '@mui/material/Box'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Collapse from '@mui/material/Collapse'; +import InputBase from '@mui/material/InputBase'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import DescriptionIcon from '@mui/icons-material/Description'; +import PsychologyIcon from '@mui/icons-material/Psychology'; +import BuildIcon from '@mui/icons-material/Build'; +import TuneIcon from '@mui/icons-material/Tune'; +import ViewQuiltIcon from '@mui/icons-material/ViewQuilt'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import AddIcon from '@mui/icons-material/Add'; +import SettingsIcon from '@mui/icons-material/Settings'; +import ExtensionIcon from '@mui/icons-material/Extension'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +const CUSTOMIZATION_ITEMS = [ + { label: 'Prompts', path: '/templates', icon: }, + { label: 'Skills', path: '/skills', icon: }, + { label: 'Actions', path: '/actions', icon: }, + { label: 'Modes', path: '/modes', icon: }, +]; +const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path)); + +interface SidebarProps { showUpdateDot: boolean } + +const Sidebar: React.FC = ({ showUpdateDot }) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + const [dashExpanded, setDashExpanded] = useState(true); + const [appsExpanded, setAppsExpanded] = useState(true); + const [customExpanded, setCustomExpanded] = useState(true); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + + const dashboardItems = useAppSelector((s) => s.dashboards.items); + const dashboardList = Object.values(dashboardItems).sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + const appsList = Object.values(useAppSelector((s) => s.outputs.items)).sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + + const isDashRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/'); + const isAppsRoute = location.pathname === '/apps' || location.pathname.startsWith('/apps/'); + const isCustomRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname); + const activeDashId = location.pathname.startsWith('/dashboard/') ? location.pathname.split('/dashboard/')[1] : null; + const activeAppId = location.pathname.startsWith('/apps/') ? location.pathname.split('/apps/')[1] : null; + + const handleDashClick = () => { + if (isDashRoute && location.pathname === '/') setDashExpanded((p) => !p); + else { navigate('/'); setDashExpanded(true); } + }; + const handleDashItemClick = (id: string) => { if (renamingId !== id) navigate(`/dashboard/${id}`); }; + const handleStartRename = (id: string, name: string) => { setRenamingId(id); setRenameValue(name); }; + const handleRenameSubmit = (id: string) => { + const t = renameValue.trim(); + if (t && t !== dashboardItems[id]?.name) dispatch(renameDashboard({ id, name: t })); + setRenamingId(null); + }; + const handleCreateDash = async (e: React.MouseEvent) => { + e.stopPropagation(); + const r = await dispatch(createDashboard('Untitled Dashboard')); + if (createDashboard.fulfilled.match(r)) navigate(`/dashboard/${r.payload.id}`); + }; + const handleAppsClick = () => { + if (isAppsRoute && location.pathname === '/apps') setAppsExpanded((p) => !p); + else { navigate('/apps'); setAppsExpanded(true); } + }; + const handleCreateApp = (e: React.MouseEvent) => { e.stopPropagation(); navigate('/apps/new'); }; + + const sectionSx = (a: boolean) => ({ borderRadius: 1.5, py: 0.6, px: 1.25, + bgcolor: a ? `${c.accent.primary}12` : 'transparent', + '&:hover': { bgcolor: a ? `${c.accent.primary}18` : `${c.text.tertiary}0A` }, transition: 'background-color 0.15s' }); + const sectionTextSx = (a: boolean) => ({ '& .MuiListItemText-primary': { + color: a ? c.text.primary : c.text.muted, fontSize: '0.82rem', fontWeight: a ? 600 : 400 } }); + const subItemSx = (a: boolean) => ({ display: 'flex', alignItems: 'center', gap: 0.75, pl: 1.25, pr: 1, py: 0.5, + ml: '-0.5px', cursor: 'pointer', borderLeft: a ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent', + bgcolor: a ? `${c.accent.primary}0C` : 'transparent', + '&:hover': { bgcolor: `${c.text.tertiary}0A` }, transition: 'background-color 0.12s, border-color 0.12s' }); + const subTextSx = (a: boolean) => ({ color: a ? c.text.secondary : c.text.ghost, fontSize: '0.78rem', + fontWeight: a ? 500 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }); + const scrollSx = { ml: 2, mt: 0.25, mb: 0.5, borderLeft: `1px solid ${c.border.medium}`, maxHeight: 240, overflow: 'auto', + '&::-webkit-scrollbar': { width: 3 }, '&::-webkit-scrollbar-track': { background: 'transparent' }, + '&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 4 }, + scrollbarWidth: 'thin' as const, scrollbarColor: `${c.border.medium} transparent` }; + const chevronSx = (exp: boolean) => ({ color: c.text.ghost, fontSize: 16, transition: 'transform 0.2s', + transform: exp ? 'rotate(180deg)' : 'rotate(0deg)' }); + const addBtnSx = { color: c.text.ghost, p: 0.25, mr: 0.25, borderRadius: 1, + '&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}14` } }; + + return ( + <> + + + + + + + + + + + + + {dashboardList.length > 0 && } + + 0} timeout={200}> + + {dashboardList.map((entry) => { + const isActive = activeDashId === entry.id; + const isRen = renamingId === entry.id; + return ( + handleDashItemClick(entry.id)} + sx={{ ...subItemSx(isActive), py: isRen ? 0.25 : 0.5, cursor: isRen ? 'default' : 'pointer' }}> + {isRen ? ( + setRenameValue(e.target.value)} + onBlur={() => handleRenameSubmit(entry.id)} + onKeyDown={(e) => { if (e.key === 'Enter') handleRenameSubmit(entry.id); if (e.key === 'Escape') setRenamingId(null); }} + onClick={(e) => e.stopPropagation()} + onFocus={(e) => e.target.select()} + sx={{ flex: 1, minWidth: 0, fontSize: '0.78rem', fontWeight: isActive ? 500 : 400, + color: isActive ? c.text.secondary : c.text.ghost, py: 0, px: 0.5, + borderRadius: 0.75, border: `1px solid ${c.accent.primary}80`, bgcolor: c.bg.page, + '& input': { padding: '1px 0' } }} + /> + ) : ( + { e.stopPropagation(); handleStartRename(entry.id, entry.name); }} + sx={subTextSx(isActive)}> + {entry.name} + + )} + + ); + })} + + + + + + { + if (isCustomRoute) setCustomExpanded((p) => !p); + else { navigate('/customization'); setCustomExpanded(true); } + }} sx={sectionSx(isCustomRoute)}> + + + + + + + + + {CUSTOMIZATION_ITEMS.map((item) => ( + + {({ isActive }) => ( + + {item.label} + + )} + + ))} + + + + + + + + + + + + + + + + {appsList.length > 0 && } + + 0} timeout={200}> + + {appsList.map((app) => { + const isActive = activeAppId === app.id; + return ( + navigate(`/apps/${app.id}`)} sx={subItemSx(isActive)}> + {app.name} + + ); + })} + + + + + + dispatch(openSettingsModal())} sx={{ + borderRadius: 1.5, py: 0.6, px: 1.25, + '&:hover': { bgcolor: `${c.text.tertiary}0A` }, transition: 'background-color 0.15s', + }}> + + + {showUpdateDot && ( + + )} + + + + + + ); +}; + +export default Sidebar; diff --git a/frontend/src/app/components/Layout/TitleBar.tsx b/frontend/src/app/components/Layout/TitleBar.tsx new file mode 100644 index 00000000..edb14bd5 --- /dev/null +++ b/frontend/src/app/components/Layout/TitleBar.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined'; +import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined'; +import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined'; +import DynamicIsland from '@/app/components/DynamicIsland'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +interface TitleBarProps { + sidebarCollapsed: boolean; + onToggleSidebar: () => void; +} + +const TitleBar: React.FC = ({ sidebarCollapsed, onToggleSidebar }) => { + const c = useClaudeTokens(); + const navigate = useNavigate(); + + const navBtnSx = { + WebkitAppRegion: 'no-drag', + color: c.text.tertiary, + p: 0.5, + borderRadius: 1, + '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, + }; + + return ( + + + + + + + + navigate(-1)} sx={navBtnSx}> + + + + + navigate(1)} sx={navBtnSx}> + + + + + + + + + + + + OpenSwarm + + + + ); +}; + +export default TitleBar; diff --git a/frontend/src/app/components/Layout/UpdateBanner.tsx b/frontend/src/app/components/Layout/UpdateBanner.tsx new file mode 100644 index 00000000..fb6f1301 --- /dev/null +++ b/frontend/src/app/components/Layout/UpdateBanner.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import LinearProgress from '@mui/material/LinearProgress'; +import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; +import CloseIcon from '@mui/icons-material/Close'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +interface UpdateBannerProps { + updateStatus: string; + availableVersion: string | null; + downloadPercent: number; + onDownload: () => void; + onInstall: () => void; + onDismiss: () => void; +} + +const UpdateBanner: React.FC = ({ + updateStatus, availableVersion, downloadPercent, + onDownload, onInstall, onDismiss, +}) => { + const c = useClaudeTokens(); + + const actionBtnSx = { + bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed }, + textTransform: 'none' as const, fontSize: '0.75rem', fontWeight: 600, + borderRadius: 1.5, minWidth: 'auto', py: 0.25, px: 1.5, + lineHeight: 1.5, flexShrink: 0, + }; + + return ( + + + + {updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`} + {updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}…`} + {updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`} + + {updateStatus === 'downloading' && ( + + )} + {updateStatus === 'downloading' && ( + + {Math.round(downloadPercent)}% + + )} + {updateStatus === 'available' && ( + + )} + {updateStatus === 'downloaded' && ( + + )} + + + + + ); +}; + +export default UpdateBanner; diff --git a/frontend/src/app/components/Layout/hooks/useSidebarResize.ts b/frontend/src/app/components/Layout/hooks/useSidebarResize.ts new file mode 100644 index 00000000..c4bcdc25 --- /dev/null +++ b/frontend/src/app/components/Layout/hooks/useSidebarResize.ts @@ -0,0 +1,53 @@ +import React, { useState, useRef, useCallback, useEffect } from 'react'; + +const SIDEBAR_MIN = 160; +const SIDEBAR_MAX = 400; +const SIDEBAR_DEFAULT = 220; +const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width'; + +export function useSidebarResize() { + const [sidebarWidth, setSidebarWidth] = useState(() => { + try { + const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY); + if (stored) { + const w = Number(stored); + if (w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) return w; + } + } catch {} + return SIDEBAR_DEFAULT; + }); + const isResizing = useRef(false); + + useEffect(() => { + try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {} + }, [sidebarWidth]); + + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isResizing.current = true; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const onMouseMove = (ev: MouseEvent) => { + if (!isResizing.current) return; + setSidebarWidth(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, ev.clientX))); + }; + + const onMouseUp = () => { + isResizing.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, []); + + const handleResizeDoubleClick = useCallback(() => { + setSidebarWidth(SIDEBAR_DEFAULT); + }, []); + + return { sidebarWidth, handleResizeStart, handleResizeDoubleClick }; +} diff --git a/frontend/src/app/components/Layout/hooks/useUpdateNotification.ts b/frontend/src/app/components/Layout/hooks/useUpdateNotification.ts new file mode 100644 index 00000000..f4e55e81 --- /dev/null +++ b/frontend/src/app/components/Layout/hooks/useUpdateNotification.ts @@ -0,0 +1,50 @@ +import { useState, useCallback } from 'react'; +import { useAppSelector } from '@/shared/hooks'; + +const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed'; + +export function useUpdateNotification() { + const updateStatus = useAppSelector((s) => s.update.status); + const availableVersion = useAppSelector((s) => s.update.availableVersion); + const downloadPercent = useAppSelector((s) => s.update.downloadPercent); + + const [dismissedVersion, setDismissedVersion] = useState(() => { + try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; } + }); + const [snackbarDismissed, setSnackbarDismissed] = useState(false); + + const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion; + const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading'; + const showUpdateDot = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion; + const showUpdateBanner = isUpdateActionable && !bannerDismissedForVersion; + const showUpdateSnackbar = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion && !snackbarDismissed; + + const handleDismissBanner = useCallback(() => { + if (availableVersion) { + try { localStorage.setItem(UPDATE_DISMISS_KEY, availableVersion); } catch {} + setDismissedVersion(availableVersion); + } + }, [availableVersion]); + + const handleDownloadUpdate = useCallback(async () => { + try { await (window as any).openswarm?.downloadUpdate(); } catch {} + }, []); + + const handleInstallUpdate = useCallback(() => { + (window as any).openswarm?.installUpdate(); + }, []); + + return { + updateStatus, + availableVersion, + downloadPercent, + snackbarDismissed, + setSnackbarDismissed, + showUpdateDot, + showUpdateBanner, + showUpdateSnackbar, + handleDismissBanner, + handleDownloadUpdate, + handleInstallUpdate, + }; +} diff --git a/frontend/src/app/components/Layout/hooks/useUrlInterception.ts b/frontend/src/app/components/Layout/hooks/useUrlInterception.ts new file mode 100644 index 00000000..ece35ec0 --- /dev/null +++ b/frontend/src/app/components/Layout/hooks/useUrlInterception.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAppDispatch } from '@/shared/hooks'; +import { createDashboard } from '@/shared/state/dashboardsSlice'; +import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice'; +import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice'; +import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; + +export function useUrlInterception(dashboardList: { id: string }[]) { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + + const openUrlInBrowser = useCallback((url: string, webContentsId?: number) => { + const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/); + if (dashMatch) { + if (webContentsId != null) { + const browserId = findBrowserByWebContentsId(webContentsId); + if (browserId) { + dispatch(addBrowserTab({ browserId, url, makeActive: true })); + return; + } + } + dispatch(addBrowserCard({ url })); + } else { + dispatch(setPendingBrowserUrl(url)); + const lastId = (window as any).__openswarm_last_dashboard_id as string | undefined; + const firstDashboard = dashboardList[0]; + const targetId = lastId || firstDashboard?.id; + if (targetId) { + navigate(`/dashboard/${targetId}`); + } else { + dispatch(createDashboard('Untitled Dashboard')).then((result: any) => { + if (createDashboard.fulfilled.match(result)) { + navigate(`/dashboard/${result.payload.id}`); + } + }); + } + } + }, [location.pathname, dashboardList, dispatch, navigate]); + + useEffect(() => { + let lastUrl = ''; + let lastTime = 0; + + const handleClick = (e: MouseEvent) => { + const anchor = (e.target as HTMLElement)?.closest?.('a'); + if (!anchor) return; + const href = anchor.getAttribute('href'); + if (!href) return; + if (!/^https?:\/\//i.test(href)) return; + if (href.startsWith('http://localhost:')) return; + + e.preventDefault(); + e.stopPropagation(); + + const now = Date.now(); + if (href === lastUrl && now - lastTime < 1000) return; + lastUrl = href; + lastTime = now; + + openUrlInBrowser(href); + }; + + document.addEventListener('click', handleClick, true); + return () => document.removeEventListener('click', handleClick, true); + }, [openUrlInBrowser]); + + useEffect(() => { + const w = window as any; + if (!w.openswarm?.onWebviewNewWindow) return; + let lastUrl = ''; + let lastTime = 0; + return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number) => { + const now = Date.now(); + if (url === lastUrl && now - lastTime < 1000) return; + lastUrl = url; + lastTime = now; + openUrlInBrowser(url, webContentsId); + }); + }, [openUrlInBrowser]); +} diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx index ec16d03d..35646a62 100644 --- a/frontend/src/app/components/OnboardingModal.tsx +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -1,284 +1,16 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Box, Typography, Modal, Button, CircularProgress } from '@mui/material'; -import LinkIcon from '@mui/icons-material/Link'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import { useAppSelector } from '@/shared/hooks'; +import React from 'react'; +import { Box, Modal } from '@mui/material'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { API_BASE } from '@/shared/config'; - -const ONBOARDING_TOOL_INTEGRATIONS = [ - { name: 'Google Workspace', desc: 'Gmail, Calendar, Drive, Docs, Sheets', color: '#4285F4', oauthProvider: 'google', - mcp_config: { type: 'stdio', command: 'uvx', args: ['--from', 'google-workspace-mcp', 'google-workspace-worker'] } }, - { name: 'GitHub', desc: 'Repos, issues, pull requests', color: '#24292E', oauthProvider: 'github', - mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] } }, - { name: 'Slack', desc: 'Channels, messages, search', color: '#4A154B', oauthProvider: 'slack', - mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack'] } }, - { name: 'Notion', desc: 'Pages, databases, search', color: '#000000', oauthProvider: 'notion', - mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@notionhq/notion-mcp-server'] } }, -]; - -const SUBSCRIPTION_PROVIDERS = [ - { id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false }, - { id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true }, - { id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, o3, o4-mini', color: '#74AA9C', preview: true }, - { id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models', color: '#8B949E', preview: true }, -]; +import { useOnboarding } from './useOnboarding'; +import ProviderStep from './ProviderStep'; +import ToolsStep from './ToolsStep'; const OnboardingModal: React.FC = () => { const c = useClaudeTokens(); - const settings = useAppSelector((s) => s.settings); - const [open, setOpen] = useState(false); - const [step, setStep] = useState<'provider' | 'tools'>('provider'); - const [connecting, setConnecting] = useState(null); - const [nineRouterReady, setNineRouterReady] = useState(null); - const [connectedTools, setConnectedTools] = useState>(new Set()); - const pollTimerRef = useRef(null); - const msgHandlerRef = useRef(null); - - // Poll for 9Router readiness (it may still be starting when onboarding shows) - useEffect(() => { - let attempts = 0; - const maxAttempts = 15; // 30 seconds - const check = () => { - fetch(`${API_BASE}/subscriptions/status`) - .then((r) => r.json()) - .then((data) => { - if (data.running) { - // Check if already has subscription - const connections = data.providers?.connections || []; - if (connections.some((p: any) => p.isActive)) { - // Already connected — don't show onboarding - return; - } - // Delay before marking ready — 9Router's OAuth needs time to warm up - setTimeout(() => setNineRouterReady(true), 3000); - } else { - attempts++; - if (attempts < maxAttempts) { - setTimeout(check, 2000); - } else { - setNineRouterReady(false); - } - } - }) - .catch(() => { - attempts++; - if (attempts < maxAttempts) setTimeout(check, 2000); - else setNineRouterReady(false); - }); - }; - check(); - }, []); - - // Show once: if not previously dismissed - useEffect(() => { - const alreadySeen = localStorage.getItem('openswarm_onboarding_seen'); - if (alreadySeen === 'true') return; - if (nineRouterReady === null) return; // still checking - - setOpen(true); - }, [nineRouterReady]); - - // Cleanup timers on unmount - useEffect(() => { - return () => { - if (pollTimerRef.current) clearInterval(pollTimerRef.current); - if (msgHandlerRef.current) window.removeEventListener('message', msgHandlerRef.current); - }; - }, []); - - const dismiss = () => { - localStorage.setItem('openswarm_onboarding_seen', 'true'); - if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } - if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } - setConnecting(null); - setOpen(false); - }; - - // Same connect logic as Settings/SubscriptionCards - const handleConnect = async (providerId: string) => { - // Cancel any previous attempt - if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } - if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } - setConnecting(providerId); - - // Delay before calling connect — avoids Claude OAuth rate limit on retries - await new Promise(r => setTimeout(r, 1000)); - - try { - const r = await fetch(`${API_BASE}/subscriptions/connect`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider: providerId }), - }); - if (!r.ok) { - setConnecting(null); - return; - } - const data = await r.json(); - - if (data.flow === 'device_code') { - if (data.verification_uri) window.open(data.verification_uri, '_blank'); - - const timer = setInterval(async () => { - try { - const pr = await fetch(`${API_BASE}/subscriptions/poll`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - provider: providerId, - device_code: data.device_code, - code_verifier: data.code_verifier, - extra_data: data.extra_data, - }), - }); - const pd = await pr.json(); - if (pd.success) { - clearInterval(timer); - pollTimerRef.current = null; - advanceToTools(); - } - } catch {} - }, 5000); - pollTimerRef.current = timer; - setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 30000); - - } else if (data.flow === 'authorization_code') { - const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); - - // Poll status as primary detection (works in Electron where postMessage may not) - const statusPoller = setInterval(async () => { - try { - const sr = await fetch(`${API_BASE}/subscriptions/status`); - const sd = await sr.json(); - const connections = sd.providers?.connections || []; - if (connections.some((p: any) => p.provider === providerId && p.isActive)) { - clearInterval(statusPoller); - pollTimerRef.current = null; - if (msgHandlerRef.current) { - window.removeEventListener('message', msgHandlerRef.current); - msgHandlerRef.current = null; - } - advanceToTools(); - } - } catch {} - }, 2000); - pollTimerRef.current = statusPoller; - - // Also listen for postMessage from callback page (faster when it works) - const msgHandler = async (event: MessageEvent) => { - const d = event.data; - const callbackData = d?.type === 'oauth_callback' ? d.data : d; - if (callbackData?.code) { - window.removeEventListener('message', msgHandler); - msgHandlerRef.current = null; - if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } - if (popup && !popup.closed) popup.close(); - try { - await fetch(`${API_BASE}/subscriptions/exchange`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - provider: providerId, - code: callbackData.code, - redirect_uri: data.redirect_uri, - code_verifier: data.code_verifier, - state: callbackData.state || data.state, - }), - }); - } catch {} - advanceToTools(); - } - }; - window.addEventListener('message', msgHandler); - msgHandlerRef.current = msgHandler; - - setTimeout(() => { - if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } - if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } - setConnecting(null); - }, 30000); - - } else { - setConnecting(null); - } - } catch { - setConnecting(null); - } - }; - - const advanceToTools = () => { - if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } - if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } - setConnecting(null); - setStep('tools'); - }; - - const handleToolConnect = async (integration: typeof ONBOARDING_TOOL_INTEGRATIONS[0]) => { - setConnecting(integration.name); - try { - // Create the tool - const createRes = await fetch(`${API_BASE}/tools/create`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: integration.name, - description: integration.desc, - mcp_config: integration.mcp_config, - auth_type: 'oauth2', - auth_status: 'configured', - oauth_provider: integration.oauthProvider, - }), - }); - if (!createRes.ok) { setConnecting(null); return; } - const { tool } = await createRes.json(); - - // Start OAuth - const oauthRes = await fetch(`${API_BASE}/tools/${tool.id}/oauth/start`, { method: 'POST' }); - if (!oauthRes.ok) { setConnecting(null); return; } - const { auth_url } = await oauthRes.json(); - - // Open popup - const popup = window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100'); - - // Listen for completion - const onMsg = (event: MessageEvent) => { - if (event.data?.type === 'oauth_complete' && event.data?.tool_id === tool.id) { - window.removeEventListener('message', onMsg); - setConnectedTools((prev) => new Set(prev).add(integration.name)); - setConnecting(null); - // Trigger discovery in background - fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {}); - } - }; - window.addEventListener('message', onMsg); - - // Fallback: poll for popup close - const poller = setInterval(() => { - if (popup && popup.closed) { - clearInterval(poller); - window.removeEventListener('message', onMsg); - // Check if connected - fetch(`${API_BASE}/tools/${tool.id}`) - .then(r => r.json()) - .then(data => { - if (data.tool?.auth_status === 'connected') { - setConnectedTools((prev) => new Set(prev).add(integration.name)); - fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {}); - } - }) - .catch(() => {}); - setConnecting(null); - } - }, 1000); - setTimeout(() => { clearInterval(poller); setConnecting(null); }, 60000); - } catch { - setConnecting(null); - } - }; - - const handleApiKey = () => advanceToTools(); - const handleSkip = () => step === 'tools' ? dismiss() : dismiss(); + const { + open, step, connecting, nineRouterReady, connectedTools, + dismiss, handleConnect, handleToolConnect, handleApiKey, handleSkip, + } = useOnboarding(); if (!open) return null; @@ -290,132 +22,20 @@ const OnboardingModal: React.FC = () => { boxShadow: '0 20px 60px rgba(0,0,0,0.4)', }}> {step === 'tools' ? ( - <> - - Connect Your Accounts - - - 10+ tools already active with no setup needed - - - Connect services below for even more capabilities - - - - {ONBOARDING_TOOL_INTEGRATIONS.map((ig) => { - const isConnected = connectedTools.has(ig.name); - const isConnecting = connecting === ig.name; - return ( - !isConnected && !isConnecting && !connecting && handleToolConnect(ig)} - sx={{ - display: 'flex', alignItems: 'center', justifyContent: 'space-between', - p: 1.5, borderRadius: `${c.radius.md}px`, - border: `1px solid ${isConnected ? `${ig.color}40` : c.border.subtle}`, - cursor: isConnected ? 'default' : connecting ? 'wait' : 'pointer', - bgcolor: isConnected ? `${ig.color}08` : 'transparent', - transition: 'border-color 0.15s, background 0.15s', - ...(!isConnected && !connecting && { '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}05` } }), - }} - > - - {ig.name} - {ig.desc} - - {isConnected ? ( - - ) : ( - - {isConnecting ? 'Connecting...' : 'Connect \u2192'} - - )} - - ); - })} - - - - + ) : ( - <> - - Welcome to OpenSwarm - - - Connect an AI model to get started - - - {/* Subscription options */} - - Use your existing subscription - - - {SUBSCRIPTION_PROVIDERS.map((p) => ( - !p.preview && !connecting && nineRouterReady && handleConnect(p.id)} - sx={{ - display: 'flex', alignItems: 'center', justifyContent: 'space-between', - p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, - cursor: p.preview || !nineRouterReady ? 'default' : connecting ? 'wait' : 'pointer', - opacity: p.preview ? 0.5 : !nineRouterReady ? 0.6 : 1, - transition: 'border-color 0.15s, background 0.15s', - ...(!p.preview && nineRouterReady && { '&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` } }), - }} - > - - {p.name} - {p.desc} - - - {p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : !nineRouterReady && nineRouterReady !== false ? 'Starting...' : 'Connect \u2192'} - - - ))} - - - {/* API key option */} - - Or use an API key - - - - I have an API key - - - Go to Settings → Models to enter your key - - - - {/* Skip */} - - + )} diff --git a/frontend/src/app/components/ProviderStep.tsx b/frontend/src/app/components/ProviderStep.tsx new file mode 100644 index 00000000..066fb7c4 --- /dev/null +++ b/frontend/src/app/components/ProviderStep.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Box, Typography, Button } from '@mui/material'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { SUBSCRIPTION_PROVIDERS } from './onboardingConstants'; + +interface ProviderStepProps { + connecting: string | null; + nineRouterReady: boolean | null; + onConnect: (providerId: string) => void; + onApiKey: () => void; + onSkip: () => void; +} + +const ProviderStep: React.FC = ({ + connecting, nineRouterReady, onConnect, onApiKey, onSkip, +}) => { + const c = useClaudeTokens(); + + return ( + <> + + Welcome to OpenSwarm + + + Connect an AI model to get started + + + + Use your existing subscription + + + {SUBSCRIPTION_PROVIDERS.map((p) => ( + !p.preview && !connecting && nineRouterReady && onConnect(p.id)} + sx={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, + cursor: p.preview || !nineRouterReady ? 'default' : connecting ? 'wait' : 'pointer', + opacity: p.preview ? 0.5 : !nineRouterReady ? 0.6 : 1, + transition: 'border-color 0.15s, background 0.15s', + ...(!p.preview && nineRouterReady && { '&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` } }), + }} + > + + {p.name} + {p.desc} + + + {p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : !nineRouterReady && nineRouterReady !== false ? 'Starting...' : 'Connect \u2192'} + + + ))} + + + + Or use an API key + + + + I have an API key + + + Go to Settings → Models to enter your key + + + + + + ); +}; + +export default ProviderStep; diff --git a/frontend/src/app/components/RichPromptEditor.tsx b/frontend/src/app/components/RichPromptEditor.tsx index d68a0570..d17eb307 100644 --- a/frontend/src/app/components/RichPromptEditor.tsx +++ b/frontend/src/app/components/RichPromptEditor.tsx @@ -1,255 +1,23 @@ -import React, { useState, useRef, useCallback, useEffect } from 'react'; +import React from 'react'; import { createPortal } from 'react-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import CommandPicker, { CommandPickerItem } from '@/app/components/CommandPicker'; -import { - SKILL_PILL_ATTR, - AttachedSkill, - createSkillPillElement, - serializeEditorContent, - deserializeToEditor, - detectEditorTrigger, - TriggerState, - EMPTY_TRIGGER, -} from '@/app/components/richEditorUtils'; +import CommandPicker from '@/app/components/CommandPicker'; import TemplateInvokeModal from '@/app/pages/AgentChat/TemplateInvokeModal'; -import { useAppSelector } from '@/shared/hooks'; -import { PromptTemplate } from '@/shared/state/templatesSlice'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { RichPromptEditorProps, LINE_HEIGHT, FONT_SIZE } from './richPromptEditorTypes'; +import { useRichPromptEditor } from './useRichPromptEditor'; -interface RichPromptEditorProps { - value: string; - onChange: (value: string) => void; - label?: string; - placeholder?: string; - minRows?: number; - maxRows?: number; -} - -const LINE_HEIGHT = 1.5; -const FONT_SIZE = 0.85; - -const RichPromptEditor: React.FC = ({ - value, - onChange, - label = '', - placeholder = '', - minRows = 3, - maxRows = 8, -}) => { - const c = useClaudeTokens(); - const editorRef = useRef(null); - const wrapperRef = useRef(null); - const [focused, setFocused] = useState(false); - const [hasContent, setHasContent] = useState(false); - - const [attachedSkills, setAttachedSkills] = useState>({}); - const attachedSkillsRef = useRef(attachedSkills); - attachedSkillsRef.current = attachedSkills; - - const removeSkillPillRef = useRef<(id: string) => void>(() => {}); - - const [picker, setPicker] = useState(EMPTY_TRIGGER); - const [pickerRect, setPickerRect] = useState(null); - const [selectedTemplate, setSelectedTemplate] = useState(null); - - const templates = useAppSelector((state) => state.templates.items); - const skills = useAppSelector((state) => state.skills.items); - - useEffect(() => { - if (picker.visible && wrapperRef.current) { - setPickerRect(wrapperRef.current.getBoundingClientRect()); - } else { - setPickerRect(null); - } - }, [picker.visible]); - - const minHeight = minRows * FONT_SIZE * LINE_HEIGHT; - const maxHeight = maxRows * FONT_SIZE * LINE_HEIGHT; - - const isLabelFloating = focused || hasContent; - - // Sync external value → editor on mount / when value changes externally - const lastEmittedRef = useRef(null); - useEffect(() => { - const editor = editorRef.current; - if (!editor) return; - if (value === lastEmittedRef.current) return; - lastEmittedRef.current = value; - - if (/\{\{skill:.+?\}\}/.test(value)) { - const skillsByName: Record = {}; - for (const s of Object.values(skills)) { - skillsByName[s.name] = { id: s.id, name: s.name, content: s.content }; - } - const restored = deserializeToEditor( - editor, - value, - skillsByName, - (id) => removeSkillPillRef.current(id), - c.font.mono, - c.status.error, - ); - setAttachedSkills(restored); - } else { - editor.textContent = value; - } - setHasContent(!!value); - }, [value]); // eslint-disable-line react-hooks/exhaustive-deps - - const emitChange = useCallback(() => { - const editor = editorRef.current; - if (!editor) return; - const serialized = serializeEditorContent(editor, attachedSkillsRef.current); - lastEmittedRef.current = serialized; - onChange(serialized); - }, [onChange]); - - const updateHasContent = useCallback(() => { - const editor = editorRef.current; - if (!editor) return; - const text = (editor.textContent || '').replace(/\u200B/g, ''); - const hasPills = editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null; - setHasContent(text.trim().length > 0 || hasPills); - }, []); - - const syncAttachedSkills = useCallback(() => { - const editor = editorRef.current; - if (!editor) return; - const pillIds = new Set( - Array.from(editor.querySelectorAll(`[${SKILL_PILL_ATTR}]`)) - .map((el) => el.getAttribute(SKILL_PILL_ATTR)) - .filter(Boolean) as string[], - ); - setAttachedSkills((prev) => { - const prevKeys = Object.keys(prev); - if (prevKeys.length === pillIds.size && prevKeys.every((k) => pillIds.has(k))) return prev; - const next: Record = {}; - for (const [id, skill] of Object.entries(prev)) { - if (pillIds.has(id)) next[id] = skill; - } - return next; - }); - }, []); - - const removeSkillPill = useCallback((skillId: string) => { - const editor = editorRef.current; - if (!editor) return; - const pill = editor.querySelector(`[${SKILL_PILL_ATTR}="${skillId}"]`); - if (pill) pill.remove(); - setAttachedSkills((prev) => { - const { [skillId]: _, ...rest } = prev; - return rest; - }); - updateHasContent(); - emitChange(); - editor.focus(); - }, [updateHasContent, emitChange]); - removeSkillPillRef.current = removeSkillPill; - - const detectTrigger = useCallback(() => { - const result = detectEditorTrigger(); - if (result) { - setPicker(result); - } else { - setPicker((p) => ({ ...p, visible: false })); - } - }, []); - - const handleInput = useCallback(() => { - updateHasContent(); - detectTrigger(); - syncAttachedSkills(); - emitChange(); - }, [updateHasContent, detectTrigger, syncAttachedSkills, emitChange]); - - const handleEditorClick = useCallback(() => { - detectTrigger(); - }, [detectTrigger]); - - const handlePickerSelect = (item: CommandPickerItem) => { - setPicker((p) => ({ ...p, visible: false })); - const editor = editorRef.current; - if (!editor) return; - editor.focus(); - - const { triggerNode, triggerOffset, filter } = picker; - if (triggerNode && triggerNode.parentNode && editor.contains(triggerNode)) { - const endOffset = Math.min(triggerOffset + 1 + filter.length, triggerNode.length); - const range = document.createRange(); - range.setStart(triggerNode, triggerOffset); - range.setEnd(triggerNode, endOffset); - range.deleteContents(); - const sel = window.getSelection(); - if (sel) { sel.removeAllRanges(); sel.addRange(range); } - } - - if (item.type === 'template') { - const tmpl = templates[item.id]; - if (!tmpl) return; - if (tmpl.fields.length === 0) { - document.execCommand('insertText', false, tmpl.template); - } else { - setSelectedTemplate(tmpl); - } - } else if (item.type === 'skill') { - const skill = skills[item.id]; - if (!skill) return; - if (editor.querySelector(`[${SKILL_PILL_ATTR}="${skill.id}"]`)) return; - - const pill = createSkillPillElement( - { id: skill.id, name: skill.name, content: skill.content }, - removeSkillPill, - c.font.mono, - c.status.error, - ); - - const sel = window.getSelection(); - if (sel && sel.rangeCount > 0) { - const range = sel.getRangeAt(0); - range.collapse(false); - range.insertNode(pill); - const spacer = document.createTextNode('\u200B'); - pill.after(spacer); - const newRange = document.createRange(); - newRange.setStartAfter(spacer); - newRange.collapse(true); - sel.removeAllRanges(); - sel.addRange(newRange); - } - - setAttachedSkills((prev) => ({ - ...prev, - [skill.id]: { id: skill.id, name: skill.name, content: skill.content }, - })); - } else if (item.type === 'mode') { - document.execCommand('insertText', false, item.name); - } else if (item.type === 'context') { - document.execCommand('insertText', false, `@${item.command} `); - } - - updateHasContent(); - emitChange(); - setTimeout(() => editor.focus(), 0); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (picker.visible && ['ArrowDown', 'ArrowUp', 'Escape', 'Tab', 'Enter'].includes(e.key)) { - e.preventDefault(); - return; - } - if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) { - e.preventDefault(); - return; - } - }; - - const handlePaste = useCallback((e: React.ClipboardEvent) => { - e.preventDefault(); - const plain = e.clipboardData.getData('text/plain'); - if (plain) document.execCommand('insertText', false, plain); - }, []); +const RichPromptEditor: React.FC = (props) => { + const { placeholder = '' } = props; + const { + c, editorRef, wrapperRef, focused, setFocused, + hasContent, picker, setPicker, pickerRect, + selectedTemplate, setSelectedTemplate, + minHeight, maxHeight, isLabelFloating, + handleInput, handleEditorClick, handlePickerSelect, + handleKeyDown, handlePaste, + updateHasContent, emitChange, + } = useRichPromptEditor(props); return ( @@ -292,7 +60,7 @@ const RichPromptEditor: React.FC = ({ cursor: 'text', }} > - {label && ( + {props.label && ( = ({ zIndex: 1, }} > - {label} + {props.label} )} - +
= ({
; + onToolConnect: (integration: ToolIntegration) => void; + onDismiss: () => void; +} + +const ToolsStep: React.FC = ({ + connecting, connectedTools, onToolConnect, onDismiss, +}) => { + const c = useClaudeTokens(); + + return ( + <> + + Connect Your Accounts + + + 10+ tools already active with no setup needed + + + Connect services below for even more capabilities + + + + {ONBOARDING_TOOL_INTEGRATIONS.map((ig) => { + const isConnected = connectedTools.has(ig.name); + const isConnecting = connecting === ig.name; + return ( + !isConnected && !isConnecting && !connecting && onToolConnect(ig)} + sx={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + p: 1.5, borderRadius: `${c.radius.md}px`, + border: `1px solid ${isConnected ? `${ig.color}40` : c.border.subtle}`, + cursor: isConnected ? 'default' : connecting ? 'wait' : 'pointer', + bgcolor: isConnected ? `${ig.color}08` : 'transparent', + transition: 'border-color 0.15s, background 0.15s', + ...(!isConnected && !connecting && { '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}05` } }), + }} + > + + {ig.name} + {ig.desc} + + {isConnected ? ( + + ) : ( + + {isConnecting ? 'Connecting...' : 'Connect \u2192'} + + )} + + ); + })} + + + + + ); +}; + +export default ToolsStep; diff --git a/frontend/src/app/components/commandPickerTypes.tsx b/frontend/src/app/components/commandPickerTypes.tsx new file mode 100644 index 00000000..82e96642 --- /dev/null +++ b/frontend/src/app/components/commandPickerTypes.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; +import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined'; +import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; +import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined'; +import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined'; + +export interface CommandPickerItem { + id: string; + type: 'template' | 'skill' | 'mode' | 'context'; + category: string; + name: string; + description: string; + command: string; + icon: React.ReactNode; + toolNames?: string[]; + iconKey?: string; +} + +export interface CommandPickerProps { + trigger: '/' | '@'; + filter: string; + onSelect: (item: CommandPickerItem) => void; + onClose: () => void; + visible: boolean; +} + +export const MODE_ICON_MAP: Record> = { + smart_toy: SmartToyOutlinedIcon, + question_answer: QuestionAnswerOutlinedIcon, + map: MapOutlinedIcon, + category: CategoryOutlinedIcon, + tune: TuneOutlinedIcon, +}; + +export function highlightMatch(text: string, query: string, color: string): React.ReactNode { + if (!query) return text; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return text; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + query.length)} + {text.slice(idx + query.length)} + + ); +} diff --git a/frontend/src/app/components/domSelectorHelpers.ts b/frontend/src/app/components/domSelectorHelpers.ts new file mode 100644 index 00000000..a6bb33db --- /dev/null +++ b/frontend/src/app/components/domSelectorHelpers.ts @@ -0,0 +1,167 @@ +import { type SelectedElement } from './ElementSelectionContext'; + +export const SELECT_ATTR = 'data-select-type'; +export const SELECT_ID_ATTR = 'data-select-id'; +export const SELECT_META_ATTR = 'data-select-meta'; + +const DRAG_SELECT_TYPES = ['agent-card', 'view-card', 'browser-card'] as const; +export const DRAG_SELECTOR = DRAG_SELECT_TYPES.map((t) => `[${SELECT_ATTR}="${t}"]`).join(','); + +export interface OverlayState { + visible: boolean; + top: number; + left: number; + width: number; + height: number; + label: string; +} + +export interface DragRect { + visible: boolean; + top: number; + left: number; + width: number; + height: number; +} + +export const EMPTY_OVERLAY: OverlayState = { visible: false, top: 0, left: 0, width: 0, height: 0, label: '' }; +export const EMPTY_DRAG: DragRect = { visible: false, top: 0, left: 0, width: 0, height: 0 }; + +const SEMANTIC_LABELS: Record = { + 'agent-card': 'Agent', + 'message': 'Message', + 'tool-call': 'Tool Call', + 'tool-group': 'Tool Group', + 'view-card': 'View', + 'browser-card': 'Browser', +}; + +export function findSelectableAncestor(target: Element, excludeId?: string | null): Element | null { + let current: Element | null = target; + while (current) { + if (current.hasAttribute(SELECT_ATTR)) { + if (excludeId && current.getAttribute(SELECT_ID_ATTR) === excludeId) return null; + return current; + } + current = current.parentElement; + } + return null; +} + +export function buildSemanticLabel(type: string, meta: Record): string { + const prefix = SEMANTIC_LABELS[type] || type; + if (meta.name) return `${prefix}: ${meta.name}`; + if (meta.role && meta.content) { + const truncated = String(meta.content).slice(0, 40); + return `${prefix} (${meta.role}): ${truncated}${String(meta.content).length > 40 ? '…' : ''}`; + } + if (meta.label) return `${prefix}: ${meta.label}`; + if (meta.tool) return `${prefix}: ${meta.tool}`; + return prefix; +} + +export function rectsIntersect( + a: { top: number; left: number; bottom: number; right: number }, + b: { top: number; left: number; bottom: number; right: number }, +): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +export function buildSelectedElement(el: Element): SelectedElement { + const type = el.getAttribute(SELECT_ATTR) || ''; + const selectId = el.getAttribute(SELECT_ID_ATTR) || ''; + let meta: Record = {}; + try { meta = JSON.parse(el.getAttribute(SELECT_META_ATTR) || '{}'); } catch {} + const rect = el.getBoundingClientRect(); + const semanticLabel = buildSemanticLabel(type, meta); + + return { + id: `sel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + selectorPath: `[${SELECT_ATTR}="${type}"][${SELECT_ID_ATTR}="${selectId}"]`, + tagName: el.tagName, + className: '', + outerHTML: '', + computedStyles: {}, + boundingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + semanticType: type as SelectedElement['semanticType'], + semanticLabel, + semanticData: { ...meta, selectId }, + }; +} + +export interface DragPreviewElement { + selectId: string; + top: number; + left: number; + width: number; + height: number; + label: string; + action: 'add' | 'remove'; +} + +export const DRAG_THRESHOLD = 5; + +export interface DomSelectorState { + overlay: OverlayState; + dragRect: DragRect; + dragPreview: DragPreviewElement[]; +} + +export function computeDragPreview( + bounds: { left: number; top: number; right: number; bottom: number }, + excludeId: string | null, + selectedIds: Map, +): DragPreviewElement[] { + const allSelectables = document.querySelectorAll(DRAG_SELECTOR); + const preview: DragPreviewElement[] = []; + const seen = new Set(); + allSelectables.forEach((el) => { + const selectId = el.getAttribute(SELECT_ID_ATTR) || ''; + if (excludeId && selectId === excludeId) return; + const rect = el.getBoundingClientRect(); + if (rectsIntersect(bounds, { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom })) { + if (seen.has(selectId)) return; + seen.add(selectId); + const type = el.getAttribute(SELECT_ATTR) || ''; + let meta: Record = {}; + try { meta = JSON.parse(el.getAttribute(SELECT_META_ATTR) || '{}'); } catch {} + preview.push({ + selectId, + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + label: buildSemanticLabel(type, meta), + action: selectedIds.has(selectId) ? 'remove' : 'add', + }); + } + }); + return preview; +} + +export function processDragSelection( + dragRect: { left: number; top: number; right: number; bottom: number }, + excludeId: string | null, + selectedIds: Map, + addElement: (el: SelectedElement) => void, + removeElement: (id: string) => void, +): void { + const allSelectables = document.querySelectorAll(DRAG_SELECTOR); + const processed = new Set(); + allSelectables.forEach((el) => { + const selectId = el.getAttribute(SELECT_ID_ATTR) || ''; + if (excludeId && selectId === excludeId) return; + const rect = el.getBoundingClientRect(); + const elRect = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; + if (rectsIntersect(dragRect, elRect)) { + if (processed.has(selectId)) return; + processed.add(selectId); + const existingId = selectedIds.get(selectId); + if (existingId) { + removeElement(existingId); + } else { + addElement(buildSelectedElement(el)); + } + } + }); +} diff --git a/frontend/src/app/components/onboardingConstants.ts b/frontend/src/app/components/onboardingConstants.ts new file mode 100644 index 00000000..7969574e --- /dev/null +++ b/frontend/src/app/components/onboardingConstants.ts @@ -0,0 +1,21 @@ +export const ONBOARDING_TOOL_INTEGRATIONS = [ + { name: 'Google Workspace', desc: 'Gmail, Calendar, Drive, Docs, Sheets', color: '#4285F4', oauthProvider: 'google', + mcp_config: { type: 'stdio', command: 'uvx', args: ['--from', 'google-workspace-mcp', 'google-workspace-worker'] } }, + { name: 'GitHub', desc: 'Repos, issues, pull requests', color: '#24292E', oauthProvider: 'github', + mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] } }, + { name: 'Slack', desc: 'Channels, messages, search', color: '#4A154B', oauthProvider: 'slack', + mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack'] } }, + { name: 'Notion', desc: 'Pages, databases, search', color: '#000000', oauthProvider: 'notion', + mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@notionhq/notion-mcp-server'] } }, +]; + +export type ToolIntegration = typeof ONBOARDING_TOOL_INTEGRATIONS[number]; + +export const SUBSCRIPTION_PROVIDERS = [ + { id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false }, + { id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true }, + { id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, o3, o4-mini', color: '#74AA9C', preview: true }, + { id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models', color: '#8B949E', preview: true }, +]; + +export type SubscriptionProvider = typeof SUBSCRIPTION_PROVIDERS[number]; diff --git a/frontend/src/app/components/richPromptEditorTypes.ts b/frontend/src/app/components/richPromptEditorTypes.ts new file mode 100644 index 00000000..2b5b5531 --- /dev/null +++ b/frontend/src/app/components/richPromptEditorTypes.ts @@ -0,0 +1,11 @@ +export interface RichPromptEditorProps { + value: string; + onChange: (value: string) => void; + label?: string; + placeholder?: string; + minRows?: number; + maxRows?: number; +} + +export const LINE_HEIGHT = 1.5; +export const FONT_SIZE = 0.85; diff --git a/frontend/src/app/components/useCommandPickerItems.tsx b/frontend/src/app/components/useCommandPickerItems.tsx new file mode 100644 index 00000000..fba108ad --- /dev/null +++ b/frontend/src/app/components/useCommandPickerItems.tsx @@ -0,0 +1,225 @@ +import { useMemo, useEffect } from 'react'; +import DescriptionIcon from '@mui/icons-material/Description'; +import PsychologyIcon from '@mui/icons-material/Psychology'; +import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; +import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; +import LanguageIcon from '@mui/icons-material/Language'; +import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; +import ViewQuiltOutlinedIcon from '@mui/icons-material/ViewQuiltOutlined'; +import { useAppSelector, useAppDispatch } from '@/shared/hooks'; +import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice'; +import { fetchOutputs } from '@/shared/state/outputsSlice'; +import { CommandPickerItem, MODE_ICON_MAP } from './commandPickerTypes'; +import { getToolGroupIcon } from './CommandPickerIcons'; + +export function useCommandPickerItems(trigger: '/' | '@', filter: string) { + const dispatch = useAppDispatch(); + const templates = useAppSelector((s) => s.templates.items); + const skills = useAppSelector((s) => s.skills.items); + const modesMap = useAppSelector((s) => s.modes.items); + const builtinTools = useAppSelector((s) => s.tools.builtinTools); + const customTools = useAppSelector((s) => s.tools.items); + const outputItems = useAppSelector((s) => s.outputs.items); + + const toolsLoaded = useAppSelector((s) => s.tools.loaded); + const builtinLoaded = useAppSelector((s) => s.tools.builtinLoaded); + const outputsLoaded = useAppSelector((s) => s.outputs.loaded); + + useEffect(() => { + if (!builtinLoaded) dispatch(fetchBuiltinTools()); + if (!toolsLoaded) dispatch(fetchTools()); + if (!outputsLoaded) dispatch(fetchOutputs()); + }, [dispatch, builtinLoaded, toolsLoaded, outputsLoaded]); + + const items: CommandPickerItem[] = useMemo(() => { + let all: CommandPickerItem[] = []; + + if (trigger === '/') { + const templateItems: CommandPickerItem[] = Object.values(templates).map((t) => ({ + id: t.id, + type: 'template' as const, + category: 'Templates', + name: t.name, + description: t.description || `Template with ${t.fields.length} fields`, + command: t.name.toLowerCase().replace(/\s+/g, '-'), + icon: , + })); + + const skillItems: CommandPickerItem[] = Object.values(skills).map((s) => ({ + id: s.id, + type: 'skill' as const, + category: 'Skills', + name: s.name, + description: s.description || 'Skill', + command: s.command || s.id, + icon: , + })); + + const modeItems: CommandPickerItem[] = Object.values(modesMap).map((m) => { + const IconComp = MODE_ICON_MAP[m.icon] || SmartToyOutlinedIcon; + return { + id: m.id, + type: 'mode' as const, + category: 'Modes', + name: m.name, + description: m.description || 'Switch to this mode', + command: m.name.toLowerCase().replace(/\s+/g, '-'), + icon: , + }; + }); + + all = [...templateItems, ...skillItems, ...modeItems]; + } else { + const atItems: CommandPickerItem[] = [ + { + id: 'file', + type: 'context' as const, + category: 'Context', + name: 'File', + description: 'Attach a file or folder as context', + command: 'file', + icon: , + }, + ]; + + const hasWebSearch = builtinTools.some((t) => t.name === 'WebSearch' && t.deferred); + const hasWebFetch = builtinTools.some((t) => t.name === 'WebFetch' && t.deferred); + if (hasWebSearch || hasWebFetch) { + const webTools = [hasWebSearch && 'WebSearch', hasWebFetch && 'WebFetch'].filter(Boolean) as string[]; + atItems.push({ + id: 'web', + type: 'context' as const, + category: 'Actions', + name: 'Web', + description: 'Search the web and fetch URLs', + command: 'web', + icon: , + toolNames: webTools, + iconKey: 'Web', + }); + } + + for (const tool of Object.values(customTools)) { + if (!tool.mcp_config || Object.keys(tool.mcp_config).length === 0) continue; + const services = tool.tool_permissions?._services as Record | undefined; + if (!services) continue; + const perms = tool.tool_permissions as Record; + const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record; + + const enabledServices: { name: string; tools: string[] }[] = []; + for (const [serviceName, serviceTools] of Object.entries(services)) { + const allToolNames = [...(serviceTools.read || []), ...(serviceTools.write || [])]; + const enabled = allToolNames.filter((name) => perms[name] !== 'deny'); + if (enabled.length > 0) enabledServices.push({ name: serviceName, tools: enabled }); + } + + if (enabledServices.length === 0) continue; + + const groupEntries = Object.entries(serviceGroups); + const emittedServices = new Set(); + + for (const [groupName, groupServiceNames] of groupEntries) { + const groupCmd = groupName.toLowerCase().replace(/\s+/g, '-'); + const groupServices = enabledServices.filter((s) => groupServiceNames.includes(s.name)); + if (groupServices.length === 0) continue; + groupServices.forEach((s) => emittedServices.add(s.name)); + + const groupIcon = getToolGroupIcon(groupName); + if (groupServices.length >= 2) { + const allTools = groupServices.flatMap((s) => s.tools); + atItems.push({ + id: `mcp-${tool.id}-group-${groupName}`, + type: 'context' as const, + category: tool.name, + name: groupName, + description: `Use all ${groupName} actions`, + command: groupCmd, + icon: groupIcon, + toolNames: allTools, + iconKey: groupName, + }); + for (const svc of groupServices) { + atItems.push({ + id: `mcp-${tool.id}-${svc.name}`, + type: 'context' as const, + category: tool.name, + name: svc.name, + description: `Use ${svc.name} actions from ${tool.name}`, + command: `${groupCmd}/${svc.name.toLowerCase().replace(/\s+/g, '-')}`, + icon: groupIcon, + toolNames: svc.tools, + iconKey: groupName, + }); + } + } else { + const svc = groupServices[0]; + atItems.push({ + id: `mcp-${tool.id}-${svc.name}`, + type: 'context' as const, + category: tool.name, + name: svc.name, + description: `Use ${svc.name} actions from ${tool.name}`, + command: svc.name.toLowerCase().replace(/\s+/g, '-'), + icon: groupIcon, + toolNames: svc.tools, + iconKey: groupName, + }); + } + } + + for (const svc of enabledServices) { + if (emittedServices.has(svc.name)) continue; + atItems.push({ + id: `mcp-${tool.id}-${svc.name}`, + type: 'context' as const, + category: tool.name, + name: svc.name, + description: `Use ${svc.name} actions from ${tool.name}`, + command: svc.name.toLowerCase().replace(/\s+/g, '-'), + icon: , + toolNames: svc.tools, + }); + } + } + + for (const out of Object.values(outputItems)) { + if (out.permission === 'deny') continue; + const cmd = out.name.toLowerCase().replace(/\s+/g, '-'); + atItems.push({ + id: `view-${out.id}`, + type: 'context' as const, + category: 'Apps', + name: out.name, + description: out.description || `Render ${out.name} view`, + command: cmd, + icon: , + toolNames: ['RenderOutput'], + iconKey: 'View', + }); + } + + all = atItems; + } + + if (!filter) return all; + const lower = filter.toLowerCase(); + return all.filter( + (item) => + item.name.toLowerCase().includes(lower) || + item.command.toLowerCase().includes(lower) || + item.description.toLowerCase().includes(lower), + ); + }, [trigger, templates, skills, modesMap, builtinTools, customTools, outputItems, filter]); + + const flatItems = useMemo(() => { + const result: { item: CommandPickerItem; isGroupStart: boolean; category: string }[] = []; + let lastCat = ''; + for (const item of items) { + result.push({ item, isGroupStart: item.category !== lastCat, category: item.category }); + lastCat = item.category; + } + return result; + }, [items]); + + return { items, flatItems, modesMap }; +} diff --git a/frontend/src/app/components/useDomElementSelector.ts b/frontend/src/app/components/useDomElementSelector.ts index 8115769b..d9a4c6d3 100644 --- a/frontend/src/app/components/useDomElementSelector.ts +++ b/frontend/src/app/components/useDomElementSelector.ts @@ -1,112 +1,14 @@ import { useEffect, useRef, useState, useCallback } from 'react'; -import { SelectedElement, useElementSelection } from './ElementSelectionContext'; +import { useElementSelection } from './ElementSelectionContext'; +import { + type OverlayState, type DragRect, type DragPreviewElement, type DomSelectorState, + EMPTY_OVERLAY, EMPTY_DRAG, DRAG_THRESHOLD, + SELECT_ATTR, SELECT_ID_ATTR, SELECT_META_ATTR, + findSelectableAncestor, buildSemanticLabel, buildSelectedElement, + computeDragPreview, processDragSelection, +} from './domSelectorHelpers'; -const SELECT_ATTR = 'data-select-type'; -const SELECT_ID_ATTR = 'data-select-id'; -const SELECT_META_ATTR = 'data-select-meta'; - -const DRAG_SELECT_TYPES = ['agent-card', 'view-card', 'browser-card'] as const; -const DRAG_SELECTOR = DRAG_SELECT_TYPES.map((t) => `[${SELECT_ATTR}="${t}"]`).join(','); - -export interface OverlayState { - visible: boolean; - top: number; - left: number; - width: number; - height: number; - label: string; -} - -export interface DragRect { - visible: boolean; - top: number; - left: number; - width: number; - height: number; -} - -const EMPTY_OVERLAY: OverlayState = { visible: false, top: 0, left: 0, width: 0, height: 0, label: '' }; -const EMPTY_DRAG: DragRect = { visible: false, top: 0, left: 0, width: 0, height: 0 }; - -const SEMANTIC_LABELS: Record = { - 'agent-card': 'Agent', - 'message': 'Message', - 'tool-call': 'Tool Call', - 'tool-group': 'Tool Group', - 'view-card': 'View', - 'browser-card': 'Browser', -}; - -function findSelectableAncestor(target: Element, excludeId?: string | null): Element | null { - let current: Element | null = target; - while (current) { - if (current.hasAttribute(SELECT_ATTR)) { - if (excludeId && current.getAttribute(SELECT_ID_ATTR) === excludeId) return null; - return current; - } - current = current.parentElement; - } - return null; -} - -function buildSemanticLabel(type: string, meta: Record): string { - const prefix = SEMANTIC_LABELS[type] || type; - if (meta.name) return `${prefix}: ${meta.name}`; - if (meta.role && meta.content) { - const truncated = String(meta.content).slice(0, 40); - return `${prefix} (${meta.role}): ${truncated}${String(meta.content).length > 40 ? '…' : ''}`; - } - if (meta.label) return `${prefix}: ${meta.label}`; - if (meta.tool) return `${prefix}: ${meta.tool}`; - return prefix; -} - -function rectsIntersect( - a: { top: number; left: number; bottom: number; right: number }, - b: { top: number; left: number; bottom: number; right: number }, -): boolean { - return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; -} - -function buildSelectedElement(el: Element): SelectedElement { - const type = el.getAttribute(SELECT_ATTR) || ''; - const selectId = el.getAttribute(SELECT_ID_ATTR) || ''; - let meta: Record = {}; - try { meta = JSON.parse(el.getAttribute(SELECT_META_ATTR) || '{}'); } catch {} - const rect = el.getBoundingClientRect(); - const semanticLabel = buildSemanticLabel(type, meta); - - return { - id: `sel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - selectorPath: `[${SELECT_ATTR}="${type}"][${SELECT_ID_ATTR}="${selectId}"]`, - tagName: el.tagName, - className: '', - outerHTML: '', - computedStyles: {}, - boundingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, - semanticType: type as SelectedElement['semanticType'], - semanticLabel, - semanticData: { ...meta, selectId }, - }; -} - -export interface DragPreviewElement { - selectId: string; - top: number; - left: number; - width: number; - height: number; - label: string; - action: 'add' | 'remove'; -} - -const DRAG_THRESHOLD = 5; - -export interface DomSelectorState { - overlay: OverlayState; - dragRect: DragRect; - dragPreview: DragPreviewElement[]; -} +export type { OverlayState, DragRect, DragPreviewElement, DomSelectorState } from './domSelectorHelpers'; export function useDomElementSelector(): DomSelectorState { const ctx = useElementSelection(); @@ -139,7 +41,6 @@ export function useDomElementSelector(): DomSelectorState { }, [ctx?.selectedElements]); const handleMouseMove = useCallback((e: MouseEvent) => { - // If we're drawing a drag rectangle, update it instead of hover overlay if (dragOriginRef.current) { const origin = dragOriginRef.current; const dx = e.clientX - origin.x; @@ -171,45 +72,14 @@ export function useDomElementSelector(): DomSelectorState { dragPreviewRafRef.current = requestAnimationFrame(() => { const b = dragBoundsRef.current; if (!b) return; - const allSelectables = document.querySelectorAll(DRAG_SELECTOR); - const preview: DragPreviewElement[] = []; - const seen = new Set(); - const excId = excludeIdRef.current; - allSelectables.forEach((el) => { - const selectId = el.getAttribute(SELECT_ID_ATTR) || ''; - if (excId && selectId === excId) return; - const rect = el.getBoundingClientRect(); - if (rectsIntersect(b, { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom })) { - if (seen.has(selectId)) return; - seen.add(selectId); - const type = el.getAttribute(SELECT_ATTR) || ''; - let meta: Record = {}; - try { meta = JSON.parse(el.getAttribute(SELECT_META_ATTR) || '{}'); } catch {} - preview.push({ - selectId, - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - label: buildSemanticLabel(type, meta), - action: selectedIdsRef.current.has(selectId) ? 'remove' : 'add', - }); - } - }); - setDragPreview(preview); + setDragPreview(computeDragPreview(b, excludeIdRef.current, selectedIdsRef.current)); }); } return; } const target = e.target as Element; - if (!target) { - setOverlay(EMPTY_OVERLAY); - hoveredRef.current = null; - return; - } - - if (target.tagName === 'IFRAME') { + if (!target || target.tagName === 'IFRAME') { setOverlay(EMPTY_OVERLAY); hoveredRef.current = null; return; @@ -265,34 +135,13 @@ export function useDomElementSelector(): DomSelectorState { right: Math.max(dragOriginRef.current.x, e.clientX), bottom: Math.max(dragOriginRef.current.y, e.clientY), }; - - const allSelectables = document.querySelectorAll(DRAG_SELECTOR); - const processed = new Set(); - - const excId = excludeIdRef.current; - allSelectables.forEach((el) => { - const selectId = el.getAttribute(SELECT_ID_ATTR) || ''; - if (excId && selectId === excId) return; - const rect = el.getBoundingClientRect(); - const elRect = { - left: rect.left, - top: rect.top, - right: rect.right, - bottom: rect.bottom, - }; - - if (rectsIntersect(dr, elRect)) { - if (processed.has(selectId)) return; - processed.add(selectId); - - const existingId = selectedIdsRef.current.get(selectId); - if (existingId) { - ctx.removeSelectedElement(existingId); - } else { - ctx.addSelectedElement(buildSelectedElement(el)); - } - } - }); + processDragSelection( + dr, + excludeIdRef.current, + selectedIdsRef.current, + ctx.addSelectedElement, + ctx.removeSelectedElement, + ); } const wasDragging = isDraggingRef.current; diff --git a/frontend/src/app/components/useOnboarding.ts b/frontend/src/app/components/useOnboarding.ts new file mode 100644 index 00000000..b2761e23 --- /dev/null +++ b/frontend/src/app/components/useOnboarding.ts @@ -0,0 +1,137 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { API_BASE } from '@/shared/config'; +import { ToolIntegration } from './onboardingConstants'; +import { useSubscriptionConnect } from './useSubscriptionConnect'; + +export function useOnboarding() { + const [open, setOpen] = useState(false); + const [step, setStep] = useState<'provider' | 'tools'>('provider'); + const [connecting, setConnecting] = useState(null); + const [nineRouterReady, setNineRouterReady] = useState(null); + const [connectedTools, setConnectedTools] = useState>(new Set()); + const pollTimerRef = useRef(null); + const msgHandlerRef = useRef(null); + + const advanceToTools = useCallback(() => { + if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } + if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } + setConnecting(null); + setStep('tools'); + }, []); + + const handleConnect = useSubscriptionConnect({ + pollTimerRef, msgHandlerRef, setConnecting, advanceToTools, + }); + + useEffect(() => { + let attempts = 0; + const maxAttempts = 15; + const check = () => { + fetch(`${API_BASE}/subscriptions/status`) + .then((r) => r.json()) + .then((data) => { + if (data.running) { + const connections = data.providers?.connections || []; + if (connections.some((p: any) => p.isActive)) return; + setTimeout(() => setNineRouterReady(true), 3000); + } else { + attempts++; + if (attempts < maxAttempts) setTimeout(check, 2000); + else setNineRouterReady(false); + } + }) + .catch(() => { + attempts++; + if (attempts < maxAttempts) setTimeout(check, 2000); + else setNineRouterReady(false); + }); + }; + check(); + }, []); + + useEffect(() => { + const alreadySeen = localStorage.getItem('openswarm_onboarding_seen'); + if (alreadySeen === 'true') return; + if (nineRouterReady === null) return; + setOpen(true); + }, [nineRouterReady]); + + useEffect(() => { + return () => { + if (pollTimerRef.current) clearInterval(pollTimerRef.current); + if (msgHandlerRef.current) window.removeEventListener('message', msgHandlerRef.current); + }; + }, []); + + const dismiss = useCallback(() => { + localStorage.setItem('openswarm_onboarding_seen', 'true'); + if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } + if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } + setConnecting(null); + setOpen(false); + }, []); + + const handleToolConnect = useCallback(async (integration: ToolIntegration) => { + setConnecting(integration.name); + try { + const createRes = await fetch(`${API_BASE}/tools/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: integration.name, + description: integration.desc, + mcp_config: integration.mcp_config, + auth_type: 'oauth2', + auth_status: 'configured', + oauth_provider: integration.oauthProvider, + }), + }); + if (!createRes.ok) { setConnecting(null); return; } + const { tool } = await createRes.json(); + + const oauthRes = await fetch(`${API_BASE}/tools/${tool.id}/oauth/start`, { method: 'POST' }); + if (!oauthRes.ok) { setConnecting(null); return; } + const { auth_url } = await oauthRes.json(); + + const popup = window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100'); + + const onMsg = (event: MessageEvent) => { + if (event.data?.type === 'oauth_complete' && event.data?.tool_id === tool.id) { + window.removeEventListener('message', onMsg); + setConnectedTools((prev) => new Set(prev).add(integration.name)); + setConnecting(null); + fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {}); + } + }; + window.addEventListener('message', onMsg); + + const poller = setInterval(() => { + if (popup && popup.closed) { + clearInterval(poller); + window.removeEventListener('message', onMsg); + fetch(`${API_BASE}/tools/${tool.id}`) + .then(r => r.json()) + .then(data => { + if (data.tool?.auth_status === 'connected') { + setConnectedTools((prev) => new Set(prev).add(integration.name)); + fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {}); + } + }) + .catch(() => {}); + setConnecting(null); + } + }, 1000); + setTimeout(() => { clearInterval(poller); setConnecting(null); }, 60000); + } catch { + setConnecting(null); + } + }, []); + + const handleApiKey = useCallback(() => advanceToTools(), [advanceToTools]); + const handleSkip = useCallback(() => dismiss(), [dismiss]); + + return { + open, step, connecting, nineRouterReady, connectedTools, + dismiss, handleConnect, handleToolConnect, handleApiKey, handleSkip, + }; +} diff --git a/frontend/src/app/components/useRichPromptEditor.ts b/frontend/src/app/components/useRichPromptEditor.ts new file mode 100644 index 00000000..6c9dc1b9 --- /dev/null +++ b/frontend/src/app/components/useRichPromptEditor.ts @@ -0,0 +1,237 @@ +import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { CommandPickerItem } from '@/app/components/CommandPicker'; +import { + SKILL_PILL_ATTR, + AttachedSkill, + createSkillPillElement, + serializeEditorContent, + deserializeToEditor, + detectEditorTrigger, + TriggerState, + EMPTY_TRIGGER, +} from '@/app/components/richEditorUtils'; +import { useAppSelector } from '@/shared/hooks'; +import { PromptTemplate } from '@/shared/state/templatesSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { RichPromptEditorProps, LINE_HEIGHT, FONT_SIZE } from './richPromptEditorTypes'; + +export function useRichPromptEditor({ + value, + onChange, + minRows = 3, + maxRows = 8, +}: RichPromptEditorProps) { + const c = useClaudeTokens(); + const editorRef = useRef(null); + const wrapperRef = useRef(null); + const [focused, setFocused] = useState(false); + const [hasContent, setHasContent] = useState(false); + const [attachedSkills, setAttachedSkills] = useState>({}); + const attachedSkillsRef = useRef(attachedSkills); + attachedSkillsRef.current = attachedSkills; + const removeSkillPillRef = useRef<(id: string) => void>(() => {}); + const [picker, setPicker] = useState(EMPTY_TRIGGER); + const [pickerRect, setPickerRect] = useState(null); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const templates = useAppSelector((state) => state.templates.items); + const skills = useAppSelector((state) => state.skills.items); + + useEffect(() => { + if (picker.visible && wrapperRef.current) { + setPickerRect(wrapperRef.current.getBoundingClientRect()); + } else { + setPickerRect(null); + } + }, [picker.visible]); + + const minHeight = minRows * FONT_SIZE * LINE_HEIGHT; + const maxHeight = maxRows * FONT_SIZE * LINE_HEIGHT; + const isLabelFloating = focused || hasContent; + + const lastEmittedRef = useRef(null); + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + if (value === lastEmittedRef.current) return; + lastEmittedRef.current = value; + + if (/\{\{skill:.+?\}\}/.test(value)) { + const skillsByName: Record = {}; + for (const s of Object.values(skills)) { + skillsByName[s.name] = { id: s.id, name: s.name, content: s.content }; + } + const restored = deserializeToEditor( + editor, + value, + skillsByName, + (id) => removeSkillPillRef.current(id), + c.font.mono, + c.status.error, + ); + setAttachedSkills(restored); + } else { + editor.textContent = value; + } + setHasContent(!!value); + }, [value]); // eslint-disable-line react-hooks/exhaustive-deps + + const emitChange = useCallback(() => { + const editor = editorRef.current; + if (!editor) return; + const serialized = serializeEditorContent(editor, attachedSkillsRef.current); + lastEmittedRef.current = serialized; + onChange(serialized); + }, [onChange]); + + const updateHasContent = useCallback(() => { + const editor = editorRef.current; + if (!editor) return; + const text = (editor.textContent || '').replace(/\u200B/g, ''); + const hasPills = editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null; + setHasContent(text.trim().length > 0 || hasPills); + }, []); + + const syncAttachedSkills = useCallback(() => { + const editor = editorRef.current; + if (!editor) return; + const pillIds = new Set( + Array.from(editor.querySelectorAll(`[${SKILL_PILL_ATTR}]`)) + .map((el) => el.getAttribute(SKILL_PILL_ATTR)) + .filter(Boolean) as string[], + ); + setAttachedSkills((prev) => { + const prevKeys = Object.keys(prev); + if (prevKeys.length === pillIds.size && prevKeys.every((k) => pillIds.has(k))) return prev; + const next: Record = {}; + for (const [id, skill] of Object.entries(prev)) { + if (pillIds.has(id)) next[id] = skill; + } + return next; + }); + }, []); + + const removeSkillPill = useCallback((skillId: string) => { + const editor = editorRef.current; + if (!editor) return; + const pill = editor.querySelector(`[${SKILL_PILL_ATTR}="${skillId}"]`); + if (pill) pill.remove(); + setAttachedSkills((prev) => { + const { [skillId]: _, ...rest } = prev; + return rest; + }); + updateHasContent(); + emitChange(); + editor.focus(); + }, [updateHasContent, emitChange]); + removeSkillPillRef.current = removeSkillPill; + + const detectTrigger = useCallback(() => { + const result = detectEditorTrigger(); + if (result) { + setPicker(result); + } else { + setPicker((p) => ({ ...p, visible: false })); + } + }, []); + + const handleInput = useCallback(() => { + updateHasContent(); + detectTrigger(); + syncAttachedSkills(); + emitChange(); + }, [updateHasContent, detectTrigger, syncAttachedSkills, emitChange]); + + const handleEditorClick = useCallback(() => { + detectTrigger(); + }, [detectTrigger]); + + const handlePickerSelect = (item: CommandPickerItem) => { + setPicker((p) => ({ ...p, visible: false })); + const editor = editorRef.current; + if (!editor) return; + editor.focus(); + + const { triggerNode, triggerOffset, filter } = picker; + if (triggerNode && triggerNode.parentNode && editor.contains(triggerNode)) { + const endOffset = Math.min(triggerOffset + 1 + filter.length, triggerNode.length); + const range = document.createRange(); + range.setStart(triggerNode, triggerOffset); + range.setEnd(triggerNode, endOffset); + range.deleteContents(); + const sel = window.getSelection(); + if (sel) { sel.removeAllRanges(); sel.addRange(range); } + } + + if (item.type === 'template') { + const tmpl = templates[item.id]; + if (!tmpl) return; + if (tmpl.fields.length === 0) { + document.execCommand('insertText', false, tmpl.template); + } else { + setSelectedTemplate(tmpl); + } + } else if (item.type === 'skill') { + const skill = skills[item.id]; + if (!skill) return; + if (editor.querySelector(`[${SKILL_PILL_ATTR}="${skill.id}"]`)) return; + + const pill = createSkillPillElement( + { id: skill.id, name: skill.name, content: skill.content }, + removeSkillPill, + c.font.mono, + c.status.error, + ); + + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + range.collapse(false); + range.insertNode(pill); + const spacer = document.createTextNode('\u200B'); + pill.after(spacer); + const newRange = document.createRange(); + newRange.setStartAfter(spacer); + newRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(newRange); + } + + setAttachedSkills((prev) => ({ + ...prev, + [skill.id]: { id: skill.id, name: skill.name, content: skill.content }, + })); + } else if (item.type === 'mode') { + document.execCommand('insertText', false, item.name); + } else if (item.type === 'context') { + document.execCommand('insertText', false, `@${item.command} `); + } + + updateHasContent(); + emitChange(); + setTimeout(() => editor.focus(), 0); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (picker.visible && ['ArrowDown', 'ArrowUp', 'Escape', 'Tab', 'Enter'].includes(e.key)) { + e.preventDefault(); + return; + } + if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) { + e.preventDefault(); + } + }; + + const handlePaste = useCallback((e: React.ClipboardEvent) => { + e.preventDefault(); + const plain = e.clipboardData.getData('text/plain'); + if (plain) document.execCommand('insertText', false, plain); + }, []); + + return { + c, editorRef, wrapperRef, focused, setFocused, hasContent, + picker, setPicker, pickerRect, selectedTemplate, setSelectedTemplate, + minHeight, maxHeight, isLabelFloating, + handleInput, handleEditorClick, handlePickerSelect, + handleKeyDown, handlePaste, updateHasContent, emitChange, + }; +} diff --git a/frontend/src/app/components/useSubscriptionConnect.ts b/frontend/src/app/components/useSubscriptionConnect.ts new file mode 100644 index 00000000..3ad22896 --- /dev/null +++ b/frontend/src/app/components/useSubscriptionConnect.ts @@ -0,0 +1,122 @@ +import { useCallback, MutableRefObject } from 'react'; +import { API_BASE } from '@/shared/config'; + +interface UseSubscriptionConnectParams { + pollTimerRef: MutableRefObject; + msgHandlerRef: MutableRefObject; + setConnecting: (v: string | null) => void; + advanceToTools: () => void; +} + +export function useSubscriptionConnect({ + pollTimerRef, msgHandlerRef, setConnecting, advanceToTools, +}: UseSubscriptionConnectParams) { + const handleConnect = useCallback(async (providerId: string) => { + if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } + if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } + setConnecting(providerId); + + await new Promise(r => setTimeout(r, 1000)); + + try { + const r = await fetch(`${API_BASE}/subscriptions/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: providerId }), + }); + if (!r.ok) { + setConnecting(null); + return; + } + const data = await r.json(); + + if (data.flow === 'device_code') { + if (data.verification_uri) window.open(data.verification_uri, '_blank'); + + const timer = setInterval(async () => { + try { + const pr = await fetch(`${API_BASE}/subscriptions/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: providerId, + device_code: data.device_code, + code_verifier: data.code_verifier, + extra_data: data.extra_data, + }), + }); + const pd = await pr.json(); + if (pd.success) { + clearInterval(timer); + pollTimerRef.current = null; + advanceToTools(); + } + } catch {} + }, 5000); + pollTimerRef.current = timer; + setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 30000); + + } else if (data.flow === 'authorization_code') { + const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); + + const statusPoller = setInterval(async () => { + try { + const sr = await fetch(`${API_BASE}/subscriptions/status`); + const sd = await sr.json(); + const connections = sd.providers?.connections || []; + if (connections.some((p: any) => p.provider === providerId && p.isActive)) { + clearInterval(statusPoller); + pollTimerRef.current = null; + if (msgHandlerRef.current) { + window.removeEventListener('message', msgHandlerRef.current); + msgHandlerRef.current = null; + } + advanceToTools(); + } + } catch {} + }, 2000); + pollTimerRef.current = statusPoller; + + const msgHandler = async (event: MessageEvent) => { + const d = event.data; + const callbackData = d?.type === 'oauth_callback' ? d.data : d; + if (callbackData?.code) { + window.removeEventListener('message', msgHandler); + msgHandlerRef.current = null; + if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } + if (popup && !popup.closed) popup.close(); + try { + await fetch(`${API_BASE}/subscriptions/exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: providerId, + code: callbackData.code, + redirect_uri: data.redirect_uri, + code_verifier: data.code_verifier, + state: callbackData.state || data.state, + }), + }); + } catch {} + advanceToTools(); + } + }; + window.addEventListener('message', msgHandler); + msgHandlerRef.current = msgHandler; + + setTimeout(() => { + if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } + if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } + setConnecting(null); + }, 30000); + + } else { + setConnecting(null); + } + } catch { + setConnecting(null); + } + }, [pollTimerRef, msgHandlerRef, setConnecting, advanceToTools]); + + return handleConnect; +} diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 326b538a..b242e8c8 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1,115 +1,25 @@ -import React, { useEffect, useLayoutEffect, useRef, useMemo, useState, useCallback } from 'react'; -import { useParams } from 'react-router-dom'; +import React, { useCallback } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; -import TextField from '@mui/material/TextField'; -import ClickAwayListener from '@mui/material/ClickAwayListener'; -import CloseIcon from '@mui/icons-material/Close'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import CheckIcon from '@mui/icons-material/Check'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { - sendMessage as sendMessageThunk, - launchAndSendFirstMessage, - generateTitle, - generateGroupMeta, - stopAgent, - handleApproval, - editMessage, - switchBranch, - duplicateSession, - setActiveSession, - updateSessionModel, - updateSessionMode, - fetchSession, - AgentMessage, -} from '@/shared/state/agentsSlice'; -import { fetchModes } from '@/shared/state/modesSlice'; -import { createSessionWs } from '@/shared/ws/WebSocketManager'; +import { AgentMessage, editMessage, switchBranch, duplicateSession, setActiveSession } from '@/shared/state/agentsSlice'; import MessageBubble from './MessageBubble'; import MessageActionBar from './MessageActionBar'; -import ToolCallBubble, { ToolPair } from './ToolCallBubble'; -import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './ToolGroupBubble'; +import ToolCallBubble from './ToolCallBubble'; +import ToolGroupBubble, { isToolGroup, isToolPair } from './ToolGroupBubble'; import ApprovalBar, { BatchApprovalBar } from './ApprovalBar'; -import ChatInput, { ChatInputHandle } from './ChatInput'; +import ChatInput from './ChatInput'; +import ThinkingBubble from './ThinkingBubble'; +import ChatHeader from './ChatHeader'; +import MessageQueue from './MessageQueue'; +import { useAgentChat } from './hooks/useAgentChat'; +import { useMessageRendering } from './hooks/useMessageRendering'; import { ContextPath } from '@/app/components/DirectoryBrowser'; -import DiffViewer from './DiffViewer'; -import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -const CONTEXT_WINDOWS: Record = { - sonnet: 200_000, - opus: 200_000, - haiku: 200_000, -}; - -function stringifyContent(content: any): string { - if (content == null) return ''; - if (typeof content === 'string') return content; - return JSON.stringify(content); -} - -const thinkingDotsKeyframes = ` -@keyframes thinking-bounce { - 0%, 80%, 100% { transform: scale(0); opacity: 0.4; } - 40% { transform: scale(1); opacity: 1; } -} -`; - -const ThinkingBubble: React.FC = () => { - const c = useClaudeTokens(); - return ( - - - - {[0, 1, 2].map((i) => ( - - ))} - - - ); -}; - -interface QueuedMessage { - prompt: string; - images?: Array<{ data: string; media_type: string }>; - contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>; - forcedTools?: string[]; - attachedSkills?: Array<{ id: string; name: string; content: string }>; - selectedBrowserIds?: string[]; -} - interface AgentChatProps { sessionId?: string; onClose?: () => void; @@ -121,284 +31,20 @@ interface AgentChatProps { onBranch?: (newSessionId: string) => void; } -const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch }) => { +const AgentChat: React.FC = ({ sessionId, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch }) => { const c = useClaudeTokens(); - const STATUS_STYLES: Record = { - running: { color: c.status.success, bg: c.status.successBg }, - waiting_approval: { color: c.status.warning, bg: c.status.warningBg }, - completed: { color: c.text.tertiary, bg: c.bg.secondary }, - error: { color: c.status.error, bg: c.status.errorBg }, - stopped: { color: c.text.tertiary, bg: c.bg.secondary }, - }; - const { id: routeId } = useParams<{ id: string }>(); - const id = sessionIdProp || routeId; - const dispatch = useAppDispatch(); - const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined)); - const modesMap = useAppSelector((state) => state.modes.items); - const scrollContainerRef = useRef(null); - const chatInputRef = useRef(null); - const isAtBottomRef = useRef(true); - const [showScrollButton, setShowScrollButton] = useState(false); - const [showResumeBubble, setShowResumeBubble] = useState(false); - const [awaitingResponse, setAwaitingResponse] = useState(false); - const [mode, setMode] = useState('agent'); - const [model, setModel] = useState('sonnet'); + const { + id, session, isDraft, dispatch, mode, model, + scrollContainerRef, chatInputRef, messageQueueRef, + showScrollButton, showResumeBubble, awaitingResponse, editingMessageId, + queueLength, setQueueLength, agentBusy, + handleScroll, scrollToBottom, handleSend, + handleModeChange, handleModelChange, + handleApprove, handleDeny, handleStop, handleResume, + handleSaveEdit, handleCancelEdit, setEditingMessageId, + } = useAgentChat({ sessionId, initialContextPaths }); - const wsRef = useRef | null>(null); - const initialContextApplied = useRef(false); - const messageQueueRef = useRef([]); - const [queueLength, setQueueLength] = useState(0); - const [queueExpanded, setQueueExpanded] = useState(false); - const [editingQueueIdx, setEditingQueueIdx] = useState(null); - const [editingQueueText, setEditingQueueText] = useState(''); - const [dragIdx, setDragIdx] = useState(null); - const [dropTargetIdx, setDropTargetIdx] = useState(null); - - const isDraft = session?.status === 'draft'; - - useEffect(() => { - if (!id || isDraft) return; - const ws = createSessionWs(id); - ws.connect(); - wsRef.current = ws; - dispatch(fetchSession(id)); - return () => { - ws.disconnect(); - wsRef.current = null; - }; - }, [id, isDraft, dispatch]); - - useEffect(() => { - if (initialContextApplied.current || !initialContextPaths?.length) return; - const timer = setTimeout(() => { - chatInputRef.current?.setContent('', initialContextPaths); - initialContextApplied.current = true; - }, 50); - return () => clearTimeout(timer); - }, [initialContextPaths]); - - useEffect(() => { - if (session) setMode(session.mode); - }, [session?.mode]); - - useEffect(() => { - if (session) setModel(session.model); - }, [session?.model]); - - useEffect(() => { - if (Object.keys(modesMap).length === 0) dispatch(fetchModes()); - }, [dispatch, modesMap]); - - const dispatchMessage = useCallback((msg: QueuedMessage) => { - if (!id) return; - setShowResumeBubble(false); - setAwaitingResponse(true); - if (isDraft) { - const config: Record = { model, mode }; - if (session?.system_prompt) config.system_prompt = session.system_prompt; - if (session?.target_directory) config.target_directory = session.target_directory; - dispatch( - launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }) - ).then((action) => { - if (launchAndSendFirstMessage.fulfilled.match(action)) { - const realId = action.payload.session.id; - dispatch(generateTitle({ sessionId: realId, prompt: msg.prompt })); - if (msg.selectedBrowserIds?.length) { - dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: realId, label: 'Use Browser' })); - } - } - }); - } else { - if (msg.selectedBrowserIds?.length) { - dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' })); - } - dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds })) - .then((action) => { - if (sendMessageThunk.rejected.match(action)) { - setAwaitingResponse(false); - } - }); - } - }, [id, isDraft, mode, model, session?.system_prompt, session?.target_directory, dispatch]); - - const agentBusy = awaitingResponse || (!isDraft && (session?.status === 'running' || session?.status === 'waiting_approval')); - - const prevStatusRef = useRef(session?.status); - useEffect(() => { - const prev = prevStatusRef.current; - const curr = session?.status; - prevStatusRef.current = curr; - let didDispatchQueued = false; - - const wasActive = prev === 'running' || prev === 'waiting_approval'; - const isTerminal = curr === 'completed' || curr === 'stopped' || curr === 'error'; - - if (wasActive && isTerminal) { - if (id) { - dispatch(fadeGlowingBrowserCards(id)); - setTimeout(() => dispatch(clearGlowingBrowserCards(id)), 2800); - } - - const nextQueued = messageQueueRef.current.shift(); - if (nextQueued) { - setQueueLength(messageQueueRef.current.length); - dispatchMessage(nextQueued); - didDispatchQueued = true; - } else { - if (curr === 'stopped') { - setShowResumeBubble(true); - } - } - - const currentMode = modesMap[mode]; - if (currentMode?.default_next_mode && modesMap[currentMode.default_next_mode]) { - setMode(currentMode.default_next_mode); - if (id && !isDraft) { - dispatch(updateSessionMode({ sessionId: id, mode: currentMode.default_next_mode as any })); - } - } - } - if (curr === 'running') { - setShowResumeBubble(false); - } - if (curr !== 'draft' && !didDispatchQueued) { - setAwaitingResponse(false); - } - }, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]); - - const SCROLL_THRESHOLD = 50; - - const handleScroll = useCallback(() => { - const el = scrollContainerRef.current; - if (!el) return; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; - isAtBottomRef.current = atBottom; - setShowScrollButton(!atBottom); - }, []); - - const scrollToBottom = useCallback(() => { - const el = scrollContainerRef.current; - if (!el) return; - el.scrollTop = el.scrollHeight; - isAtBottomRef.current = true; - setShowScrollButton(false); - }, []); - - useLayoutEffect(() => { - if (isAtBottomRef.current) { - const el = scrollContainerRef.current; - if (el) el.scrollTop = el.scrollHeight; - } - }, [session?.messages.length, session?.streamingMessage?.content]); - - const handleSend = (prompt: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>, forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => { - if (!id) return; - const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }; - if (agentBusy) { - messageQueueRef.current.push(msg); - setQueueLength(messageQueueRef.current.length); - return; - } - dispatchMessage(msg); - }; - - const handleModeChange = useCallback((newMode: string) => { - setMode(newMode); - if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: newMode })); - }, [id, isDraft, dispatch]); - - const handleModelChange = useCallback((newModel: string) => { - setModel(newModel); - if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel })); - }, [id, isDraft, dispatch]); - - const handleApprove = (requestId: string, updatedInput?: Record) => { - dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput })); - }; - - const handleDeny = (requestId: string, message?: string) => { - dispatch(handleApproval({ requestId, behavior: 'deny', message })); - }; - - const handleStop = () => { - if (!id) return; - dispatch(stopAgent({ sessionId: id })); - }; - - const handleResume = useCallback(() => { - if (!id) return; - setShowResumeBubble(false); - dispatch(sendMessageThunk({ - sessionId: id, - prompt: "Continue where you left off. Start you're response EXACTLY with 'Sorry, let me pick up where I left off", - mode, - model, - hidden: true, - })); - }, [id, mode, model, dispatch]); - - const [editingMessageId, setEditingMessageId] = useState(null); - - const handleSaveEdit = useCallback( - (messageId: string, newContent: string) => { - if (!id) return; - dispatch(editMessage({ sessionId: id, messageId, content: newContent })); - setEditingMessageId(null); - }, - [id, dispatch] - ); - - const handleCancelEdit = useCallback(() => { - setEditingMessageId(null); - }, []); - - const activeBranchMessages = useMemo(() => { - if (!session) return []; - const branchId = session.active_branch_id || 'main'; - const branch = session.branches?.[branchId]; - - if (!branch || !branch.fork_point_message_id) { - return session.messages.filter((m) => m.branch_id === 'main' || m.branch_id === branchId); - } - - const segments: Array<{ branchId: string; upToMessageId?: string }> = []; - let cur = branch; - let curId = branchId; - while (cur && cur.fork_point_message_id) { - segments.unshift({ branchId: curId, upToMessageId: cur.fork_point_message_id }); - curId = cur.parent_branch_id || 'main'; - cur = session.branches?.[curId]; - } - segments.unshift({ branchId: curId }); - - const result: typeof session.messages = []; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - const nextForkMsgId = seg.upToMessageId; - if (nextForkMsgId) { - const forkIdx = session.messages.findIndex((m) => m.id === nextForkMsgId); - const pre = session.messages - .slice(0, forkIdx) - .filter((m) => m.branch_id === seg.branchId); - result.push(...pre); - } else if (i < segments.length - 1) { - const nextFork = segments[i + 1].upToMessageId; - const forkIdx = nextFork - ? session.messages.findIndex((m) => m.id === nextFork) - : session.messages.length; - result.push( - ...session.messages.slice(0, forkIdx).filter((m) => m.branch_id === seg.branchId) - ); - } else { - result.push(...session.messages.filter((m) => m.branch_id === seg.branchId)); - } - } - const leafMsgs = session.messages.filter((m) => m.branch_id === branchId); - if (!result.some((m) => m.branch_id === branchId)) { - result.push(...leafMsgs); - } - return result; - }, [session?.messages, session?.active_branch_id, session?.branches]); + const { activeBranchMessages, renderItems, lastAssistantIdsInTurn, getSiblingBranches, contextEstimate } = useMessageRendering(session, model, id, isDraft); const handleRegenerate = useCallback( (assistantMsg: AgentMessage) => { @@ -421,306 +67,37 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const dashId = session?.dashboard_id; const action = await dispatch(duplicateSession({ sessionId: id, dashboardId: dashId, upToMessageId })); if (duplicateSession.fulfilled.match(action)) { - if (onBranch) { - onBranch(action.payload.id); - } else { - dispatch(setActiveSession(action.payload.id)); - } + if (onBranch) onBranch(action.payload.id); + else dispatch(setActiveSession(action.payload.id)); } }, [id, dispatch, onBranch, session?.dashboard_id]); - const contextEstimate = useMemo(() => { - const limit = CONTEXT_WINDOWS[model] || 200_000; - let totalChars = 0; - if (session?.system_prompt) totalChars += session.system_prompt.length; - for (const msg of activeBranchMessages) { - totalChars += stringifyContent(msg.content).length; - } - if (session?.streamingMessage) { - totalChars += (session.streamingMessage.content || '').length; - } - const used = Math.round(totalChars / 4); - return { used, limit }; - }, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model]); - - const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval'; - - const renderItems: RenderItem[] = useMemo(() => { - const isOutputCall = (m: AgentMessage) => - m.role === 'tool_call' && typeof m.content === 'object' && m.content.tool === 'RenderOutput'; - const isOutputResult = (m: AgentMessage) => { - if (m.role !== 'tool_result') return false; - try { - const parsed = typeof m.content === 'string' ? JSON.parse(m.content) : m.content; - return !!(parsed?.output_id && parsed?.frontend_code); - } catch { return false; } - }; - - const items: RenderItem[] = []; - let i = 0; - while (i < activeBranchMessages.length) { - const msg = activeBranchMessages[i]; - if (msg.role === 'tool_call' || msg.role === 'tool_result') { - const group: typeof activeBranchMessages = []; - while ( - i < activeBranchMessages.length && - (activeBranchMessages[i].role === 'tool_call' || - activeBranchMessages[i].role === 'tool_result') - ) { - group.push(activeBranchMessages[i]); - i++; - } - - const regular: typeof activeBranchMessages = []; - const outputItems: typeof activeBranchMessages = []; - for (const m of group) { - if (isOutputCall(m) || isOutputResult(m)) { outputItems.push(m); continue; } - regular.push(m); - } - - const calls = regular.filter((m) => m.role === 'tool_call'); - const results = regular.filter((m) => m.role === 'tool_result'); - const pairs: ToolPair[] = calls.map((call, idx) => ({ - type: 'tool_pair' as const, - id: `pair-${call.id}`, - call, - result: results[idx] || null, - })); - - const mcpServers = new Set( - calls.map((m) => { - const tool = typeof m.content === 'object' ? m.content.tool || '' : ''; - const match = tool.match(/^mcp__([^_]+(?:-[^_]+)*)__/); - return match ? match[1] : ''; - }).filter(Boolean) - ); - const allSameMcp = mcpServers.size === 1 && pairs.length > 0; - - if (allSameMcp) { - const mcpServer = [...mcpServers][0]; - const toolNames = new Set( - calls.map((m) => (typeof m.content === 'object' ? m.content.tool : '')) - ); - const label = - toolNames.size === 1 ? calls[0].content?.tool || 'Tool calls' : `${calls.length} tool calls`; - items.push({ - type: 'tool_group', - id: `group-${group[0].id}`, - pairs, - label, - callCount: calls.length, - mcpServer, - } satisfies ToolGroup); - } else if (pairs.length <= 2) { - items.push(...pairs); - } else if (pairs.length > 0) { - const toolNames = new Set( - calls.map((m) => (typeof m.content === 'object' ? m.content.tool : '')) - ); - const label = - toolNames.size === 1 ? calls[0].content?.tool || 'Tool calls' : `${calls.length} tool calls`; - items.push({ - type: 'tool_group', - id: `group-${group[0].id}`, - pairs, - label, - callCount: calls.length, - } satisfies ToolGroup); - } - - items.push(...outputItems); - } else { - if (!msg.hidden) { - items.push(msg); - } - i++; - } - } - return items; - }, [activeBranchMessages]); - - const lastAssistantIdsInTurn = useMemo(() => { - const ids = new Set(); - let lastAssistantId: string | null = null; - for (const item of renderItems) { - if (!isToolGroup(item) && !isToolPair(item)) { - const msg = item as AgentMessage; - if (msg.role === 'assistant') { - lastAssistantId = msg.id; - } else if (msg.role === 'user') { - if (lastAssistantId) ids.add(lastAssistantId); - lastAssistantId = null; - } - } - } - if (lastAssistantId) ids.add(lastAssistantId); - return ids; - }, [renderItems]); - - const groupMetaRequestedRef = useRef>(new Set()); - const groupMetaRefinedRef = useRef>(new Set()); - - useEffect(() => { - if (!id || isDraft) return; - const toolGroups = renderItems.filter(isToolGroup) as ToolGroup[]; - const meta = session?.tool_group_meta ?? {}; - - for (const group of toolGroups) { - const allDone = group.pairs.every((p) => p.result !== null); - - if (!groupMetaRequestedRef.current.has(group.id) && !meta[group.id]) { - groupMetaRequestedRef.current.add(group.id); - const toolCalls = group.pairs.map((p) => { - const c = p.call.content; - const tool = typeof c === 'object' ? c.tool || '' : ''; - const input = typeof c === 'object' ? c.input : ''; - const summary = typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120); - return { tool, input_summary: summary }; - }); - dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls })); - } - - if (allDone && meta[group.id] && !meta[group.id].is_refined && !groupMetaRefinedRef.current.has(group.id)) { - groupMetaRefinedRef.current.add(group.id); - const toolCalls = group.pairs.map((p) => { - const c = p.call.content; - const tool = typeof c === 'object' ? c.tool || '' : ''; - const input = typeof c === 'object' ? c.input : ''; - const summary = typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120); - return { tool, input_summary: summary }; - }); - const resultsSummary = group.pairs - .filter((p) => p.result) - .map((p) => { - const rc = p.result!.content; - const text = typeof rc === 'string' ? rc : typeof rc === 'object' && rc?.text ? rc.text : JSON.stringify(rc); - return text.slice(0, 150); - }); - dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls, resultsSummary, isRefinement: true })); - } - } - }, [renderItems, id, isDraft, session?.tool_group_meta, dispatch]); - - const getSiblingBranches = useCallback( - (messageId: string): string[] => { - if (!session?.branches) return []; - - const directForks = Object.values(session.branches) - .filter((b) => b.fork_point_message_id === messageId) - .map((b) => b.id); - if (directForks.length > 0) { - const originalMsg = session.messages.find((m) => m.id === messageId); - const parentBranchId = originalMsg?.branch_id || 'main'; - return [parentBranchId, ...directForks]; - } - - const msg = session.messages.find((m) => m.id === messageId); - if (!msg || msg.role !== 'user') return []; - const msgBranch = session.branches[msg.branch_id]; - if (!msgBranch?.fork_point_message_id) return []; - const branchUserMsgs = session.messages.filter( - (m) => m.branch_id === msg.branch_id && m.role === 'user' - ); - if (branchUserMsgs.length === 0 || branchUserMsgs[0].id !== messageId) return []; - - const forkPointId = msgBranch.fork_point_message_id; - const siblingBranches = Object.values(session.branches) - .filter((b) => b.fork_point_message_id === forkPointId) - .map((b) => b.id); - const parentBranchId = msgBranch.parent_branch_id || 'main'; - return [parentBranchId, ...siblingBranches]; - }, - [session?.branches, session?.messages] - ); - if (!session) { return ( - - Session not found - + Session not found ); } - - const isActive = session.status === 'running' || session.status === 'waiting_approval' || session.status === 'draft'; - const statusStyle = STATUS_STYLES[session.status] || { color: c.text.tertiary, bg: c.bg.secondary }; + const sessionRunning = session.status === 'running' || session.status === 'waiting_approval'; return ( - {!embedded && ( - - - - {session.name} - {!isDraft && statusStyle && ( - - )} - - {!isDraft && ( - - - {session.model} - - - {session.branch_name} - - {session.cost_usd > 0 && ( - - ${session.cost_usd.toFixed(4)} - - )} - - )} - - {!isDraft && id && } - {onClose && ( - - - - )} - - )} - + {!embedded && } {renderItems.map((item) => { @@ -736,19 +113,11 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const isEditing = editingMessageId === msg.id; const siblings = getSiblingBranches(msg.id); const hasBranches = siblings.length > 0; - const currentBranchIdx = hasBranches - ? siblings.indexOf(session.active_branch_id || 'main') - : 0; + const currentBranchIdx = hasBranches ? siblings.indexOf(session.active_branch_id || 'main') : 0; const rawText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); - return ( - + {!isEditing && (msg.role === 'user' || (msg.role === 'assistant' && lastAssistantIdsInTurn.has(msg.id))) && ( = ({ sessionId: sessionIdProp, onClose onEdit={msg.role === 'user' ? () => setEditingMessageId(msg.id) : undefined} onRegenerate={msg.role === 'assistant' ? () => handleRegenerate(msg) : undefined} onBranch={msg.role === 'assistant' ? () => handleBranchChat(msg.id) : undefined} - branchNav={ - hasBranches - ? { - currentIndex: Math.max(0, currentBranchIdx), - totalBranches: siblings.length, - onPrevious: () => { - const prevBranch = siblings[Math.max(0, currentBranchIdx - 1)]; - if (prevBranch && id) dispatch(switchBranch({ sessionId: id, branchId: prevBranch })); - }, - onNext: () => { - const nextBranch = siblings[Math.min(siblings.length - 1, currentBranchIdx + 1)]; - if (nextBranch && id) dispatch(switchBranch({ sessionId: id, branchId: nextBranch })); - }, - } - : undefined - } + branchNav={hasBranches ? { + currentIndex: Math.max(0, currentBranchIdx), + totalBranches: siblings.length, + onPrevious: () => { const b = siblings[Math.max(0, currentBranchIdx - 1)]; if (b && id) dispatch(switchBranch({ sessionId: id, branchId: b })); }, + onNext: () => { const b = siblings[Math.min(siblings.length - 1, currentBranchIdx + 1)]; if (b && id) dispatch(switchBranch({ sessionId: id, branchId: b })); }, + } : undefined} /> )} @@ -785,12 +144,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose isPending sessionId={session.id} call={{ - id: session.streamingMessage.id, - role: 'tool_call', + id: session.streamingMessage.id, role: 'tool_call', content: { tool: session.streamingMessage.tool_name || '', input: session.streamingMessage.content }, - timestamp: new Date().toISOString(), - branch_id: session.active_branch_id || 'main', - parent_id: null, + timestamp: new Date().toISOString(), branch_id: session.active_branch_id || 'main', parent_id: null, }} /> ) : ( @@ -798,44 +154,28 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose key={`streaming-${session.streamingMessage.id}`} isStreaming message={{ - id: session.streamingMessage.id, - role: session.streamingMessage.role, - content: session.streamingMessage.content, - timestamp: new Date().toISOString(), - branch_id: session.active_branch_id || 'main', - parent_id: null, + id: session.streamingMessage.id, role: session.streamingMessage.role, + content: session.streamingMessage.content, timestamp: new Date().toISOString(), + branch_id: session.active_branch_id || 'main', parent_id: null, }} /> ) )} - {(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && ( - - )} + {(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && } {showResumeBubble && session.status === 'stopped' && ( - - Resume Agent Response - + Resume Agent Response )} @@ -845,18 +185,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose @@ -864,7 +196,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} - {session.pending_approvals.length > 1 ? ( ) : ( @@ -872,262 +203,44 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )) )} - {isGlowing ? ( { e.stopPropagation(); onDismissGlow?.(); }} sx={{ - mx: 1.5, - mb: 1.5, - py: 1.25, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 2.5, - cursor: 'pointer', - fontWeight: 600, - fontSize: '0.85rem', - color: c.accent.primary, - border: `1.5px solid ${c.accent.primary}`, + mx: 1.5, mb: 1.5, py: 1.25, display: 'flex', alignItems: 'center', justifyContent: 'center', + borderRadius: 2.5, cursor: 'pointer', fontWeight: 600, fontSize: '0.85rem', + color: c.accent.primary, border: `1.5px solid ${c.accent.primary}`, background: `${c.accent.primary}08`, boxShadow: `0 0 12px ${c.accent.primary}25, inset 0 0 12px ${c.accent.primary}08`, animation: 'continue-chat-glow 2s ease-in-out infinite', transition: 'background 0.15s, box-shadow 0.15s', '@keyframes continue-chat-glow': { - '0%, 100%': { - boxShadow: `0 0 12px ${c.accent.primary}25, inset 0 0 12px ${c.accent.primary}08`, - }, - '50%': { - boxShadow: `0 0 20px ${c.accent.primary}40, inset 0 0 20px ${c.accent.primary}15`, - }, - }, - '&:hover': { - background: `${c.accent.primary}14`, - boxShadow: `0 0 24px ${c.accent.primary}50, inset 0 0 20px ${c.accent.primary}18`, + '0%, 100%': { boxShadow: `0 0 12px ${c.accent.primary}25, inset 0 0 12px ${c.accent.primary}08` }, + '50%': { boxShadow: `0 0 20px ${c.accent.primary}40, inset 0 0 20px ${c.accent.primary}15` }, }, + '&:hover': { background: `${c.accent.primary}14`, boxShadow: `0 0 24px ${c.accent.primary}50, inset 0 0 20px ${c.accent.primary}18` }, }} > Continue chat ) : ( - { if (queueExpanded) { setQueueExpanded(false); setEditingQueueIdx(null); } }}> - - {queueLength > 0 && ( - - { setQueueExpanded((v) => !v); setEditingQueueIdx(null); }} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.5, - px: 1.25, - py: 0.25, - borderRadius: '8px 8px 0 0', - bgcolor: c.bg.surface, - border: `1px solid ${c.border.subtle}`, - borderBottom: 'none', - cursor: 'pointer', - userSelect: 'none', - '&:hover': { bgcolor: c.bg.secondary }, - transition: 'background 0.12s', - }} - > - {queueExpanded - ? - : - } - - {queueLength} queued - - - { e.stopPropagation(); messageQueueRef.current = []; setQueueLength(0); setQueueExpanded(false); setEditingQueueIdx(null); }} - sx={{ p: 0.15, color: c.text.tertiary, '&:hover': { color: c.status.error } }} - > - - - - - - {queueExpanded && ( - - {messageQueueRef.current.map((msg, idx) => ( - { - setDragIdx(idx); - e.dataTransfer.effectAllowed = 'move'; - }} - onDragOver={(e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - if (dragIdx !== null && dragIdx !== idx) setDropTargetIdx(idx); - }} - onDragLeave={() => { if (dropTargetIdx === idx) setDropTargetIdx(null); }} - onDrop={(e) => { - e.preventDefault(); - if (dragIdx !== null && dragIdx !== idx) { - const q = messageQueueRef.current; - const [item] = q.splice(dragIdx, 1); - q.splice(idx, 0, item); - setQueueLength(q.length); - } - setDragIdx(null); - setDropTargetIdx(null); - }} - onDragEnd={() => { setDragIdx(null); setDropTargetIdx(null); }} - sx={{ - display: 'flex', - alignItems: 'flex-start', - gap: 0.75, - px: 1.5, - py: 1, - borderBottom: idx < queueLength - 1 ? `1px solid ${c.border.subtle}` : 'none', - '&:hover': { bgcolor: c.bg.secondary }, - transition: 'background 0.1s, opacity 0.15s', - ...(dragIdx === idx ? { opacity: 0.35 } : {}), - ...(dropTargetIdx === idx && dragIdx !== null && dragIdx !== idx - ? { borderTop: `2px solid ${c.accent.primary}` } - : {}), - }} - > - - - - {editingQueueIdx === idx ? ( - - setEditingQueueText(e.target.value)} - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - const trimmed = editingQueueText.trim(); - if (trimmed) { - messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed }; - setQueueLength(messageQueueRef.current.length); - } - setEditingQueueIdx(null); - } - if (e.key === 'Escape') setEditingQueueIdx(null); - }} - sx={{ - '& .MuiOutlinedInput-root': { - fontSize: '0.78rem', - color: c.text.primary, - '& fieldset': { borderColor: c.border.medium }, - '&.Mui-focused fieldset': { borderColor: c.accent.primary }, - }, - }} - /> - { - const trimmed = editingQueueText.trim(); - if (trimmed) { - messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed }; - setQueueLength(messageQueueRef.current.length); - } - setEditingQueueIdx(null); - }} - sx={{ p: 0.25, color: c.accent.primary, mt: 0.25 }} - > - - - - ) : ( - - {msg.prompt} - - )} - {editingQueueIdx !== idx && ( - - - { setEditingQueueIdx(idx); setEditingQueueText(msg.prompt); }} - sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.text.primary } }} - > - - - - - { - messageQueueRef.current.splice(idx, 1); - setQueueLength(messageQueueRef.current.length); - if (messageQueueRef.current.length === 0) setQueueExpanded(false); - }} - sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.status.error } }} - > - - - - - )} - - ))} - - )} - - )} - - - + + + )} diff --git a/frontend/src/app/pages/AgentChat/AgentToolBubble.tsx b/frontend/src/app/pages/AgentChat/AgentToolBubble.tsx new file mode 100644 index 00000000..ccd3f005 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/AgentToolBubble.tsx @@ -0,0 +1,193 @@ +import React, { useState, useCallback, useMemo, useRef } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Collapse from '@mui/material/Collapse'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import BlockIcon from '@mui/icons-material/Block'; +import CallSplitIcon from '@mui/icons-material/CallSplit'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { expandSession, collapseSession, fetchSession } from '@/shared/state/agentsSlice'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { placeCard, removeCard, setGlowingAgentCard, clearGlowingAgentCard, DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { ElapsedTimer } from './ElapsedTimer'; +import { + ToolCallBubbleProps, ensureToolCallKeyframes, getToolData, formatElapsed, + parseToolResult, getResultSummary, parseInvokedSessionId, + parseInvokeAgentResult, parseCreateAgentResult, +} from './toolCallUtils'; + +function useRevealAgent( + revealTargetSessionId: string | null, sessionId: string | undefined, + bubbleRef: React.RefObject, label: string, +) { + const dispatch = useAppDispatch(); + const cards = useAppSelector((s) => s.dashboardLayout.cards); + const sessions = useAppSelector((s) => s.agents.sessions); + return useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (!revealTargetSessionId || !sessionId) return; + if (cards[revealTargetSessionId]) { + dispatch(collapseSession(revealTargetSessionId)); + dispatch(removeCard(revealTargetSessionId)); + setTimeout(() => dispatch(clearGlowingAgentCard(revealTargetSessionId)), 500); + return; + } + let sourceYRatio: number | undefined; + if (bubbleRef.current) { + const cardEl = bubbleRef.current.closest('[data-select-type="agent-card"]') as HTMLElement | null; + if (cardEl) { + const cr = cardEl.getBoundingClientRect(), br = bubbleRef.current.getBoundingClientRect(); + sourceYRatio = Math.max(0, Math.min(1, (br.top + br.height / 2 - cr.top) / cr.height)); + } + } + const doPlace = () => { + const parentCard = cards[sessionId]; + const targetX = parentCard ? parentCard.x + parentCard.width + GRID_GAP * 12 : 40; + let targetY = parentCard ? parentCard.y : 100; + if (parentCard) { + const colCards = Object.values(cards).filter((c) => Math.abs(c.x - targetX) < 50 && c.session_id !== revealTargetSessionId); + if (colCards.length > 0) targetY = Math.max(...colCards.map((c) => c.y + Math.max(EXPANDED_CARD_MIN_H, c.height))) + GRID_GAP; + } + dispatch(placeCard({ sessionId: revealTargetSessionId, x: targetX, y: targetY, width: DEFAULT_CARD_W, height: DEFAULT_CARD_H })); + dispatch(expandSession(revealTargetSessionId)); + dispatch(setGlowingAgentCard({ sessionId: revealTargetSessionId, sourceId: sessionId, sourceYRatio, label })); + }; + if (!sessions[revealTargetSessionId]) dispatch(fetchSession(revealTargetSessionId)).then(doPlace); + else doPlace(); + }, [revealTargetSessionId, sessionId, cards, sessions, dispatch, label, bubbleRef]); +} + +const mdSx = (c: any) => ({ + borderTop: `1px solid ${c.border.subtle}`, px: 1.5, py: 1.25, maxHeight: 400, + overflowY: 'auto', overflowX: 'hidden', color: c.text.secondary, fontFamily: c.font.sans, + fontSize: '0.78rem', lineHeight: 1.65, overflowWrap: 'anywhere', wordBreak: 'break-word', + '& p': { m: 0, mb: 0.75, '&:last-child': { mb: 0 } }, + '& h1, & h2, & h3, & h4': { color: c.text.primary, fontFamily: c.font.sans, mt: 1, mb: 0.5, '&:first-of-type': { mt: 0 } }, + '& h1': { fontSize: '0.88rem' }, '& h2': { fontSize: '0.84rem' }, '& h3': { fontSize: '0.8rem' }, '& h4': { fontSize: '0.78rem' }, + '& strong': { color: c.text.primary, fontWeight: 600 }, + '& a': { color: c.accent.primary, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }, + '& ul, & ol': { pl: 2, mb: 0.75, mt: 0 }, '& li': { mb: 0.2 }, + '& blockquote': { m: 0, mb: 0.75, pl: 1, ml: 0, borderLeft: `2px solid ${c.border.subtle}`, color: c.text.tertiary, fontStyle: 'italic' }, + '& code': { bgcolor: c.bg.secondary, px: 0.4, py: 0.15, borderRadius: 0.5, fontSize: '0.72rem', fontFamily: c.font.mono }, + '& pre': { bgcolor: c.bg.secondary, borderRadius: 1, p: 1, overflow: 'auto', fontSize: '0.72rem', fontFamily: c.font.mono, m: 0, mb: 0.75 }, + '& pre code': { bgcolor: 'transparent', p: 0 }, + '& hr': { border: 'none', borderTop: `1px solid ${c.border.subtle}`, my: 0.75 }, + '&::-webkit-scrollbar': { width: 5 }, '&::-webkit-scrollbar-track': { background: 'transparent' }, + '&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3 }, +} as const); + +function useAgentBubbleState(call: any, result: any, isPending: boolean, isStreaming: boolean) { + const c = useClaudeTokens(); + const [expanded, setExpanded] = useState(false); + const bubbleRef = useRef(null); + const { toolName, input, isDenied } = getToolData(call); + const showTimer = isPending && !isDenied && !isStreaming; + const resultContent = result?.content; + const hasSR = resultContent && typeof resultContent === 'object' && 'text' in resultContent; + const resultRawText: string = hasSR ? resultContent.text : typeof resultContent === 'string' ? resultContent : resultContent ? JSON.stringify(resultContent, null, 2) : ''; + const resultElapsedMs: number | null = hasSR ? resultContent.elapsed_ms ?? null : null; + const parsedResult = useMemo(() => (result ? parseToolResult(toolName, resultRawText) : null), [result, toolName, resultRawText]); + const resultSummary = result ? getResultSummary(toolName, resultRawText) : null; + const isError = resultSummary?.startsWith('✗') || (parsedResult?.type === 'bash' && parsedResult.exitCode !== null && parsedResult.exitCode !== 0) || (parsedResult?.type === 'text' && parsedResult.isError); + const toggle = useCallback(() => { if (!isStreaming) setExpanded((v) => !v); }, [isStreaming]); + const accentRgb = c.accent.primary.replace('#', '').match(/.{2}/g)?.map((h) => parseInt(h, 16)).join(', ') || '189, 100, 57'; + const selectAttrs = { 'data-select-type': 'tool-call' as const, 'data-select-id': call.id, 'data-select-meta': JSON.stringify({ tool: toolName, inputSummary: '' }) }; + return { c, expanded, bubbleRef, toolName, input, isDenied, showTimer, resultRawText, resultElapsedMs, isError, toggle, accentRgb, selectAttrs }; +} + +export const InvokeAgentBubble: React.FC = ({ call, result = null, isPending = false, isStreaming = false, sessionId }) => { + ensureToolCallKeyframes(); + const s = useAgentBubbleState(call, result, isPending, isStreaming); + const invokeAgentParsed = useMemo(() => (result ? parseInvokeAgentResult(s.resultRawText) : null), [result, s.resultRawText]); + const invokedSessionId = useMemo(() => (result ? parseInvokedSessionId(s.resultRawText) : null), [result, s.resultRawText]); + const handleRevealAgent = useRevealAgent(invokedSessionId, sessionId, s.bubbleRef, 'Invoke Agent'); + const agentName = invokeAgentParsed?.agentName || s.input?.session_id || 'Agent'; + const responsePreview = invokeAgentParsed?.response || ''; + const costLabel = invokeAgentParsed?.cost ? `$${invokeAgentParsed.cost}` : null; + const hasResponse = !!invokeAgentParsed; + + return ( + + + + + InvokeAgent + + {agentName} + + {!hasResponse && !s.showTimer && } + {hasResponse && responsePreview && !s.expanded && {responsePreview.slice(0, 100)}{responsePreview.length > 100 ? '…' : ''}} + {s.expanded && } + {s.isDenied && denied} + {hasResponse && !s.isDenied && ( + + {s.isError ? : } + {s.resultElapsedMs != null && {formatElapsed(s.resultElapsedMs)}} + {costLabel && {costLabel}} + + )} + {s.showTimer && } + {invokedSessionId && } + {hasResponse && {s.expanded ? : }} + + + + {children} }}>{responsePreview} + + + + + ); +}; + +export const CreateAgentBubble: React.FC = ({ call, result = null, isPending = false, isStreaming = false, sessionId }) => { + ensureToolCallKeyframes(); + const s = useAgentBubbleState(call, result, isPending, isStreaming); + const resultContent = result?.content; + const hasSR = resultContent && typeof resultContent === 'object' && 'text' in resultContent; + const createAgentResponse = useMemo(() => (result ? parseCreateAgentResult(s.resultRawText) : ''), [result, s.resultRawText]); + const createAgentSessionId: string | null = useMemo(() => (hasSR && resultContent?.sub_session_id) ? resultContent.sub_session_id : null, [hasSR, resultContent]); + const handleRevealAgent = useRevealAgent(createAgentSessionId, sessionId, s.bubbleRef, 'Create Agent'); + const taskPrompt = s.input?.prompt || s.input?.task || s.input?.message || ''; + const taskLabel = taskPrompt ? (taskPrompt.length > 40 ? taskPrompt.slice(0, 40) + '…' : taskPrompt) : 'Sub-agent'; + const hasResponse = !!createAgentResponse; + + return ( + + + + + CreateAgent + + {taskLabel} + + {!hasResponse && !s.showTimer && } + {hasResponse && createAgentResponse && !s.expanded && {createAgentResponse.slice(0, 100)}{createAgentResponse.length > 100 ? '…' : ''}} + {s.expanded && } + {s.isDenied && denied} + {hasResponse && !s.isDenied && ( + + {s.isError ? : } + {s.resultElapsedMs != null && {formatElapsed(s.resultElapsedMs)}} + + )} + {s.showTimer && } + {createAgentSessionId && } + {hasResponse && {s.expanded ? : }} + + + + {children} }}>{createAgentResponse} + + + + + ); +}; diff --git a/frontend/src/app/pages/AgentChat/ApprovalBar.tsx b/frontend/src/app/pages/AgentChat/ApprovalBar.tsx index 6e703f03..4fee5b2c 100644 --- a/frontend/src/app/pages/AgentChat/ApprovalBar.tsx +++ b/frontend/src/app/pages/AgentChat/ApprovalBar.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; @@ -6,178 +6,23 @@ import TextField from '@mui/material/TextField'; import Chip from '@mui/material/Chip'; import Collapse from '@mui/material/Collapse'; import IconButton from '@mui/material/IconButton'; -import SendIcon from '@mui/icons-material/Send'; import CheckIcon from '@mui/icons-material/Check'; import CloseIcon from '@mui/icons-material/Close'; -import TerminalIcon from '@mui/icons-material/Terminal'; -import DescriptionIcon from '@mui/icons-material/Description'; -import EditIcon from '@mui/icons-material/Edit'; -import SearchIcon from '@mui/icons-material/Search'; -import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer'; -import BuildIcon from '@mui/icons-material/Build'; import ExtensionIcon from '@mui/icons-material/Extension'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import { ApprovalRequest } from '@/shared/state/agentsSlice'; -import { useAppSelector } from '@/shared/hooks'; -import { ToolDefinition } from '@/shared/state/toolsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { parseMcpToolName, useMcpToolMeta, getMcpInputSummary } from './approvalUtils'; +import ToolPreview, { getToolIcon, CodeBlock } from './ToolPreview'; +import { QuestionForm } from './QuestionForm'; -// --------------------------------------------------------------------------- -// Integration metadata (icons, colors) for known MCP servers -// --------------------------------------------------------------------------- - -interface IntegrationMeta { - label: string; - color: string; - icon: React.ReactNode; -} - -const GoogleIcon = ( - - - - - - -); - -const RedditIcon = ( - - - - -); - -const INTEGRATION_META: Record = { - 'Google Workspace': { label: 'Google Workspace', color: '#4285F4', icon: GoogleIcon }, - 'xbird': { label: 'X / Twitter', color: '#1DA1F2', icon: 𝕏 }, - 'Reddit': { label: 'Reddit', color: '#FF4500', icon: RedditIcon }, -}; - -// --------------------------------------------------------------------------- -// MCP tool name parser -// --------------------------------------------------------------------------- - -export interface ParsedTool { - isMcp: boolean; - serverSlug: string; - actionName: string; - displayName: string; -} - -export function parseMcpToolName(rawName: string): ParsedTool { - const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/); - if (!m) { - return { isMcp: false, serverSlug: '', actionName: rawName, displayName: rawName }; - } - const serverSlug = m[1]; - const actionName = m[2]; - const displayName = actionName - .replace(/_/g, ' ') - .replace(/\b\w/g, (ch) => ch.toUpperCase()); - return { isMcp: true, serverSlug, actionName, displayName }; -} - -function sanitizeServerName(name: string): string { - return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); -} - -// --------------------------------------------------------------------------- -// Look up MCP tool metadata from the Redux tools store -// --------------------------------------------------------------------------- - -interface McpToolMeta { - integration: IntegrationMeta | null; - description: string; - serverLabel: string; -} - -export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta { - const toolItems = useAppSelector((s) => s.tools.items); - - return useMemo(() => { - if (!parsed.isMcp) { - return { integration: null, description: '', serverLabel: '' }; - } - - const toolDef: ToolDefinition | undefined = Object.values(toolItems).find( - (t) => t.mcp_config && Object.keys(t.mcp_config).length > 0 && sanitizeServerName(t.name) === parsed.serverSlug - ); - - if (!toolDef) { - return { integration: null, description: '', serverLabel: parsed.serverSlug }; - } - - const description = toolDef.tool_permissions?._tool_descriptions?.[parsed.actionName] || ''; - const integration = INTEGRATION_META[toolDef.name] || null; - const serverLabel = toolDef.name; - - return { integration, description, serverLabel }; - }, [parsed, toolItems]); -} - -// --------------------------------------------------------------------------- -// Smart input summary for MCP tools -// --------------------------------------------------------------------------- - -function getMcpInputSummary(actionName: string, toolInput: Record): string { - const lower = actionName.toLowerCase(); - - if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) { - const query = toolInput.query || toolInput.search_query || toolInput.q || ''; - const to = toolInput.to || toolInput.recipient || ''; - const subject = toolInput.subject || ''; - if (query) return `Search: "${query}"`; - if (to && subject) return `To ${to} — ${subject}`; - if (to) return `To ${to}`; - if (subject) return `Subject: ${subject}`; - } - - if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) { - const summary = toolInput.summary || toolInput.title || toolInput.event_name || ''; - const start = toolInput.start || toolInput.start_time || toolInput.date || ''; - if (summary && start) return `${summary} — ${start}`; - if (summary) return summary; - if (start) return `Date: ${start}`; - } - - if (lower.includes('drive') || lower.includes('doc') || lower.includes('sheet') || lower.includes('slide')) { - const name = toolInput.name || toolInput.title || toolInput.filename || toolInput.file_name || ''; - const query = toolInput.query || toolInput.q || ''; - if (name) return name; - if (query) return `Search: "${query}"`; - } - - if (lower.includes('tweet') || lower.includes('post') || lower.includes('send') || lower.includes('reply')) { - const text = toolInput.text || toolInput.content || toolInput.body || toolInput.message || ''; - if (text) return text.length > 80 ? text.slice(0, 77) + '...' : text; - } - - if (lower.includes('search') || lower.includes('find') || lower.includes('query') || lower.includes('list')) { - const query = toolInput.query || toolInput.q || toolInput.search_query || toolInput.keyword || toolInput.term || ''; - if (query) return `"${query}"`; - } - - const stringVals: string[] = []; - for (const [key, val] of Object.entries(toolInput)) { - if (key.startsWith('_')) continue; - if (typeof val === 'string' && val.trim()) { - stringVals.push(val.trim()); - } - if (stringVals.length >= 2) break; - } - if (stringVals.length > 0) { - const joined = stringVals.join(' -- '); - return joined.length > 100 ? joined.slice(0, 97) + '...' : joined; - } - - return ''; -} - -// --------------------------------------------------------------------------- -// Shared components -// --------------------------------------------------------------------------- +export { QuestionForm } from './QuestionForm'; +export type { QuestionFormProps } from './QuestionForm'; +export { BatchApprovalBar } from './BatchApprovalBar'; +export { parseMcpToolName, useMcpToolMeta } from './approvalUtils'; +export type { ParsedTool } from './approvalUtils'; +export { getToolIcon } from './ToolPreview'; interface Props { request: ApprovalRequest; @@ -185,430 +30,19 @@ interface Props { onDeny: (requestId: string, message?: string) => void; } -export function getToolIcon(toolName: string) { - switch (toolName) { - case 'Bash': return ; - case 'Read': return ; - case 'Write': case 'Edit': return ; - case 'Grep': case 'Glob': return ; - case 'AskUserQuestion': return ; - default: return ; - } -} - -interface ToolPreviewProps { - request: ApprovalRequest; - tokens: ReturnType; -} - -const CodeBlock: React.FC<{ tokens: ReturnType; children: React.ReactNode }> = ({ tokens: c, children }) => ( - - {children} - -); - -const ToolPreview: React.FC = ({ request, tokens: c }) => { - const { tool_name, tool_input } = request; - - switch (tool_name) { - case 'Bash': { - return ( - - {tool_input.description && ( - - {tool_input.description} - - )} - {tool_input.command || '(empty command)'} - - ); - } - - case 'Read': - return ( - - - - {tool_input.file_path || tool_input.path || JSON.stringify(tool_input)} - - - ); - - case 'Write': - case 'Edit': { - const path = tool_input.file_path || tool_input.path || ''; - const content = tool_input.content || tool_input.new_content || tool_input.old_string; - return ( - - - - - {path} - - - {content && {typeof content === 'string' ? content : JSON.stringify(content, null, 2)}} - - ); - } - - case 'Grep': - case 'Glob': { - const pattern = tool_input.pattern || tool_input.glob_pattern || tool_input.query || ''; - const path = tool_input.path || tool_input.directory || ''; - return ( - - - - {path && ( - - in {path} - - )} - - - ); - } - - case 'AskUserQuestion': - return null; - - default: { - const preview = tool_input.command || tool_input.file_path || tool_input.path || tool_input.query || null; - if (preview) { - return {preview}; - } - return {JSON.stringify(tool_input, null, 2)}; - } - } -}; - -// --------------------------------------------------------------------------- -// QuestionForm (AskUserQuestion — unchanged) -// --------------------------------------------------------------------------- - -function getOptionKey(opt: any): string { - return opt.id || opt.value || opt.label || opt.text || String(opt); -} - -function getOptionLabel(opt: any): string { - return opt.label || opt.value || opt.text || String(opt); -} - -type Answers = Record; - -export interface QuestionFormProps { - request: ApprovalRequest; - onApprove: (requestId: string, updatedInput?: Record) => void; - onDeny: (requestId: string, message?: string) => void; - compact?: boolean; -} - -const OTHER_KEY = '__other__'; - -export const QuestionForm: React.FC = ({ request, onApprove, onDeny, compact }) => { - const c = useClaudeTokens(); - const questions: any[] = request.tool_input.questions || []; - const [answers, setAnswers] = useState(() => { - const init: Answers = {}; - questions.forEach((q: any, i: number) => { - init[i] = q.multiSelect ? [] : ''; - }); - return init; - }); - const [otherActive, setOtherActive] = useState>({}); - const [otherText, setOtherText] = useState>({}); - - const toggleOption = useCallback((qIdx: number, key: string, multi: boolean) => { - setAnswers((prev) => { - const copy = { ...prev }; - if (multi) { - const arr = Array.isArray(copy[qIdx]) ? [...(copy[qIdx] as string[])] : []; - const idx = arr.indexOf(key); - if (idx >= 0) arr.splice(idx, 1); - else arr.push(key); - copy[qIdx] = arr; - } else { - copy[qIdx] = copy[qIdx] === key ? '' : key; - } - return copy; - }); - if (key !== OTHER_KEY) { - if (!multi) { - setOtherActive((prev) => ({ ...prev, [qIdx]: false })); - setOtherText((prev) => ({ ...prev, [qIdx]: '' })); - } - } - }, []); - - const toggleOther = useCallback((qIdx: number, multi: boolean) => { - setOtherActive((prev) => { - const wasActive = !!prev[qIdx]; - if (wasActive) { - setOtherText((p) => ({ ...p, [qIdx]: '' })); - } - if (!multi && !wasActive) { - setAnswers((p) => ({ ...p, [qIdx]: '' })); - } - return { ...prev, [qIdx]: !wasActive }; - }); - }, []); - - const setTextAnswer = useCallback((qIdx: number, text: string) => { - setAnswers((prev) => ({ ...prev, [qIdx]: text })); - }, []); - - const handleSubmit = () => { - const answersDict: Record = {}; - questions.forEach((q: any, i: number) => { - const questionText = q.question || q.prompt || q.text || ''; - const hasOptions = Array.isArray(q.options) && q.options.length > 0; - let answer = answers[i]; - if (hasOptions && otherActive[i] && otherText[i]) { - if (q.multiSelect) { - const arr = Array.isArray(answer) ? [...answer] : []; - arr.push(otherText[i]); - answer = arr; - } else { - answer = otherText[i]; - } - } - if (Array.isArray(answer)) { - answersDict[questionText] = answer.join(', '); - } else { - answersDict[questionText] = answer || ''; - } - }); - onApprove(request.id, { ...request.tool_input, questions, answers: answersDict }); - }; - - const isSelected = (qIdx: number, key: string): boolean => { - const val = answers[qIdx]; - if (Array.isArray(val)) return val.includes(key); - return val === key; - }; - - return ( - - - - - - - Agent has a question - - - - - {questions.map((q: any, i: number) => { - const hasOptions = Array.isArray(q.options) && q.options.length > 0; - const multi = !!q.multiSelect; - const isOtherActive = !!otherActive[i]; - return ( - - {q.header && ( - - {q.header} - - )} - - {q.question || q.prompt || q.text || '(question)'} - - {hasOptions ? ( - - - {q.options.map((opt: any) => { - const key = getOptionKey(opt); - const selected = isSelected(i, key); - return ( - toggleOption(i, key, multi)} - sx={{ - fontSize: '0.78rem', - fontWeight: selected ? 600 : 400, - cursor: 'pointer', - color: selected ? c.accent.primary : c.text.secondary, - bgcolor: selected ? `${c.accent.primary}18` : 'transparent', - borderColor: selected ? c.accent.primary : c.border.medium, - borderWidth: 1, - borderStyle: 'solid', - transition: 'all 0.15s ease', - '&:hover': { - bgcolor: selected ? `${c.accent.primary}24` : `${c.text.secondary}0a`, - borderColor: selected ? c.accent.primary : c.text.secondary, - }, - }} - /> - ); - })} - toggleOther(i, multi)} - sx={{ - fontSize: '0.78rem', - fontWeight: isOtherActive ? 600 : 400, - fontStyle: 'italic', - cursor: 'pointer', - color: isOtherActive ? c.accent.primary : c.text.muted, - bgcolor: isOtherActive ? `${c.accent.primary}18` : 'transparent', - borderColor: isOtherActive ? c.accent.primary : c.border.subtle, - borderWidth: 1, - borderStyle: 'dashed', - transition: 'all 0.15s ease', - '&:hover': { - bgcolor: isOtherActive ? `${c.accent.primary}24` : `${c.text.secondary}0a`, - borderColor: isOtherActive ? c.accent.primary : c.border.medium, - }, - }} - /> - - {isOtherActive && ( - setOtherText((prev) => ({ ...prev, [i]: e.target.value }))} - fullWidth - size="small" - autoFocus - sx={{ - mt: 0.25, - '& .MuiOutlinedInput-root': { - color: c.text.primary, - fontSize: '0.82rem', - '& fieldset': { borderColor: c.border.medium }, - '&:hover fieldset': { borderColor: c.border.strong }, - '&.Mui-focused fieldset': { borderColor: c.accent.primary }, - }, - }} - /> - )} - - ) : ( - setTextAnswer(i, e.target.value)} - fullWidth - size="small" - multiline - maxRows={4} - sx={{ - '& .MuiOutlinedInput-root': { - color: c.text.primary, - fontSize: '0.82rem', - '& fieldset': { borderColor: c.border.medium }, - '&:hover fieldset': { borderColor: c.border.strong }, - '&.Mui-focused fieldset': { borderColor: c.accent.primary }, - }, - }} - /> - )} - - ); - })} - - - - - - - - ); -}; - -// --------------------------------------------------------------------------- -// GenericApprovalBar — redesigned for MCP tools -// --------------------------------------------------------------------------- - const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => { const c = useClaudeTokens(); const [denyMessage, setDenyMessage] = useState(''); const [showDenyInput, setShowDenyInput] = useState(false); const [detailsExpanded, setDetailsExpanded] = useState(false); - const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]); const meta = useMcpToolMeta(parsed); - const accentColor = meta.integration?.color || c.status.warning; const summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : ''; if (!parsed.isMcp) { return ( - + {getToolIcon(request.tool_name)} @@ -616,68 +50,39 @@ const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => Permission Required - + - - {showDenyInput && ( - setDenyMessage(e.target.value)} - fullWidth - size="small" + setDenyMessage(e.target.value)} fullWidth size="small" sx={{ - mb: 1.5, - '& .MuiOutlinedInput-root': { - color: c.text.primary, - fontSize: '0.8rem', + mb: 1.5, '& .MuiOutlinedInput-root': { + color: c.text.primary, fontSize: '0.8rem', '& fieldset': { borderColor: c.border.strong }, '&.Mui-focused fieldset': { borderColor: c.status.error }, }, - }} - /> + }} /> )} - - {showDenyInput ? ( - ) : ( - )} @@ -687,97 +92,49 @@ const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => } return ( - - {/* Header row */} + - + {meta.integration?.icon || } - {parsed.displayName} - + {meta.description && ( - + {meta.description} )} - {/* Input summary / details */} {summary && ( - setDetailsExpanded((v) => !v)} - > - + setDetailsExpanded((v) => !v)}> + {summary} @@ -787,85 +144,39 @@ const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => )} - - {JSON.stringify(request.tool_input, null, 2)} - + {JSON.stringify(request.tool_input, null, 2)} - {/* Deny reason input */} {showDenyInput && ( - setDenyMessage(e.target.value)} - fullWidth - size="small" - autoFocus + setDenyMessage(e.target.value)} fullWidth size="small" autoFocus sx={{ '& .MuiOutlinedInput-root': { - color: c.text.primary, - fontSize: '0.8rem', + color: c.text.primary, fontSize: '0.8rem', '& fieldset': { borderColor: c.border.strong }, '&.Mui-focused fieldset': { borderColor: c.status.error }, }, - }} - /> + }} /> )} - {/* Action buttons */} - {showDenyInput ? ( - ) : ( - )} @@ -874,10 +185,6 @@ const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => ); }; -// --------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------- - const ApprovalBar: React.FC = (props) => { if (props.request.tool_name === 'AskUserQuestion') { return ; @@ -885,276 +192,4 @@ const ApprovalBar: React.FC = (props) => { return ; }; -// --------------------------------------------------------------------------- -// BatchApprovalBar — grouped mass approve/deny when many approvals pending -// --------------------------------------------------------------------------- - -interface ToolGroup { - toolName: string; - parsed: ParsedTool; - requests: ApprovalRequest[]; -} - -interface BatchApprovalBarProps { - requests: ApprovalRequest[]; - onApprove: (requestId: string, updatedInput?: Record) => void; - onDeny: (requestId: string, message?: string) => void; -} - -export const BatchApprovalBar: React.FC = ({ requests, onApprove, onDeny }) => { - const c = useClaudeTokens(); - const [expandedGroup, setExpandedGroup] = useState(null); - - const questions = requests.filter((r) => r.tool_name === 'AskUserQuestion'); - const nonQuestions = requests.filter((r) => r.tool_name !== 'AskUserQuestion'); - - const groups = useMemo(() => { - const map = new Map(); - for (const req of nonQuestions) { - const existing = map.get(req.tool_name); - if (existing) { - existing.requests.push(req); - } else { - map.set(req.tool_name, { - toolName: req.tool_name, - parsed: parseMcpToolName(req.tool_name), - requests: [req], - }); - } - } - return Array.from(map.values()); - }, [nonQuestions]); - - const handleApproveAll = () => { - for (const req of nonQuestions) onApprove(req.id); - }; - - const handleDenyAll = () => { - for (const req of nonQuestions) onDeny(req.id); - }; - - const handleApproveGroup = (group: ToolGroup) => { - for (const req of group.requests) onApprove(req.id); - }; - - const handleDenyGroup = (group: ToolGroup) => { - for (const req of group.requests) onDeny(req.id); - }; - - return ( - - {questions.map((req) => ( - - ))} - - {nonQuestions.length > 1 && ( - - {/* Global actions bar */} - - - {nonQuestions.length} pending approvals - - - - - - {/* Per-group rows */} - {groups.map((group) => ( - setExpandedGroup((prev) => prev === group.toolName ? null : group.toolName)} - onApprove={onApprove} - onDeny={onDeny} - onApproveGroup={() => handleApproveGroup(group)} - onDenyGroup={() => handleDenyGroup(group)} - /> - ))} - - )} - - {nonQuestions.length === 1 && ( - - )} - - ); -}; - -// --------------------------------------------------------------------------- -// GroupRow — a single tool-name group within the batch bar -// --------------------------------------------------------------------------- - -interface GroupRowProps { - group: ToolGroup; - expanded: boolean; - onToggle: () => void; - onApprove: (requestId: string, updatedInput?: Record) => void; - onDeny: (requestId: string, message?: string) => void; - onApproveGroup: () => void; - onDenyGroup: () => void; -} - -const GroupRow: React.FC = ({ group, expanded, onToggle, onApprove, onDeny, onApproveGroup, onDenyGroup }) => { - const c = useClaudeTokens(); - const meta = useMcpToolMeta(group.parsed); - const accentColor = meta.integration?.color || c.status.warning; - - return ( - - - - {group.parsed.isMcp - ? (meta.integration?.icon || ) - : getToolIcon(group.toolName)} - - - - {group.parsed.isMcp ? group.parsed.displayName : group.toolName} - - - - - {group.requests.length > 1 && ( - <> - - - - )} - - - {expanded ? : } - - - - - - {group.requests.map((req) => ( - - ))} - - - - ); -}; - export default ApprovalBar; diff --git a/frontend/src/app/pages/AgentChat/AssistantBubbleContent.tsx b/frontend/src/app/pages/AgentChat/AssistantBubbleContent.tsx new file mode 100644 index 00000000..83e1d933 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/AssistantBubbleContent.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +const streamingCursorKeyframes = ` +@keyframes blink-cursor { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} +`; + +const StreamingCursor: React.FC = () => { + const c = useClaudeTokens(); + return ( + <> + + + + ); +}; + +interface Props { + rawText: string; + isStreaming?: boolean; +} + +const AssistantBubbleContent: React.FC = ({ rawText, isStreaming }) => { + const c = useClaudeTokens(); + return ( + + ( + {children} + ), + }} + >{rawText} + {isStreaming && } + + ); +}; + +export default AssistantBubbleContent; diff --git a/frontend/src/app/pages/AgentChat/AttachedContextSection.tsx b/frontend/src/app/pages/AgentChat/AttachedContextSection.tsx new file mode 100644 index 00000000..02350210 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/AttachedContextSection.tsx @@ -0,0 +1,178 @@ +import React, { useState, useMemo } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import Tooltip from '@mui/material/Tooltip'; +import Collapse from '@mui/material/Collapse'; +import AdsClickIcon from '@mui/icons-material/AdsClick'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; +import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; +import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined'; +import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; +import { AgentMessage } from '@/shared/state/agentsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { SKILL_COLOR } from '@/app/components/richEditorUtils'; +import { ParsedElement } from './messageBubbleUtils'; + +interface ContextGroup { + key: string; + icon: React.ReactNode; + color: string; + label: string; + chips: Array<{ label: string; tooltip?: string; icon: React.ReactNode }>; +} + +function buildContextGroups( + elements: ParsedElement[], + message: AgentMessage, +): ContextGroup[] { + const groups: ContextGroup[] = []; + + if (elements.length > 0) { + groups.push({ + key: 'elements', + icon: , + color: '#3b82f6', + label: `${elements.length} element${elements.length > 1 ? 's' : ''} selected`, + chips: elements.map((el) => ({ + label: el.label, + tooltip: el.selector, + icon: , + })), + }); + } + + const contextPaths = message.context_paths; + if (contextPaths && contextPaths.length > 0) { + const files = contextPaths.filter((cp) => cp.type === 'file'); + const dirs = contextPaths.filter((cp) => cp.type === 'directory'); + const allPaths = [...dirs, ...files]; + const label = [ + dirs.length > 0 ? `${dirs.length} folder${dirs.length > 1 ? 's' : ''}` : '', + files.length > 0 ? `${files.length} file${files.length > 1 ? 's' : ''}` : '', + ].filter(Boolean).join(', ') + ' attached'; + groups.push({ + key: 'paths', + icon: , + color: '#10b981', + label, + chips: allPaths.map((cp) => { + const name = cp.path.split('/').filter(Boolean).pop() || cp.path; + return { + label: name, + tooltip: cp.path, + icon: cp.type === 'directory' + ? + : , + }; + }), + }); + } + + const skills = message.attached_skills; + if (skills && skills.length > 0) { + groups.push({ + key: 'skills', + icon: , + color: SKILL_COLOR, + label: `${skills.length} skill${skills.length > 1 ? 's' : ''}`, + chips: skills.map((s) => ({ + label: s.name, + icon: , + })), + }); + } + + const forcedTools = message.forced_tools; + if (forcedTools && forcedTools.length > 0) { + groups.push({ + key: 'tools', + icon: , + color: '#f59e0b', + label: `${forcedTools.length} action${forcedTools.length > 1 ? 's' : ''} requested`, + chips: forcedTools.map((t) => ({ + label: t, + icon: , + })), + }); + } + + return groups; +} + +const AttachedContextSection: React.FC<{ + elements: ParsedElement[]; + message: AgentMessage; + c: ReturnType; +}> = ({ elements, message, c }) => { + const [expanded, setExpanded] = useState(false); + const groups = useMemo(() => buildContextGroups(elements, message), [elements, message]); + + if (groups.length === 0) return null; + + return ( + + setExpanded(!expanded)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + cursor: 'pointer', + mb: 0.5, + '&:hover': { opacity: 0.8 }, + }} + > + {groups.map((g) => ( + + {g.icon} + + ))} + + {groups.map((g) => g.label).join(' · ')} + + + + + {groups.map((g) => ( + + + {g.label} + + + {g.chips.map((chip, i) => ( + + + + ))} + + + ))} + + + ); +}; + +export default AttachedContextSection; diff --git a/frontend/src/app/pages/AgentChat/AttachmentChips.tsx b/frontend/src/app/pages/AgentChat/AttachmentChips.tsx new file mode 100644 index 00000000..4564243e --- /dev/null +++ b/frontend/src/app/pages/AgentChat/AttachmentChips.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import Tooltip from '@mui/material/Tooltip'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; +import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; +import AdsClickIcon from '@mui/icons-material/AdsClick'; +import { getToolGroupIcon } from '@/app/components/CommandPicker'; +import type { SelectedElement } from '@/app/components/ElementSelectionContext'; +import type { ContextPath } from '@/app/components/DirectoryBrowser'; + +export interface ForcedToolGroup { + label: string; + tools: string[]; + icon?: React.ReactNode; + iconKey?: string; +} + +interface Props { + contextPaths: ContextPath[]; + onRemoveContextPath: (idx: number) => void; + copiedPathIdx: number | null; + onCopyPath: (idx: number) => void; + forcedTools: ForcedToolGroup[]; + onRemoveForcedTool: (idx: number) => void; + selectedElements: SelectedElement[]; + onRemoveElement: (id: string) => void; + hasImages: boolean; + c: { + accent: { primary: string }; + font: { mono: string }; + status: { error: string; info: string }; + }; +} + +const AttachmentChips: React.FC = ({ + contextPaths, onRemoveContextPath, copiedPathIdx, onCopyPath, + forcedTools, onRemoveForcedTool, + selectedElements, onRemoveElement, + hasImages, c, +}) => ( + <> + {contextPaths.length > 0 && ( + + {contextPaths.map((cp, idx) => { + const label = cp.path.split('/').filter(Boolean).slice(-2).join('/'); + return ( + + : } + label={label} size="small" + onClick={() => onCopyPath(idx)} + onDelete={() => onRemoveContextPath(idx)} + sx={{ + bgcolor: `${c.accent.primary}12`, color: c.accent.primary, + fontSize: '0.72rem', fontFamily: c.font.mono, height: 26, maxWidth: 220, cursor: 'pointer', + '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, + '& .MuiChip-deleteIcon': { color: c.accent.primary, fontSize: 16, '&:hover': { color: c.status.error } }, + }} + /> + + ); + })} + + )} + + {forcedTools.length > 0 && ( + 0) ? 0.25 : 1, pb: 0 }}> + {forcedTools.map((ft, idx) => ( + {ft.icon || getToolGroupIcon(ft.iconKey || ft.label, 14)}} + label={`@${ft.label.toLowerCase()}`} size="small" + onDelete={() => onRemoveForcedTool(idx)} + sx={{ + bgcolor: `${c.status.info}15`, color: c.status.info, + fontSize: '0.72rem', fontFamily: c.font.mono, height: 26, maxWidth: 220, + '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, + '& .MuiChip-deleteIcon': { color: c.status.info, fontSize: 16, '&:hover': { color: c.status.error } }, + }} + /> + ))} + + )} + + {selectedElements.length > 0 && ( + 0 || forcedTools.length > 0) ? 0.25 : 1, pb: 0 }}> + {selectedElements.map((el) => { + const chipLabel = el.semanticLabel ? el.semanticLabel + : el.className ? `${el.tagName.toLowerCase()}.${el.className.split(' ')[0]}` + : el.tagName.toLowerCase(); + const tooltipText = el.semanticType + ? `${el.semanticType}: ${el.semanticLabel || el.selectorPath}` + : el.selectorPath; + return ( + + } label={chipLabel} size="small" + onDelete={() => onRemoveElement(el.id)} + sx={{ + bgcolor: 'rgba(59, 130, 246, 0.1)', color: '#3b82f6', + fontSize: '0.72rem', fontFamily: c.font.mono, height: 26, maxWidth: 220, + '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, + '& .MuiChip-deleteIcon': { color: '#3b82f6', fontSize: 16, '&:hover': { color: c.status.error } }, + '& .MuiChip-icon': { color: '#3b82f6' }, + }} + /> + + ); + })} + + )} + +); + +export default AttachmentChips; diff --git a/frontend/src/app/pages/AgentChat/BatchApprovalBar.tsx b/frontend/src/app/pages/AgentChat/BatchApprovalBar.tsx new file mode 100644 index 00000000..5a53e4af --- /dev/null +++ b/frontend/src/app/pages/AgentChat/BatchApprovalBar.tsx @@ -0,0 +1,179 @@ +import React, { useState, useMemo } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Collapse from '@mui/material/Collapse'; +import IconButton from '@mui/material/IconButton'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import ExtensionIcon from '@mui/icons-material/Extension'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { ApprovalRequest } from '@/shared/state/agentsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { ParsedTool, parseMcpToolName, useMcpToolMeta } from './approvalUtils'; +import { getToolIcon } from './ToolPreview'; +import { QuestionForm } from './QuestionForm'; +import ApprovalBar from './ApprovalBar'; + +interface ToolGroup { + toolName: string; + parsed: ParsedTool; + requests: ApprovalRequest[]; +} + +interface BatchApprovalBarProps { + requests: ApprovalRequest[]; + onApprove: (requestId: string, updatedInput?: Record) => void; + onDeny: (requestId: string, message?: string) => void; +} + +export const BatchApprovalBar: React.FC = ({ requests, onApprove, onDeny }) => { + const c = useClaudeTokens(); + const [expandedGroup, setExpandedGroup] = useState(null); + const questions = requests.filter((r) => r.tool_name === 'AskUserQuestion'); + const nonQuestions = requests.filter((r) => r.tool_name !== 'AskUserQuestion'); + + const groups = useMemo(() => { + const map = new Map(); + for (const req of nonQuestions) { + const existing = map.get(req.tool_name); + if (existing) { + existing.requests.push(req); + } else { + map.set(req.tool_name, { + toolName: req.tool_name, + parsed: parseMcpToolName(req.tool_name), + requests: [req], + }); + } + } + return Array.from(map.values()); + }, [nonQuestions]); + + const handleApproveAll = () => { for (const req of nonQuestions) onApprove(req.id); }; + const handleDenyAll = () => { for (const req of nonQuestions) onDeny(req.id); }; + const handleApproveGroup = (g: ToolGroup) => { for (const req of g.requests) onApprove(req.id); }; + const handleDenyGroup = (g: ToolGroup) => { for (const req of g.requests) onDeny(req.id); }; + + return ( + + {questions.map((req) => ( + + ))} + + {nonQuestions.length > 1 && ( + + + + {nonQuestions.length} pending approvals + + + + + + {groups.map((group) => ( + setExpandedGroup((prev) => prev === group.toolName ? null : group.toolName)} + onApprove={onApprove} + onDeny={onDeny} + onApproveGroup={() => handleApproveGroup(group)} + onDenyGroup={() => handleDenyGroup(group)} + /> + ))} + + )} + + {nonQuestions.length === 1 && ( + + )} + + ); +}; + +interface GroupRowProps { + group: ToolGroup; + expanded: boolean; + onToggle: () => void; + onApprove: (requestId: string, updatedInput?: Record) => void; + onDeny: (requestId: string, message?: string) => void; + onApproveGroup: () => void; + onDenyGroup: () => void; +} + +const GroupRow: React.FC = ({ group, expanded, onToggle, onApprove, onDeny, onApproveGroup, onDenyGroup }) => { + const c = useClaudeTokens(); + const meta = useMcpToolMeta(group.parsed); + const accentColor = meta.integration?.color || c.status.warning; + + return ( + + + + {group.parsed.isMcp + ? (meta.integration?.icon || ) + : getToolIcon(group.toolName)} + + + + {group.parsed.isMcp ? group.parsed.displayName : group.toolName} + + + + + {group.requests.length > 1 && ( + <> + + + + )} + + + {expanded ? : } + + + + + + {group.requests.map((req) => ( + + ))} + + + + ); +}; diff --git a/frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx b/frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx index 010c1782..63cc5aed 100644 --- a/frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx +++ b/frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx @@ -1,149 +1,21 @@ import React, { useEffect, useRef, useMemo } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import LanguageIcon from '@mui/icons-material/Language'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import TouchAppOutlinedIcon from '@mui/icons-material/TouchAppOutlined'; -import KeyboardOutlinedIcon from '@mui/icons-material/KeyboardOutlined'; -import CameraAltOutlinedIcon from '@mui/icons-material/CameraAltOutlined'; -import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined'; -import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined'; -import CodeOutlinedIcon from '@mui/icons-material/CodeOutlined'; -import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector, useAppDispatch } from '@/shared/hooks'; -import { AgentMessage, AgentSession, fetchBrowserAgentChildren } from '@/shared/state/agentsSlice'; +import { AgentSession, fetchBrowserAgentChildren } from '@/shared/state/agentsSlice'; import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext'; import type { RootState } from '@/shared/state/store'; +import { formatMessage, darkFeedColors, lightFeedColors } from './browserFeedUtils'; +import type { FeedEntry } from './browserFeedUtils'; +import { EntryRow, SessionStatusChip } from './BrowserFeedEntryRow'; interface Props { parentSessionId: string; browserId?: string; } -interface FeedEntry { - type: 'thought' | 'action' | 'result' | 'system'; - text: string; - actionTool?: string; - sessionLabel?: string; -} - -function formatMessage(msg: AgentMessage): FeedEntry | null { - if (msg.role === 'user') return null; - - if (msg.role === 'assistant' && typeof msg.content === 'string') { - const trimmed = msg.content.trim(); - if (!trimmed) return null; - return { type: 'thought', text: trimmed }; - } - - if (msg.role === 'tool_call') { - const content = - typeof msg.content === 'string' - ? (() => { try { return JSON.parse(msg.content); } catch { return {}; } })() - : msg.content; - const tool = content?.tool || content?.name || '?'; - const input = content?.input || {}; - let brief = ''; - switch (tool) { - case 'BrowserNavigate': - brief = `Navigate → ${input.url || '...'}`; - break; - case 'BrowserClick': - brief = `Click ${input.selector || '...'}`; - break; - case 'BrowserType': { - const txt = (input.text || '').slice(0, 40); - const ellipsis = (input.text || '').length > 40 ? '…' : ''; - brief = `Type "${txt}${ellipsis}" into ${input.selector || '...'}`; - break; - } - case 'BrowserScreenshot': - brief = 'Screenshot'; - break; - case 'BrowserGetText': - brief = 'Read page text'; - break; - case 'BrowserGetElements': - brief = `Inspect elements${input.selector ? ` (${input.selector})` : ''}`; - break; - case 'BrowserEvaluate': - brief = `Evaluate JS`; - break; - default: - brief = `${tool}(${JSON.stringify(input).slice(0, 60)})`; - } - return { type: 'action', text: brief, actionTool: tool }; - } - - if (msg.role === 'tool_result') { - const content = - typeof msg.content === 'string' - ? (() => { try { return JSON.parse(msg.content); } catch { return { text: msg.content }; } })() - : msg.content; - const toolName = content?.tool_name || ''; - const elapsed = content?.elapsed_ms; - const text = content?.text || ''; - - if (toolName === 'BrowserScreenshot') { - return { type: 'result', text: `Screenshot captured${elapsed ? ` (${elapsed}ms)` : ''}` }; - } - const preview = text.length > 120 ? text.slice(0, 120) + '…' : text; - return { type: 'result', text: `${preview}${elapsed ? ` (${elapsed}ms)` : ''}` }; - } - - if (msg.role === 'system') { - return { type: 'system', text: typeof msg.content === 'string' ? msg.content : '' }; - } - - return null; -} - -type SvgIconComponent = typeof OpenInNewIcon; - -function getActionIcon(tool?: string): SvgIconComponent { - switch (tool) { - case 'BrowserNavigate': return OpenInNewIcon; - case 'BrowserClick': return TouchAppOutlinedIcon; - case 'BrowserType': return KeyboardOutlinedIcon; - case 'BrowserScreenshot': return CameraAltOutlinedIcon; - case 'BrowserGetText': return ArticleOutlinedIcon; - case 'BrowserGetElements': return AccountTreeOutlinedIcon; - case 'BrowserEvaluate': return CodeOutlinedIcon; - default: return BuildOutlinedIcon; - } -} - -interface FeedColors { - thought: string; - thoughtIcon: string; - result: string; - error: string; - errorIcon: string; - scrollThumb: string; -} - -const darkFeedColors: FeedColors = { - thought: '#a0aab8', - thoughtIcon: '#555b6e', - result: '#555b6e', - error: '#ff8787', - errorIcon: '#ff8787', - scrollThumb: '#2a2d3e', -}; - -const lightFeedColors: FeedColors = { - thought: '#555550', - thoughtIcon: '#9e9c95', - result: '#9e9c95', - error: '#c03030', - errorIcon: '#c03030', - scrollThumb: '#ccc9c0', -}; - const selectBrowserSessions = createSelector( [(state: RootState) => state.agents.sessions, (_: RootState, parentSessionId: string) => parentSessionId, @@ -289,115 +161,4 @@ const BrowserAgentInlineFeed: React.FC = ({ parentSessionId, browserId }) ); }; -const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors }> = ({ entry, accentColor, fc }) => { - const c = useClaudeTokens(); - - if (entry.type === 'thought') { - return ( - - - - {entry.text} - - - ); - } - - if (entry.type === 'action') { - const ActionIcon = getActionIcon(entry.actionTool); - return ( - - - - {entry.text} - - - ); - } - - if (entry.type === 'result') { - return ( - - - ↳ {entry.text} - - - ); - } - - if (entry.type === 'system') { - return ( - - - - {entry.text} - - - ); - } - - return null; -}; - -const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => { - const c = useClaudeTokens(); - if (status === 'running') { - return ( - - ); - } - if (status === 'completed') { - return ; - } - if (status === 'error') { - return ; - } - return null; -}; - export default React.memo(BrowserAgentInlineFeed); diff --git a/frontend/src/app/pages/AgentChat/BrowserFeedEntryRow.tsx b/frontend/src/app/pages/AgentChat/BrowserFeedEntryRow.tsx new file mode 100644 index 00000000..db42a443 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/BrowserFeedEntryRow.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { FeedEntry, FeedColors } from './browserFeedUtils'; +import { getActionIcon } from './browserFeedUtils'; + +export const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors }> = ({ entry, accentColor, fc }) => { + const c = useClaudeTokens(); + + if (entry.type === 'thought') { + return ( + + + + {entry.text} + + + ); + } + + if (entry.type === 'action') { + const ActionIcon = getActionIcon(entry.actionTool); + return ( + + + + {entry.text} + + + ); + } + + if (entry.type === 'result') { + return ( + + + ↳ {entry.text} + + + ); + } + + if (entry.type === 'system') { + return ( + + + + {entry.text} + + + ); + } + + return null; +}; + +export const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => { + const c = useClaudeTokens(); + if (status === 'running') { + return ( + + ); + } + if (status === 'completed') { + return ; + } + if (status === 'error') { + return ; + } + return null; +}; diff --git a/frontend/src/app/pages/AgentChat/ChatHeader.tsx b/frontend/src/app/pages/AgentChat/ChatHeader.tsx new file mode 100644 index 00000000..f6f6e507 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatHeader.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; +import DiffViewer from './DiffViewer'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +interface ChatHeaderProps { + session: { + name: string; + status: string; + model: string; + branch_name: string | null; + cost_usd: number; + id: string; + }; + isDraft: boolean; + id: string | undefined; + onClose?: () => void; +} + +const ChatHeader: React.FC = ({ session, isDraft, id, onClose }) => { + const c = useClaudeTokens(); + const STATUS_STYLES: Record = { + running: { color: c.status.success, bg: c.status.successBg }, + waiting_approval: { color: c.status.warning, bg: c.status.warningBg }, + completed: { color: c.text.tertiary, bg: c.bg.secondary }, + error: { color: c.status.error, bg: c.status.errorBg }, + stopped: { color: c.text.tertiary, bg: c.bg.secondary }, + }; + const statusStyle = STATUS_STYLES[session.status] || { color: c.text.tertiary, bg: c.bg.secondary }; + + return ( + + + + {session.name} + {!isDraft && ( + + )} + + {!isDraft && ( + + {session.model} + {session.branch_name} + {session.cost_usd > 0 && ( + + ${session.cost_usd.toFixed(4)} + + )} + + )} + + {!isDraft && id && } + {onClose && ( + + + + )} + + ); +}; + +export default ChatHeader; diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index fd0f058e..69ede990 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -1,82 +1,37 @@ -import React, { useState, useRef, useCallback, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react'; +import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react'; import Box from '@mui/material/Box'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import Chip from '@mui/material/Chip'; -import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; -import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; -import StopIcon from '@mui/icons-material/Stop'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; -import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined'; -import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; -import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined'; -import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined'; -import CloseIcon from '@mui/icons-material/Close'; -import FolderOpenIcon from '@mui/icons-material/FolderOpen'; -import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; -import Modal from '@mui/material/Modal'; import CircularProgress from '@mui/material/CircularProgress'; import AttachFileIcon from '@mui/icons-material/AttachFile'; -import AdsClickIcon from '@mui/icons-material/AdsClick'; -import CommandPicker, { CommandPickerItem, getToolGroupIcon } from '@/app/components/CommandPicker'; -import { useElementSelection, SelectedElement } from '@/app/components/ElementSelectionContext'; -import { getClipboardCards, clearClipboard } from '@/shared/dashboardClipboard'; -import { getWebview } from '@/shared/browserRegistry'; -import { API_BASE } from '@/shared/config'; -import { ContextPath } from '@/app/components/DirectoryBrowser'; -import { - SKILL_PILL_ATTR, - AttachedSkill, - createSkillPillElement, - serializeEditorContent, - detectEditorTrigger, - TriggerState, - EMPTY_TRIGGER, -} from '@/app/components/richEditorUtils'; +import CommandPicker from '@/app/components/CommandPicker'; +import { useElementSelection } from '@/app/components/ElementSelectionContext'; +import type { ContextPath } from '@/app/components/DirectoryBrowser'; +import { type AttachedSkill, type TriggerState, EMPTY_TRIGGER, serializeEditorContent } from '@/app/components/richEditorUtils'; import TemplateInvokeModal from './TemplateInvokeModal'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { fetchModes } from '@/shared/state/modesSlice'; -import { PromptTemplate } from '@/shared/state/templatesSlice'; +import { useAppSelector } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { AttachedImage } from './ImageAttachments'; +import ImageAttachments from './ImageAttachments'; +import type { ForcedToolGroup } from './AttachmentChips'; +import AttachmentChips from './AttachmentChips'; +import ModelModeSelector from './ModelModeSelector'; +import { useChatSubmit } from './hooks/useChatSubmit'; -export interface AttachedImage { - data: string; - media_type: string; - preview: string; -} - -export interface ForcedToolGroup { - label: string; - tools: string[]; - icon?: React.ReactNode; - iconKey?: string; -} - +export type { AttachedImage } from './ImageAttachments'; +export type { ForcedToolGroup } from './AttachmentChips'; export type { AttachedSkill } from '@/app/components/richEditorUtils'; -interface Props { +export interface Props { onSend: (message: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: ContextPath[], forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => void; disabled?: boolean; - mode: string; - onModeChange: (mode: string) => void; - model: string; - onModelChange: (model: string) => void; - provider?: string; - onProviderChange?: (provider: string) => void; - isRunning?: boolean; - onStop?: () => void; + mode: string; onModeChange: (mode: string) => void; + model: string; onModelChange: (model: string) => void; + provider?: string; onProviderChange?: (provider: string) => void; + isRunning?: boolean; onStop?: () => void; autoRunMode?: boolean; contextEstimate?: { used: number; limit: number }; - embedded?: boolean; - autoFocus?: boolean; - sessionId?: string; - queueLength?: number; + embedded?: boolean; autoFocus?: boolean; + sessionId?: string; queueLength?: number; } export interface ChatInputHandle { @@ -84,104 +39,30 @@ export interface ChatInputHandle { setContent: (prompt: string, contextPaths?: ContextPath[], forcedTools?: ForcedToolGroup[]) => void; } -const ICON_MAP: Record = { - smart_toy: , - question_answer: , - map: , - category: , - tune: , -}; - -const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy }; - -const FALLBACK_MODELS = [ - { value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000 }, - { value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000 }, - { value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000 }, -]; - -function formatTokenCount(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return String(n); -} - -const ContextRing: React.FC<{ used: number; limit: number; accentColor: string; trackColor: string }> = ({ used, limit, accentColor, trackColor }) => { - if (used === 0) return null; - const pct = Math.min((used / limit) * 100, 100); - const size = 20; - const strokeWidth = 2; - const radius = (size - strokeWidth) / 2; - const circumference = 2 * Math.PI * radius; - const dashOffset = circumference * (1 - pct / 100); - const tooltip = `${pct.toFixed(1)}% \u00B7 ${formatTokenCount(used)} / ${formatTokenCount(limit)} context used`; - - return ( - - - - - - - - - ); -}; - -const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => { +const ChatInput = forwardRef(({ + onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, + isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, +}, ref) => { const c = useClaudeTokens(); const editorRef = useRef(null); const containerRef = useRef(null); const generalFileInputRef = useRef(null); - const dispatch = useAppDispatch(); const elementSelection = useElementSelection(); const fallbackOwnerIdRef = useRef(`input-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`); const ownerId = sessionId || fallbackOwnerIdRef.current; - useEffect(() => { - if (autoFocus) editorRef.current?.focus(); - }, [autoFocus]); + useEffect(() => { if (autoFocus) editorRef.current?.focus(); }, [autoFocus]); const [hasContent, setHasContent] = useState(false); const [attachedSkills, setAttachedSkills] = useState>({}); const attachedSkillsRef = useRef(attachedSkills); attachedSkillsRef.current = attachedSkills; - const [picker, setPicker] = useState(EMPTY_TRIGGER); - const [selectedTemplate, setSelectedTemplate] = useState(null); + const [selectedTemplate, setSelectedTemplate] = useState(null); const templates = useAppSelector((state) => state.templates.items); const skills = useAppSelector((state) => state.skills.items); const modesMap = useAppSelector((state) => state.modes.items); - const modesArr = useMemo(() => Object.values(modesMap), [modesMap]); - const modelsByProvider = useAppSelector((state) => state.models.byProvider); - const modelsLoaded = useAppSelector((state) => state.models.loaded); - - // Build flat model list with provider grouping - const allModelOptions = useMemo(() => { - if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) { - return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } }; - } - const flat: Array<{ value: string; label: string; context_window: number; provider: string }> = []; - const grouped: Record> = {}; - for (const [prov, models] of Object.entries(modelsByProvider)) { - grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000 })); - for (const m of models) { - flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov }); - } - } - return { flat, grouped }; - }, [modelsByProvider, modelsLoaded]); - - useEffect(() => { - if (modesArr.length === 0) dispatch(fetchModes()); - }, [dispatch, modesArr.length]); const [images, setImages] = useState([]); const [lightboxSrc, setLightboxSrc] = useState(null); @@ -199,1015 +80,108 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, }, setContent: (prompt: string, newContextPaths?: ContextPath[], newForcedTools?: ForcedToolGroup[]) => { const editor = editorRef.current; - if (editor) { - editor.textContent = prompt; - setHasContent(!!prompt); - } + if (editor) { editor.textContent = prompt; setHasContent(!!prompt); } if (newContextPaths) setContextPaths(newContextPaths); if (newForcedTools) setForcedTools(newForcedTools); }, }), [contextPaths, forcedTools]); - const [modeAnchor, setModeAnchor] = useState(null); - const [modelAnchor, setModelAnchor] = useState(null); + const { + handleSend, handlePickerSelect, handlePaste, handleKeyDown, + handleInput, handleEditorClick, handleDragOver, handleDragLeave, handleDrop, + addImageFiles, uploadAndAttachFiles, removeImage, + } = useChatSubmit({ + editorRef, attachedSkillsRef, generalFileInputRef, disabled, autoRunMode, + images, contextPaths, forcedTools, picker, templates, skills, ownerId, + elementSelection, onSend, onModeChange, setImages, setContextPaths, + setForcedTools, setPicker, setHasContent, setAttachedSkills, + setIsUploading, setIsDragOver, setSelectedTemplate, c, + }); - const currentMode = modesMap[mode]; - const FALLBACK_MODE = { ...FALLBACK_MODE_BASE, color: c.accent.primary }; - const modeConf = currentMode - ? { label: currentMode.name, icon: ICON_MAP[currentMode.icon] || ICON_MAP.smart_toy, color: currentMode.color } - : FALLBACK_MODE; - - const updateHasContent = useCallback(() => { - const editor = editorRef.current; - if (!editor) return; - const text = (editor.textContent || '').replace(/\u200B/g, ''); - const hasPills = editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null; - setHasContent(text.trim().length > 0 || hasPills); - }, []); - - const syncAttachedSkills = useCallback(() => { - const editor = editorRef.current; - if (!editor) return; - const pillIds = new Set( - Array.from(editor.querySelectorAll(`[${SKILL_PILL_ATTR}]`)) - .map((el) => el.getAttribute(SKILL_PILL_ATTR)) - .filter(Boolean) as string[], - ); - setAttachedSkills((prev) => { - const prevKeys = Object.keys(prev); - if (prevKeys.length === pillIds.size && prevKeys.every((k) => pillIds.has(k))) return prev; - const next: Record = {}; - for (const [id, skill] of Object.entries(prev)) { - if (pillIds.has(id)) next[id] = skill; - } - return next; - }); - }, []); - - const removeSkillPill = useCallback((skillId: string) => { - const editor = editorRef.current; - if (!editor) return; - const pill = editor.querySelector(`[${SKILL_PILL_ATTR}="${skillId}"]`); - if (pill) pill.remove(); - setAttachedSkills((prev) => { - const { [skillId]: _, ...rest } = prev; - return rest; - }); - const text = (editor.textContent || '').replace(/\u200B/g, ''); - const hasPills = editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null; - setHasContent(text.trim().length > 0 || hasPills); - editor.focus(); - }, []); - - const addImageFiles = useCallback((files: FileList | File[]) => { - Array.from(files).forEach((file) => { - if (!file.type.startsWith('image/')) return; - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - const base64 = result.split(',')[1]; - setImages((prev) => [ - ...prev, - { data: base64, media_type: file.type, preview: result }, - ]); - }; - reader.readAsDataURL(file); - }); - }, []); - - const uploadAndAttachFiles = useCallback(async (files: File[]) => { - if (files.length === 0) return; - setIsUploading(true); - try { - const formData = new FormData(); - files.forEach((f) => formData.append('files', f)); - const resp = await fetch(`${API_BASE}/settings/upload-files`, { - method: 'POST', - body: formData, - }); - if (!resp.ok) throw new Error('Upload failed'); - const data = await resp.json(); - const newPaths: ContextPath[] = (data.files || []).map((f: { path: string }) => ({ - path: f.path, - type: 'file' as const, - })); - setContextPaths((prev) => [...prev, ...newPaths]); - } catch (err) { - console.error('File upload failed:', err); - } finally { - setIsUploading(false); - } - }, []); - - const handleSend = useCallback(async () => { - const editor = editorRef.current; - if (!editor || disabled) return; - const serialized = serializeEditorContent(editor, attachedSkillsRef.current); - let trimmed = serialized.trim(); - if (!trimmed) return; - - const selectedEls = elementSelection?.elementsByOwner?.[ownerId] ?? []; - let allImages = images.length > 0 - ? images.map(({ data, media_type }) => ({ data, media_type })) - : []; - - if (selectedEls.length > 0) { - const lines: string[] = ['\n\n---\nSelected UI Elements:\n']; - for (let i = 0; i < selectedEls.length; i++) { - const el = selectedEls[i]; - - if (el.semanticType === 'browser-card' && el.semanticData?.selectId) { - const wv = getWebview(el.semanticData.selectId as string); - const url = wv ? (el.semanticData.url || wv.getURL()) : (el.semanticData.url || ''); - const title = wv ? (el.semanticData.name || wv.getTitle()) : (el.semanticLabel || ''); - lines.push(`${i + 1}. [Browser Card] ${title}`); - lines.push(` browser_id: ${el.semanticData.selectId}`); - if (url) lines.push(` URL: ${url}`); - lines.push(` (Use BrowserAgent with this browser_id to interact with it, or CreateBrowserAgent for a new browser)`); - } else if (el.semanticType && el.semanticData) { - const typeLabel = { - 'agent-card': 'Agent Card', - 'message': 'Message', - 'tool-call': 'Tool Call', - 'tool-group': 'Tool Group', - 'view-card': 'App Card', - 'browser-card': 'Browser Card', - 'dom-element': 'Element', - }[el.semanticType] || el.semanticType; - lines.push(`${i + 1}. [${typeLabel}] ${el.semanticLabel || ''}`); - const { selectId, ...rest } = el.semanticData; - if (selectId) lines.push(` ID: ${selectId}`); - const metaStr = Object.entries(rest) - .filter(([, v]) => v != null) - .map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`) - .join(', '); - if (metaStr) lines.push(` ${metaStr}`); - if (el.semanticType === 'agent-card' && selectId) { - lines.push(` (Use InvokeAgent with session_id "${selectId}" to query this agent with full conversation context)`); - } - } else { - const styleStr = Object.entries(el.computedStyles) - .map(([k, v]) => `${k}: ${v}`) - .join('; '); - lines.push(`${i + 1}. \`${el.selectorPath}\` (${el.tagName.toLowerCase()})`); - lines.push(` Selector: ${el.selectorPath}`); - lines.push(` HTML: ${el.outerHTML.length > 500 ? el.outerHTML.slice(0, 500) + '...' : el.outerHTML}`); - if (styleStr) lines.push(` Key styles: ${styleStr}`); - } - lines.push(''); - - if (el.screenshot) { - const base64 = el.screenshot.replace(/^data:image\/\w+;base64,/, ''); - allImages.push({ data: base64, media_type: 'image/png' }); - } - } - trimmed += lines.join('\n'); - } - - const sendImages = allImages.length > 0 ? allImages : undefined; - const allForcedToolNames = forcedTools.flatMap((ft) => ft.tools); - const currentSkills = Object.values(attachedSkillsRef.current); - const sendSkills = currentSkills.length > 0 - ? currentSkills.map((s) => ({ id: s.id, name: s.name, content: s.content })) - : undefined; - const browserIds = selectedEls - .filter((el) => el.semanticType === 'browser-card' && el.semanticData?.selectId) - .map((el) => el.semanticData!.selectId as string); - onSend( - trimmed, - sendImages, - contextPaths.length > 0 ? contextPaths : undefined, - allForcedToolNames.length > 0 ? allForcedToolNames : undefined, - sendSkills, - browserIds.length > 0 ? browserIds : undefined, - ); - editor.innerHTML = ''; - setImages([]); - setContextPaths([]); - setForcedTools([]); - setAttachedSkills({}); - setHasContent(false); - elementSelection?.clearOwnerElements(ownerId); - }, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId]); - - const detectTrigger = useCallback(() => { - const result = detectEditorTrigger(); - if (result) { - setPicker(result); - } else { - setPicker((p) => ({ ...p, visible: false })); - } - }, []); - - const handleInput = useCallback(() => { - updateHasContent(); - detectTrigger(); - syncAttachedSkills(); - }, [updateHasContent, detectTrigger, syncAttachedSkills]); - - const handleEditorClick = useCallback(() => { - detectTrigger(); - }, [detectTrigger]); - - const handlePickerSelect = (item: CommandPickerItem) => { - setPicker((p) => ({ ...p, visible: false })); - const editor = editorRef.current; - if (!editor) return; - - editor.focus(); - - const { triggerNode, triggerOffset, filter } = picker; - if (triggerNode && triggerNode.parentNode && editor.contains(triggerNode)) { - const endOffset = Math.min(triggerOffset + 1 + filter.length, triggerNode.length); - const range = document.createRange(); - range.setStart(triggerNode, triggerOffset); - range.setEnd(triggerNode, endOffset); - range.deleteContents(); - const sel = window.getSelection(); - if (sel) { sel.removeAllRanges(); sel.addRange(range); } - } - - if (item.type === 'template') { - const tmpl = templates[item.id]; - if (!tmpl) return; - if (tmpl.fields.length === 0) { - document.execCommand('insertText', false, tmpl.template); - } else { - setSelectedTemplate(tmpl); - } - } else if (item.type === 'skill') { - const skill = skills[item.id]; - if (!skill) return; - if (editor.querySelector(`[${SKILL_PILL_ATTR}="${skill.id}"]`)) return; - - const pill = createSkillPillElement( - { id: skill.id, name: skill.name, content: skill.content }, - removeSkillPill, - c.font.mono, - c.status.error, - ); - - const sel = window.getSelection(); - if (sel && sel.rangeCount > 0) { - const range = sel.getRangeAt(0); - range.collapse(false); - range.insertNode(pill); - const spacer = document.createTextNode('\u200B'); - pill.after(spacer); - const newRange = document.createRange(); - newRange.setStartAfter(spacer); - newRange.collapse(true); - sel.removeAllRanges(); - sel.addRange(newRange); - } - - setAttachedSkills((prev) => ({ - ...prev, - [skill.id]: { id: skill.id, name: skill.name, content: skill.content }, - })); - } else if (item.type === 'mode') { - onModeChange(item.id); - } else if (item.type === 'context') { - if (item.command === 'file') { - generalFileInputRef.current?.click(); - } else if (item.toolNames && item.toolNames.length > 0) { - setForcedTools((prev) => [...prev, { label: item.name, tools: item.toolNames!, icon: item.icon, iconKey: item.iconKey }]); - } else { - document.execCommand('insertText', false, `@${item.command} `); - } - } - - updateHasContent(); - setTimeout(() => editor.focus(), 0); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (picker.visible && ['ArrowDown', 'ArrowUp', 'Escape', 'Tab', 'Enter'].includes(e.key)) { - e.preventDefault(); - return; - } - if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) { - e.preventDefault(); - return; - } - if (e.key === 'Enter' && !e.shiftKey && !autoRunMode) { - e.preventDefault(); - handleSend(); - } - }; - - const handlePaste = useCallback((e: React.ClipboardEvent) => { - const copied = getClipboardCards(); - if (copied.length > 0 && elementSelection) { - e.preventDefault(); - for (const card of copied) { - const semanticTypeMap: Record = { - agent: 'agent-card', - view: 'view-card', - browser: 'browser-card', - }; - const semanticType = semanticTypeMap[card.type]; - if (!semanticType) continue; - const labelMap: Record = { - 'agent-card': 'Agent', - 'view-card': 'View', - 'browser-card': 'Browser', - }; - const semanticLabel = (labelMap[semanticType] || semanticType) + ': ' + card.name; - const el: SelectedElement = { - id: `sel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - selectorPath: `[data-select-type="${semanticType}"][data-select-id="${card.id}"]`, - tagName: 'DIV', - className: '', - outerHTML: '', - computedStyles: {}, - boundingRect: { x: 0, y: 0, width: 0, height: 0 }, - semanticType, - semanticLabel, - semanticData: { ...card.meta, selectId: card.id }, - }; - elementSelection.addElementForOwner(ownerId, el); - } - clearClipboard(); - return; - } - - const items = e.clipboardData?.items; - if (!items) return; - const imageFiles: File[] = []; - for (let i = 0; i < items.length; i++) { - if (items[i].type.startsWith('image/')) { - const file = items[i].getAsFile(); - if (file) imageFiles.push(file); - } - } - if (imageFiles.length > 0) { - e.preventDefault(); - addImageFiles(imageFiles); - return; - } - e.preventDefault(); - const plain = e.clipboardData.getData('text/plain'); - if (plain) document.execCommand('insertText', false, plain); - }, [addImageFiles, elementSelection, ownerId]); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.dataTransfer.types.includes('Files')) { - setIsDragOver(true); - } - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - if (e.dataTransfer.files.length === 0) return; - const allFiles = Array.from(e.dataTransfer.files); - const imageFiles = allFiles.filter((f) => f.type.startsWith('image/')); - const otherFiles = allFiles.filter((f) => !f.type.startsWith('image/')); - if (imageFiles.length > 0) addImageFiles(imageFiles); - if (otherFiles.length > 0) uploadAndAttachFiles(otherFiles); - }, [addImageFiles, uploadAndAttachFiles]); - - const removeImage = useCallback((idx: number) => { - setImages((prev) => prev.filter((_, i) => i !== idx)); - }, []); - - const menuPaperProps = { - sx: { - bgcolor: c.bg.surface, - border: `1px solid ${c.border.subtle}`, - borderRadius: '10px', - minWidth: 180, - maxHeight: 400, - boxShadow: c.shadow.lg, - '& .MuiMenuItem-root': { - fontSize: '0.8rem', - color: c.text.secondary, - py: 0.75, - px: 1.5, - '&:hover': { bgcolor: c.bg.secondary }, - }, - }, + const handleCopyPath = (idx: number) => { + navigator.clipboard.writeText(contextPaths[idx].path); + setCopiedPathIdx(idx); + setTimeout(() => setCopiedPathIdx((cur) => cur === idx ? null : cur), 1200); }; const selectedElements = elementSelection?.elementsByOwner?.[ownerId] ?? []; const hasAttachments = images.length > 0 || contextPaths.length > 0 || forcedTools.length > 0 || selectedElements.length > 0; + const modeLabel = modesMap[mode]?.name || 'Agent'; return ( - + ...(embedded ? {} : { + mx: 1.5, mb: 1.5, borderRadius: '16px', + border: isDragOver ? `1px solid ${c.accent.primary}` : `1px solid ${c.border.subtle}`, + bgcolor: c.bg.surface, boxShadow: c.shadow.md, transition: 'border-color 0.15s', + }), + }}> + {isDragOver && ( - + - - Drop files here - + Drop files here )} {isUploading && ( - + - - Attaching files… - + Attaching files… )} - setPicker((p) => ({ ...p, visible: false }))} - visible={picker.visible} - /> + onClose={() => setPicker((prev) => ({ ...prev, visible: false }))} + visible={picker.visible} /> - {images.length > 0 && ( - - {images.map((img, idx) => ( - setLightboxSrc(img.preview)} - > - - { e.stopPropagation(); removeImage(idx); }} - sx={{ - position: 'absolute', - top: -2, - right: -2, - width: 18, - height: 18, - bgcolor: c.bg.surface, - border: `1px solid ${c.border.medium}`, - color: c.text.tertiary, - '&:hover': { bgcolor: c.bg.secondary, color: c.text.primary }, - }} - > - - - - ))} - - )} + setLightboxSrc(null)} c={c} /> - {contextPaths.length > 0 && ( - 0 ? 0.25 : 1, pb: 0 }}> - {contextPaths.map((cp, idx) => { - const label = cp.path.split('/').filter(Boolean).slice(-2).join('/'); - return ( - - - : - } - label={label} - size="small" - onClick={() => { - navigator.clipboard.writeText(cp.path); - setCopiedPathIdx(idx); - setTimeout(() => setCopiedPathIdx((cur) => cur === idx ? null : cur), 1200); - }} - onDelete={() => setContextPaths((prev) => prev.filter((_, i) => i !== idx))} - sx={{ - bgcolor: `${c.accent.primary}12`, - color: c.accent.primary, - fontSize: '0.72rem', - fontFamily: c.font.mono, - height: 26, - maxWidth: 220, - cursor: 'pointer', - '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, - '& .MuiChip-deleteIcon': { - color: c.accent.primary, - fontSize: 16, - '&:hover': { color: c.status.error }, - }, - }} - /> - - ); - })} - - )} - - {forcedTools.length > 0 && ( - 0 || contextPaths.length > 0) ? 0.25 : 1, pb: 0 }}> - {forcedTools.map((ft, idx) => ( - {ft.icon || getToolGroupIcon(ft.iconKey || ft.label, 14)}} - label={`@${ft.label.toLowerCase()}`} - size="small" - onDelete={() => setForcedTools((prev) => prev.filter((_, i) => i !== idx))} - sx={{ - bgcolor: `${c.status.info}15`, - color: c.status.info, - fontSize: '0.72rem', - fontFamily: c.font.mono, - height: 26, - maxWidth: 220, - '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, - '& .MuiChip-deleteIcon': { - color: c.status.info, - fontSize: 16, - '&:hover': { color: c.status.error }, - }, - }} - /> - ))} - - )} - - {selectedElements.length > 0 && ( - 0 || contextPaths.length > 0 || forcedTools.length > 0) ? 0.25 : 1, pb: 0 }}> - {selectedElements.map((el) => { - const chipLabel = el.semanticLabel - ? el.semanticLabel - : el.className - ? `${el.tagName.toLowerCase()}.${el.className.split(' ')[0]}` - : el.tagName.toLowerCase(); - const tooltipText = el.semanticType - ? `${el.semanticType}: ${el.semanticLabel || el.selectorPath}` - : el.selectorPath; - return ( - - } - label={chipLabel} - size="small" - onDelete={() => elementSelection?.removeOwnerElement(ownerId, el.id)} - sx={{ - bgcolor: 'rgba(59, 130, 246, 0.1)', - color: '#3b82f6', - fontSize: '0.72rem', - fontFamily: c.font.mono, - height: 26, - maxWidth: 220, - '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, - '& .MuiChip-deleteIcon': { - color: '#3b82f6', - fontSize: 16, - '&:hover': { color: c.status.error }, - }, - '& .MuiChip-icon': { - color: '#3b82f6', - }, - }} - /> - - ); - })} - - )} + setContextPaths((prev) => prev.filter((_, i) => i !== idx))} + copiedPathIdx={copiedPathIdx} onCopyPath={handleCopyPath} + forcedTools={forcedTools} + onRemoveForcedTool={(idx) => setForcedTools((prev) => prev.filter((_, i) => i !== idx))} + selectedElements={selectedElements} + onRemoveElement={(id) => elementSelection?.removeOwnerElement(ownerId, id)} + hasImages={images.length > 0} c={c} /> -
+ width: '100%', minHeight: '1.5em', maxHeight: 200, overflowY: 'auto', + background: 'transparent', border: 'none', outline: 'none', color: c.text.primary, + fontSize: '0.875rem', lineHeight: '1.5', fontFamily: 'inherit', + wordBreak: 'break-word', whiteSpace: 'pre-wrap', + }} /> {!hasContent && ( -
- {disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued — type another or wait…` : 'Agent is working — messages will queue…') : `${modeConf.label}, @ for context, / for commands`} +
+ {disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued — type another or wait…` : 'Agent is working — messages will queue…') : `${modeLabel}, @ for context, / for commands`}
)} - - setModeAnchor(e.currentTarget)} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.5, - px: 1, - py: 0.375, - borderRadius: '999px', - cursor: 'pointer', - userSelect: 'none', - color: modeConf.color, - bgcolor: `${modeConf.color}14`, - '&:hover': { bgcolor: `${modeConf.color}22` }, - transition: 'background 0.15s', - }} - > - {modeConf.icon} - - {modeConf.label} - - - - - setModeAnchor(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'left' }} - transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} - slotProps={{ paper: menuPaperProps }} - > - {modesArr.map((m) => { - const icon = ICON_MAP[m.icon] || ICON_MAP.smart_toy; - return ( - { - onModeChange(m.id); - setModeAnchor(null); - }} - > - - {icon} - - - - ); - })} - - - setModelAnchor(e.currentTarget)} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.25, - px: 0.75, - py: 0.25, - borderRadius: '6px', - cursor: 'pointer', - userSelect: 'none', - color: c.text.muted, - '&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }, - transition: 'background 0.15s', - }} - > - - {(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()} - - - - - setModelAnchor(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'left' }} - transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} - slotProps={{ paper: menuPaperProps }} - > - {Object.entries(allModelOptions.grouped).map(([prov, models]) => [ - - - {prov} - - , - ...models.map((opt) => ( - { - onModelChange(opt.value); - if (onProviderChange) { - // Derive API-level provider key from the display group name - const provLower = prov.toLowerCase(); - const providerMap: Record = { - anthropic: 'anthropic', - openai: 'openai', - google: 'gemini', - // OpenRouter-backed providers - xai: 'openrouter', - meta: 'openrouter', - deepseek: 'openrouter', - mistral: 'openrouter', - qwen: 'openrouter', - cohere: 'openrouter', - }; - onProviderChange(providerMap[provLower] || provLower); - } - setModelAnchor(null); - }} - > - - - )), - ]).flat()} - - - - - {contextEstimate && ( - - )} - - {elementSelection && !autoRunMode && (() => { - const isMySelectMode = elementSelection.selectMode && elementSelection.activeOwnerId === ownerId; - return ( - - e.preventDefault()} - 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' }, - animation: 'selectBtnPulse 2s ease-in-out infinite', - '@keyframes selectBtnPulse': { - '0%, 100%': { boxShadow: '0 0 0 0 rgba(59,130,246,0.4)' }, - '50%': { boxShadow: '0 0 0 4px rgba(59,130,246,0.1)' }, - }, - } - : { - color: c.text.tertiary, - '&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' }, - }), - transition: 'background-color 0.15s, color 0.15s', - }} - > - - - - ); - })()} - - { - if (!e.target.files) return; - const all = Array.from(e.target.files); - const imgs = all.filter((f) => f.type.startsWith('image/')); - const rest = all.filter((f) => !f.type.startsWith('image/')); - if (imgs.length > 0) addImageFiles(imgs); - if (rest.length > 0) uploadAndAttachFiles(rest); - 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 && ( - - {hasContent && ( - - - - - - )} - {isRunning ? ( - - - - - - ) : !hasContent ? ( - - - - - - - - ) : null} - - )} - + {selectedTemplate && ( - setSelectedTemplate(null)} onApply={(rendered) => { const editor = editorRef.current; @@ -1226,49 +200,6 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, }} /> )} - - setLightboxSrc(null)} - sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }} - > - setLightboxSrc(null)} - sx={{ position: 'relative', outline: 'none', maxWidth: '90vw', maxHeight: '90vh' }} - > - setLightboxSrc(null)} - sx={{ - position: 'absolute', - top: -16, - right: -16, - bgcolor: c.bg.surface, - border: `1px solid ${c.border.medium}`, - color: c.text.secondary, - width: 32, - height: 32, - zIndex: 1, - '&:hover': { bgcolor: c.bg.secondary }, - boxShadow: c.shadow.md, - }} - > - - - e.stopPropagation()} - style={{ - maxWidth: '90vw', - maxHeight: '90vh', - borderRadius: 8, - boxShadow: '0 8px 32px rgba(0,0,0,0.4)', - display: 'block', - }} - /> - - - ); }); diff --git a/frontend/src/app/pages/AgentChat/ContextRing.tsx b/frontend/src/app/pages/AgentChat/ContextRing.tsx new file mode 100644 index 00000000..70b5c9c7 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ContextRing.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; + +export function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +const ContextRing: React.FC<{ + used: number; limit: number; accentColor: string; trackColor: string; +}> = ({ used, limit, accentColor, trackColor }) => { + if (used === 0) return null; + const pct = Math.min((used / limit) * 100, 100); + const size = 20; + const strokeWidth = 2; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const dashOffset = circumference * (1 - pct / 100); + const tooltip = `${pct.toFixed(1)}% \u00B7 ${formatTokenCount(used)} / ${formatTokenCount(limit)} context used`; + + return ( + + + + + + + + + ); +}; + +export default ContextRing; diff --git a/frontend/src/app/pages/AgentChat/ElapsedTimer.tsx b/frontend/src/app/pages/AgentChat/ElapsedTimer.tsx new file mode 100644 index 00000000..02ebd66c --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ElapsedTimer.tsx @@ -0,0 +1,41 @@ +import React, { useState, useEffect } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +export const ElapsedTimer: React.FC<{ startTime: string }> = ({ startTime }) => { + const c = useClaudeTokens(); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + const start = new Date(startTime).getTime(); + const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000)); + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [startTime]); + + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + const display = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; + + return ( + + + + {display} + + + ); +}; diff --git a/frontend/src/app/pages/AgentChat/GmailCard.tsx b/frontend/src/app/pages/AgentChat/GmailCard.tsx new file mode 100644 index 00000000..364c395e --- /dev/null +++ b/frontend/src/app/pages/AgentChat/GmailCard.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import EmailIcon from '@mui/icons-material/Email'; +import SendIcon from '@mui/icons-material/Send'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useCardColors } from './toolCallColors'; +import { getGmailHeader, formatTimestamp, stripHtml } from './toolCallUtils'; + +export { getGmailHeader } from './toolCallUtils'; + +export function extractEmailFields(msg: any) { + const subject = msg.subject || getGmailHeader(msg, 'Subject') || '(no subject)'; + const from = msg.from || msg.sender || getGmailHeader(msg, 'From') || ''; + const to = msg.to || msg.recipient || getGmailHeader(msg, 'To') || ''; + const rawDate = msg.date || msg.internalDate || msg.receivedAt || getGmailHeader(msg, 'Date') || ''; + const date = formatTimestamp(rawDate); + const snippet = msg.snippet || ''; + const body = msg.body || msg.text || msg.textBody || ''; + const htmlBody = msg.htmlBody || msg.html || ''; + const bodyPreview = body || (htmlBody ? stripHtml(htmlBody) : ''); + return { subject, from, to, date, snippet, bodyPreview }; +} + +export const GmailCard: React.FC<{ data: Record; action: string; hideSubjectHeader?: boolean }> = ({ data, action, hideSubjectHeader }) => { + const c = useClaudeTokens(); + const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_BODY, TC_MUTED, TC_DIM, TC_ACCENT, TC_SUCCESS, TC_WARNING } = useCardColors(); + const email = extractEmailFields(data); + const labels = data.labelIds || data.labels || []; + const attachments = data.attachments || []; + const isSend = action.includes('send'); + const isSearch = action.includes('search') || action.includes('list'); + const messages: any[] = data.messages || (isSearch && data.results ? data.results : []); + + if (messages.length > 0) { + return ( + + {messages.slice(0, 5).map((msg: any, i: number) => { + const m = extractEmailFields(msg); + return ( + + + {m.subject} + {m.date && {m.date}} + + {m.from && {m.from}} + {(m.snippet || m.bodyPreview) && ( + + {(m.snippet || m.bodyPreview).slice(0, 120)}{(m.snippet || m.bodyPreview).length > 120 ? '…' : ''} + + )} + + ); + })} + {messages.length > 5 && +{messages.length - 5} more} + + ); + } + + return ( + + {!hideSubjectHeader && ( + + {isSend ? : } + {email.subject} + + )} + + {(email.from || email.to || email.date) && ( + + {email.from && ( + + From + {email.from} + + )} + {email.to && ( + + To + {email.to} + + )} + {email.date && ( + + Date + {email.date} + + )} + + )} + {labels.length > 0 && ( + + {labels.map((l: string, i: number) => ( + + {l} + + ))} + + )} + {(email.snippet || email.bodyPreview) && ( + + {children} }}> + {email.bodyPreview || email.snippet} + + + )} + {attachments.length > 0 && ( + + {attachments.map((a: any, i: number) => ( + + + {a.filename || a.name || 'attachment'} + + ))} + + )} + + + ); +}; diff --git a/frontend/src/app/pages/AgentChat/ImageAttachments.tsx b/frontend/src/app/pages/AgentChat/ImageAttachments.tsx new file mode 100644 index 00000000..c058b0c8 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ImageAttachments.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Modal from '@mui/material/Modal'; +import CloseIcon from '@mui/icons-material/Close'; + +export interface AttachedImage { + data: string; + media_type: string; + preview: string; +} + +interface Props { + images: AttachedImage[]; + onRemoveImage: (idx: number) => void; + lightboxSrc: string | null; + onOpenLightbox: (src: string) => void; + onCloseLightbox: () => void; + c: { + border: { subtle: string; medium: string }; + bg: { surface: string; secondary: string }; + text: { secondary: string; tertiary: string; primary: string }; + shadow: { md: string }; + }; +} + +const ImageAttachments: React.FC = ({ + images, onRemoveImage, lightboxSrc, onOpenLightbox, onCloseLightbox, c, +}) => ( + <> + {images.length > 0 && ( + + {images.map((img, idx) => ( + onOpenLightbox(img.preview)}> + + { e.stopPropagation(); onRemoveImage(idx); }} sx={{ + position: 'absolute', top: -2, right: -2, width: 18, height: 18, + bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`, + color: c.text.tertiary, + '&:hover': { bgcolor: c.bg.secondary, color: c.text.primary }, + }}> + + + + ))} + + )} + + + + + + + e.stopPropagation()} + style={{ + maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8, + boxShadow: '0 8px 32px rgba(0,0,0,0.4)', display: 'block', + }} + /> + + + +); + +export default ImageAttachments; diff --git a/frontend/src/app/pages/AgentChat/McpServiceCards.tsx b/frontend/src/app/pages/AgentChat/McpServiceCards.tsx new file mode 100644 index 00000000..afc4d797 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/McpServiceCards.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import EventIcon from '@mui/icons-material/Event'; +import FolderIcon from '@mui/icons-material/Folder'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useCardColors, useTermColors } from './toolCallColors'; +import { formatTimestamp, ParsedMcpResult } from './toolCallUtils'; +import { GmailCard } from './GmailCard'; + +const CalendarCard: React.FC<{ data: Record; hideHeader?: boolean }> = ({ data, hideHeader }) => { + const c = useClaudeTokens(); + const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_BODY, TC_DIM, TC_SUCCESS } = useCardColors(); + const items: any[] = data.items || (Array.isArray(data) ? data : []); + const single = !items.length ? data : null; + + if (single && (single.summary || single.start)) { + const start = single.start?.dateTime || single.start?.date || single.start || ''; + const end = single.end?.dateTime || single.end?.date || single.end || ''; + return ( + + {!hideHeader && ( + + + {single.summary || '(no title)'} + + )} + + {start && ( + + Start + {formatTimestamp(start)} + + )} + {end && ( + + End + {formatTimestamp(end)} + + )} + {single.location && ( + + Where + {single.location} + + )} + {single.description && ( + +
+                {single.description.slice(0, 300)}{single.description.length > 300 ? '…' : ''}
+              
+
+ )} +
+
+ ); + } + + if (items.length > 0) { + return ( + + {items.slice(0, 6).map((item: any, i: number) => ( + + {item.summary || '(no title)'} + {formatTimestamp(item.start?.dateTime || item.start?.date || item.start)} + + ))} + {items.length > 6 && +{items.length - 6} more} + + ); + } + + return null; +}; + +const DriveCard: React.FC<{ data: Record }> = ({ data }) => { + const c = useClaudeTokens(); + const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_DIM, TC_WARNING } = useCardColors(); + const files: any[] = data.files || (Array.isArray(data) ? data : []); + const single = !files.length && data.name ? data : null; + + if (single) { + return ( + + + + {single.name} + {single.mimeType && {single.mimeType}} + + + ); + } + + if (files.length > 0) { + return ( + + {files.slice(0, 8).map((f: any, i: number) => ( + + + {f.name || f.id} + {f.mimeType && {f.mimeType.split('/').pop()}} + + ))} + + ); + } + + return null; +}; + +const GenericMcpCard: React.FC<{ data: Record }> = ({ data }) => { + const c = useClaudeTokens(); + const { TC_DIM, TC_BODY } = useCardColors(); + const entries = Object.entries(data).filter(([, v]) => v != null); + + if (entries.length === 0) + return (empty response); + + return ( + + {entries.slice(0, 20).map(([key, val], i) => { + const isLong = typeof val === 'string' && val.length > 100; + const isObj = typeof val === 'object'; + return ( + + {key} + {isObj ? ( +
{JSON.stringify(val, null, 2).slice(0, 500)}
+ ) : isLong ? ( +
{String(val).slice(0, 500)}{String(val).length > 500 ? '…' : ''}
+ ) : ( + {String(val)} + )} +
+ ); + })} + {entries.length > 20 && +{entries.length - 20} more fields} +
+ ); +}; + +export const McpResultCard: React.FC<{ parsed: ParsedMcpResult; compact?: boolean }> = ({ parsed, compact }) => { + const tc = useTermColors(); + const { service, action, data } = parsed; + + if (data.error || data.is_error) { + return ( + + {data.error || data.message || JSON.stringify(data, null, 2)} + + ); + } + + if (service === 'gmail') return ; + if (service === 'calendar') return ; + if (service === 'drive' || service === 'sheets') return ; + + return ; +}; diff --git a/frontend/src/app/pages/AgentChat/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/MessageBubble.tsx index 11cfb126..5b2cd10b 100644 --- a/frontend/src/app/pages/AgentChat/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/MessageBubble.tsx @@ -1,404 +1,14 @@ -import React, { useState, useMemo } from 'react'; +import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import TextField from '@mui/material/TextField'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import Tooltip from '@mui/material/Tooltip'; -import Collapse from '@mui/material/Collapse'; -import Modal from '@mui/material/Modal'; -import CloseIcon from '@mui/icons-material/Close'; -import AdsClickIcon from '@mui/icons-material/AdsClick'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; -import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; -import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined'; -import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { AgentMessage } from '@/shared/state/agentsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { SKILL_COLOR } from '@/app/components/richEditorUtils'; +import { parseElementContext, MessageBubbleProps } from './messageBubbleUtils'; +import UserBubbleContent from './UserBubbleContent'; +import AssistantBubbleContent from './AssistantBubbleContent'; import ViewBubble from './ViewBubble'; -const streamingCursorKeyframes = ` -@keyframes blink-cursor { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } -} -`; - -const StreamingCursor: React.FC = () => { +const MessageBubble: React.FC = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming }) => { const c = useClaudeTokens(); - return ( - <> - - - - ); -}; - -const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n'; - -interface ParsedElement { - label: string; - selector: string; - isSemantic?: boolean; -} - -function parseElementContext(text: string): { userMessage: string; elements: ParsedElement[] } { - const sepIdx = text.indexOf(ELEMENT_SEPARATOR); - if (sepIdx === -1) return { userMessage: text, elements: [] }; - - const userMessage = text.slice(0, sepIdx); - const elementSection = text.slice(sepIdx + ELEMENT_SEPARATOR.length); - - const elements: ParsedElement[] = []; - const blocks = elementSection.split(/\n(?=\d+\.\s)/).filter(Boolean); - for (const block of blocks) { - const semanticMatch = block.match(/\d+\.\s+\[([^\]]+)\]\s*(.*)/); - if (semanticMatch) { - const typeLabel = semanticMatch[1]; - const rest = semanticMatch[2].trim(); - elements.push({ - label: `${typeLabel}: ${rest.split('\n')[0]}`, - selector: typeLabel, - isSemantic: true, - }); - continue; - } - - const labelMatch = block.match(/`([^`]+)`\s+\((\w+)\)/); - const selectorMatch = block.match(/Selector:\s*(.+)/); - if (labelMatch) { - elements.push({ - label: labelMatch[1], - selector: selectorMatch?.[1]?.trim() ?? labelMatch[1], - }); - } - } - - return { userMessage, elements }; -} - -const SKILL_PILL_RE = /\{\{skill:([^}]+)\}\}/g; - -function renderUserTextWithPills(text: string, c: ReturnType): React.ReactNode[] { - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; - const re = new RegExp(SKILL_PILL_RE.source, 'g'); - while ((match = re.exec(text)) !== null) { - if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); - } - const skillName = match[1]; - parts.push( - } - label={skillName} - size="small" - sx={{ - bgcolor: `${SKILL_COLOR}18`, - color: SKILL_COLOR, - fontSize: '0.72rem', - fontFamily: c.font.mono, - height: 20, - mx: 0.25, - verticalAlign: 'baseline', - '& .MuiChip-icon': { color: SKILL_COLOR }, - }} - />, - ); - lastIndex = re.lastIndex; - } - if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)); - } - return parts; -} - -interface ContextGroup { - key: string; - icon: React.ReactNode; - color: string; - label: string; - chips: Array<{ label: string; tooltip?: string; icon: React.ReactNode }>; -} - -function buildContextGroups( - elements: ParsedElement[], - message: AgentMessage, -): ContextGroup[] { - const groups: ContextGroup[] = []; - - if (elements.length > 0) { - groups.push({ - key: 'elements', - icon: , - color: '#3b82f6', - label: `${elements.length} element${elements.length > 1 ? 's' : ''} selected`, - chips: elements.map((el) => ({ - label: el.label, - tooltip: el.selector, - icon: , - })), - }); - } - - const contextPaths = message.context_paths; - if (contextPaths && contextPaths.length > 0) { - const files = contextPaths.filter((cp) => cp.type === 'file'); - const dirs = contextPaths.filter((cp) => cp.type === 'directory'); - const allPaths = [...dirs, ...files]; - const label = [ - dirs.length > 0 ? `${dirs.length} folder${dirs.length > 1 ? 's' : ''}` : '', - files.length > 0 ? `${files.length} file${files.length > 1 ? 's' : ''}` : '', - ].filter(Boolean).join(', ') + ' attached'; - groups.push({ - key: 'paths', - icon: , - color: '#10b981', - label, - chips: allPaths.map((cp) => { - const name = cp.path.split('/').filter(Boolean).pop() || cp.path; - return { - label: name, - tooltip: cp.path, - icon: cp.type === 'directory' - ? - : , - }; - }), - }); - } - - const skills = message.attached_skills; - if (skills && skills.length > 0) { - groups.push({ - key: 'skills', - icon: , - color: SKILL_COLOR, - label: `${skills.length} skill${skills.length > 1 ? 's' : ''}`, - chips: skills.map((s) => ({ - label: s.name, - icon: , - })), - }); - } - - const forcedTools = message.forced_tools; - if (forcedTools && forcedTools.length > 0) { - groups.push({ - key: 'tools', - icon: , - color: '#f59e0b', - label: `${forcedTools.length} action${forcedTools.length > 1 ? 's' : ''} requested`, - chips: forcedTools.map((t) => ({ - label: t, - icon: , - })), - }); - } - - return groups; -} - -const AttachedContextSection: React.FC<{ - elements: ParsedElement[]; - message: AgentMessage; - c: ReturnType; -}> = ({ elements, message, c }) => { - const [expanded, setExpanded] = useState(false); - const groups = useMemo(() => buildContextGroups(elements, message), [elements, message]); - - if (groups.length === 0) return null; - - return ( - - setExpanded(!expanded)} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.5, - cursor: 'pointer', - mb: 0.5, - '&:hover': { opacity: 0.8 }, - }} - > - {groups.map((g) => ( - - {g.icon} - - ))} - - {groups.map((g) => g.label).join(' · ')} - - - - - {groups.map((g) => ( - - - {g.label} - - - {g.chips.map((chip, i) => ( - - - - ))} - - - ))} - - - ); -}; - -const ImageLightbox: React.FC<{ - open: boolean; - src: string; - onClose: () => void; - c: ReturnType; -}> = ({ open, src, onClose, c }) => ( - - - - - - e.stopPropagation()} - style={{ - maxWidth: '90vw', - maxHeight: '90vh', - borderRadius: 8, - boxShadow: '0 8px 32px rgba(0,0,0,0.4)', - display: 'block', - }} - /> - - -); - -const MessageImageThumbnails: React.FC<{ - images: Array<{ data: string; media_type: string }>; - c: ReturnType; -}> = ({ images, c }) => { - const [lightboxSrc, setLightboxSrc] = useState(null); - - if (images.length === 0) return null; - - return ( - <> - - {images.map((img, idx) => { - const src = `data:${img.media_type};base64,${img.data}`; - return ( - setLightboxSrc(src)} - sx={{ - width: 64, - height: 64, - flexShrink: 0, - borderRadius: '8px', - overflow: 'hidden', - border: `1px solid ${c.border.subtle}`, - cursor: 'pointer', - transition: 'opacity 0.15s, transform 0.15s', - '&:hover': { opacity: 0.85, transform: 'scale(1.04)' }, - }} - > - - - ); - })} - - setLightboxSrc(null)} - c={c} - /> - - ); -}; - -interface Props { - message: AgentMessage; - editing?: boolean; - onSaveEdit?: (messageId: string, newContent: string) => void; - onCancelEdit?: () => void; - isStreaming?: boolean; -} - -const MessageBubble: React.FC = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming }) => { - const c = useClaudeTokens(); - const [editText, setEditText] = useState(''); const { role, content } = message; if (role === 'system') { @@ -440,24 +50,6 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o ? parseElementContext(rawText) : { userMessage: rawText, elements: [] }; - React.useEffect(() => { - if (editing) setEditText(rawText); - }, [editing, rawText]); - - const handleCancelEdit = () => { - setEditText(''); - onCancelEdit?.(); - }; - - const handleSaveEdit = () => { - const trimmed = editText.trim(); - if (trimmed && trimmed !== rawText && onSaveEdit) { - onSaveEdit(message.id, trimmed); - } - setEditText(''); - onCancelEdit?.(); - }; - const truncatedContent = typeof content === 'string' ? content.slice(0, 200) : JSON.stringify(content).slice(0, 200); @@ -487,151 +79,17 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o }} > {isUser ? ( - editing ? ( - - setEditText(e.target.value)} - variant="outlined" - size="small" - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSaveEdit(); - } - if (e.key === 'Escape') handleCancelEdit(); - }} - sx={{ - '& .MuiOutlinedInput-root': { - color: c.text.primary, - fontSize: '0.875rem', - '& fieldset': { borderColor: c.border.strong }, - '&:hover fieldset': { borderColor: c.text.tertiary }, - '&.Mui-focused fieldset': { borderColor: c.accent.primary }, - }, - }} - /> - - - - - - ) : ( - - {message.images && message.images.length > 0 && ( - - )} - - {renderUserTextWithPills(displayText, c)} - - - - ) + ) : ( - - ( - {children} - ), - }} - >{rawText} - {isStreaming && } - + )}
diff --git a/frontend/src/app/pages/AgentChat/MessageImageThumbnails.tsx b/frontend/src/app/pages/AgentChat/MessageImageThumbnails.tsx new file mode 100644 index 00000000..3f095a97 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/MessageImageThumbnails.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Modal from '@mui/material/Modal'; +import CloseIcon from '@mui/icons-material/Close'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +const ImageLightbox: React.FC<{ + open: boolean; + src: string; + onClose: () => void; + c: ReturnType; +}> = ({ open, src, onClose, c }) => ( + + + + + + e.stopPropagation()} + style={{ + maxWidth: '90vw', + maxHeight: '90vh', + borderRadius: 8, + boxShadow: '0 8px 32px rgba(0,0,0,0.4)', + display: 'block', + }} + /> + + +); + +interface Props { + images: Array<{ data: string; media_type: string }>; + c: ReturnType; +} + +const MessageImageThumbnails: React.FC = ({ images, c }) => { + const [lightboxSrc, setLightboxSrc] = useState(null); + + if (images.length === 0) return null; + + return ( + <> + + {images.map((img, idx) => { + const src = `data:${img.media_type};base64,${img.data}`; + return ( + setLightboxSrc(src)} + sx={{ + width: 64, + height: 64, + flexShrink: 0, + borderRadius: '8px', + overflow: 'hidden', + border: `1px solid ${c.border.subtle}`, + cursor: 'pointer', + transition: 'opacity 0.15s, transform 0.15s', + '&:hover': { opacity: 0.85, transform: 'scale(1.04)' }, + }} + > + + + ); + })} + + setLightboxSrc(null)} + c={c} + /> + + ); +}; + +export default MessageImageThumbnails; diff --git a/frontend/src/app/pages/AgentChat/MessageQueue.tsx b/frontend/src/app/pages/AgentChat/MessageQueue.tsx new file mode 100644 index 00000000..7a844110 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/MessageQueue.tsx @@ -0,0 +1,182 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import TextField from '@mui/material/TextField'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import CloseIcon from '@mui/icons-material/Close'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import CheckIcon from '@mui/icons-material/Check'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { QueuedMessage } from './hooks/useAgentChat'; + +interface MessageQueueProps { + messageQueueRef: React.MutableRefObject; + queueLength: number; + setQueueLength: (len: number) => void; + children: React.ReactNode; +} + +const MessageQueue: React.FC = ({ messageQueueRef, queueLength, setQueueLength, children }) => { + const c = useClaudeTokens(); + const [queueExpanded, setQueueExpanded] = useState(false); + const [editingQueueIdx, setEditingQueueIdx] = useState(null); + const [editingQueueText, setEditingQueueText] = useState(''); + const [dragIdx, setDragIdx] = useState(null); + const [dropTargetIdx, setDropTargetIdx] = useState(null); + + return ( + { if (queueExpanded) { setQueueExpanded(false); setEditingQueueIdx(null); } }}> + + {queueLength > 0 && ( + + { setQueueExpanded((v) => !v); setEditingQueueIdx(null); }} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, px: 1.25, py: 0.25, + borderRadius: '8px 8px 0 0', bgcolor: c.bg.surface, + border: `1px solid ${c.border.subtle}`, borderBottom: 'none', + cursor: 'pointer', userSelect: 'none', + '&:hover': { bgcolor: c.bg.secondary }, transition: 'background 0.12s', + }} + > + {queueExpanded + ? + : + } + + {queueLength} queued + + + { e.stopPropagation(); messageQueueRef.current = []; setQueueLength(0); setQueueExpanded(false); setEditingQueueIdx(null); }} + sx={{ p: 0.15, color: c.text.tertiary, '&:hover': { color: c.status.error } }} + > + + + + + {queueExpanded && ( + + {messageQueueRef.current.map((msg, idx) => ( + { setDragIdx(idx); e.dataTransfer.effectAllowed = 'move'; }} + onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragIdx !== null && dragIdx !== idx) setDropTargetIdx(idx); }} + onDragLeave={() => { if (dropTargetIdx === idx) setDropTargetIdx(null); }} + onDrop={(e) => { + e.preventDefault(); + if (dragIdx !== null && dragIdx !== idx) { + const q = messageQueueRef.current; + const [item] = q.splice(dragIdx, 1); + q.splice(idx, 0, item); + setQueueLength(q.length); + } + setDragIdx(null); + setDropTargetIdx(null); + }} + onDragEnd={() => { setDragIdx(null); setDropTargetIdx(null); }} + sx={{ + display: 'flex', alignItems: 'flex-start', gap: 0.75, px: 1.5, py: 1, + borderBottom: idx < queueLength - 1 ? `1px solid ${c.border.subtle}` : 'none', + '&:hover': { bgcolor: c.bg.secondary }, + transition: 'background 0.1s, opacity 0.15s', + ...(dragIdx === idx ? { opacity: 0.35 } : {}), + ...(dropTargetIdx === idx && dragIdx !== null && dragIdx !== idx + ? { borderTop: `2px solid ${c.accent.primary}` } : {}), + }} + > + + + + {editingQueueIdx === idx ? ( + + setEditingQueueText(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + const trimmed = editingQueueText.trim(); + if (trimmed) { + messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed }; + setQueueLength(messageQueueRef.current.length); + } + setEditingQueueIdx(null); + } + if (e.key === 'Escape') setEditingQueueIdx(null); + }} + sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.78rem', color: c.text.primary, '& fieldset': { borderColor: c.border.medium }, '&.Mui-focused fieldset': { borderColor: c.accent.primary } } }} + /> + { + const trimmed = editingQueueText.trim(); + if (trimmed) { + messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed }; + setQueueLength(messageQueueRef.current.length); + } + setEditingQueueIdx(null); + }} + sx={{ p: 0.25, color: c.accent.primary, mt: 0.25 }} + > + + + + ) : ( + + {msg.prompt} + + )} + {editingQueueIdx !== idx && ( + + + { setEditingQueueIdx(idx); setEditingQueueText(msg.prompt); }} sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.text.primary } }}> + + + + + { + messageQueueRef.current.splice(idx, 1); + setQueueLength(messageQueueRef.current.length); + if (messageQueueRef.current.length === 0) setQueueExpanded(false); + }} + sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.status.error } }} + > + + + + + )} + + ))} + + )} + + )} + {children} + + + ); +}; + +export default MessageQueue; diff --git a/frontend/src/app/pages/AgentChat/ModelModeSelector.tsx b/frontend/src/app/pages/AgentChat/ModelModeSelector.tsx new file mode 100644 index 00000000..e8d96734 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ModelModeSelector.tsx @@ -0,0 +1,242 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import Box from '@mui/material/Box'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; +import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined'; +import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; +import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined'; +import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined'; +import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import StopIcon from '@mui/icons-material/Stop'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import AdsClickIcon from '@mui/icons-material/AdsClick'; +import { useElementSelection } from '@/app/components/ElementSelectionContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { fetchModes } from '@/shared/state/modesSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import ContextRing from './ContextRing'; + +const ICON_MAP: Record = { + smart_toy: , + question_answer: , + map: , + category: , + tune: , +}; +const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy }; +const FALLBACK_MODELS = [ + { value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000 }, + { value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000 }, + { value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000 }, +]; + +interface Props { + mode: string; onModeChange: (mode: string) => void; + model: string; onModelChange: (model: string) => void; + provider?: string; onProviderChange?: (provider: string) => void; + contextEstimate?: { used: number; limit: number }; + ownerId: string; sessionId?: string; + autoRunMode?: boolean; hasContent: boolean; + isRunning?: boolean; disabled?: boolean; + onSend: () => void; onStop?: () => void; + addImageFiles: (files: FileList | File[]) => void; + uploadAndAttachFiles: (files: File[]) => void; + generalFileInputRef: React.RefObject; + queueLength?: number; +} + +const ModelModeSelector: React.FC = ({ + mode, onModeChange, model, onModelChange, provider, onProviderChange, + contextEstimate, ownerId, sessionId, + autoRunMode, hasContent, isRunning, disabled, onSend, onStop, + addImageFiles, uploadAndAttachFiles, generalFileInputRef, queueLength = 0, +}) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const elementSelection = useElementSelection(); + const modesMap = useAppSelector((s) => s.modes.items); + const modelsByProvider = useAppSelector((s) => s.models.byProvider); + const modelsLoaded = useAppSelector((s) => s.models.loaded); + const modesArr = useMemo(() => Object.values(modesMap), [modesMap]); + const [modeAnchor, setModeAnchor] = useState(null); + const [modelAnchor, setModelAnchor] = useState(null); + + useEffect(() => { if (modesArr.length === 0) dispatch(fetchModes()); }, [dispatch, modesArr.length]); + + const allModelOptions = useMemo(() => { + if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) { + return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } }; + } + const flat: Array<{ value: string; label: string; context_window: number; provider: string }> = []; + const grouped: Record> = {}; + for (const [prov, models] of Object.entries(modelsByProvider)) { + grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000 })); + for (const m of models) flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov }); + } + return { flat, grouped }; + }, [modelsByProvider, modelsLoaded]); + + const currentMode = modesMap[mode]; + const FALLBACK_MODE = { ...FALLBACK_MODE_BASE, color: c.accent.primary }; + const modeConf = currentMode + ? { label: currentMode.name, icon: ICON_MAP[currentMode.icon] || ICON_MAP.smart_toy, color: currentMode.color } + : FALLBACK_MODE; + + const menuPaperProps = { sx: { + bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: '10px', + minWidth: 180, maxHeight: 400, boxShadow: c.shadow.lg, + '& .MuiMenuItem-root': { fontSize: '0.8rem', color: c.text.secondary, py: 0.75, px: 1.5, '&:hover': { bgcolor: c.bg.secondary } }, + }}; + + const isMySelectMode = elementSelection?.selectMode && elementSelection.activeOwnerId === ownerId; + + return ( + + setModeAnchor(e.currentTarget)} sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, px: 1, py: 0.375, + borderRadius: '999px', cursor: 'pointer', userSelect: 'none', + color: modeConf.color, bgcolor: `${modeConf.color}14`, + '&:hover': { bgcolor: `${modeConf.color}22` }, transition: 'background 0.15s', + }}> + {modeConf.icon} + {modeConf.label} + + + + setModeAnchor(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'left' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: menuPaperProps }}> + {modesArr.map((m) => ( + { onModeChange(m.id); setModeAnchor(null); }}> + {ICON_MAP[m.icon] || ICON_MAP.smart_toy} + + + ))} + + + setModelAnchor(e.currentTarget)} sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.25, px: 0.75, py: 0.25, + borderRadius: '6px', cursor: 'pointer', userSelect: 'none', color: c.text.muted, + '&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }, transition: 'background 0.15s', + }}> + + {(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()} + + + + + setModelAnchor(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'left' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: menuPaperProps }}> + {Object.entries(allModelOptions.grouped).map(([prov, models]) => [ + + {prov} + , + ...models.map((opt) => ( + { + onModelChange(opt.value); + if (onProviderChange) { + const provLower = prov.toLowerCase(); + const providerMap: Record = { anthropic: 'anthropic', openai: 'openai', google: 'gemini', xai: 'openrouter', meta: 'openrouter', deepseek: 'openrouter', mistral: 'openrouter', qwen: 'openrouter', cohere: 'openrouter' }; + onProviderChange(providerMap[provLower] || provLower); + } + setModelAnchor(null); + }}> + + + )), + ]).flat()} + + + + + {contextEstimate && ( + + )} + + {elementSelection && !autoRunMode && ( + + e.preventDefault()} onClick={() => { + if (isMySelectMode) { elementSelection.setSelectMode(false); return; } + if (elementSelection.activeOwnerId !== ownerId) elementSelection.clearOwnerElements(ownerId); + elementSelection.setActiveOwnerId(ownerId); + elementSelection.setExcludeSelectId(sessionId || null); + elementSelection.setSelectMode(true); + }} sx={{ + p: 0.5, + ...(isMySelectMode + ? { bgcolor: '#3b82f6', color: '#fff', '&:hover': { bgcolor: '#2563eb' }, + animation: 'selectBtnPulse 2s ease-in-out infinite', + '@keyframes selectBtnPulse': { '0%, 100%': { boxShadow: '0 0 0 0 rgba(59,130,246,0.4)' }, '50%': { boxShadow: '0 0 0 4px rgba(59,130,246,0.1)' } } } + : { color: c.text.tertiary, '&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' } }), + transition: 'background-color 0.15s, color 0.15s', + }}> + + + + )} + + } type="file" multiple hidden onChange={(e) => { + if (!e.target.files) return; + const all = Array.from(e.target.files); + const imgs = all.filter((f) => f.type.startsWith('image/')); + const rest = all.filter((f) => !f.type.startsWith('image/')); + if (imgs.length > 0) addImageFiles(imgs); + if (rest.length > 0) uploadAndAttachFiles(rest); + 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 && ( + + {hasContent && ( + + + + + + )} + {isRunning ? ( + + + + + + ) : !hasContent ? ( + + + + + + ) : null} + + )} + + ); +}; + +export default ModelModeSelector; diff --git a/frontend/src/app/pages/AgentChat/QuestionForm.tsx b/frontend/src/app/pages/AgentChat/QuestionForm.tsx new file mode 100644 index 00000000..9805a841 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/QuestionForm.tsx @@ -0,0 +1,238 @@ +import React, { useCallback, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Chip from '@mui/material/Chip'; +import SendIcon from '@mui/icons-material/Send'; +import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer'; +import { ApprovalRequest } from '@/shared/state/agentsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +function getOptionKey(opt: any): string { + return opt.id || opt.value || opt.label || opt.text || String(opt); +} + +function getOptionLabel(opt: any): string { + return opt.label || opt.value || opt.text || String(opt); +} + +type Answers = Record; + +export interface QuestionFormProps { + request: ApprovalRequest; + onApprove: (requestId: string, updatedInput?: Record) => void; + onDeny: (requestId: string, message?: string) => void; + compact?: boolean; +} + +const OTHER_KEY = '__other__'; + +export const QuestionForm: React.FC = ({ request, onApprove, onDeny, compact }) => { + const c = useClaudeTokens(); + const questions: any[] = request.tool_input.questions || []; + const [answers, setAnswers] = useState(() => { + const init: Answers = {}; + questions.forEach((q: any, i: number) => { + init[i] = q.multiSelect ? [] : ''; + }); + return init; + }); + const [otherActive, setOtherActive] = useState>({}); + const [otherText, setOtherText] = useState>({}); + + const toggleOption = useCallback((qIdx: number, key: string, multi: boolean) => { + setAnswers((prev) => { + const copy = { ...prev }; + if (multi) { + const arr = Array.isArray(copy[qIdx]) ? [...(copy[qIdx] as string[])] : []; + const idx = arr.indexOf(key); + if (idx >= 0) arr.splice(idx, 1); + else arr.push(key); + copy[qIdx] = arr; + } else { + copy[qIdx] = copy[qIdx] === key ? '' : key; + } + return copy; + }); + if (key !== OTHER_KEY) { + if (!multi) { + setOtherActive((prev) => ({ ...prev, [qIdx]: false })); + setOtherText((prev) => ({ ...prev, [qIdx]: '' })); + } + } + }, []); + + const toggleOther = useCallback((qIdx: number, multi: boolean) => { + setOtherActive((prev) => { + const wasActive = !!prev[qIdx]; + if (wasActive) { + setOtherText((p) => ({ ...p, [qIdx]: '' })); + } + if (!multi && !wasActive) { + setAnswers((p) => ({ ...p, [qIdx]: '' })); + } + return { ...prev, [qIdx]: !wasActive }; + }); + }, []); + + const setTextAnswer = useCallback((qIdx: number, text: string) => { + setAnswers((prev) => ({ ...prev, [qIdx]: text })); + }, []); + + const handleSubmit = () => { + const answersDict: Record = {}; + questions.forEach((q: any, i: number) => { + const questionText = q.question || q.prompt || q.text || ''; + const hasOptions = Array.isArray(q.options) && q.options.length > 0; + let answer = answers[i]; + if (hasOptions && otherActive[i] && otherText[i]) { + if (q.multiSelect) { + const arr = Array.isArray(answer) ? [...answer] : []; + arr.push(otherText[i]); + answer = arr; + } else { + answer = otherText[i]; + } + } + if (Array.isArray(answer)) { + answersDict[questionText] = answer.join(', '); + } else { + answersDict[questionText] = answer || ''; + } + }); + onApprove(request.id, { ...request.tool_input, questions, answers: answersDict }); + }; + + const isSelected = (qIdx: number, key: string): boolean => { + const val = answers[qIdx]; + if (Array.isArray(val)) return val.includes(key); + return val === key; + }; + + return ( + + + + + + + Agent has a question + + + + + {questions.map((q: any, i: number) => { + const hasOptions = Array.isArray(q.options) && q.options.length > 0; + const multi = !!q.multiSelect; + const isOtherActive = !!otherActive[i]; + return ( + + {q.header && ( + + {q.header} + + )} + + {q.question || q.prompt || q.text || '(question)'} + + {hasOptions ? ( + + + {q.options.map((opt: any) => { + const key = getOptionKey(opt); + const selected = isSelected(i, key); + return ( + toggleOption(i, key, multi)} + sx={{ + fontSize: '0.78rem', fontWeight: selected ? 600 : 400, cursor: 'pointer', + color: selected ? c.accent.primary : c.text.secondary, + bgcolor: selected ? `${c.accent.primary}18` : 'transparent', + borderColor: selected ? c.accent.primary : c.border.medium, + borderWidth: 1, borderStyle: 'solid', transition: 'all 0.15s ease', + '&:hover': { + bgcolor: selected ? `${c.accent.primary}24` : `${c.text.secondary}0a`, + borderColor: selected ? c.accent.primary : c.text.secondary, + }, + }} + /> + ); + })} + toggleOther(i, multi)} + sx={{ + fontSize: '0.78rem', fontWeight: isOtherActive ? 600 : 400, + fontStyle: 'italic', cursor: 'pointer', + color: isOtherActive ? c.accent.primary : c.text.muted, + bgcolor: isOtherActive ? `${c.accent.primary}18` : 'transparent', + borderColor: isOtherActive ? c.accent.primary : c.border.subtle, + borderWidth: 1, borderStyle: 'dashed', transition: 'all 0.15s ease', + '&:hover': { + bgcolor: isOtherActive ? `${c.accent.primary}24` : `${c.text.secondary}0a`, + borderColor: isOtherActive ? c.accent.primary : c.border.medium, + }, + }} + /> + + {isOtherActive && ( + setOtherText((prev) => ({ ...prev, [i]: e.target.value }))} + fullWidth size="small" autoFocus + sx={{ + mt: 0.25, + '& .MuiOutlinedInput-root': { + color: c.text.primary, fontSize: '0.82rem', + '& fieldset': { borderColor: c.border.medium }, + '&:hover fieldset': { borderColor: c.border.strong }, + '&.Mui-focused fieldset': { borderColor: c.accent.primary }, + }, + }} + /> + )} + + ) : ( + setTextAnswer(i, e.target.value)} + fullWidth size="small" multiline maxRows={4} + sx={{ + '& .MuiOutlinedInput-root': { + color: c.text.primary, fontSize: '0.82rem', + '& fieldset': { borderColor: c.border.medium }, + '&:hover fieldset': { borderColor: c.border.strong }, + '&.Mui-focused fieldset': { borderColor: c.accent.primary }, + }, + }} + /> + )} + + ); + })} + + + + + + + + ); +}; diff --git a/frontend/src/app/pages/AgentChat/ThinkingBubble.tsx b/frontend/src/app/pages/AgentChat/ThinkingBubble.tsx new file mode 100644 index 00000000..6f1a4dc1 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ThinkingBubble.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +export const CONTEXT_WINDOWS: Record = { + sonnet: 200_000, + opus: 200_000, + haiku: 200_000, +}; + +const thinkingDotsKeyframes = ` +@keyframes thinking-bounce { + 0%, 80%, 100% { transform: scale(0); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} +`; + +const ThinkingBubble: React.FC = () => { + const c = useClaudeTokens(); + return ( + + + + {[0, 1, 2].map((i) => ( + + ))} + + + ); +}; + +export default ThinkingBubble; diff --git a/frontend/src/app/pages/AgentChat/ToolCallBubble.tsx b/frontend/src/app/pages/AgentChat/ToolCallBubble.tsx index 1b2bf7ef..981a4d63 100644 --- a/frontend/src/app/pages/AgentChat/ToolCallBubble.tsx +++ b/frontend/src/app/pages/AgentChat/ToolCallBubble.tsx @@ -1,1867 +1,75 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Collapse from '@mui/material/Collapse'; import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import TerminalIcon from '@mui/icons-material/Terminal'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import BlockIcon from '@mui/icons-material/Block'; -import EmailIcon from '@mui/icons-material/Email'; -import EventIcon from '@mui/icons-material/Event'; -import FolderIcon from '@mui/icons-material/Folder'; -import AttachFileIcon from '@mui/icons-material/AttachFile'; import SearchIcon from '@mui/icons-material/Search'; -import SendIcon from '@mui/icons-material/Send'; -import CallSplitIcon from '@mui/icons-material/CallSplit'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { AgentMessage, expandSession, collapseSession, fetchSession } from '@/shared/state/agentsSlice'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { placeCard, removeCard, setGlowingAgentCard, clearGlowingAgentCard, DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice'; -import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext'; +import GoogleServiceIcon from '@/app/components/GoogleServiceIcon'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import BrowserAgentInlineFeed from './BrowserAgentInlineFeed'; - -const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 14 }) => { - if (service === 'gmail') { - return ( - - {/* Left blue bar */} - - {/* Right green bar */} - - {/* Red M chevron */} - - {/* Top-left blue triangle */} - - {/* Top-right yellow triangle */} - - {/* Top red V */} - - - ); - } - if (service === 'calendar') { - return ( - - - - 31 - - ); - } - if (service === 'drive' || service === 'sheets') { - return ( - - - - - - - ); - } - return null; -}; - -export interface ToolPair { - type: 'tool_pair'; - id: string; - call: AgentMessage; - result: AgentMessage | null; -} - -let toolCallKeyframesInjected = false; -function ensureToolCallKeyframes() { - if (toolCallKeyframesInjected) return; - toolCallKeyframesInjected = true; - const style = document.createElement('style'); - style.setAttribute('data-tool-call-keyframes', ''); - style.textContent = ` -@keyframes tool-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} -@keyframes border-glow { - 0%, 100% { box-shadow: 0 0 0 0 rgba(var(--glow-rgb), 0); } - 50% { box-shadow: 0 0 10px 2px rgba(var(--glow-rgb), 0.12); } -} -@keyframes blink-cursor { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } -} -`; - document.head.appendChild(style); -} - -const ElapsedTimer: React.FC<{ startTime: string }> = ({ startTime }) => { - const c = useClaudeTokens(); - const [elapsed, setElapsed] = useState(0); - - useEffect(() => { - const start = new Date(startTime).getTime(); - const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000)); - tick(); - const interval = setInterval(tick, 1000); - return () => clearInterval(interval); - }, [startTime]); - - const mins = Math.floor(elapsed / 60); - const secs = elapsed % 60; - const display = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; - - return ( - - - - {display} - - - ); -}; - -function formatElapsed(ms: number): string { - if (ms >= 60000) return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`; - if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`; - return `${ms}ms`; -} - -function getToolData(call: AgentMessage) { - const content = typeof call.content === 'object' ? call.content : {}; - return { - toolName: content.tool || 'Unknown', - input: content.input || {}, - isDenied: content.approved === false, - toolId: content.id, - }; -} - -function isBashTool(name: string) { - return name === 'Bash' || name === 'bash'; -} - -export interface McpToolInfo { - isMcp: boolean; - serverSlug: string; - action: string; - service: string; - displayName: string; -} - -export function parseMcpToolName(rawName: string): McpToolInfo { - const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/); - if (!m) return { isMcp: false, serverSlug: '', action: '', service: '', displayName: rawName }; - const serverSlug = m[1]; - const action = m[2]; - const display = action.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase()); - - const lower = action.toLowerCase(); - let service = ''; - if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) service = 'gmail'; - else if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) service = 'calendar'; - else if (lower.includes('drive') || lower.includes('file')) service = 'drive'; - else if (lower.includes('sheet') || lower.includes('spreadsheet')) service = 'sheets'; - else if (lower.includes('doc') || lower.includes('paragraph')) service = 'docs'; - else if (lower.includes('contact')) service = 'contacts'; - - return { isMcp: true, serverSlug, action, service, displayName: display }; -} - -function getMcpInputSummary(input: any): string { - if (!input || typeof input !== 'object') return ''; - const keys = Object.keys(input); - if (keys.length === 0) return ''; - if (keys.length === 1) { - const v = input[keys[0]]; - const s = typeof v === 'string' ? v : JSON.stringify(v); - return s.length > 60 ? s.slice(0, 60) + '…' : s; - } - return keys.slice(0, 3).map((k) => { - const v = input[k]; - const s = typeof v === 'string' ? v : JSON.stringify(v); - return `${k}: ${s.length > 30 ? s.slice(0, 30) + '…' : s}`; - }).join(' '); -} - -function getInputSummary(toolName: string, input: any): string { - try { - const mcp = parseMcpToolName(toolName); - if (mcp.isMcp) return getMcpInputSummary(input); - - const n = toolName.toLowerCase(); - if (isBashTool(toolName)) { - const cmd = input.command || ''; - return `$ ${cmd.slice(0, 80)}${cmd.length > 80 ? '…' : ''}`; - } - if (n === 'read') return input.file_path || input.path || ''; - if (n === 'write') return input.file_path || input.path || ''; - if (n === 'edit' || n === 'multiedit' || n === 'strreplace') - return input.file_path || input.path || ''; - if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || ''; - if (n === 'grep' || n === 'ripgrep') { - const pat = input.pattern || input.regex || ''; - const path = input.path || input.directory || ''; - return path ? `/${pat}/ in ${path}` : `/${pat}/`; - } - if (n === 'websearch') return input.query || input.search_term || ''; - if (n === 'webfetch') return input.url || ''; - if (n === 'todoread' || n === 'todowrite') return 'todos'; - if (n === 'ls') return input.path || '.'; - return ''; - } catch { - return ''; - } -} - -function formatMcpInputDisplay(input: any): string { - if (!input || typeof input !== 'object') return String(input ?? ''); - return Object.entries(input) - .map(([k, v]) => { - const s = typeof v === 'string' ? v : JSON.stringify(v, null, 2); - return `${k}: ${s}`; - }) - .join('\n'); -} - -function formatInputDisplay(toolName: string, input: any): string { - try { - const mcp = parseMcpToolName(toolName); - if (mcp.isMcp) return formatMcpInputDisplay(input); - - const n = toolName.toLowerCase(); - if (isBashTool(toolName)) return input.command || ''; - if (n === 'read') { - const p = input.file_path || input.path || ''; - const parts = [p]; - if (input.offset) parts.push(`offset: ${input.offset}`); - if (input.limit) parts.push(`limit: ${input.limit}`); - return parts.join(' '); - } - if (n === 'write') { - const p = input.file_path || input.path || ''; - const content = input.content || ''; - const preview = content.length > 300 ? content.slice(0, 300) + '\n…' : content; - return `${p}\n\n${preview}`; - } - if (n === 'edit' || n === 'strreplace') { - const p = input.file_path || input.path || ''; - const old = input.old_string || input.old_text || ''; - const nw = input.new_string || input.new_text || ''; - const lines = [p, '']; - if (old) { - const oldPreview = old.length > 200 ? old.slice(0, 200) + '…' : old; - lines.push(`- ${oldPreview.split('\n').join('\n- ')}`); - } - if (nw) { - const nwPreview = nw.length > 200 ? nw.slice(0, 200) + '…' : nw; - lines.push(`+ ${nwPreview.split('\n').join('\n+ ')}`); - } - return lines.join('\n'); - } - if (n === 'multiedit') { - const p = input.file_path || input.path || ''; - const edits = input.edits || []; - const lines = [p]; - for (const e of edits.slice(0, 3)) { - const old = e.old_string || e.old_text || ''; - lines.push(` - ${old.split('\n')[0].slice(0, 60)}…`); - } - if (edits.length > 3) lines.push(` … +${edits.length - 3} more edits`); - return lines.join('\n'); - } - if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || ''; - if (n === 'grep' || n === 'ripgrep') { - const pat = input.pattern || input.regex || ''; - const path = input.path || input.directory || ''; - const parts = [`pattern: ${pat}`]; - if (path) parts.push(`path: ${path}`); - if (input.include) parts.push(`include: ${input.include}`); - return parts.join('\n'); - } - if (n === 'websearch') return input.query || input.search_term || ''; - if (n === 'webfetch') return input.url || ''; - } catch {} - if (typeof input === 'string') return input; - return JSON.stringify(input, null, 2); -} - -interface ParsedBashResult { - type: 'bash'; - stdout: string; - stderr: string; - exitCode: number | null; -} - -interface ParsedTextResult { - type: 'text'; - content: string; - isError?: boolean; -} - -interface ParsedMcpResult { - type: 'mcp'; - service: string; - action: string; - data: Record; - rawText: string; -} - -type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult; - -function parseToolResult(toolName: string, rawText: string): ParsedResult { - if (isBashTool(toolName)) { - try { - const parsed = JSON.parse(rawText); - if (typeof parsed === 'object' && parsed !== null && 'stdout' in parsed) { - const exitMatch = (parsed.stdout || '').match(/[Ee]xit code:\s*(\d+)/); - return { - type: 'bash', - stdout: parsed.stdout || '', - stderr: parsed.stderr || '', - exitCode: exitMatch ? parseInt(exitMatch[1], 10) : null, - }; - } - } catch {} - } - - const mcp = parseMcpToolName(toolName); - if (mcp.isMcp) { - try { - let parsed = JSON.parse(rawText); - - if (Array.isArray(parsed) && parsed.some((b: any) => b?.type === 'text' && typeof b?.text === 'string')) { - const textContent = parsed - .filter((b: any) => b?.type === 'text') - .map((b: any) => b.text) - .join('\n'); - try { - parsed = JSON.parse(textContent); - } catch { - return { type: 'mcp', service: mcp.service, action: mcp.action, data: {}, rawText: textContent }; - } - } - - if (typeof parsed === 'object' && parsed !== null) { - return { type: 'mcp', service: mcp.service, action: mcp.action, data: parsed, rawText }; - } - } catch {} - return { type: 'mcp', service: mcp.service, action: mcp.action, data: {}, rawText }; - } - - try { - const parsed = JSON.parse(rawText); - if (typeof parsed === 'object' && parsed !== null) { - if ('stdout' in parsed) { - return { type: 'text', content: parsed.stdout || '' }; - } - if ('content' in parsed && typeof parsed.content === 'string') { - return { type: 'text', content: parsed.content, isError: !!parsed.is_error }; - } - if ('result' in parsed && typeof parsed.result === 'string') { - return { type: 'text', content: parsed.result }; - } - if ('output' in parsed && typeof parsed.output === 'string') { - return { type: 'text', content: parsed.output }; - } - const n = toolName.toLowerCase(); - if (n === 'glob' && Array.isArray(parsed)) { - return { type: 'text', content: parsed.join('\n') }; - } - } - } catch {} - - return { type: 'text', content: rawText }; -} - -export function getMcpShortAction(mcpInfo: McpToolInfo): string { - const { action, service } = mcpInfo; - let short = action; - if (service && action.toLowerCase().startsWith(service.toLowerCase() + '_')) { - short = action.slice(service.length + 1); - } - return short.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase()); -} - -export function getResultSummary(toolName: string, rawText: string): string { - const parsed = parseToolResult(toolName, rawText); - - if (parsed.type === 'bash') { - const lines = parsed.stdout.split('\n').filter((l) => l.trim()).length; - if (parsed.exitCode !== null && parsed.exitCode !== 0) return `✗ exit ${parsed.exitCode}`; - if (parsed.stderr && !parsed.stdout) return '✗ stderr'; - return `✓ ${lines} line${lines !== 1 ? 's' : ''}`; - } - - if (parsed.type === 'mcp') { - const d = parsed.data; - if (parsed.service === 'gmail') { - const subj = d.subject || getGmailHeader(d, 'Subject'); - if (subj) return subj; - if (Array.isArray(d.messages)) return `${d.messages.length} email${d.messages.length !== 1 ? 's' : ''}`; - if (d.id || d.messageId) return '✓ done'; - } - if (parsed.service === 'calendar') { - if (d.summary) return d.summary.slice(0, 40); - if (Array.isArray(d.items)) return `${d.items.length} event${d.items.length !== 1 ? 's' : ''}`; - } - if (parsed.service === 'drive') { - if (d.name) return d.name; - if (Array.isArray(d.files)) return `${d.files.length} file${d.files.length !== 1 ? 's' : ''}`; - } - if (d.error || d.is_error) return '✗ error'; - return '✓ done'; - } - - const text = parsed.content; - const lines = text.split('\n'); - const lineCount = lines.length; - const n = toolName.toLowerCase(); - - try { - if (n === 'glob') { - const fileCount = lines.filter((l) => l.trim()).length; - return `${fileCount} file${fileCount !== 1 ? 's' : ''}`; - } - if (n === 'grep' || n === 'ripgrep') { - const matchCount = lines.filter((l) => l.trim()).length; - return `${matchCount} match${matchCount !== 1 ? 'es' : ''}`; - } - if (n === 'read') return `${lineCount} lines`; - if (n === 'write') { - if (text.toLowerCase().includes('success') || text.toLowerCase().includes('written')) - return '✓ written'; - return '✓ done'; - } - if (n === 'edit' || n === 'multiedit' || n === 'strreplace') { - if (text.toLowerCase().includes('success') || text.toLowerCase().includes('applied')) - return '✓ applied'; - return '✓ done'; - } - if (n === 'websearch') return 'results'; - if (n === 'webfetch') return `${lineCount} lines`; - if (parsed.isError) return '✗ error'; - } catch {} - - return `${lineCount} line${lineCount !== 1 ? 's' : ''}`; -} - -function getPromptPrefix(toolName: string): string { - if (isBashTool(toolName)) return '$ '; - const mcp = parseMcpToolName(toolName); - if (mcp.isMcp) return `❯ ${mcp.displayName} `; - return `❯ ${toolName} `; -} - -interface ToolCallBubbleProps { - call: AgentMessage; - result?: AgentMessage | null; - isPending?: boolean; - isStreaming?: boolean; - mcpCompact?: boolean; - sessionId?: string; -} - -interface TermColors { - TERM_BG: string; - TERM_BORDER: string; - PROMPT_COLOR: string; - CMD_COLOR: string; - OUTPUT_COLOR: string; - PATH_COLOR: string; - ADD_COLOR: string; - DEL_COLOR: string; - STDERR_COLOR: string; - WARN_COLOR: string; - NUM_COLOR: string; - DIM_COLOR: string; - DIFF_HEADER_COLOR: string; - SCROLLBAR_THUMB: string; -} - -const darkTermColors: TermColors = { - TERM_BG: '#131520', - TERM_BORDER: '#1e2030', - PROMPT_COLOR: '#7ec699', - CMD_COLOR: '#e8ecf4', - OUTPUT_COLOR: '#a0aab8', - PATH_COLOR: '#82aaff', - ADD_COLOR: '#7ec699', - DEL_COLOR: '#ff8787', - STDERR_COLOR: '#ff8787', - WARN_COLOR: '#ffcb6b', - NUM_COLOR: '#f78c6c', - DIM_COLOR: '#555b6e', - DIFF_HEADER_COLOR: '#c792ea', - SCROLLBAR_THUMB: '#2a2d3e', -}; - -const lightTermColors: TermColors = { - TERM_BG: '#f4f3ee', - TERM_BORDER: '#e2e0d8', - PROMPT_COLOR: '#2d7a3e', - CMD_COLOR: '#2a2a28', - OUTPUT_COLOR: '#555550', - PATH_COLOR: '#3060a8', - ADD_COLOR: '#2d7a3e', - DEL_COLOR: '#c03030', - STDERR_COLOR: '#c03030', - WARN_COLOR: '#8a6518', - NUM_COLOR: '#c05020', - DIM_COLOR: '#9e9c95', - DIFF_HEADER_COLOR: '#7c4daa', - SCROLLBAR_THUMB: '#ccc9c0', -}; - -function useTermColors(): TermColors { - const { mode } = useThemeMode(); - return mode === 'dark' ? darkTermColors : lightTermColors; -} - -function colorizeInput(toolName: string, text: string, tc: TermColors): React.ReactNode { - const n = toolName.toLowerCase(); - const mcp = parseMcpToolName(toolName); - - if (mcp.isMcp) { - const lines = text.split('\n'); - return ( - <> - {lines.map((line, i) => { - const nl = i < lines.length - 1 ? '\n' : ''; - const colonIdx = line.indexOf(':'); - if (colonIdx > 0 && colonIdx < 30) { - return ( - - {line.slice(0, colonIdx + 1)} - {line.slice(colonIdx + 1)} - {nl} - - ); - } - return {line}{nl}; - })} - - ); - } - - if (isBashTool(toolName)) return {text}; - - if (n === 'edit' || n === 'strreplace' || n === 'multiedit') { - const lines = text.split('\n'); - return ( - <> - {lines.map((line, i) => { - const nl = i < lines.length - 1 ? '\n' : ''; - if (i === 0 && (line.startsWith('/') || line.includes('.'))) - return {line}{nl}; - if (line.startsWith('+ ')) - return {line}{nl}; - if (line.startsWith('- ')) - return {line}{nl}; - return {line}{nl}; - })} - - ); - } - - if (n === 'write') { - const lines = text.split('\n'); - return ( - <> - {lines.map((line, i) => { - const nl = i < lines.length - 1 ? '\n' : ''; - if (i === 0 && (line.startsWith('/') || line.includes('.'))) - return {line}{nl}; - return {line}{nl}; - })} - - ); - } - - if (n === 'read' || n === 'glob' || n === 'webfetch') { - if (/^\//.test(text) || text.includes('/')) - return {text}; - } - - if (n === 'grep' || n === 'ripgrep') { - const lines = text.split('\n'); - return ( - <> - {lines.map((line, i) => { - const nl = i < lines.length - 1 ? '\n' : ''; - if (line.startsWith('pattern:')) - return ( - - pattern: - {line.slice(9)} - {nl} - - ); - if (line.startsWith('path:')) - return ( - - path: - {line.slice(6)} - {nl} - - ); - return {line}{nl}; - })} - - ); - } - - return {text}; -} - -function colorizeOutput(toolName: string, text: string, tc: TermColors): React.ReactNode { - if (!text) return (empty); - - const lines = text.split('\n'); - const n = toolName.toLowerCase(); - - return ( - <> - {lines.map((line, i) => { - const nl = i < lines.length - 1 ? '\n' : ''; - const trimmed = line.trimStart(); - - if (/^\/\S+/.test(trimmed)) - return {line}{nl}; - - if (n === 'grep' || n === 'ripgrep') { - const grepMatch = line.match(/^(\S+?:\d+[:-])/); - if (grepMatch) { - return ( - - {grepMatch[1]} - {line.slice(grepMatch[1].length)} - {nl} - - ); - } - const fileHeader = line.match(/^(\S+\.\w+)$/); - if (fileHeader) - return {line}{nl}; - } - - if (line.startsWith('@@') && line.includes('@@')) - return {line}{nl}; - if (line.startsWith('+')) - return {line}{nl}; - if (line.startsWith('-')) - return {line}{nl}; - - if (/\b[Ee]rror\b/.test(line)) - return {line}{nl}; - if (/\b[Ww]arning\b/.test(line)) - return {line}{nl}; - - if (n === 'read') { - const lineNumMatch = line.match(/^(\s*\d+\s*[|:])/); - if (lineNumMatch) { - return ( - - {lineNumMatch[1]} - {line.slice(lineNumMatch[1].length)} - {nl} - - ); - } - } - - return {line}{nl}; - })} - - ); -} - - -function formatTimestamp(ts: string | number | undefined): string { - if (!ts) return ''; - try { - const d = typeof ts === 'number' ? new Date(ts) : new Date(ts); - if (isNaN(d.getTime())) return String(ts); - return d.toLocaleDateString('en-US', { - weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', - hour: 'numeric', minute: '2-digit', - }); - } catch { return String(ts); } -} - -function stripHtml(html: string): string { - const tmp = document.createElement('div'); - tmp.innerHTML = html; - return tmp.textContent || tmp.innerText || ''; -} - -interface CardColors { - TC_BG: string; - TC_BORDER: string; - TC_HOVER: string; - TC_HEADING: string; - TC_BODY: string; - TC_MUTED: string; - TC_DIM: string; - TC_ACCENT: string; - TC_SUCCESS: string; - TC_WARNING: string; -} - -const darkCardColors: CardColors = { - TC_BG: 'rgba(255,255,255,0.03)', - TC_BORDER: 'rgba(255,255,255,0.06)', - TC_HOVER: 'rgba(255,255,255,0.05)', - TC_HEADING: '#C2C0B6', - TC_BODY: '#9C9A92', - TC_MUTED: '#85837C', - TC_DIM: 'rgba(156,154,146,0.5)', - TC_ACCENT: '#c4633a', - TC_SUCCESS: '#7AB948', - TC_WARNING: '#D1A041', -}; - -const lightCardColors: CardColors = { - TC_BG: 'rgba(0,0,0,0.03)', - TC_BORDER: 'rgba(0,0,0,0.08)', - TC_HOVER: 'rgba(0,0,0,0.05)', - TC_HEADING: '#3D3D3A', - TC_BODY: '#555550', - TC_MUTED: '#73726C', - TC_DIM: 'rgba(115,114,108,0.5)', - TC_ACCENT: '#ae5630', - TC_SUCCESS: '#265B19', - TC_WARNING: '#805C1F', -}; - -function useCardColors(): CardColors { - const { mode } = useThemeMode(); - return mode === 'dark' ? darkCardColors : lightCardColors; -} - -function getGmailHeader(msg: any, name: string): string { - if (msg.payload?.headers && Array.isArray(msg.payload.headers)) { - const h = msg.payload.headers.find( - (hdr: any) => (hdr.name || '').toLowerCase() === name.toLowerCase() - ); - if (h) return h.value || ''; - } - if (msg.headers && typeof msg.headers === 'object' && !Array.isArray(msg.headers)) { - return msg.headers[name] || msg.headers[name.toLowerCase()] || ''; - } - return ''; -} - -function extractEmailFields(msg: any) { - const subject = msg.subject || getGmailHeader(msg, 'Subject') || '(no subject)'; - const from = msg.from || msg.sender || getGmailHeader(msg, 'From') || ''; - const to = msg.to || msg.recipient || getGmailHeader(msg, 'To') || ''; - const rawDate = msg.date || msg.internalDate || msg.receivedAt || getGmailHeader(msg, 'Date') || ''; - const date = formatTimestamp(rawDate); - const snippet = msg.snippet || ''; - const body = msg.body || msg.text || msg.textBody || ''; - const htmlBody = msg.htmlBody || msg.html || ''; - const bodyPreview = body || (htmlBody ? stripHtml(htmlBody) : ''); - return { subject, from, to, date, snippet, bodyPreview }; -} - -const GmailCard: React.FC<{ data: Record; action: string; hideSubjectHeader?: boolean }> = ({ data, action, hideSubjectHeader }) => { - const c = useClaudeTokens(); - const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_BODY, TC_MUTED, TC_DIM, TC_ACCENT, TC_SUCCESS, TC_WARNING } = useCardColors(); - const email = extractEmailFields(data); - const labels = data.labelIds || data.labels || []; - const attachments = data.attachments || []; - - const isSend = action.includes('send'); - const isSearch = action.includes('search') || action.includes('list'); - const messages: any[] = data.messages || (isSearch && data.results ? data.results : []); - - if (messages.length > 0) { - return ( - - {messages.slice(0, 5).map((msg: any, i: number) => { - const m = extractEmailFields(msg); - return ( - - - - {m.subject} - - {m.date && ( - - {m.date} - - )} - - {m.from && ( - - {m.from} - - )} - {(m.snippet || m.bodyPreview) && ( - - {(m.snippet || m.bodyPreview).slice(0, 120)} - {(m.snippet || m.bodyPreview).length > 120 ? '…' : ''} - - )} - - ); - })} - {messages.length > 5 && ( - - +{messages.length - 5} more - - )} - - ); - } - - return ( - - {!hideSubjectHeader && ( - - {isSend ? ( - - ) : ( - - )} - - {email.subject} - - - )} - - - {(email.from || email.to || email.date) && ( - - {email.from && ( - - From - {email.from} - - )} - {email.to && ( - - To - {email.to} - - )} - {email.date && ( - - Date - {email.date} - - )} - - )} - - {labels.length > 0 && ( - - {labels.map((l: string, i: number) => ( - - {l} - - ))} - - )} - - {(email.snippet || email.bodyPreview) && ( - - {children} }} - > - {email.bodyPreview || email.snippet} - - - )} - - {attachments.length > 0 && ( - - {attachments.map((a: any, i: number) => ( - - - - {a.filename || a.name || 'attachment'} - - - ))} - - )} - - - ); -}; - -const CalendarCard: React.FC<{ data: Record; hideHeader?: boolean }> = ({ data, hideHeader }) => { - const c = useClaudeTokens(); - const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_BODY, TC_DIM, TC_SUCCESS } = useCardColors(); - const items: any[] = data.items || (Array.isArray(data) ? data : []); - const single = !items.length ? data : null; - - if (single && (single.summary || single.start)) { - const start = single.start?.dateTime || single.start?.date || single.start || ''; - const end = single.end?.dateTime || single.end?.date || single.end || ''; - return ( - - {!hideHeader && ( - - - - {single.summary || '(no title)'} - - - )} - - {start && ( - - Start - {formatTimestamp(start)} - - )} - {end && ( - - End - {formatTimestamp(end)} - - )} - {single.location && ( - - Where - {single.location} - - )} - {single.description && ( - -
-                {single.description.slice(0, 300)}
-                {single.description.length > 300 ? '…' : ''}
-              
-
- )} -
-
- ); - } - - if (items.length > 0) { - return ( - - {items.slice(0, 6).map((item: any, i: number) => ( - - - {item.summary || '(no title)'} - - - {formatTimestamp(item.start?.dateTime || item.start?.date || item.start)} - - - ))} - {items.length > 6 && ( - - +{items.length - 6} more - - )} - - ); - } - - return null; -}; - -const DriveCard: React.FC<{ data: Record }> = ({ data }) => { - const c = useClaudeTokens(); - const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_DIM, TC_WARNING } = useCardColors(); - const files: any[] = data.files || (Array.isArray(data) ? data : []); - const single = !files.length && data.name ? data : null; - - if (single) { - return ( - - - - - {single.name} - - {single.mimeType && ( - {single.mimeType} - )} - - - ); - } - - if (files.length > 0) { - return ( - - {files.slice(0, 8).map((f: any, i: number) => ( - - - {f.name || f.id} - {f.mimeType && ( - - {f.mimeType.split('/').pop()} - - )} - - ))} - - ); - } - - return null; -}; - -const GenericMcpCard: React.FC<{ data: Record }> = ({ data }) => { - const c = useClaudeTokens(); - const { TC_DIM, TC_BODY } = useCardColors(); - const entries = Object.entries(data).filter(([, v]) => v != null); - - if (entries.length === 0) - return (empty response); - - return ( - - {entries.slice(0, 20).map(([key, val], i) => { - const isLong = typeof val === 'string' && val.length > 100; - const isObj = typeof val === 'object'; - return ( - - - {key} - - {isObj ? ( -
-                {JSON.stringify(val, null, 2).slice(0, 500)}
-              
- ) : isLong ? ( -
-                {String(val).slice(0, 500)}{String(val).length > 500 ? '…' : ''}
-              
- ) : ( - {String(val)} - )} -
- ); - })} - {entries.length > 20 && ( - - +{entries.length - 20} more fields - - )} -
- ); -}; - -const McpResultCard: React.FC<{ parsed: ParsedMcpResult; compact?: boolean }> = ({ parsed, compact }) => { - const tc = useTermColors(); - const { service, action, data } = parsed; - - if (data.error || data.is_error) { - return ( - - - {data.error || data.message || JSON.stringify(data, null, 2)} - - - ); - } - - if (service === 'gmail') return ; - if (service === 'calendar') return ; - if (service === 'drive' || service === 'sheets') return ; - - return ; -}; - -function isBrowserAgentTool(name: string): boolean { - if (name === 'CreateBrowserAgent' || name === 'BrowserAgent' || name === 'BrowserAgents') return true; - const mcp = parseMcpToolName(name); - return mcp.isMcp && mcp.serverSlug === 'openswarm-browser-agent'; -} - -function isInvokeAgentTool(name: string): boolean { - if (name === 'InvokeAgent') return true; - const mcp = parseMcpToolName(name); - return mcp.isMcp && mcp.serverSlug === 'openswarm-invoke-agent'; -} - -function isCreateAgentTool(name: string): boolean { - return name === 'Agent'; -} - -function parseInvokedSessionId(rawText: string): string | null { - const match = rawText.match(/\(forked session:\s*([a-f0-9]+)\)/); - return match ? match[1] : null; -} - -interface InvokeAgentParsed { - agentName: string; - sessionId: string | null; - cost: string | null; - response: string; -} - -function parseCreateAgentResult(rawText: string): string { - if (!rawText) return ''; - try { - const parsed = JSON.parse(rawText); - if (typeof parsed === 'string') return parsed; - if (typeof parsed === 'object' && parsed !== null) { - if (parsed.text) return parsed.text; - if (parsed.content) return typeof parsed.content === 'string' ? parsed.content : JSON.stringify(parsed.content); - if (parsed.result) return typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result); - } - } catch {} - return rawText; -} - -function parseInvokeAgentResult(rawText: string): InvokeAgentParsed | null { - const headerMatch = rawText.match( - /\*\*Invoked Agent Result\*\*(?:\s*—\s*(.+?))?\s*\(forked session:\s*([a-f0-9]+)\)/, - ); - if (!headerMatch) return null; - - const agentName = headerMatch[1]?.trim() || 'Agent'; - const sessionId = headerMatch[2]; - - const costMatch = rawText.match(/\*Cost:\s*\$([0-9.]+)\*/); - const cost = costMatch ? costMatch[1] : null; - - const bodyStart = rawText.indexOf('\n\n'); - let response = bodyStart >= 0 ? rawText.slice(bodyStart + 2).trim() : ''; - if (response.startsWith('*Cost:')) { - const afterCost = response.indexOf('\n'); - response = afterCost >= 0 ? response.slice(afterCost + 1).trim() : ''; - } - - return { agentName, sessionId, cost, response }; -} +import { useTermColors, colorizeInput, colorizeOutput } from './toolCallColors'; +import { ElapsedTimer } from './ElapsedTimer'; +import { McpResultCard } from './McpServiceCards'; +import { InvokeAgentBubble, CreateAgentBubble } from './AgentToolBubble'; +import { + ToolCallBubbleProps, ensureToolCallKeyframes, getToolData, parseMcpToolName, + getMcpShortAction, getInputSummary, formatInputDisplay, parseToolResult, + getResultSummary, getPromptPrefix, formatElapsed, + isBrowserAgentTool, isInvokeAgentTool, isCreateAgentTool, +} from './toolCallUtils'; + +export { parseMcpToolName, getMcpShortAction, getResultSummary } from './toolCallUtils'; +export type { ToolPair, McpToolInfo } from './toolCallUtils'; const ToolCallBubble: React.FC = React.memo( ({ call, result = null, isPending = false, isStreaming = false, mcpCompact = false, sessionId }) => { ensureToolCallKeyframes(); - const c = useClaudeTokens(); const tc = useTermColors(); - const dispatch = useAppDispatch(); - const cards = useAppSelector((s) => s.dashboardLayout.cards); const [expanded, setExpanded] = useState(false); - const bubbleRef = useRef(null); - const { toolName, input, isDenied } = getToolData(call); const mcpInfo = useMemo(() => parseMcpToolName(toolName), [toolName]); const inputSummary = getInputSummary(toolName, input); const formattedInput = useMemo(() => formatInputDisplay(toolName, input), [toolName, input]); const showTimer = isPending && !isDenied && !isStreaming; - const isBrowserAgent = isBrowserAgentTool(toolName); - const isInvokeAgent = isInvokeAgentTool(toolName); - const isCreateAgent = isCreateAgentTool(toolName); const browserAgentAutoExpand = isBrowserAgent && isPending && !isStreaming; const showBody = expanded || isStreaming || browserAgentAutoExpand; - const resultContent = result?.content; - const hasStructuredResult = - resultContent && typeof resultContent === 'object' && 'text' in resultContent; - const resultRawText: string = hasStructuredResult - ? resultContent.text - : typeof resultContent === 'string' - ? resultContent - : resultContent - ? JSON.stringify(resultContent, null, 2) - : ''; - const resultElapsedMs: number | null = hasStructuredResult - ? resultContent.elapsed_ms ?? null - : null; - - const parsedResult = useMemo( - () => (result ? parseToolResult(toolName, resultRawText) : null), - [result, toolName, resultRawText], - ); + const hasStructuredResult = resultContent && typeof resultContent === 'object' && 'text' in resultContent; + const resultRawText: string = hasStructuredResult ? resultContent.text : typeof resultContent === 'string' ? resultContent : resultContent ? JSON.stringify(resultContent, null, 2) : ''; + const resultElapsedMs: number | null = hasStructuredResult ? resultContent.elapsed_ms ?? null : null; + const parsedResult = useMemo(() => (result ? parseToolResult(toolName, resultRawText) : null), [result, toolName, resultRawText]); const resultSummary = result ? getResultSummary(toolName, resultRawText) : null; - const isError = - resultSummary?.startsWith('✗') || - (parsedResult?.type === 'bash' && parsedResult.exitCode !== null && parsedResult.exitCode !== 0) || - (parsedResult?.type === 'text' && parsedResult.isError); - - const invokedSessionId = useMemo( - () => (isInvokeAgent && result ? parseInvokedSessionId(resultRawText) : null), - [isInvokeAgent, result, resultRawText], - ); - - const invokeAgentParsed = useMemo( - () => (isInvokeAgent && result ? parseInvokeAgentResult(resultRawText) : null), - [isInvokeAgent, result, resultRawText], - ); - - const createAgentResponse = useMemo( - () => (isCreateAgent && result ? parseCreateAgentResult(resultRawText) : ''), - [isCreateAgent, result, resultRawText], - ); - - const createAgentSessionId: string | null = useMemo( - () => (isCreateAgent && hasStructuredResult && resultContent?.sub_session_id) ? resultContent.sub_session_id : null, - [isCreateAgent, hasStructuredResult, resultContent], - ); - - const revealTargetSessionId = invokedSessionId || createAgentSessionId; - - const sessions = useAppSelector((s) => s.agents.sessions); - - const handleRevealAgent = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - if (!revealTargetSessionId || !sessionId) return; - - if (cards[revealTargetSessionId]) { - dispatch(collapseSession(revealTargetSessionId)); - dispatch(removeCard(revealTargetSessionId)); - setTimeout(() => { - dispatch(clearGlowingAgentCard(revealTargetSessionId)); - }, 500); - return; - } - - let sourceYRatio: number | undefined; - if (bubbleRef.current) { - const bubbleEl = bubbleRef.current; - const cardEl = bubbleEl.closest('[data-select-type="agent-card"]') as HTMLElement | null; - if (cardEl) { - const cardRect = cardEl.getBoundingClientRect(); - const bubbleRect = bubbleEl.getBoundingClientRect(); - const bubbleCenterY = bubbleRect.top + bubbleRect.height / 2; - const ratio = (bubbleCenterY - cardRect.top) / cardRect.height; - sourceYRatio = Math.max(0, Math.min(1, ratio)); - } - } - - const doPlace = () => { - const parentCard = cards[sessionId]; - const targetX = parentCard - ? parentCard.x + parentCard.width + GRID_GAP * 12 - : 40; - let targetY = parentCard ? parentCard.y : 100; - if (parentCard) { - const columnCards = Object.values(cards).filter( - (c) => Math.abs(c.x - targetX) < 50 && c.session_id !== revealTargetSessionId, - ); - if (columnCards.length > 0) { - const lowestBottom = Math.max( - ...columnCards.map((c) => c.y + Math.max(EXPANDED_CARD_MIN_H, c.height)), - ); - targetY = lowestBottom + GRID_GAP; - } - } - dispatch(placeCard({ - sessionId: revealTargetSessionId, - x: targetX, - y: targetY, - width: DEFAULT_CARD_W, - height: DEFAULT_CARD_H, - })); - dispatch(expandSession(revealTargetSessionId)); - const label = isCreateAgent ? 'Create Agent' : isInvokeAgent ? 'Invoke Agent' : 'Agent'; - dispatch(setGlowingAgentCard({ sessionId: revealTargetSessionId, sourceId: sessionId, sourceYRatio, label })); - }; - - if (!sessions[revealTargetSessionId]) { - dispatch(fetchSession(revealTargetSessionId)).then(doPlace); - } else { - doPlace(); - } - }, - [revealTargetSessionId, sessionId, cards, sessions, dispatch], - ); - - const toggle = useCallback(() => { - if (!isStreaming) setExpanded((v) => !v); - }, [isStreaming]); - - const accentRgb = c.accent.primary - .replace('#', '') - .match(/.{2}/g) - ?.map((h) => parseInt(h, 16)) - .join(', ') || '189, 100, 57'; - + const isError = resultSummary?.startsWith('✗') || (parsedResult?.type === 'bash' && parsedResult.exitCode !== null && parsedResult.exitCode !== 0) || (parsedResult?.type === 'text' && parsedResult.isError); + const toggle = useCallback(() => { if (!isStreaming) setExpanded((v) => !v); }, [isStreaming]); + const accentRgb = c.accent.primary.replace('#', '').match(/.{2}/g)?.map((h) => parseInt(h, 16)).join(', ') || '189, 100, 57'; const promptPrefix = getPromptPrefix(toolName); const shortAction = mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName; + const serviceLabel = mcpInfo.isMcp && mcpInfo.service ? mcpInfo.service.charAt(0).toUpperCase() + mcpInfo.service.slice(1) : shortAction; + const ServiceIcon = mcpInfo.isMcp && mcpInfo.service ? : null; + const selectAttrs = { 'data-select-type': 'tool-call' as const, 'data-select-id': call.id, 'data-select-meta': JSON.stringify({ tool: toolName, inputSummary }) }; - const serviceLabel = mcpInfo.isMcp && mcpInfo.service - ? mcpInfo.service.charAt(0).toUpperCase() + mcpInfo.service.slice(1) - : shortAction; - - const ServiceIcon = mcpInfo.isMcp && mcpInfo.service - ? - : null; - - const selectAttrs = { - 'data-select-type': 'tool-call' as const, - 'data-select-id': call.id, - 'data-select-meta': JSON.stringify({ tool: toolName, inputSummary }), - }; - - if (isInvokeAgent) { - const agentName = invokeAgentParsed?.agentName || input?.session_id || 'Agent'; - const responsePreview = invokeAgentParsed?.response || ''; - const costLabel = invokeAgentParsed?.cost ? `$${invokeAgentParsed.cost}` : null; - const hasResponse = !!invokeAgentParsed; - - return ( - - - {/* Header */} - - - - InvokeAgent - - - - {agentName} - - - - {!hasResponse && !showTimer && } - - {hasResponse && responsePreview && !expanded && ( - - {responsePreview.slice(0, 100)}{responsePreview.length > 100 ? '…' : ''} - - )} - {expanded && } - - {isDenied && ( - - - denied - - )} - - {hasResponse && !isDenied && ( - - {isError ? ( - - ) : ( - - )} - {resultElapsedMs != null && ( - - {formatElapsed(resultElapsedMs)} - - )} - {costLabel && ( - - {costLabel} - - )} - - )} - - {showTimer && } - - {invokedSessionId && ( - - - - - - )} - - {hasResponse && ( - - {expanded ? : } - - )} - - - {/* Expanded body — markdown rendered, not terminal */} - - - ( - {children} - ), - }} - > - {responsePreview} - - - - - - ); - } - - if (isCreateAgent) { - const taskPrompt = input?.prompt || input?.task || input?.message || ''; - const taskLabel = taskPrompt - ? taskPrompt.length > 40 ? taskPrompt.slice(0, 40) + '…' : taskPrompt - : 'Sub-agent'; - const hasResponse = !!createAgentResponse; - - return ( - - - - - - CreateAgent - - - - {taskLabel} - - - - {!hasResponse && !showTimer && } - - {hasResponse && createAgentResponse && !expanded && ( - - {createAgentResponse.slice(0, 100)}{createAgentResponse.length > 100 ? '…' : ''} - - )} - {expanded && } - - {isDenied && ( - - - denied - - )} - - {hasResponse && !isDenied && ( - - {isError ? ( - - ) : ( - - )} - {resultElapsedMs != null && ( - - {formatElapsed(resultElapsedMs)} - - )} - - )} - - {showTimer && } - - {createAgentSessionId && ( - - - - - - )} - - {hasResponse && ( - - {expanded ? : } - - )} - - - - - ( - {children} - ), - }} - > - {createAgentResponse} - - - - - - ); - } + if (isInvokeAgentTool(toolName)) return ; + if (isCreateAgentTool(toolName)) return ; if (mcpCompact && mcpInfo.isMcp) { return ( - + {ServiceIcon} - - {serviceLabel} - + {serviceLabel} {resultSummary && !isError && ( - - {resultSummary} - + {resultSummary} )} {!resultSummary && !showTimer && } - {showTimer && ( - <> - - - - )} + {showTimer && <>} {isDenied && ( @@ -1870,55 +78,24 @@ const ToolCallBubble: React.FC = React.memo( )} {result && !isDenied && ( - {isError ? ( - - ) : ( - - )} - {resultElapsedMs != null && ( - - {formatElapsed(resultElapsedMs)} - - )} + {isError ? : } + {resultElapsedMs != null && {formatElapsed(resultElapsedMs)}} )} {showBody ? : } - - - {isBrowserAgent && sessionId && ( - - )} + + {isBrowserAgent && sessionId && } {parsedResult && parsedResult.type === 'mcp' ? ( ) : parsedResult ? ( -
-                  {parsedResult.type === 'text' ? parsedResult.content : ''}
-                
+
{parsedResult.type === 'text' ? parsedResult.content : ''}
) : null} {!parsedResult && isPending && !isStreaming && !isBrowserAgent && ( - - - + )}
@@ -1928,248 +105,61 @@ const ToolCallBubble: React.FC = React.memo( return ( - - {/* Header */} - + + {mcpInfo.isMcp && mcpInfo.service ? - : (() => { - const n = toolName.toLowerCase(); - if (n.includes('search') || n === 'grep' || n === 'glob') - return ; - return ; - })() - } - - {mcpInfo.isMcp ? mcpInfo.displayName : toolName} - - {mcpInfo.isMcp && ( - - {mcpInfo.serverSlug} - - )} - {inputSummary && !isStreaming && ( - - {inputSummary} - - )} + : (() => { const n = toolName.toLowerCase(); if (n.includes('search') || n === 'grep' || n === 'glob') return ; return ; })()} + {mcpInfo.isMcp ? mcpInfo.displayName : toolName} + {mcpInfo.isMcp && {mcpInfo.serverSlug}} + {inputSummary && !isStreaming && {inputSummary}} {!inputSummary && } {isStreaming && } - {isDenied && ( - - denied - + denied )} {result && !isDenied && ( - {isError ? ( - - ) : ( - - )} - - {resultSummary} - - {resultElapsedMs != null && ( - - {formatElapsed(resultElapsedMs)} - - )} + {isError ? : } + {resultSummary} + {resultElapsedMs != null && {formatElapsed(resultElapsedMs)}} )} {showTimer && } - {!isStreaming && ( - {showBody ? ( - - ) : ( - - )} + {showBody ? : } )} - - {/* Unified terminal body */} - - {/* Prompt + command */} -
-                
-                  {promptPrefix}
-                
-                {isStreaming ? (
-                  {call.content?.input ?? ''}
-                ) : (
-                  colorizeInput(toolName, formattedInput, tc)
-                )}
-                {isStreaming && (
-                  
-                )}
+            
+              
+                {promptPrefix}
+                {isStreaming ? {call.content?.input ?? ''} : colorizeInput(toolName, formattedInput, tc)}
+                {isStreaming && }
               
- - {/* Browser agent inline feed */} - {isBrowserAgent && sessionId && ( - - )} - - {/* Output */} + {isBrowserAgent && sessionId && } {parsedResult && parsedResult.type === 'mcp' ? ( ) : parsedResult ? ( -
+                
                   {parsedResult.type === 'bash' ? (
                     <>
-                      {parsedResult.stdout.trim() &&
-                        colorizeOutput(toolName, parsedResult.stdout, tc)}
-                      {parsedResult.stderr.trim() && (
-                        <>
-                          {parsedResult.stdout.trim() && '\n'}
-                          {parsedResult.stderr}
-                        
-                      )}
-                      {!parsedResult.stdout.trim() && !parsedResult.stderr.trim() && (
-                        (no output)
-                      )}
+                      {parsedResult.stdout.trim() && colorizeOutput(toolName, parsedResult.stdout, tc)}
+                      {parsedResult.stderr.trim() && <>{parsedResult.stdout.trim() && '\n'}{parsedResult.stderr}}
+                      {!parsedResult.stdout.trim() && !parsedResult.stderr.trim() && (no output)}
                     
                   ) : (
-                    <>
-                      {parsedResult.isError ? (
-                        {parsedResult.content || '(empty)'}
-                      ) : (
-                        colorizeOutput(toolName, parsedResult.content, tc)
-                      )}
-                    
+                    <>{parsedResult.isError ? {parsedResult.content || '(empty)'} : colorizeOutput(toolName, parsedResult.content, tc)}
                   )}
                 
) : null} - - {/* Pending indicator when waiting for result (skip for browser agent — feed replaces it) */} {!parsedResult && isPending && !isStreaming && !isBrowserAgent && ( - - - + )} diff --git a/frontend/src/app/pages/AgentChat/ToolPreview.tsx b/frontend/src/app/pages/AgentChat/ToolPreview.tsx new file mode 100644 index 00000000..5fd82c33 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ToolPreview.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import DescriptionIcon from '@mui/icons-material/Description'; +import EditIcon from '@mui/icons-material/Edit'; +import SearchIcon from '@mui/icons-material/Search'; +import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer'; +import BuildIcon from '@mui/icons-material/Build'; +import { ApprovalRequest } from '@/shared/state/agentsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +export function getToolIcon(toolName: string) { + switch (toolName) { + case 'Bash': return ; + case 'Read': return ; + case 'Write': case 'Edit': return ; + case 'Grep': case 'Glob': return ; + case 'AskUserQuestion': return ; + default: return ; + } +} + +interface ToolPreviewProps { + request: ApprovalRequest; + tokens: ReturnType; +} + +export const CodeBlock: React.FC<{ tokens: ReturnType; children: React.ReactNode }> = ({ tokens: c, children }) => ( + + {children} + +); + +const ToolPreview: React.FC = ({ request, tokens: c }) => { + const { tool_name, tool_input } = request; + + switch (tool_name) { + case 'Bash': { + return ( + + {tool_input.description && ( + + {tool_input.description} + + )} + {tool_input.command || '(empty command)'} + + ); + } + + case 'Read': + return ( + + + + {tool_input.file_path || tool_input.path || JSON.stringify(tool_input)} + + + ); + + case 'Write': + case 'Edit': { + const path = tool_input.file_path || tool_input.path || ''; + const content = tool_input.content || tool_input.new_content || tool_input.old_string; + return ( + + + + + {path} + + + {content && {typeof content === 'string' ? content : JSON.stringify(content, null, 2)}} + + ); + } + + case 'Grep': + case 'Glob': { + const pattern = tool_input.pattern || tool_input.glob_pattern || tool_input.query || ''; + const path = tool_input.path || tool_input.directory || ''; + return ( + + + + {path && ( + + in {path} + + )} + + + ); + } + + case 'AskUserQuestion': + return null; + + default: { + const preview = tool_input.command || tool_input.file_path || tool_input.path || tool_input.query || null; + if (preview) { + return {preview}; + } + return {JSON.stringify(tool_input, null, 2)}; + } + } +}; + +export default ToolPreview; diff --git a/frontend/src/app/pages/AgentChat/UserBubbleContent.tsx b/frontend/src/app/pages/AgentChat/UserBubbleContent.tsx new file mode 100644 index 00000000..7b757a6d --- /dev/null +++ b/frontend/src/app/pages/AgentChat/UserBubbleContent.tsx @@ -0,0 +1,152 @@ +import React, { useState, useEffect } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined'; +import { AgentMessage } from '@/shared/state/agentsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { SKILL_COLOR } from '@/app/components/richEditorUtils'; +import { ParsedElement, SKILL_PILL_RE } from './messageBubbleUtils'; +import AttachedContextSection from './AttachedContextSection'; +import MessageImageThumbnails from './MessageImageThumbnails'; + +function renderUserTextWithPills(text: string, c: ReturnType): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + const re = new RegExp(SKILL_PILL_RE.source, 'g'); + while ((match = re.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + const skillName = match[1]; + parts.push( + } + label={skillName} + size="small" + sx={{ + bgcolor: `${SKILL_COLOR}18`, + color: SKILL_COLOR, + fontSize: '0.72rem', + fontFamily: c.font.mono, + height: 20, + mx: 0.25, + verticalAlign: 'baseline', + '& .MuiChip-icon': { color: SKILL_COLOR }, + }} + />, + ); + lastIndex = re.lastIndex; + } + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + return parts; +} + +interface Props { + message: AgentMessage; + displayText: string; + rawText: string; + selectedElements: ParsedElement[]; + editing: boolean; + onSaveEdit?: (messageId: string, newContent: string) => void; + onCancelEdit?: () => void; +} + +const UserBubbleContent: React.FC = ({ + message, displayText, rawText, selectedElements, editing, onSaveEdit, onCancelEdit, +}) => { + const c = useClaudeTokens(); + const [editText, setEditText] = useState(''); + + useEffect(() => { + if (editing) setEditText(rawText); + }, [editing, rawText]); + + const handleCancelEdit = () => { + setEditText(''); + onCancelEdit?.(); + }; + + const handleSaveEdit = () => { + const trimmed = editText.trim(); + if (trimmed && trimmed !== rawText && onSaveEdit) { + onSaveEdit(message.id, trimmed); + } + setEditText(''); + onCancelEdit?.(); + }; + + if (editing) { + return ( + + setEditText(e.target.value)} + variant="outlined" + size="small" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSaveEdit(); + } + if (e.key === 'Escape') handleCancelEdit(); + }} + sx={{ + '& .MuiOutlinedInput-root': { + color: c.text.primary, + fontSize: '0.875rem', + '& fieldset': { borderColor: c.border.strong }, + '&:hover fieldset': { borderColor: c.text.tertiary }, + '&.Mui-focused fieldset': { borderColor: c.accent.primary }, + }, + }} + /> + + + + + + ); + } + + return ( + + {message.images && message.images.length > 0 && ( + + )} + + {renderUserTextWithPills(displayText, c)} + + + + ); +}; + +export default UserBubbleContent; diff --git a/frontend/src/app/pages/AgentChat/ViewBubble.tsx b/frontend/src/app/pages/AgentChat/ViewBubble.tsx index ec7b2062..90aab881 100644 --- a/frontend/src/app/pages/AgentChat/ViewBubble.tsx +++ b/frontend/src/app/pages/AgentChat/ViewBubble.tsx @@ -3,16 +3,14 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import Collapse from '@mui/material/Collapse'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; import Icon from '@mui/material/Icon'; import OpenInFullIcon from '@mui/icons-material/OpenInFull'; -import CloseIcon from '@mui/icons-material/Close'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { useAppSelector } from '@/shared/hooks'; import { SERVE_BASE } from '@/shared/state/outputsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import ViewPreview from '../Views/ViewPreview'; +import { StreamingPlaceholder, ViewBubbleDialog } from './ViewBubbleParts'; interface Props { toolInput: Record; @@ -47,46 +45,11 @@ const ViewBubble: React.FC = ({ toolInput, toolResult, isStreaming }) => if (isStreaming && !hasPreview) { return ( - - - {outputIcon} - - {outputName} - - - - Rendering… - - - + ); } @@ -193,13 +156,7 @@ const ViewBubble: React.FC = ({ toolInput, toolResult, isStreaming }) => {/* Preview */} {hasPreview && ( - + = ({ toolInput, toolResult, isStreaming }) => - {/* Fullscreen dialog */} - setExpanded(false)} - maxWidth="lg" - fullWidth - PaperProps={{ - sx: { - height: '85vh', - display: 'flex', - flexDirection: 'column', - borderRadius: '12px', - overflow: 'hidden', - }, - }} - > - - {outputIcon} - {outputName} - setExpanded(false)} size="small" sx={{ color: c.text.tertiary }}> - - - - - - - + outputColor={outputColor} + outputIcon={outputIcon} + outputName={outputName} + serveUrl={serveUrl} + frontendCode={frontendCode} + inputData={inputData} + backendResult={backendResult} + /> ); }; diff --git a/frontend/src/app/pages/AgentChat/ViewBubbleParts.tsx b/frontend/src/app/pages/AgentChat/ViewBubbleParts.tsx new file mode 100644 index 00000000..73bb0bce --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ViewBubbleParts.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import Icon from '@mui/material/Icon'; +import CloseIcon from '@mui/icons-material/Close'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import ViewPreview from '../Views/ViewPreview'; + +interface StreamingPlaceholderProps { + outputColor: string; + outputIcon: string; + outputName: string; +} + +export const StreamingPlaceholder: React.FC = ({ + outputColor, outputIcon, outputName, +}) => { + const c = useClaudeTokens(); + return ( + + + {outputIcon} + + {outputName} + + + + Rendering… + + + + ); +}; + +interface ViewBubbleDialogProps { + expanded: boolean; + onClose: () => void; + outputColor: string; + outputIcon: string; + outputName: string; + serveUrl?: string; + frontendCode: string; + inputData: Record; + backendResult: any; +} + +export const ViewBubbleDialog: React.FC = ({ + expanded, onClose, outputColor, outputIcon, outputName, + serveUrl, frontendCode, inputData, backendResult, +}) => { + const c = useClaudeTokens(); + return ( + + + {outputIcon} + {outputName} + + + + + + + + + ); +}; diff --git a/frontend/src/app/pages/AgentChat/approvalUtils.tsx b/frontend/src/app/pages/AgentChat/approvalUtils.tsx new file mode 100644 index 00000000..a53dbe0e --- /dev/null +++ b/frontend/src/app/pages/AgentChat/approvalUtils.tsx @@ -0,0 +1,139 @@ +import React, { useMemo } from 'react'; +import { useAppSelector } from '@/shared/hooks'; +import { ToolDefinition } from '@/shared/state/toolsSlice'; + +export interface IntegrationMeta { + label: string; + color: string; + icon: React.ReactNode; +} + +const GoogleIcon = ( + + + + + + +); + +const RedditIcon = ( + + + + +); + +export const INTEGRATION_META: Record = { + 'Google Workspace': { label: 'Google Workspace', color: '#4285F4', icon: GoogleIcon }, + 'xbird': { label: 'X / Twitter', color: '#1DA1F2', icon: 𝕏 }, + 'Reddit': { label: 'Reddit', color: '#FF4500', icon: RedditIcon }, +}; + +export interface ParsedTool { + isMcp: boolean; + serverSlug: string; + actionName: string; + displayName: string; +} + +export function parseMcpToolName(rawName: string): ParsedTool { + const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/); + if (!m) { + return { isMcp: false, serverSlug: '', actionName: rawName, displayName: rawName }; + } + const serverSlug = m[1]; + const actionName = m[2]; + const displayName = actionName + .replace(/_/g, ' ') + .replace(/\b\w/g, (ch) => ch.toUpperCase()); + return { isMcp: true, serverSlug, actionName, displayName }; +} + +export function sanitizeServerName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); +} + +export interface McpToolMeta { + integration: IntegrationMeta | null; + description: string; + serverLabel: string; +} + +export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta { + const toolItems = useAppSelector((s) => s.tools.items); + + return useMemo(() => { + if (!parsed.isMcp) { + return { integration: null, description: '', serverLabel: '' }; + } + + const toolDef: ToolDefinition | undefined = Object.values(toolItems).find( + (t) => t.mcp_config && Object.keys(t.mcp_config).length > 0 && sanitizeServerName(t.name) === parsed.serverSlug + ); + + if (!toolDef) { + return { integration: null, description: '', serverLabel: parsed.serverSlug }; + } + + const description = toolDef.tool_permissions?._tool_descriptions?.[parsed.actionName] || ''; + const integration = INTEGRATION_META[toolDef.name] || null; + const serverLabel = toolDef.name; + + return { integration, description, serverLabel }; + }, [parsed, toolItems]); +} + +export function getMcpInputSummary(actionName: string, toolInput: Record): string { + const lower = actionName.toLowerCase(); + + if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) { + const query = toolInput.query || toolInput.search_query || toolInput.q || ''; + const to = toolInput.to || toolInput.recipient || ''; + const subject = toolInput.subject || ''; + if (query) return `Search: "${query}"`; + if (to && subject) return `To ${to} — ${subject}`; + if (to) return `To ${to}`; + if (subject) return `Subject: ${subject}`; + } + + if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) { + const summary = toolInput.summary || toolInput.title || toolInput.event_name || ''; + const start = toolInput.start || toolInput.start_time || toolInput.date || ''; + if (summary && start) return `${summary} — ${start}`; + if (summary) return summary; + if (start) return `Date: ${start}`; + } + + if (lower.includes('drive') || lower.includes('doc') || lower.includes('sheet') || lower.includes('slide')) { + const name = toolInput.name || toolInput.title || toolInput.filename || toolInput.file_name || ''; + const query = toolInput.query || toolInput.q || ''; + if (name) return name; + if (query) return `Search: "${query}"`; + } + + if (lower.includes('tweet') || lower.includes('post') || lower.includes('send') || lower.includes('reply')) { + const text = toolInput.text || toolInput.content || toolInput.body || toolInput.message || ''; + if (text) return text.length > 80 ? text.slice(0, 77) + '...' : text; + } + + if (lower.includes('search') || lower.includes('find') || lower.includes('query') || lower.includes('list')) { + const query = toolInput.query || toolInput.q || toolInput.search_query || toolInput.keyword || toolInput.term || ''; + if (query) return `"${query}"`; + } + + const stringVals: string[] = []; + for (const [key, val] of Object.entries(toolInput)) { + if (key.startsWith('_')) continue; + if (typeof val === 'string' && val.trim()) { + stringVals.push(val.trim()); + } + if (stringVals.length >= 2) break; + } + if (stringVals.length > 0) { + const joined = stringVals.join(' -- '); + return joined.length > 100 ? joined.slice(0, 97) + '...' : joined; + } + + return ''; +} diff --git a/frontend/src/app/pages/AgentChat/browserFeedUtils.ts b/frontend/src/app/pages/AgentChat/browserFeedUtils.ts new file mode 100644 index 00000000..6e5ff83d --- /dev/null +++ b/frontend/src/app/pages/AgentChat/browserFeedUtils.ts @@ -0,0 +1,129 @@ +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import TouchAppOutlinedIcon from '@mui/icons-material/TouchAppOutlined'; +import KeyboardOutlinedIcon from '@mui/icons-material/KeyboardOutlined'; +import CameraAltOutlinedIcon from '@mui/icons-material/CameraAltOutlined'; +import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined'; +import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined'; +import CodeOutlinedIcon from '@mui/icons-material/CodeOutlined'; +import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; +import type { AgentMessage } from '@/shared/state/agentsSlice'; + +export interface FeedEntry { + type: 'thought' | 'action' | 'result' | 'system'; + text: string; + actionTool?: string; + sessionLabel?: string; +} + +export interface FeedColors { + thought: string; + thoughtIcon: string; + result: string; + error: string; + errorIcon: string; + scrollThumb: string; +} + +export const darkFeedColors: FeedColors = { + thought: '#a0aab8', + thoughtIcon: '#555b6e', + result: '#555b6e', + error: '#ff8787', + errorIcon: '#ff8787', + scrollThumb: '#2a2d3e', +}; + +export const lightFeedColors: FeedColors = { + thought: '#555550', + thoughtIcon: '#9e9c95', + result: '#9e9c95', + error: '#c03030', + errorIcon: '#c03030', + scrollThumb: '#ccc9c0', +}; + +export function formatMessage(msg: AgentMessage): FeedEntry | null { + if (msg.role === 'user') return null; + + if (msg.role === 'assistant' && typeof msg.content === 'string') { + const trimmed = msg.content.trim(); + if (!trimmed) return null; + return { type: 'thought', text: trimmed }; + } + + if (msg.role === 'tool_call') { + const content = + typeof msg.content === 'string' + ? (() => { try { return JSON.parse(msg.content); } catch { return {}; } })() + : msg.content; + const tool = content?.tool || content?.name || '?'; + const input = content?.input || {}; + let brief = ''; + switch (tool) { + case 'BrowserNavigate': + brief = `Navigate → ${input.url || '...'}`; + break; + case 'BrowserClick': + brief = `Click ${input.selector || '...'}`; + break; + case 'BrowserType': { + const txt = (input.text || '').slice(0, 40); + const ellipsis = (input.text || '').length > 40 ? '…' : ''; + brief = `Type "${txt}${ellipsis}" into ${input.selector || '...'}`; + break; + } + case 'BrowserScreenshot': + brief = 'Screenshot'; + break; + case 'BrowserGetText': + brief = 'Read page text'; + break; + case 'BrowserGetElements': + brief = `Inspect elements${input.selector ? ` (${input.selector})` : ''}`; + break; + case 'BrowserEvaluate': + brief = `Evaluate JS`; + break; + default: + brief = `${tool}(${JSON.stringify(input).slice(0, 60)})`; + } + return { type: 'action', text: brief, actionTool: tool }; + } + + if (msg.role === 'tool_result') { + const content = + typeof msg.content === 'string' + ? (() => { try { return JSON.parse(msg.content); } catch { return { text: msg.content }; } })() + : msg.content; + const toolName = content?.tool_name || ''; + const elapsed = content?.elapsed_ms; + const text = content?.text || ''; + + if (toolName === 'BrowserScreenshot') { + return { type: 'result', text: `Screenshot captured${elapsed ? ` (${elapsed}ms)` : ''}` }; + } + const preview = text.length > 120 ? text.slice(0, 120) + '…' : text; + return { type: 'result', text: `${preview}${elapsed ? ` (${elapsed}ms)` : ''}` }; + } + + if (msg.role === 'system') { + return { type: 'system', text: typeof msg.content === 'string' ? msg.content : '' }; + } + + return null; +} + +export type SvgIconComponent = typeof OpenInNewIcon; + +export function getActionIcon(tool?: string): SvgIconComponent { + switch (tool) { + case 'BrowserNavigate': return OpenInNewIcon; + case 'BrowserClick': return TouchAppOutlinedIcon; + case 'BrowserType': return KeyboardOutlinedIcon; + case 'BrowserScreenshot': return CameraAltOutlinedIcon; + case 'BrowserGetText': return ArticleOutlinedIcon; + case 'BrowserGetElements': return AccountTreeOutlinedIcon; + case 'BrowserEvaluate': return CodeOutlinedIcon; + default: return BuildOutlinedIcon; + } +} diff --git a/frontend/src/app/pages/AgentChat/hooks/useAgentChat.ts b/frontend/src/app/pages/AgentChat/hooks/useAgentChat.ts new file mode 100644 index 00000000..675fae77 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/hooks/useAgentChat.ts @@ -0,0 +1,222 @@ +import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { + sendMessage as sendMessageThunk, + launchAndSendFirstMessage, + generateTitle, + stopAgent, + handleApproval, + editMessage, + updateSessionModel, + updateSessionMode, + fetchSession, +} from '@/shared/state/agentsSlice'; +import { fetchModes } from '@/shared/state/modesSlice'; +import { createSessionWs } from '@/shared/ws/WebSocketManager'; +import type { ChatInputHandle } from '../ChatInput'; +import type { ContextPath } from '@/app/components/DirectoryBrowser'; +import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice'; + +export interface QueuedMessage { + prompt: string; + images?: Array<{ data: string; media_type: string }>; + contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>; + forcedTools?: string[]; + attachedSkills?: Array<{ id: string; name: string; content: string }>; + selectedBrowserIds?: string[]; +} + +interface UseAgentChatParams { + sessionId?: string; + initialContextPaths?: ContextPath[]; +} + +export function useAgentChat({ sessionId: sessionIdProp, initialContextPaths }: UseAgentChatParams) { + const { id: routeId } = useParams<{ id: string }>(); + const id = sessionIdProp || routeId; + const dispatch = useAppDispatch(); + const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined)); + const modesMap = useAppSelector((state) => state.modes.items); + const scrollContainerRef = useRef(null); + const chatInputRef = useRef(null); + const isAtBottomRef = useRef(true); + const [showScrollButton, setShowScrollButton] = useState(false); + const [showResumeBubble, setShowResumeBubble] = useState(false); + const [awaitingResponse, setAwaitingResponse] = useState(false); + const [mode, setMode] = useState('agent'); + const [model, setModel] = useState('sonnet'); + const wsRef = useRef | null>(null); + const initialContextApplied = useRef(false); + const messageQueueRef = useRef([]); + const [queueLength, setQueueLength] = useState(0); + const [editingMessageId, setEditingMessageId] = useState(null); + + const isDraft = session?.status === 'draft'; + + useEffect(() => { + if (!id || isDraft) return; + const ws = createSessionWs(id); + ws.connect(); + wsRef.current = ws; + dispatch(fetchSession(id)); + return () => { ws.disconnect(); wsRef.current = null; }; + }, [id, isDraft, dispatch]); + + useEffect(() => { + if (initialContextApplied.current || !initialContextPaths?.length) return; + const timer = setTimeout(() => { + chatInputRef.current?.setContent('', initialContextPaths); + initialContextApplied.current = true; + }, 50); + return () => clearTimeout(timer); + }, [initialContextPaths]); + + useEffect(() => { if (session) setMode(session.mode); }, [session?.mode]); + useEffect(() => { if (session) setModel(session.model); }, [session?.model]); + useEffect(() => { if (Object.keys(modesMap).length === 0) dispatch(fetchModes()); }, [dispatch, modesMap]); + + const dispatchMessage = useCallback((msg: QueuedMessage) => { + if (!id) return; + setShowResumeBubble(false); + setAwaitingResponse(true); + if (isDraft) { + const config: Record = { model, mode }; + if (session?.system_prompt) config.system_prompt = session.system_prompt; + if (session?.target_directory) config.target_directory = session.target_directory; + dispatch( + launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }) + ).then((action) => { + if (launchAndSendFirstMessage.fulfilled.match(action)) { + const realId = action.payload.session.id; + dispatch(generateTitle({ sessionId: realId, prompt: msg.prompt })); + if (msg.selectedBrowserIds?.length) { + dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: realId, label: 'Use Browser' })); + } + } + }); + } else { + if (msg.selectedBrowserIds?.length) { + dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' })); + } + dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds })) + .then((action) => { if (sendMessageThunk.rejected.match(action)) setAwaitingResponse(false); }); + } + }, [id, isDraft, mode, model, session?.system_prompt, session?.target_directory, dispatch]); + + const agentBusy = awaitingResponse || (!isDraft && (session?.status === 'running' || session?.status === 'waiting_approval')); + + const prevStatusRef = useRef(session?.status); + useEffect(() => { + const prev = prevStatusRef.current; + const curr = session?.status; + prevStatusRef.current = curr; + let didDispatchQueued = false; + const wasActive = prev === 'running' || prev === 'waiting_approval'; + const isTerminal = curr === 'completed' || curr === 'stopped' || curr === 'error'; + if (wasActive && isTerminal) { + if (id) { + dispatch(fadeGlowingBrowserCards(id)); + setTimeout(() => dispatch(clearGlowingBrowserCards(id)), 2800); + } + const nextQueued = messageQueueRef.current.shift(); + if (nextQueued) { + setQueueLength(messageQueueRef.current.length); + dispatchMessage(nextQueued); + didDispatchQueued = true; + } else if (curr === 'stopped') { + setShowResumeBubble(true); + } + const currentMode = modesMap[mode]; + if (currentMode?.default_next_mode && modesMap[currentMode.default_next_mode]) { + setMode(currentMode.default_next_mode); + if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: currentMode.default_next_mode as any })); + } + } + if (curr === 'running') setShowResumeBubble(false); + if (curr !== 'draft' && !didDispatchQueued) setAwaitingResponse(false); + }, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]); + + const SCROLL_THRESHOLD = 50; + const handleScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; + isAtBottomRef.current = atBottom; + setShowScrollButton(!atBottom); + }, []); + + const scrollToBottom = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + isAtBottomRef.current = true; + setShowScrollButton(false); + }, []); + + useLayoutEffect(() => { + if (isAtBottomRef.current) { + const el = scrollContainerRef.current; + if (el) el.scrollTop = el.scrollHeight; + } + }, [session?.messages.length, session?.streamingMessage?.content]); + + const handleSend = (prompt: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>, forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => { + if (!id) return; + const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }; + if (agentBusy) { + messageQueueRef.current.push(msg); + setQueueLength(messageQueueRef.current.length); + return; + } + dispatchMessage(msg); + }; + + const handleModeChange = useCallback((newMode: string) => { + setMode(newMode); + if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: newMode })); + }, [id, isDraft, dispatch]); + + const handleModelChange = useCallback((newModel: string) => { + setModel(newModel); + if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel })); + }, [id, isDraft, dispatch]); + + const handleApprove = (requestId: string, updatedInput?: Record) => { + dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput })); + }; + const handleDeny = (requestId: string, message?: string) => { + dispatch(handleApproval({ requestId, behavior: 'deny', message })); + }; + const handleStop = () => { if (id) dispatch(stopAgent({ sessionId: id })); }; + + const handleResume = useCallback(() => { + if (!id) return; + setShowResumeBubble(false); + dispatch(sendMessageThunk({ + sessionId: id, + prompt: "Continue where you left off. Start you're response EXACTLY with 'Sorry, let me pick up where I left off", + mode, model, hidden: true, + })); + }, [id, mode, model, dispatch]); + + const handleSaveEdit = useCallback( + (messageId: string, newContent: string) => { + if (!id) return; + dispatch(editMessage({ sessionId: id, messageId, content: newContent })); + setEditingMessageId(null); + }, [id, dispatch] + ); + const handleCancelEdit = useCallback(() => { setEditingMessageId(null); }, []); + + return { + id, session, isDraft, dispatch, mode, model, + scrollContainerRef, chatInputRef, messageQueueRef, + showScrollButton, showResumeBubble, awaitingResponse, editingMessageId, + queueLength, setQueueLength, agentBusy, + handleScroll, scrollToBottom, handleSend, + handleModeChange, handleModelChange, + handleApprove, handleDeny, handleStop, handleResume, + handleSaveEdit, handleCancelEdit, setEditingMessageId, + }; +} diff --git a/frontend/src/app/pages/AgentChat/hooks/useChatSubmit.ts b/frontend/src/app/pages/AgentChat/hooks/useChatSubmit.ts new file mode 100644 index 00000000..02498089 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/hooks/useChatSubmit.ts @@ -0,0 +1,248 @@ +import React, { useCallback } from 'react'; +import type { CommandPickerItem } from '@/app/components/CommandPicker'; +import { useElementSelection, type SelectedElement } from '@/app/components/ElementSelectionContext'; +import { getClipboardCards, clearClipboard } from '@/shared/dashboardClipboard'; +import { getWebview } from '@/shared/browserRegistry'; +import { API_BASE } from '@/shared/config'; +import type { ContextPath } from '@/app/components/DirectoryBrowser'; +import { + SKILL_PILL_ATTR, type AttachedSkill, createSkillPillElement, + serializeEditorContent, type TriggerState, detectEditorTrigger, +} from '@/app/components/richEditorUtils'; +import type { PromptTemplate } from '@/shared/state/templatesSlice'; +import type { AttachedImage } from '../ImageAttachments'; +import type { ForcedToolGroup } from '../AttachmentChips'; + +export interface ChatSubmitParams { + editorRef: React.RefObject; attachedSkillsRef: React.MutableRefObject>; + generalFileInputRef: React.RefObject; + disabled?: boolean; autoRunMode?: boolean; images: AttachedImage[]; contextPaths: ContextPath[]; + forcedTools: ForcedToolGroup[]; picker: TriggerState; templates: Record; + skills: Record; ownerId: string; + elementSelection: ReturnType; + onSend: (msg: string, imgs?: Array<{ data: string; media_type: string }>, ctx?: ContextPath[], tools?: string[], skills?: Array<{ id: string; name: string; content: string }>, browserIds?: string[]) => void; + onModeChange: (mode: string) => void; + setImages: React.Dispatch>; setContextPaths: React.Dispatch>; + setForcedTools: React.Dispatch>; setPicker: React.Dispatch>; + setHasContent: React.Dispatch>; setAttachedSkills: React.Dispatch>>; + setIsUploading: React.Dispatch>; setIsDragOver: React.Dispatch>; + setSelectedTemplate: React.Dispatch>; + c: { font: { mono: string }; status: { error: string } }; +} + +export function useChatSubmit(p: ChatSubmitParams) { + const { + editorRef, attachedSkillsRef, generalFileInputRef, disabled, autoRunMode, + images, contextPaths, forcedTools, picker, templates, skills, ownerId, + elementSelection, onSend, onModeChange, setImages, setContextPaths, + setForcedTools, setPicker, setHasContent, setAttachedSkills, + setIsUploading, setIsDragOver, setSelectedTemplate, c, + } = p; + const updateHasContent = useCallback(() => { + const editor = editorRef.current; + if (!editor) return; + const text = (editor.textContent || '').replace(/\u200B/g, ''); + setHasContent(text.trim().length > 0 || editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null); + }, []); + const syncAttachedSkills = useCallback(() => { + const editor = editorRef.current; + if (!editor) return; + const pillIds = new Set( + Array.from(editor.querySelectorAll(`[${SKILL_PILL_ATTR}]`)) + .map((el) => el.getAttribute(SKILL_PILL_ATTR)).filter(Boolean) as string[], + ); + setAttachedSkills((prev) => { + const prevKeys = Object.keys(prev); + if (prevKeys.length === pillIds.size && prevKeys.every((k) => pillIds.has(k))) return prev; + const next: Record = {}; + for (const [id, skill] of Object.entries(prev)) { if (pillIds.has(id)) next[id] = skill; } + return next; + }); + }, []); + const removeSkillPill = useCallback((skillId: string) => { + const editor = editorRef.current; + if (!editor) return; + const pill = editor.querySelector(`[${SKILL_PILL_ATTR}="${skillId}"]`); + if (pill) pill.remove(); + setAttachedSkills((prev) => { const { [skillId]: _, ...rest } = prev; return rest; }); + const text = (editor.textContent || '').replace(/\u200B/g, ''); + setHasContent(text.trim().length > 0 || editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null); + editor.focus(); + }, []); + const detectTrigger = useCallback(() => { const r = detectEditorTrigger(); r ? setPicker(r) : setPicker((prev) => ({ ...prev, visible: false })); }, []); + const handleInput = useCallback(() => { updateHasContent(); detectTrigger(); syncAttachedSkills(); }, [updateHasContent, detectTrigger, syncAttachedSkills]); + const handleEditorClick = useCallback(() => { detectTrigger(); }, [detectTrigger]); + const addImageFiles = useCallback((files: FileList | File[]) => { + Array.from(files).forEach((file) => { + if (!file.type.startsWith('image/')) return; + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + setImages((prev) => [...prev, { data: result.split(',')[1], media_type: file.type, preview: result }]); + }; + reader.readAsDataURL(file); + }); + }, []); + const uploadAndAttachFiles = useCallback(async (files: File[]) => { + if (files.length === 0) return; + setIsUploading(true); + try { + const formData = new FormData(); + files.forEach((f) => formData.append('files', f)); + const resp = await fetch(`${API_BASE}/settings/upload-files`, { method: 'POST', body: formData }); + if (!resp.ok) throw new Error('Upload failed'); + const data = await resp.json(); + const newPaths: ContextPath[] = (data.files || []).map((f: { path: string }) => ({ path: f.path, type: 'file' as const })); + setContextPaths((prev) => [...prev, ...newPaths]); + } catch (err) { console.error('File upload failed:', err); } + finally { setIsUploading(false); } + }, []); + const handleSend = useCallback(async () => { + const editor = editorRef.current; + if (!editor || disabled) return; + let trimmed = serializeEditorContent(editor, attachedSkillsRef.current).trim(); + if (!trimmed) return; + const selectedEls = elementSelection?.elementsByOwner?.[ownerId] ?? []; + let allImages = images.length > 0 ? images.map(({ data, media_type }) => ({ data, media_type })) : []; + if (selectedEls.length > 0) { + const lines: string[] = ['\n\n---\nSelected UI Elements:\n']; + for (let i = 0; i < selectedEls.length; i++) { + const el = selectedEls[i]; + if (el.semanticType === 'browser-card' && el.semanticData?.selectId) { + const wv = getWebview(el.semanticData.selectId as string); + const url = wv ? (el.semanticData.url || wv.getURL()) : (el.semanticData.url || ''); + const title = wv ? (el.semanticData.name || wv.getTitle()) : (el.semanticLabel || ''); + lines.push(`${i + 1}. [Browser Card] ${title}`, ` browser_id: ${el.semanticData.selectId}`); + if (url) lines.push(` URL: ${url}`); + lines.push(' (Use BrowserAgent with this browser_id to interact with it, or CreateBrowserAgent for a new browser)'); + } else if (el.semanticType && el.semanticData) { + const typeLabel = { 'agent-card': 'Agent Card', message: 'Message', 'tool-call': 'Tool Call', 'tool-group': 'Tool Group', 'view-card': 'App Card', 'browser-card': 'Browser Card', 'dom-element': 'Element' }[el.semanticType] || el.semanticType; + lines.push(`${i + 1}. [${typeLabel}] ${el.semanticLabel || ''}`); + const { selectId, ...rest } = el.semanticData; + if (selectId) lines.push(` ID: ${selectId}`); + const metaStr = Object.entries(rest).filter(([, v]) => v != null).map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', '); + if (metaStr) lines.push(` ${metaStr}`); + if (el.semanticType === 'agent-card' && selectId) lines.push(` (Use InvokeAgent with session_id "${selectId}" to query this agent with full conversation context)`); + } else { + const styleStr = Object.entries(el.computedStyles).map(([k, v]) => `${k}: ${v}`).join('; '); + lines.push(`${i + 1}. \`${el.selectorPath}\` (${el.tagName.toLowerCase()})`, ` Selector: ${el.selectorPath}`); + lines.push(` HTML: ${el.outerHTML.length > 500 ? el.outerHTML.slice(0, 500) + '...' : el.outerHTML}`); + if (styleStr) lines.push(` Key styles: ${styleStr}`); + } + lines.push(''); + if (el.screenshot) allImages.push({ data: el.screenshot.replace(/^data:image\/\w+;base64,/, ''), media_type: 'image/png' }); + } + trimmed += lines.join('\n'); + } + const allForcedToolNames = forcedTools.flatMap((ft) => ft.tools); + const currentSkills = Object.values(attachedSkillsRef.current); + const sendSkills = currentSkills.length > 0 ? currentSkills.map((s) => ({ id: s.id, name: s.name, content: s.content })) : undefined; + const browserIds = selectedEls.filter((el) => el.semanticType === 'browser-card' && el.semanticData?.selectId).map((el) => el.semanticData!.selectId as string); + onSend(trimmed, allImages.length > 0 ? allImages : undefined, contextPaths.length > 0 ? contextPaths : undefined, + allForcedToolNames.length > 0 ? allForcedToolNames : undefined, sendSkills, browserIds.length > 0 ? browserIds : undefined); + editor.innerHTML = ''; + setImages([]); setContextPaths([]); setForcedTools([]); setAttachedSkills({}); setHasContent(false); + elementSelection?.clearOwnerElements(ownerId); + }, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId]); + const handlePickerSelect = (item: CommandPickerItem) => { + setPicker((prev) => ({ ...prev, visible: false })); + const editor = editorRef.current; + if (!editor) return; + editor.focus(); + const { triggerNode, triggerOffset, filter } = picker; + if (triggerNode && triggerNode.parentNode && editor.contains(triggerNode)) { + const endOffset = Math.min(triggerOffset + 1 + filter.length, triggerNode.length); + const range = document.createRange(); + range.setStart(triggerNode, triggerOffset); + range.setEnd(triggerNode, endOffset); + range.deleteContents(); + const sel = window.getSelection(); + if (sel) { sel.removeAllRanges(); sel.addRange(range); } + } + if (item.type === 'template') { + const tmpl = templates[item.id]; + if (!tmpl) return; + if (tmpl.fields.length === 0) document.execCommand('insertText', false, tmpl.template); + else setSelectedTemplate(tmpl); + } else if (item.type === 'skill') { + const skill = skills[item.id]; + if (!skill || editor.querySelector(`[${SKILL_PILL_ATTR}="${skill.id}"]`)) return; + const pill = createSkillPillElement({ id: skill.id, name: skill.name, content: skill.content }, removeSkillPill, c.font.mono, c.status.error); + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + range.collapse(false); + range.insertNode(pill); + const spacer = document.createTextNode('\u200B'); + pill.after(spacer); + const newRange = document.createRange(); + newRange.setStartAfter(spacer); + newRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(newRange); + } + setAttachedSkills((prev) => ({ ...prev, [skill.id]: { id: skill.id, name: skill.name, content: skill.content } })); + } else if (item.type === 'mode') { + onModeChange(item.id); + } else if (item.type === 'context') { + if (item.command === 'file') generalFileInputRef.current?.click(); + else if (item.toolNames && item.toolNames.length > 0) setForcedTools((prev) => [...prev, { label: item.name, tools: item.toolNames!, icon: item.icon, iconKey: item.iconKey }]); + else document.execCommand('insertText', false, `@${item.command} `); + } + updateHasContent(); + setTimeout(() => editor.focus(), 0); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (picker.visible && ['ArrowDown', 'ArrowUp', 'Escape', 'Tab', 'Enter'].includes(e.key)) { e.preventDefault(); return; } + if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) { e.preventDefault(); return; } + if (e.key === 'Enter' && !e.shiftKey && !autoRunMode) { e.preventDefault(); handleSend(); } + }; + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const copied = getClipboardCards(); + if (copied.length > 0 && elementSelection) { + e.preventDefault(); + for (const card of copied) { + const semanticTypeMap: Record = { agent: 'agent-card', view: 'view-card', browser: 'browser-card' }; + const semanticType = semanticTypeMap[card.type]; + if (!semanticType) continue; + const labelMap: Record = { 'agent-card': 'Agent', 'view-card': 'View', 'browser-card': 'Browser' }; + const el: SelectedElement = { + id: `sel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + selectorPath: `[data-select-type="${semanticType}"][data-select-id="${card.id}"]`, + tagName: 'DIV', className: '', outerHTML: '', computedStyles: {}, + boundingRect: { x: 0, y: 0, width: 0, height: 0 }, + semanticType, semanticLabel: (labelMap[semanticType] || semanticType) + ': ' + card.name, + semanticData: { ...card.meta, selectId: card.id }, + }; + elementSelection.addElementForOwner(ownerId, el); + } + clearClipboard(); + return; + } + const items = e.clipboardData?.items; + if (items) { + const imageFiles: File[] = []; + for (let i = 0; i < items.length; i++) { + if (items[i].type.startsWith('image/')) { const file = items[i].getAsFile(); if (file) imageFiles.push(file); } + } + if (imageFiles.length > 0) { e.preventDefault(); addImageFiles(imageFiles); return; } + } + e.preventDefault(); + const plain = e.clipboardData?.getData('text/plain'); + if (plain) document.execCommand('insertText', false, plain); + }, [addImageFiles, elementSelection, ownerId]); + const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) setIsDragOver(true); }, []); + const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); }, []); + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); e.stopPropagation(); setIsDragOver(false); + if (e.dataTransfer.files.length === 0) return; + const allFiles = Array.from(e.dataTransfer.files); + const imageFiles = allFiles.filter((f) => f.type.startsWith('image/')); + const otherFiles = allFiles.filter((f) => !f.type.startsWith('image/')); + if (imageFiles.length > 0) addImageFiles(imageFiles); + if (otherFiles.length > 0) uploadAndAttachFiles(otherFiles); + }, [addImageFiles, uploadAndAttachFiles]); + const removeImage = useCallback((idx: number) => setImages((prev) => prev.filter((_, i) => i !== idx)), []); + + return { handleSend, handlePickerSelect, handlePaste, handleKeyDown, handleInput, handleEditorClick, handleDragOver, handleDragLeave, handleDrop, addImageFiles, uploadAndAttachFiles, removeImage }; +} diff --git a/frontend/src/app/pages/AgentChat/hooks/useMessageRendering.ts b/frontend/src/app/pages/AgentChat/hooks/useMessageRendering.ts new file mode 100644 index 00000000..826e4450 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/hooks/useMessageRendering.ts @@ -0,0 +1,196 @@ +import { useMemo, useCallback, useEffect, useRef } from 'react'; +import { useAppDispatch } from '@/shared/hooks'; +import { type AgentMessage, generateGroupMeta } from '@/shared/state/agentsSlice'; +import type { ToolPair } from '../ToolCallBubble'; +import { type RenderItem, type ToolGroup, isToolGroup, isToolPair } from '../ToolGroupBubble'; +import { CONTEXT_WINDOWS } from '../ThinkingBubble'; + +function stringifyContent(content: any): string { + if (content == null) return ''; + return typeof content === 'string' ? content : JSON.stringify(content); +} + +export function useMessageRendering(session: any, model: string, id: string | undefined, isDraft: boolean) { + const dispatch = useAppDispatch(); + + const activeBranchMessages: AgentMessage[] = useMemo(() => { + if (!session) return []; + const branchId = session.active_branch_id || 'main'; + const branch = session.branches?.[branchId]; + if (!branch || !branch.fork_point_message_id) { + return session.messages.filter((m: AgentMessage) => m.branch_id === 'main' || m.branch_id === branchId); + } + const segments: Array<{ branchId: string; upToMessageId?: string }> = []; + let cur = branch; + let curId = branchId; + while (cur && cur.fork_point_message_id) { + segments.unshift({ branchId: curId, upToMessageId: cur.fork_point_message_id }); + curId = cur.parent_branch_id || 'main'; + cur = session.branches?.[curId]; + } + segments.unshift({ branchId: curId }); + const result: AgentMessage[] = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const nextForkMsgId = seg.upToMessageId; + if (nextForkMsgId) { + const forkIdx = session.messages.findIndex((m: AgentMessage) => m.id === nextForkMsgId); + result.push(...session.messages.slice(0, forkIdx).filter((m: AgentMessage) => m.branch_id === seg.branchId)); + } else if (i < segments.length - 1) { + const nextFork = segments[i + 1].upToMessageId; + const forkIdx = nextFork ? session.messages.findIndex((m: AgentMessage) => m.id === nextFork) : session.messages.length; + result.push(...session.messages.slice(0, forkIdx).filter((m: AgentMessage) => m.branch_id === seg.branchId)); + } else { + result.push(...session.messages.filter((m: AgentMessage) => m.branch_id === seg.branchId)); + } + } + const leafMsgs = session.messages.filter((m: AgentMessage) => m.branch_id === branchId); + if (!result.some((m: AgentMessage) => m.branch_id === branchId)) result.push(...leafMsgs); + return result; + }, [session?.messages, session?.active_branch_id, session?.branches]); + + const renderItems: RenderItem[] = useMemo(() => { + const isOutputCall = (m: AgentMessage) => + m.role === 'tool_call' && typeof m.content === 'object' && m.content.tool === 'RenderOutput'; + const isOutputResult = (m: AgentMessage) => { + if (m.role !== 'tool_result') return false; + try { + const parsed = typeof m.content === 'string' ? JSON.parse(m.content) : m.content; + return !!(parsed?.output_id && parsed?.frontend_code); + } catch { return false; } + }; + const items: RenderItem[] = []; + let i = 0; + while (i < activeBranchMessages.length) { + const msg = activeBranchMessages[i]; + if (msg.role === 'tool_call' || msg.role === 'tool_result') { + const group: AgentMessage[] = []; + while (i < activeBranchMessages.length && (activeBranchMessages[i].role === 'tool_call' || activeBranchMessages[i].role === 'tool_result')) { + group.push(activeBranchMessages[i]); + i++; + } + const regular: AgentMessage[] = []; + const outputItems: AgentMessage[] = []; + for (const m of group) { + if (isOutputCall(m) || isOutputResult(m)) { outputItems.push(m); continue; } + regular.push(m); + } + const calls = regular.filter((m) => m.role === 'tool_call'); + const results = regular.filter((m) => m.role === 'tool_result'); + const pairs: ToolPair[] = calls.map((call, idx) => ({ + type: 'tool_pair' as const, id: `pair-${call.id}`, call, result: results[idx] || null, + })); + const mcpServers = new Set( + calls.map((m) => { + const tool = typeof m.content === 'object' ? m.content.tool || '' : ''; + const match = tool.match(/^mcp__([^_]+(?:-[^_]+)*)__/); + return match ? match[1] : ''; + }).filter(Boolean) + ); + const allSameMcp = mcpServers.size === 1 && pairs.length > 0; + if (allSameMcp) { + const mcpServer = [...mcpServers][0]; + const toolNames = new Set(calls.map((m) => (typeof m.content === 'object' ? m.content.tool : ''))); + const label = toolNames.size === 1 ? calls[0].content?.tool || 'Tool calls' : `${calls.length} tool calls`; + items.push({ type: 'tool_group', id: `group-${group[0].id}`, pairs, label, callCount: calls.length, mcpServer } satisfies ToolGroup); + } else if (pairs.length <= 2) { + items.push(...pairs); + } else if (pairs.length > 0) { + const toolNames = new Set(calls.map((m) => (typeof m.content === 'object' ? m.content.tool : ''))); + const label = toolNames.size === 1 ? calls[0].content?.tool || 'Tool calls' : `${calls.length} tool calls`; + items.push({ type: 'tool_group', id: `group-${group[0].id}`, pairs, label, callCount: calls.length } satisfies ToolGroup); + } + items.push(...outputItems); + } else { + if (!msg.hidden) items.push(msg); + i++; + } + } + return items; + }, [activeBranchMessages]); + + const lastAssistantIdsInTurn = useMemo(() => { + const ids = new Set(); + let lastAssistantId: string | null = null; + for (const item of renderItems) { + if (!isToolGroup(item) && !isToolPair(item)) { + const msg = item as AgentMessage; + if (msg.role === 'assistant') lastAssistantId = msg.id; + else if (msg.role === 'user') { + if (lastAssistantId) ids.add(lastAssistantId); + lastAssistantId = null; + } + } + } + if (lastAssistantId) ids.add(lastAssistantId); + return ids; + }, [renderItems]); + + const groupMetaRequestedRef = useRef(new Set()); + const groupMetaRefinedRef = useRef(new Set()); + useEffect(() => { + if (!id || isDraft) return; + const toolGroups = renderItems.filter(isToolGroup) as ToolGroup[]; + const meta = session?.tool_group_meta ?? {}; + for (const group of toolGroups) { + const allDone = group.pairs.every((p) => p.result !== null); + if (!groupMetaRequestedRef.current.has(group.id) && !meta[group.id]) { + groupMetaRequestedRef.current.add(group.id); + const toolCalls = group.pairs.map((p) => { + const c = p.call.content; + const tool = typeof c === 'object' ? c.tool || '' : ''; + const input = typeof c === 'object' ? c.input : ''; + return { tool, input_summary: (typeof input === 'string' ? input : JSON.stringify(input)).slice(0, 120) }; + }); + dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls })); + } + if (allDone && meta[group.id] && !meta[group.id].is_refined && !groupMetaRefinedRef.current.has(group.id)) { + groupMetaRefinedRef.current.add(group.id); + const toolCalls = group.pairs.map((p) => { + const c = p.call.content; + const tool = typeof c === 'object' ? c.tool || '' : ''; + const input = typeof c === 'object' ? c.input : ''; + return { tool, input_summary: (typeof input === 'string' ? input : JSON.stringify(input)).slice(0, 120) }; + }); + const resultsSummary = group.pairs.filter((p) => p.result).map((p) => { + const rc = p.result!.content; + const text = typeof rc === 'string' ? rc : typeof rc === 'object' && rc?.text ? rc.text : JSON.stringify(rc); + return text.slice(0, 150); + }); + dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls, resultsSummary, isRefinement: true })); + } + } + }, [renderItems, id, isDraft, session?.tool_group_meta, dispatch]); + + const getSiblingBranches = useCallback((messageId: string): string[] => { + if (!session?.branches) return []; + const directForks = Object.values(session.branches) + .filter((b: any) => b.fork_point_message_id === messageId).map((b: any) => b.id); + if (directForks.length > 0) { + const originalMsg = session.messages.find((m: AgentMessage) => m.id === messageId); + return [originalMsg?.branch_id || 'main', ...directForks]; + } + const msg = session.messages.find((m: AgentMessage) => m.id === messageId); + if (!msg || msg.role !== 'user') return []; + const msgBranch = session.branches[msg.branch_id]; + if (!msgBranch?.fork_point_message_id) return []; + const branchUserMsgs = session.messages.filter( + (m: AgentMessage) => m.branch_id === msg.branch_id && m.role === 'user' + ); + if (branchUserMsgs.length === 0 || branchUserMsgs[0].id !== messageId) return []; + const forkPointId = msgBranch.fork_point_message_id; + const siblingBranches = Object.values(session.branches) + .filter((b: any) => b.fork_point_message_id === forkPointId).map((b: any) => b.id); + return [msgBranch.parent_branch_id || 'main', ...siblingBranches]; + }, [session?.branches, session?.messages]); + + const contextEstimate = useMemo(() => { + const limit = CONTEXT_WINDOWS[model] || 200_000; + let chars = (session?.system_prompt || '').length; + for (const msg of activeBranchMessages) chars += stringifyContent(msg.content).length; + if (session?.streamingMessage) chars += (session.streamingMessage.content || '').length; + return { used: Math.round(chars / 4), limit }; + }, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model]); + + return { activeBranchMessages, renderItems, lastAssistantIdsInTurn, getSiblingBranches, contextEstimate }; +} diff --git a/frontend/src/app/pages/AgentChat/messageBubbleUtils.ts b/frontend/src/app/pages/AgentChat/messageBubbleUtils.ts new file mode 100644 index 00000000..da7373e2 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/messageBubbleUtils.ts @@ -0,0 +1,54 @@ +import { AgentMessage } from '@/shared/state/agentsSlice'; + +export const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n'; + +export interface ParsedElement { + label: string; + selector: string; + isSemantic?: boolean; +} + +export function parseElementContext(text: string): { userMessage: string; elements: ParsedElement[] } { + const sepIdx = text.indexOf(ELEMENT_SEPARATOR); + if (sepIdx === -1) return { userMessage: text, elements: [] }; + + const userMessage = text.slice(0, sepIdx); + const elementSection = text.slice(sepIdx + ELEMENT_SEPARATOR.length); + + const elements: ParsedElement[] = []; + const blocks = elementSection.split(/\n(?=\d+\.\s)/).filter(Boolean); + for (const block of blocks) { + const semanticMatch = block.match(/\d+\.\s+\[([^\]]+)\]\s*(.*)/); + if (semanticMatch) { + const typeLabel = semanticMatch[1]; + const rest = semanticMatch[2].trim(); + elements.push({ + label: `${typeLabel}: ${rest.split('\n')[0]}`, + selector: typeLabel, + isSemantic: true, + }); + continue; + } + + const labelMatch = block.match(/`([^`]+)`\s+\((\w+)\)/); + const selectorMatch = block.match(/Selector:\s*(.+)/); + if (labelMatch) { + elements.push({ + label: labelMatch[1], + selector: selectorMatch?.[1]?.trim() ?? labelMatch[1], + }); + } + } + + return { userMessage, elements }; +} + +export const SKILL_PILL_RE = /\{\{skill:([^}]+)\}\}/g; + +export interface MessageBubbleProps { + message: AgentMessage; + editing?: boolean; + onSaveEdit?: (messageId: string, newContent: string) => void; + onCancelEdit?: () => void; + isStreaming?: boolean; +} diff --git a/frontend/src/app/pages/AgentChat/toolCallColors.tsx b/frontend/src/app/pages/AgentChat/toolCallColors.tsx new file mode 100644 index 00000000..7b5a8c84 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/toolCallColors.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { useThemeMode } from '@/shared/styles/ThemeContext'; +import { parseMcpToolName, isBashTool } from './toolCallUtils'; + +export interface TermColors { + TERM_BG: string; TERM_BORDER: string; PROMPT_COLOR: string; CMD_COLOR: string; + OUTPUT_COLOR: string; PATH_COLOR: string; ADD_COLOR: string; DEL_COLOR: string; + STDERR_COLOR: string; WARN_COLOR: string; NUM_COLOR: string; DIM_COLOR: string; + DIFF_HEADER_COLOR: string; SCROLLBAR_THUMB: string; +} + +const darkTermColors: TermColors = { + TERM_BG: '#131520', TERM_BORDER: '#1e2030', PROMPT_COLOR: '#7ec699', CMD_COLOR: '#e8ecf4', + OUTPUT_COLOR: '#a0aab8', PATH_COLOR: '#82aaff', ADD_COLOR: '#7ec699', DEL_COLOR: '#ff8787', + STDERR_COLOR: '#ff8787', WARN_COLOR: '#ffcb6b', NUM_COLOR: '#f78c6c', DIM_COLOR: '#555b6e', + DIFF_HEADER_COLOR: '#c792ea', SCROLLBAR_THUMB: '#2a2d3e', +}; + +const lightTermColors: TermColors = { + TERM_BG: '#f4f3ee', TERM_BORDER: '#e2e0d8', PROMPT_COLOR: '#2d7a3e', CMD_COLOR: '#2a2a28', + OUTPUT_COLOR: '#555550', PATH_COLOR: '#3060a8', ADD_COLOR: '#2d7a3e', DEL_COLOR: '#c03030', + STDERR_COLOR: '#c03030', WARN_COLOR: '#8a6518', NUM_COLOR: '#c05020', DIM_COLOR: '#9e9c95', + DIFF_HEADER_COLOR: '#7c4daa', SCROLLBAR_THUMB: '#ccc9c0', +}; + +export function useTermColors(): TermColors { + const { mode } = useThemeMode(); + return mode === 'dark' ? darkTermColors : lightTermColors; +} + +export interface CardColors { + TC_BG: string; TC_BORDER: string; TC_HOVER: string; TC_HEADING: string; TC_BODY: string; + TC_MUTED: string; TC_DIM: string; TC_ACCENT: string; TC_SUCCESS: string; TC_WARNING: string; +} + +const darkCardColors: CardColors = { + TC_BG: 'rgba(255,255,255,0.03)', TC_BORDER: 'rgba(255,255,255,0.06)', + TC_HOVER: 'rgba(255,255,255,0.05)', TC_HEADING: '#C2C0B6', TC_BODY: '#9C9A92', + TC_MUTED: '#85837C', TC_DIM: 'rgba(156,154,146,0.5)', + TC_ACCENT: '#c4633a', TC_SUCCESS: '#7AB948', TC_WARNING: '#D1A041', +}; + +const lightCardColors: CardColors = { + TC_BG: 'rgba(0,0,0,0.03)', TC_BORDER: 'rgba(0,0,0,0.08)', + TC_HOVER: 'rgba(0,0,0,0.05)', TC_HEADING: '#3D3D3A', TC_BODY: '#555550', + TC_MUTED: '#73726C', TC_DIM: 'rgba(115,114,108,0.5)', + TC_ACCENT: '#ae5630', TC_SUCCESS: '#265B19', TC_WARNING: '#805C1F', +}; + +export function useCardColors(): CardColors { + const { mode } = useThemeMode(); + return mode === 'dark' ? darkCardColors : lightCardColors; +} + +export function colorizeInput(toolName: string, text: string, tc: TermColors): React.ReactNode { + const n = toolName.toLowerCase(); + const mcp = parseMcpToolName(toolName); + + if (mcp.isMcp) { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + const colonIdx = line.indexOf(':'); + if (colonIdx > 0 && colonIdx < 30) { + return ( + + {line.slice(0, colonIdx + 1)} + {line.slice(colonIdx + 1)} + {nl} + + ); + } + return {line}{nl}; + })} + + ); + } + + if (isBashTool(toolName)) return {text}; + + if (n === 'edit' || n === 'strreplace' || n === 'multiedit') { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + if (i === 0 && (line.startsWith('/') || line.includes('.'))) + return {line}{nl}; + if (line.startsWith('+ ')) + return {line}{nl}; + if (line.startsWith('- ')) + return {line}{nl}; + return {line}{nl}; + })} + + ); + } + + if (n === 'write') { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + if (i === 0 && (line.startsWith('/') || line.includes('.'))) + return {line}{nl}; + return {line}{nl}; + })} + + ); + } + + if (n === 'read' || n === 'glob' || n === 'webfetch') { + if (/^\//.test(text) || text.includes('/')) + return {text}; + } + + if (n === 'grep' || n === 'ripgrep') { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + if (line.startsWith('pattern:')) + return ( + + pattern: + {line.slice(9)} + {nl} + + ); + if (line.startsWith('path:')) + return ( + + path: + {line.slice(6)} + {nl} + + ); + return {line}{nl}; + })} + + ); + } + + return {text}; +} + +export function colorizeOutput(toolName: string, text: string, tc: TermColors): React.ReactNode { + if (!text) return (empty); + + const lines = text.split('\n'); + const n = toolName.toLowerCase(); + + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + const trimmed = line.trimStart(); + + if (/^\/\S+/.test(trimmed)) + return {line}{nl}; + + if (n === 'grep' || n === 'ripgrep') { + const grepMatch = line.match(/^(\S+?:\d+[:-])/); + if (grepMatch) { + return ( + + {grepMatch[1]} + {line.slice(grepMatch[1].length)} + {nl} + + ); + } + const fileHeader = line.match(/^(\S+\.\w+)$/); + if (fileHeader) + return {line}{nl}; + } + + if (line.startsWith('@@') && line.includes('@@')) + return {line}{nl}; + if (line.startsWith('+')) + return {line}{nl}; + if (line.startsWith('-')) + return {line}{nl}; + + if (/\b[Ee]rror\b/.test(line)) + return {line}{nl}; + if (/\b[Ww]arning\b/.test(line)) + return {line}{nl}; + + if (n === 'read') { + const lineNumMatch = line.match(/^(\s*\d+\s*[|:])/); + if (lineNumMatch) { + return ( + + {lineNumMatch[1]} + {line.slice(lineNumMatch[1].length)} + {nl} + + ); + } + } + + return {line}{nl}; + })} + + ); +} diff --git a/frontend/src/app/pages/AgentChat/toolCallUtils.ts b/frontend/src/app/pages/AgentChat/toolCallUtils.ts new file mode 100644 index 00000000..cc817146 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/toolCallUtils.ts @@ -0,0 +1,249 @@ +import { AgentMessage } from '@/shared/state/agentsSlice'; + +export interface ToolPair { type: 'tool_pair'; id: string; call: AgentMessage; result: AgentMessage | null; } +export interface McpToolInfo { isMcp: boolean; serverSlug: string; action: string; service: string; displayName: string; } +export interface ParsedBashResult { type: 'bash'; stdout: string; stderr: string; exitCode: number | null; } +export interface ParsedTextResult { type: 'text'; content: string; isError?: boolean; } +export interface ParsedMcpResult { type: 'mcp'; service: string; action: string; data: Record; rawText: string; } +export type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult; +export interface ToolCallBubbleProps { + call: AgentMessage; result?: AgentMessage | null; isPending?: boolean; + isStreaming?: boolean; mcpCompact?: boolean; sessionId?: string; +} +export interface InvokeAgentParsed { agentName: string; sessionId: string | null; cost: string | null; response: string; } + +let toolCallKeyframesInjected = false; +export function ensureToolCallKeyframes() { + if (toolCallKeyframesInjected) return; + toolCallKeyframesInjected = true; + const style = document.createElement('style'); + style.setAttribute('data-tool-call-keyframes', ''); + style.textContent = ` +@keyframes tool-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } +@keyframes border-glow { 0%, 100% { box-shadow: 0 0 0 0 rgba(var(--glow-rgb), 0); } 50% { box-shadow: 0 0 10px 2px rgba(var(--glow-rgb), 0.12); } } +@keyframes blink-cursor { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }`; + document.head.appendChild(style); +} +export function formatElapsed(ms: number): string { + if (ms >= 60000) return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`; + return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`; +} +export function getToolData(call: AgentMessage) { + const content = typeof call.content === 'object' ? call.content : {}; + return { toolName: content.tool || 'Unknown', input: content.input || {}, isDenied: content.approved === false, toolId: content.id }; +} +export function isBashTool(name: string) { return name === 'Bash' || name === 'bash'; } + +export function parseMcpToolName(rawName: string): McpToolInfo { + const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/); + if (!m) return { isMcp: false, serverSlug: '', action: '', service: '', displayName: rawName }; + const serverSlug = m[1], action = m[2]; + const display = action.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase()); + const lower = action.toLowerCase(); + let service = ''; + if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) service = 'gmail'; + else if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) service = 'calendar'; + else if (lower.includes('drive') || lower.includes('file')) service = 'drive'; + else if (lower.includes('sheet') || lower.includes('spreadsheet')) service = 'sheets'; + else if (lower.includes('doc') || lower.includes('paragraph')) service = 'docs'; + else if (lower.includes('contact')) service = 'contacts'; + return { isMcp: true, serverSlug, action, service, displayName: display }; +} + +export function getInputSummary(toolName: string, input: any): string { + try { + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) { + if (!input || typeof input !== 'object') return ''; + const keys = Object.keys(input); + if (keys.length === 0) return ''; + if (keys.length === 1) { const v = input[keys[0]], s = typeof v === 'string' ? v : JSON.stringify(v); return s.length > 60 ? s.slice(0, 60) + '…' : s; } + return keys.slice(0, 3).map((k) => { const v = input[k], s = typeof v === 'string' ? v : JSON.stringify(v); return `${k}: ${s.length > 30 ? s.slice(0, 30) + '…' : s}`; }).join(' '); + } + const n = toolName.toLowerCase(); + if (isBashTool(toolName)) { const cmd = input.command || ''; return `$ ${cmd.slice(0, 80)}${cmd.length > 80 ? '…' : ''}`; } + if (n === 'read' || n === 'write') return input.file_path || input.path || ''; + if (n === 'edit' || n === 'multiedit' || n === 'strreplace') return input.file_path || input.path || ''; + if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || ''; + if (n === 'grep' || n === 'ripgrep') { + const pat = input.pattern || input.regex || '', path = input.path || input.directory || ''; + return path ? `/${pat}/ in ${path}` : `/${pat}/`; + } + if (n === 'websearch') return input.query || input.search_term || ''; + if (n === 'webfetch') return input.url || ''; + if (n === 'todoread' || n === 'todowrite') return 'todos'; + if (n === 'ls') return input.path || '.'; + return ''; + } catch { return ''; } +} +export function formatInputDisplay(toolName: string, input: any): string { + try { + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) { + if (!input || typeof input !== 'object') return String(input ?? ''); + return Object.entries(input).map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v, null, 2)}`).join('\n'); + } + const n = toolName.toLowerCase(); + if (isBashTool(toolName)) return input.command || ''; + if (n === 'read') { + const p = input.file_path || input.path || '', parts = [p]; + if (input.offset) parts.push(`offset: ${input.offset}`); + if (input.limit) parts.push(`limit: ${input.limit}`); + return parts.join(' '); + } + if (n === 'write') { + const p = input.file_path || input.path || '', c = input.content || ''; + return `${p}\n\n${c.length > 300 ? c.slice(0, 300) + '\n…' : c}`; + } + if (n === 'edit' || n === 'strreplace') { + const p = input.file_path || input.path || '', old = input.old_string || input.old_text || '', nw = input.new_string || input.new_text || ''; + const lines = [p, '']; + if (old) { const o = old.length > 200 ? old.slice(0, 200) + '…' : old; lines.push(`- ${o.split('\n').join('\n- ')}`); } + if (nw) { const n2 = nw.length > 200 ? nw.slice(0, 200) + '…' : nw; lines.push(`+ ${n2.split('\n').join('\n+ ')}`); } + return lines.join('\n'); + } + if (n === 'multiedit') { + const edits = input.edits || [], lines = [input.file_path || input.path || '']; + for (const e of edits.slice(0, 3)) lines.push(` - ${(e.old_string || e.old_text || '').split('\n')[0].slice(0, 60)}…`); + if (edits.length > 3) lines.push(` … +${edits.length - 3} more edits`); + return lines.join('\n'); + } + if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || ''; + if (n === 'grep' || n === 'ripgrep') { + const pat = input.pattern || input.regex || '', path = input.path || input.directory || ''; + const parts = [`pattern: ${pat}`]; + if (path) parts.push(`path: ${path}`); + if (input.include) parts.push(`include: ${input.include}`); + return parts.join('\n'); + } + if (n === 'websearch') return input.query || input.search_term || ''; + if (n === 'webfetch') return input.url || ''; + } catch {} + return typeof input === 'string' ? input : JSON.stringify(input, null, 2); +} +export function parseToolResult(toolName: string, rawText: string): ParsedResult { + if (isBashTool(toolName)) { + try { + const p = JSON.parse(rawText); + if (typeof p === 'object' && p !== null && 'stdout' in p) { + const em = (p.stdout || '').match(/[Ee]xit code:\s*(\d+)/); + return { type: 'bash', stdout: p.stdout || '', stderr: p.stderr || '', exitCode: em ? parseInt(em[1], 10) : null }; + } + } catch {} + } + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) { + try { + let p = JSON.parse(rawText); + if (Array.isArray(p) && p.some((b: any) => b?.type === 'text' && typeof b?.text === 'string')) { + const tc = p.filter((b: any) => b?.type === 'text').map((b: any) => b.text).join('\n'); + try { p = JSON.parse(tc); } catch { return { type: 'mcp', service: mcp.service, action: mcp.action, data: {}, rawText: tc }; } + } + if (typeof p === 'object' && p !== null) return { type: 'mcp', service: mcp.service, action: mcp.action, data: p, rawText }; + } catch {} + return { type: 'mcp', service: mcp.service, action: mcp.action, data: {}, rawText }; + } + try { + const p = JSON.parse(rawText); + if (typeof p === 'object' && p !== null) { + if ('stdout' in p) return { type: 'text', content: p.stdout || '' }; + if ('content' in p && typeof p.content === 'string') return { type: 'text', content: p.content, isError: !!p.is_error }; + if ('result' in p && typeof p.result === 'string') return { type: 'text', content: p.result }; + if ('output' in p && typeof p.output === 'string') return { type: 'text', content: p.output }; + if (toolName.toLowerCase() === 'glob' && Array.isArray(p)) return { type: 'text', content: p.join('\n') }; + } + } catch {} + return { type: 'text', content: rawText }; +} +export function getMcpShortAction(mcpInfo: McpToolInfo): string { + let short = mcpInfo.action; + if (mcpInfo.service && short.toLowerCase().startsWith(mcpInfo.service.toLowerCase() + '_')) short = short.slice(mcpInfo.service.length + 1); + return short.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase()); +} +export function getGmailHeader(msg: any, name: string): string { + if (msg.payload?.headers && Array.isArray(msg.payload.headers)) { const h = msg.payload.headers.find((hdr: any) => (hdr.name || '').toLowerCase() === name.toLowerCase()); if (h) return h.value || ''; } + if (msg.headers && typeof msg.headers === 'object' && !Array.isArray(msg.headers)) return msg.headers[name] || msg.headers[name.toLowerCase()] || ''; + return ''; +} +export function getResultSummary(toolName: string, rawText: string): string { + const parsed = parseToolResult(toolName, rawText); + if (parsed.type === 'bash') { + const lc = parsed.stdout.split('\n').filter((l) => l.trim()).length; + if (parsed.exitCode !== null && parsed.exitCode !== 0) return `✗ exit ${parsed.exitCode}`; + if (parsed.stderr && !parsed.stdout) return '✗ stderr'; + return `✓ ${lc} line${lc !== 1 ? 's' : ''}`; + } + if (parsed.type === 'mcp') { + const d = parsed.data; + if (parsed.service === 'gmail') { const subj = d.subject || getGmailHeader(d, 'Subject'); if (subj) return subj; if (Array.isArray(d.messages)) return `${d.messages.length} email${d.messages.length !== 1 ? 's' : ''}`; if (d.id || d.messageId) return '✓ done'; } + if (parsed.service === 'calendar') { if (d.summary) return d.summary.slice(0, 40); if (Array.isArray(d.items)) return `${d.items.length} event${d.items.length !== 1 ? 's' : ''}`; } + if (parsed.service === 'drive') { if (d.name) return d.name; if (Array.isArray(d.files)) return `${d.files.length} file${d.files.length !== 1 ? 's' : ''}`; } + if (d.error || d.is_error) return '✗ error'; + return '✓ done'; + } + const text = parsed.content, lines = text.split('\n'), lc = lines.length, n = toolName.toLowerCase(); + try { + if (n === 'glob') { const fc = lines.filter((l) => l.trim()).length; return `${fc} file${fc !== 1 ? 's' : ''}`; } + if (n === 'grep' || n === 'ripgrep') { const mc = lines.filter((l) => l.trim()).length; return `${mc} match${mc !== 1 ? 'es' : ''}`; } + if (n === 'read') return `${lc} lines`; + if (n === 'write') return text.toLowerCase().includes('success') || text.toLowerCase().includes('written') ? '✓ written' : '✓ done'; + if (n === 'edit' || n === 'multiedit' || n === 'strreplace') return text.toLowerCase().includes('success') || text.toLowerCase().includes('applied') ? '✓ applied' : '✓ done'; + if (n === 'websearch') return 'results'; + if (n === 'webfetch') return `${lc} lines`; + if (parsed.isError) return '✗ error'; + } catch {} + return `${lc} line${lc !== 1 ? 's' : ''}`; +} +export function getPromptPrefix(toolName: string): string { + if (isBashTool(toolName)) return '$ '; + const mcp = parseMcpToolName(toolName); + return mcp.isMcp ? `❯ ${mcp.displayName} ` : `❯ ${toolName} `; +} +export function formatTimestamp(ts: string | number | undefined): string { + if (!ts) return ''; + try { + const d = typeof ts === 'number' ? new Date(ts) : new Date(ts); + if (isNaN(d.getTime())) return String(ts); + return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }); + } catch { return String(ts); } +} +export function stripHtml(html: string): string { + const tmp = document.createElement('div'); tmp.innerHTML = html; return tmp.textContent || tmp.innerText || ''; +} +export function isBrowserAgentTool(name: string): boolean { + if (name === 'CreateBrowserAgent' || name === 'BrowserAgent' || name === 'BrowserAgents') return true; + const m = parseMcpToolName(name); return m.isMcp && m.serverSlug === 'openswarm-browser-agent'; +} +export function isInvokeAgentTool(name: string): boolean { + if (name === 'InvokeAgent') return true; + const m = parseMcpToolName(name); return m.isMcp && m.serverSlug === 'openswarm-invoke-agent'; +} +export function isCreateAgentTool(name: string): boolean { return name === 'Agent'; } +export function parseInvokedSessionId(rawText: string): string | null { return rawText.match(/\(forked session:\s*([a-f0-9]+)\)/)?.[1] || null; } +export function parseCreateAgentResult(rawText: string): string { + if (!rawText) return ''; + try { + const p = JSON.parse(rawText); + if (typeof p === 'string') return p; + if (typeof p === 'object' && p !== null) { + if (p.text) return p.text; + if (p.content) return typeof p.content === 'string' ? p.content : JSON.stringify(p.content); + if (p.result) return typeof p.result === 'string' ? p.result : JSON.stringify(p.result); + } + } catch {} + return rawText; +} +export function parseInvokeAgentResult(rawText: string): InvokeAgentParsed | null { + const hm = rawText.match(/\*\*Invoked Agent Result\*\*(?:\s*—\s*(.+?))?\s*\(forked session:\s*([a-f0-9]+)\)/); + if (!hm) return null; + const agentName = hm[1]?.trim() || 'Agent', sessionId = hm[2]; + const costMatch = rawText.match(/\*Cost:\s*\$([0-9.]+)\*/); + const cost = costMatch ? costMatch[1] : null; + const bodyStart = rawText.indexOf('\n\n'); + let response = bodyStart >= 0 ? rawText.slice(bodyStart + 2).trim() : ''; + if (response.startsWith('*Cost:')) { + const ac = response.indexOf('\n'); + response = ac >= 0 ? response.slice(ac + 1).trim() : ''; + } + return { agentName, sessionId, cost, response }; +} diff --git a/frontend/src/app/pages/Analytics/PixelChart.tsx b/frontend/src/app/pages/Analytics/PixelChart.tsx index f889517b..845db171 100644 --- a/frontend/src/app/pages/Analytics/PixelChart.tsx +++ b/frontend/src/app/pages/Analytics/PixelChart.tsx @@ -1,29 +1,8 @@ -import React, { useRef, useEffect, useCallback } from 'react'; +import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; - -const PALETTES = { - salmon: ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'], - blue: ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'], - coral: ['#993344', '#AA3D4E', '#BB4455', '#CC5566', '#DD6677'], - green: ['#447755', '#558866', '#669977', '#77AA88', '#88BB99'], - purple: ['#665588', '#7766AA', '#8877BB', '#9988CC', '#AA99DD'], -} as const; - -type PaletteKey = keyof typeof PALETTES; - -interface PixelChartProps { - data: { label: string; value: number }[]; - palette?: PaletteKey; - height?: number; - pixelSize?: number; - formatValue?: (v: number) => string; - glow?: boolean; - showXLabels?: boolean; - showYScale?: boolean; - mode?: 'bar' | 'area'; // 'area' draws a filled line chart instead of bars -} +import { PixelChartProps } from './pixelChartTypes'; +import { usePixelChart } from './usePixelChart'; const PixelChart: React.FC = ({ data, @@ -36,313 +15,11 @@ const PixelChart: React.FC = ({ showYScale = true, mode = 'bar', }) => { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const animRef = useRef(0); - const progressRef = useRef(0); - const hoverIdxRef = useRef(-1); - const tooltipRef = useRef(null); - const c = useClaudeTokens(); - const colors = PALETTES[palette]; - - const maxVal = Math.max(...data.map((d) => d.value), 0.001); - - // Compute nice Y-axis ticks - const yTicks = (() => { - if (maxVal <= 0) return [0]; - const rawStep = maxVal / 3; - const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); - const normalised = rawStep / magnitude; - let niceStep: number; - if (normalised <= 1) niceStep = magnitude; - else if (normalised <= 2) niceStep = 2 * magnitude; - else if (normalised <= 5) niceStep = 5 * magnitude; - else niceStep = 10 * magnitude; - const ticks: number[] = []; - for (let v = 0; v <= maxVal * 1.1; v += niceStep) { - ticks.push(v); - } - if (ticks.length < 2) ticks.push(niceStep); - return ticks; - })(); - - // X-axis labels: show first, last, and up to 3 evenly spaced - const xLabels = (() => { - if (data.length <= 1) return data.map((d, i) => ({ idx: i, label: d.label })); - if (data.length <= 5) return data.map((d, i) => ({ idx: i, label: d.label })); - const result: { idx: number; label: string }[] = []; - result.push({ idx: 0, label: data[0].label }); - const step = Math.floor(data.length / 4); - for (let i = 1; i <= 3; i++) { - const idx = Math.min(i * step, data.length - 2); - if (idx > 0 && idx < data.length - 1) { - result.push({ idx, label: data[idx].label }); - } - } - result.push({ idx: data.length - 1, label: data[data.length - 1].label }); - return result; - })(); - - const Y_LABEL_WIDTH = showYScale ? 80 : 0; - - const draw = useCallback(() => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container || data.length === 0) return; - - const dpr = window.devicePixelRatio || 1; - const totalW = container.clientWidth; - const chartW = totalW - Y_LABEL_WIDTH; - const h = height; - canvas.width = totalW * dpr; - canvas.height = h * dpr; - canvas.style.width = `${totalW}px`; - canvas.style.height = `${h}px`; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - ctx.scale(dpr, dpr); - - const px = pixelSize; - const gridCols = Math.floor(chartW / px); - const gridRows = Math.floor(h / px); - const effectiveMax = yTicks[yTicks.length - 1] || maxVal; - - ctx.clearRect(0, 0, totalW, h); - - // Y-axis labels and horizontal grid lines - if (showYScale) { - ctx.font = '10px monospace'; - ctx.textAlign = 'right'; - ctx.textBaseline = 'middle'; - - for (const tick of yTicks) { - const yNorm = effectiveMax > 0 ? tick / effectiveMax : 0; - const yPx = h - yNorm * (h - px); - - // Grid line - ctx.strokeStyle = c.border.subtle; - ctx.lineWidth = 0.5; - ctx.setLineDash([2, 4]); - ctx.beginPath(); - ctx.moveTo(Y_LABEL_WIDTH, yPx); - ctx.lineTo(totalW, yPx); - ctx.stroke(); - ctx.setLineDash([]); - - // Label - const label = formatValue ? formatValue(tick) : (tick % 1 === 0 ? String(tick) : tick.toFixed(1)); - ctx.fillStyle = c.text.ghost; - ctx.fillText(label, Y_LABEL_WIDTH - 8, yPx); - } - } - - // Subtle grid dots in chart area - ctx.fillStyle = c.border.subtle; - for (let gy = 0; gy < gridRows; gy += 5) { - for (let gx = 0; gx < gridCols; gx += 5) { - ctx.fillRect(Y_LABEL_WIDTH + gx * px, gy * px, 1, 1); - } - } - - const progress = Math.min(progressRef.current, 1); - const hoverIdx = hoverIdxRef.current; - - if (mode === 'area') { - // -- Area / line chart mode -- - const usableH = h - px * 2; - const points: { x: number; y: number }[] = []; - - for (let i = 0; i < data.length; i++) { - const val = data[i].value; - const norm = effectiveMax > 0 ? val / effectiveMax : 0; - const x = Y_LABEL_WIDTH + (i / Math.max(data.length - 1, 1)) * chartW; - const y = h - px - norm * usableH * progress; - points.push({ x, y }); - } - - if (points.length > 0) { - // Filled area with gradient - const gradient = ctx.createLinearGradient(0, 0, 0, h); - gradient.addColorStop(0, colors[colors.length - 1] + '60'); - gradient.addColorStop(0.5, colors[Math.floor(colors.length / 2)] + '30'); - gradient.addColorStop(1, colors[0] + '08'); - - ctx.beginPath(); - ctx.moveTo(points[0].x, h); - for (let i = 0; i < points.length; i++) { - if (i === 0) { - ctx.lineTo(points[i].x, points[i].y); - } else { - const prev = points[i - 1]; - const curr = points[i]; - const cpx = (prev.x + curr.x) / 2; - ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y); - } - } - ctx.lineTo(points[points.length - 1].x, h); - ctx.closePath(); - ctx.fillStyle = gradient; - ctx.fill(); - - // Line on top - ctx.beginPath(); - for (let i = 0; i < points.length; i++) { - if (i === 0) { - ctx.moveTo(points[i].x, points[i].y); - } else { - const prev = points[i - 1]; - const curr = points[i]; - const cpx = (prev.x + curr.x) / 2; - ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y); - } - } - ctx.strokeStyle = colors[colors.length - 1]; - ctx.lineWidth = 2; - ctx.stroke(); - - // Glow on line - if (glow) { - ctx.shadowColor = colors[colors.length - 1]; - ctx.shadowBlur = 8; - ctx.stroke(); - ctx.shadowBlur = 0; - } - - // Data point dots - for (let i = 0; i < points.length; i++) { - if (data[i].value > 0) { - const isHov = i === hoverIdx; - ctx.beginPath(); - ctx.arc(points[i].x, points[i].y, isHov ? 4 : 2.5, 0, Math.PI * 2); - ctx.fillStyle = isHov ? colors[colors.length - 1] : colors[Math.floor(colors.length / 2)]; - ctx.fill(); - if (isHov) { - ctx.strokeStyle = colors[colors.length - 1]; - ctx.lineWidth = 1.5; - ctx.stroke(); - } - } - } - - // Pixel scatter in the filled area for the pixel art feel - for (let i = 0; i < points.length - 1; i++) { - const p1 = points[i]; - const p2 = points[i + 1]; - const steps = Math.ceil((p2.x - p1.x) / px); - for (let s = 0; s < steps; s++) { - const t = s / steps; - const x = p1.x + t * (p2.x - p1.x); - const lineY = p1.y + t * (p2.y - p1.y); - for (let py = lineY + px * 2; py < h - px; py += px * 2) { - if (Math.random() > 0.65) { - const depth = (py - lineY) / (h - lineY); - const ci = Math.max(0, Math.floor((1 - depth) * (colors.length - 1))); - ctx.globalAlpha = 0.15 + (1 - depth) * 0.2; - ctx.fillStyle = colors[ci]; - ctx.fillRect(Math.floor(x / px) * px, Math.floor(py / px) * px, px - 1, px - 1); - } - } - } - } - ctx.globalAlpha = 1; - } - } else { - // -- Bar chart mode (original) -- - const barSlots = data.length; - const totalBarPx = Math.max(1, Math.floor(gridCols / barSlots)); - const barW = Math.max(1, totalBarPx - 1); - - for (let i = 0; i < data.length; i++) { - const val = data[i].value; - const normalised = effectiveMax > 0 ? val / effectiveMax : 0; - const usableRows = gridRows - 2; - const targetH = Math.max(normalised > 0 ? 1 : 0, Math.round(normalised * usableRows)); - const barH = Math.round(targetH * progress); - const barX = i * totalBarPx; - const isHovered = i === hoverIdx; - - for (let row = 0; row < barH; row++) { - const y = gridRows - 1 - row; - const colorIdx = Math.min(colors.length - 1, Math.floor((row / Math.max(barH - 1, 1)) * (colors.length - 1))); - const baseColor = isHovered ? colors[Math.min(colorIdx + 1, colors.length - 1)] : colors[colorIdx]; - - for (let col = 0; col < barW; col++) { - ctx.fillStyle = baseColor; - ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, y * px, px - 1, px - 1); - } - } - - if (glow && barH > 0) { - const topY = (gridRows - 1 - barH + 1) * px; - ctx.shadowColor = colors[colors.length - 1]; - ctx.shadowBlur = 6; - ctx.fillStyle = colors[colors.length - 1]; - for (let col = 0; col < barW; col++) { - ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, topY, px - 1, px - 1); - } - ctx.shadowBlur = 0; - } - } - } - }, [data, height, pixelSize, c, colors, glow, maxVal, yTicks, showYScale, Y_LABEL_WIDTH, formatValue, mode]); - - useEffect(() => { - progressRef.current = 0; - let start: number | null = null; - const animate = (ts: number) => { - if (!start) start = ts; - progressRef.current = Math.min(1, (ts - start) / 600); - draw(); - if (progressRef.current < 1) animRef.current = requestAnimationFrame(animate); - }; - animRef.current = requestAnimationFrame(animate); - return () => cancelAnimationFrame(animRef.current); - }, [data, draw]); - - useEffect(() => { - const handleResize = () => draw(); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, [draw]); - - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current; - const tooltip = tooltipRef.current; - if (!canvas || !tooltip || data.length === 0) return; - - const rect = canvas.getBoundingClientRect(); - const mx = e.clientX - rect.left - Y_LABEL_WIDTH; - if (mx < 0) { hoverIdxRef.current = -1; tooltip.style.opacity = '0'; draw(); return; } - - const chartW = rect.width - Y_LABEL_WIDTH; - const gridCols = Math.floor(chartW / pixelSize); - const totalBarPx = Math.max(1, Math.floor(gridCols / data.length)); - const idx = Math.floor(mx / (totalBarPx * pixelSize)); - - if (idx >= 0 && idx < data.length) { - hoverIdxRef.current = idx; - const d = data[idx]; - const valStr = formatValue ? formatValue(d.value) : d.value.toFixed(2); - tooltip.textContent = `${d.label}: ${valStr}`; - tooltip.style.opacity = '1'; - tooltip.style.left = `${e.clientX - rect.left}px`; - tooltip.style.top = `${e.clientY - rect.top - 28}px`; - } else { - hoverIdxRef.current = -1; - tooltip.style.opacity = '0'; - } - draw(); - }, - [data, pixelSize, draw, formatValue, Y_LABEL_WIDTH], - ); - - const handleMouseLeave = useCallback(() => { - hoverIdxRef.current = -1; - if (tooltipRef.current) tooltipRef.current.style.opacity = '0'; - draw(); - }, [draw]); + const { + canvasRef, containerRef, tooltipRef, + xLabels, Y_LABEL_WIDTH, + handleMouseMove, handleMouseLeave, c, + } = usePixelChart({ data, palette, height, pixelSize, formatValue, glow, showYScale, mode }); return ( @@ -352,7 +29,6 @@ const PixelChart: React.FC = ({ onMouseLeave={handleMouseLeave} style={{ display: 'block', width: '100%', imageRendering: 'pixelated', cursor: 'crosshair' }} /> - {/* X-axis labels */} {showXLabels && data.length > 0 && ( {xLabels.map((xl) => ( @@ -373,7 +49,6 @@ const PixelChart: React.FC = ({ ))} )} - {/* Tooltip */} string) | undefined, + borderSubtle: string, + textGhost: string, +) { + ctx.font = '10px monospace'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + + for (const tick of yTicks) { + const yNorm = effectiveMax > 0 ? tick / effectiveMax : 0; + const yPx = h - yNorm * (h - px); + + ctx.strokeStyle = borderSubtle; + ctx.lineWidth = 0.5; + ctx.setLineDash([2, 4]); + ctx.beginPath(); + ctx.moveTo(yLabelWidth, yPx); + ctx.lineTo(totalW, yPx); + ctx.stroke(); + ctx.setLineDash([]); + + const label = formatValue ? formatValue(tick) : (tick % 1 === 0 ? String(tick) : tick.toFixed(1)); + ctx.fillStyle = textGhost; + ctx.fillText(label, yLabelWidth - 8, yPx); + } +} + +export function drawGridDots( + ctx: CanvasRenderingContext2D, + gridRows: number, + gridCols: number, + px: number, + yLabelWidth: number, + borderSubtle: string, +) { + ctx.fillStyle = borderSubtle; + for (let gy = 0; gy < gridRows; gy += 5) { + for (let gx = 0; gx < gridCols; gx += 5) { + ctx.fillRect(yLabelWidth + gx * px, gy * px, 1, 1); + } + } +} + +export function drawAreaChart(p: ChartDrawParams) { + const { ctx, data, h, px, chartW, effectiveMax, yLabelWidth, progress, hoverIdx, colors, glow } = p; + const usableH = h - px * 2; + const points: { x: number; y: number }[] = []; + + for (let i = 0; i < data.length; i++) { + const val = data[i].value; + const norm = effectiveMax > 0 ? val / effectiveMax : 0; + const x = yLabelWidth + (i / Math.max(data.length - 1, 1)) * chartW; + const y = h - px - norm * usableH * progress; + points.push({ x, y }); + } + + if (points.length === 0) return; + + const gradient = ctx.createLinearGradient(0, 0, 0, h); + gradient.addColorStop(0, colors[colors.length - 1] + '60'); + gradient.addColorStop(0.5, colors[Math.floor(colors.length / 2)] + '30'); + gradient.addColorStop(1, colors[0] + '08'); + + ctx.beginPath(); + ctx.moveTo(points[0].x, h); + for (let i = 0; i < points.length; i++) { + if (i === 0) { + ctx.lineTo(points[i].x, points[i].y); + } else { + const prev = points[i - 1]; + const curr = points[i]; + const cpx = (prev.x + curr.x) / 2; + ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y); + } + } + ctx.lineTo(points[points.length - 1].x, h); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); + + ctx.beginPath(); + for (let i = 0; i < points.length; i++) { + if (i === 0) { + ctx.moveTo(points[i].x, points[i].y); + } else { + const prev = points[i - 1]; + const curr = points[i]; + const cpx = (prev.x + curr.x) / 2; + ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y); + } + } + ctx.strokeStyle = colors[colors.length - 1]; + ctx.lineWidth = 2; + ctx.stroke(); + + if (glow) { + ctx.shadowColor = colors[colors.length - 1]; + ctx.shadowBlur = 8; + ctx.stroke(); + ctx.shadowBlur = 0; + } + + for (let i = 0; i < points.length; i++) { + if (data[i].value > 0) { + const isHov = i === hoverIdx; + ctx.beginPath(); + ctx.arc(points[i].x, points[i].y, isHov ? 4 : 2.5, 0, Math.PI * 2); + ctx.fillStyle = isHov ? colors[colors.length - 1] : colors[Math.floor(colors.length / 2)]; + ctx.fill(); + if (isHov) { + ctx.strokeStyle = colors[colors.length - 1]; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + } + + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i]; + const p2 = points[i + 1]; + const steps = Math.ceil((p2.x - p1.x) / px); + for (let s = 0; s < steps; s++) { + const t = s / steps; + const x = p1.x + t * (p2.x - p1.x); + const lineY = p1.y + t * (p2.y - p1.y); + for (let py = lineY + px * 2; py < h - px; py += px * 2) { + if (Math.random() > 0.65) { + const depth = (py - lineY) / (h - lineY); + const ci = Math.max(0, Math.floor((1 - depth) * (colors.length - 1))); + ctx.globalAlpha = 0.15 + (1 - depth) * 0.2; + ctx.fillStyle = colors[ci]; + ctx.fillRect(Math.floor(x / px) * px, Math.floor(py / px) * px, px - 1, px - 1); + } + } + } + } + ctx.globalAlpha = 1; +} + +export function drawBarChart(p: ChartDrawParams) { + const { ctx, data, gridRows, gridCols, effectiveMax, yLabelWidth, px, progress, hoverIdx, colors, glow } = p; + const barSlots = data.length; + const totalBarPx = Math.max(1, Math.floor(gridCols / barSlots)); + const barW = Math.max(1, totalBarPx - 1); + + for (let i = 0; i < data.length; i++) { + const val = data[i].value; + const normalised = effectiveMax > 0 ? val / effectiveMax : 0; + const usableRows = gridRows - 2; + const targetH = Math.max(normalised > 0 ? 1 : 0, Math.round(normalised * usableRows)); + const barH = Math.round(targetH * progress); + const barX = i * totalBarPx; + const isHovered = i === hoverIdx; + + for (let row = 0; row < barH; row++) { + const y = gridRows - 1 - row; + const colorIdx = Math.min(colors.length - 1, Math.floor((row / Math.max(barH - 1, 1)) * (colors.length - 1))); + const baseColor = isHovered ? colors[Math.min(colorIdx + 1, colors.length - 1)] : colors[colorIdx]; + + for (let col = 0; col < barW; col++) { + ctx.fillStyle = baseColor; + ctx.fillRect(yLabelWidth + (barX + col) * px, y * px, px - 1, px - 1); + } + } + + if (glow && barH > 0) { + const topY = (gridRows - 1 - barH + 1) * px; + ctx.shadowColor = colors[colors.length - 1]; + ctx.shadowBlur = 6; + ctx.fillStyle = colors[colors.length - 1]; + for (let col = 0; col < barW; col++) { + ctx.fillRect(yLabelWidth + (barX + col) * px, topY, px - 1, px - 1); + } + ctx.shadowBlur = 0; + } + } +} diff --git a/frontend/src/app/pages/Analytics/pixelChartTypes.ts b/frontend/src/app/pages/Analytics/pixelChartTypes.ts new file mode 100644 index 00000000..8e90a4a0 --- /dev/null +++ b/frontend/src/app/pages/Analytics/pixelChartTypes.ts @@ -0,0 +1,37 @@ +export const PALETTES = { + salmon: ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'], + blue: ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'], + coral: ['#993344', '#AA3D4E', '#BB4455', '#CC5566', '#DD6677'], + green: ['#447755', '#558866', '#669977', '#77AA88', '#88BB99'], + purple: ['#665588', '#7766AA', '#8877BB', '#9988CC', '#AA99DD'], +} as const; + +export type PaletteKey = keyof typeof PALETTES; + +export interface PixelChartProps { + data: { label: string; value: number }[]; + palette?: PaletteKey; + height?: number; + pixelSize?: number; + formatValue?: (v: number) => string; + glow?: boolean; + showXLabels?: boolean; + showYScale?: boolean; + mode?: 'bar' | 'area'; +} + +export interface ChartDrawParams { + ctx: CanvasRenderingContext2D; + data: { label: string; value: number }[]; + h: number; + px: number; + gridCols: number; + gridRows: number; + chartW: number; + effectiveMax: number; + yLabelWidth: number; + progress: number; + hoverIdx: number; + colors: readonly string[]; + glow: boolean; +} diff --git a/frontend/src/app/pages/Analytics/usePixelChart.ts b/frontend/src/app/pages/Analytics/usePixelChart.ts new file mode 100644 index 00000000..97dbcc69 --- /dev/null +++ b/frontend/src/app/pages/Analytics/usePixelChart.ts @@ -0,0 +1,166 @@ +import React, { useRef, useEffect, useCallback } from 'react'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { PALETTES, PixelChartProps } from './pixelChartTypes'; +import { drawYAxis, drawGridDots, drawAreaChart, drawBarChart } from './pixelChartRenderers'; + +export function computeYTicks(maxVal: number): number[] { + if (maxVal <= 0) return [0]; + const rawStep = maxVal / 3; + const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); + const normalised = rawStep / magnitude; + let niceStep: number; + if (normalised <= 1) niceStep = magnitude; + else if (normalised <= 2) niceStep = 2 * magnitude; + else if (normalised <= 5) niceStep = 5 * magnitude; + else niceStep = 10 * magnitude; + const ticks: number[] = []; + for (let v = 0; v <= maxVal * 1.1; v += niceStep) { + ticks.push(v); + } + if (ticks.length < 2) ticks.push(niceStep); + return ticks; +} + +export function computeXLabels(data: { label: string; value: number }[]): { idx: number; label: string }[] { + if (data.length <= 1) return data.map((d, i) => ({ idx: i, label: d.label })); + if (data.length <= 5) return data.map((d, i) => ({ idx: i, label: d.label })); + const result: { idx: number; label: string }[] = []; + result.push({ idx: 0, label: data[0].label }); + const step = Math.floor(data.length / 4); + for (let i = 1; i <= 3; i++) { + const idx = Math.min(i * step, data.length - 2); + if (idx > 0 && idx < data.length - 1) { + result.push({ idx, label: data[idx].label }); + } + } + result.push({ idx: data.length - 1, label: data[data.length - 1].label }); + return result; +} + +type UsePixelChartProps = Required> & + Pick; + +export function usePixelChart({ + data, + palette = 'salmon', + height = 140, + pixelSize = 6, + formatValue, + glow = true, + showYScale = true, + mode = 'bar', +}: UsePixelChartProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const animRef = useRef(0); + const progressRef = useRef(0); + const hoverIdxRef = useRef(-1); + const tooltipRef = useRef(null); + const c = useClaudeTokens(); + const colors = PALETTES[palette]; + + const maxVal = Math.max(...data.map((d) => d.value), 0.001); + const yTicks = computeYTicks(maxVal); + const xLabels = computeXLabels(data); + const Y_LABEL_WIDTH = showYScale ? 80 : 0; + + const draw = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container || data.length === 0) return; + + const dpr = window.devicePixelRatio || 1; + const totalW = container.clientWidth; + const chartW = totalW - Y_LABEL_WIDTH; + const h = height; + canvas.width = totalW * dpr; + canvas.height = h * dpr; + canvas.style.width = `${totalW}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.scale(dpr, dpr); + + const px = pixelSize; + const gridCols = Math.floor(chartW / px); + const gridRows = Math.floor(h / px); + const effectiveMax = yTicks[yTicks.length - 1] || maxVal; + + ctx.clearRect(0, 0, totalW, h); + + if (showYScale) { + drawYAxis(ctx, yTicks, effectiveMax, h, px, Y_LABEL_WIDTH, totalW, formatValue, c.border.subtle, c.text.ghost); + } + drawGridDots(ctx, gridRows, gridCols, px, Y_LABEL_WIDTH, c.border.subtle); + + const progress = Math.min(progressRef.current, 1); + const hoverIdx = hoverIdxRef.current; + const params = { ctx, data, h, px, gridCols, gridRows, chartW, effectiveMax, yLabelWidth: Y_LABEL_WIDTH, progress, hoverIdx, colors, glow }; + + if (mode === 'area') { + drawAreaChart(params); + } else { + drawBarChart(params); + } + }, [data, height, pixelSize, c, colors, glow, maxVal, yTicks, showYScale, Y_LABEL_WIDTH, formatValue, mode]); + + useEffect(() => { + progressRef.current = 0; + let start: number | null = null; + const animate = (ts: number) => { + if (!start) start = ts; + progressRef.current = Math.min(1, (ts - start) / 600); + draw(); + if (progressRef.current < 1) animRef.current = requestAnimationFrame(animate); + }; + animRef.current = requestAnimationFrame(animate); + return () => cancelAnimationFrame(animRef.current); + }, [data, draw]); + + useEffect(() => { + const handleResize = () => draw(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [draw]); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + const tooltip = tooltipRef.current; + if (!canvas || !tooltip || data.length === 0) return; + + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left - Y_LABEL_WIDTH; + if (mx < 0) { hoverIdxRef.current = -1; tooltip.style.opacity = '0'; draw(); return; } + + const chartW = rect.width - Y_LABEL_WIDTH; + const gridCols = Math.floor(chartW / pixelSize); + const totalBarPx = Math.max(1, Math.floor(gridCols / data.length)); + const idx = Math.floor(mx / (totalBarPx * pixelSize)); + + if (idx >= 0 && idx < data.length) { + hoverIdxRef.current = idx; + const d = data[idx]; + const valStr = formatValue ? formatValue(d.value) : d.value.toFixed(2); + tooltip.textContent = `${d.label}: ${valStr}`; + tooltip.style.opacity = '1'; + tooltip.style.left = `${e.clientX - rect.left}px`; + tooltip.style.top = `${e.clientY - rect.top - 28}px`; + } else { + hoverIdxRef.current = -1; + tooltip.style.opacity = '0'; + } + draw(); + }, + [data, pixelSize, draw, formatValue, Y_LABEL_WIDTH], + ); + + const handleMouseLeave = useCallback(() => { + hoverIdxRef.current = -1; + if (tooltipRef.current) tooltipRef.current.style.opacity = '0'; + draw(); + }, [draw]); + + return { canvasRef, containerRef, tooltipRef, xLabels, Y_LABEL_WIDTH, handleMouseMove, handleMouseLeave, c }; +} diff --git a/frontend/src/app/pages/Commands/AtCommandsSection.tsx b/frontend/src/app/pages/Commands/AtCommandsSection.tsx new file mode 100644 index 00000000..708930e7 --- /dev/null +++ b/frontend/src/app/pages/Commands/AtCommandsSection.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import AlternateEmailIcon from '@mui/icons-material/AlternateEmail'; +import { SectionHeader } from './CommandsHelpers'; +import { AtCommand } from './commandsTypes'; + +interface AtCommandsSectionProps { + atCommands: AtCommand[]; + c: any; +} + +const AtCommandsSection: React.FC = ({ atCommands, c }) => ( + + } + title="@ Context Commands" + subtitle="Type @ in chat to attach context and activate actions" + count={atCommands.length} + c={c} + /> + + {atCommands.length === 0 ? ( + + + + No @ commands yet. Install MCP actions to see them here. + + + ) : ( + + {atCommands.map((cmd) => ( + + + {cmd.icon} + + + {cmd.prefix} + + + + {cmd.description} + + + ))} + + )} + +); + +export default AtCommandsSection; diff --git a/frontend/src/app/pages/Commands/Commands.tsx b/frontend/src/app/pages/Commands/Commands.tsx index 49bdcdf6..d6acca1e 100644 --- a/frontend/src/app/pages/Commands/Commands.tsx +++ b/frontend/src/app/pages/Commands/Commands.tsx @@ -1,554 +1,22 @@ -import React, { useEffect, useMemo } from 'react'; +import React from 'react'; import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import DescriptionIcon from '@mui/icons-material/Description'; -import PsychologyIcon from '@mui/icons-material/Psychology'; -import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; -import AlternateEmailIcon from '@mui/icons-material/AlternateEmail'; -import KeyboardIcon from '@mui/icons-material/Keyboard'; -import TerminalIcon from '@mui/icons-material/Terminal'; -import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; -import LanguageIcon from '@mui/icons-material/Language'; -import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; -import ViewQuiltOutlinedIcon from '@mui/icons-material/ViewQuiltOutlined'; -import { useAppSelector, useAppDispatch } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice'; -import { getToolGroupIcon } from '@/app/components/CommandPicker'; -import { fetchOutputs } from '@/shared/state/outputsSlice'; -import { fetchTemplates } from '@/shared/state/templatesSlice'; -import { fetchSkills } from '@/shared/state/skillsSlice'; -import { fetchModes } from '@/shared/state/modesSlice'; - -interface SlashCommand { - id: string; - type: 'template' | 'skill' | 'mode'; - name: string; - description: string; - command: string; -} - -interface AtCommand { - prefix: string; - label: string; - description: string; - icon: React.ReactNode; - source: string; - isChild?: boolean; -} - -interface Shortcut { - key: string; - description: string; - category: 'navigation' | 'action'; -} - -const SHORTCUTS: Shortcut[] = [ - { key: 'd', description: 'Go to Dashboard', category: 'navigation' }, - { key: 't', description: 'Go to Templates', category: 'navigation' }, - { key: '1-9', description: 'Open agent by position', category: 'navigation' }, - { key: 'Shift+A', description: 'Approve all pending', category: 'action' }, - { key: 'Shift+D', description: 'Deny all pending', category: 'action' }, - { key: '?', description: 'Show shortcuts dialog', category: 'navigation' }, -]; - -const KeyBadge: React.FC<{ keys: string; c: any }> = ({ keys, c }) => ( - - - {keys} - - -); - -const SectionHeader: React.FC<{ - icon: React.ReactNode; - title: string; - subtitle: string; - count?: number; - c: any; -}> = ({ icon, title, subtitle, count, c }) => ( - - {icon} - - - - {title} - - {count !== undefined && ( - - )} - - {subtitle} - - -); +import { useCommands } from './hooks/useCommands'; +import SlashCommandsSection from './SlashCommandsSection'; +import AtCommandsSection from './AtCommandsSection'; +import ShortcutsSection from './ShortcutsSection'; export const CommandsContent: React.FC = () => { const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - const templates = useAppSelector((state) => state.templates.items); - const skills = useAppSelector((state) => state.skills.items); - const modesMap = useAppSelector((state) => state.modes.items); - const builtinTools = useAppSelector((state) => state.tools.builtinTools); - const customTools = useAppSelector((state) => state.tools.items); - const outputItems = useAppSelector((state) => state.outputs.items); - - const templatesLoaded = useAppSelector((state) => state.templates.loaded); - const skillsLoaded = useAppSelector((state) => state.skills.loaded); - const modesLoaded = useAppSelector((state) => state.modes.loaded); - const builtinLoaded = useAppSelector((state) => state.tools.builtinLoaded); - const toolsLoaded = useAppSelector((state) => state.tools.loaded); - const outputsLoaded = useAppSelector((state) => state.outputs.loaded); - - useEffect(() => { - if (!templatesLoaded) dispatch(fetchTemplates()); - if (!skillsLoaded) dispatch(fetchSkills()); - if (!modesLoaded) dispatch(fetchModes()); - if (!builtinLoaded) dispatch(fetchBuiltinTools()); - if (!toolsLoaded) dispatch(fetchTools()); - if (!outputsLoaded) dispatch(fetchOutputs()); - }, [dispatch, templatesLoaded, skillsLoaded, modesLoaded, builtinLoaded, toolsLoaded, outputsLoaded]); - - const slashCommands: SlashCommand[] = useMemo(() => [ - ...Object.values(templates).map((t) => ({ - id: t.id, - type: 'template' as const, - name: t.name, - description: t.description || `Template with ${t.fields.length} fields`, - command: t.name.toLowerCase().replace(/\s+/g, '-'), - })), - ...Object.values(skills).map((s) => ({ - id: s.id, - type: 'skill' as const, - name: s.name, - description: s.description || 'Skill', - command: s.command || s.id, - })), - ...Object.values(modesMap).map((m) => ({ - id: m.id, - type: 'mode' as const, - name: m.name, - description: m.description || 'Switch to this mode', - command: m.name.toLowerCase().replace(/\s+/g, '-'), - })), - ], [templates, skills, modesMap]); - - const atCommands: AtCommand[] = useMemo(() => { - const items: AtCommand[] = [ - { prefix: '@file', label: 'File', description: 'Attach a file or folder as context', icon: , source: 'builtin' }, - ]; - - const hasWebSearch = builtinTools.some((t) => t.name === 'WebSearch' && t.deferred); - const hasWebFetch = builtinTools.some((t) => t.name === 'WebFetch' && t.deferred); - if (hasWebSearch || hasWebFetch) { - items.push({ - prefix: '@web', - label: 'Web', - description: 'Search the web and fetch URLs', - icon: , - source: 'builtin', - }); - } - - for (const tool of Object.values(customTools)) { - if (!tool.mcp_config || Object.keys(tool.mcp_config).length === 0) continue; - const services = tool.tool_permissions?._services as Record | undefined; - if (!services) continue; - const perms = tool.tool_permissions as Record; - const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record; - - const enabledServices: { name: string }[] = []; - for (const [serviceName, serviceTools] of Object.entries(services)) { - const allToolNames = [...(serviceTools.read || []), ...(serviceTools.write || [])]; - const enabled = allToolNames.filter((name) => perms[name] !== 'deny'); - if (enabled.length > 0) enabledServices.push({ name: serviceName }); - } - - if (enabledServices.length === 0) continue; - - const groupEntries = Object.entries(serviceGroups); - const emittedServices = new Set(); - - for (const [groupName, groupServiceNames] of groupEntries) { - const groupCmd = groupName.toLowerCase().replace(/\s+/g, '-'); - const groupServices = enabledServices.filter((s) => groupServiceNames.includes(s.name)); - if (groupServices.length === 0) continue; - groupServices.forEach((s) => emittedServices.add(s.name)); - - const groupIcon = getToolGroupIcon(groupName, 18); - if (groupServices.length >= 2) { - items.push({ - prefix: `@${groupCmd}`, - label: groupName, - description: `Use all ${groupName} actions`, - icon: groupIcon, - source: tool.name, - }); - for (const svc of groupServices) { - items.push({ - prefix: `@${groupCmd}/${svc.name.toLowerCase().replace(/\s+/g, '-')}`, - label: svc.name, - description: `Use ${svc.name} actions from ${tool.name}`, - icon: groupIcon, - source: tool.name, - isChild: true, - }); - } - } else { - const svc = groupServices[0]; - items.push({ - prefix: `@${svc.name.toLowerCase().replace(/\s+/g, '-')}`, - label: svc.name, - description: `Use ${svc.name} actions from ${tool.name}`, - icon: groupIcon, - source: tool.name, - }); - } - } - - for (const svc of enabledServices) { - if (emittedServices.has(svc.name)) continue; - items.push({ - prefix: `@${svc.name.toLowerCase().replace(/\s+/g, '-')}`, - label: svc.name, - description: `Use ${svc.name} actions from ${tool.name}`, - icon: , - source: tool.name, - }); - } - } - - for (const out of Object.values(outputItems)) { - if (out.permission === 'deny') continue; - const cmd = out.name.toLowerCase().replace(/\s+/g, '-'); - items.push({ - prefix: `@${cmd}`, - label: out.name, - description: out.description || `Render ${out.name} view`, - icon: , - source: 'view', - }); - } - - return items; - }, [builtinTools, customTools, outputItems]); - - const navShortcuts = SHORTCUTS.filter((s) => s.category === 'navigation'); - const actionShortcuts = SHORTCUTS.filter((s) => s.category === 'action'); + const { slashCommands, atCommands, modesMap, navShortcuts, actionShortcuts } = useCommands(); return ( - {/* Slash Commands */} - - } - title="Slash Commands" - subtitle="Type / in chat to invoke templates, skills, and modes" - count={slashCommands.length} - c={c} - /> - - {slashCommands.length === 0 ? ( - - - - No slash commands yet. Create templates, skills, or modes to see them here. - - - ) : ( - - {slashCommands.map((cmd) => ( - - - {cmd.type === 'template' ? ( - - ) : cmd.type === 'mode' ? ( - - ) : ( - - )} - - - /{cmd.command} - - - - {cmd.description} - - - ))} - - )} - - - - - {/* @ Commands */} - - } - title="@ Context Commands" - subtitle="Type @ in chat to attach context and activate actions" - count={atCommands.length} - c={c} - /> - - {atCommands.length === 0 ? ( - - - - No @ commands yet. Install MCP actions to see them here. - - - ) : ( - - {atCommands.map((cmd) => ( - - - {cmd.icon} - - - {cmd.prefix} - - - - {cmd.description} - - - ))} - - )} - - - - - {/* Keyboard Shortcuts */} - - } - title="Keyboard Shortcuts" - subtitle="Press ? anywhere to see the quick-reference dialog" - count={SHORTCUTS.length} - c={c} - /> - - - {/* Navigation */} - - - Navigation - - - {navShortcuts.map((s) => ( - - - {s.description} - - - - ))} - - - - {/* Actions */} - - - Actions - - - {actionShortcuts.map((s) => ( - - - {s.description} - - - - ))} - - - - + + + + + ); }; diff --git a/frontend/src/app/pages/Commands/CommandsHelpers.tsx b/frontend/src/app/pages/Commands/CommandsHelpers.tsx new file mode 100644 index 00000000..c7a42e01 --- /dev/null +++ b/frontend/src/app/pages/Commands/CommandsHelpers.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; + +export const KeyBadge: React.FC<{ keys: string; c: any }> = ({ keys, c }) => ( + + + {keys} + + +); + +export const SectionHeader: React.FC<{ + icon: React.ReactNode; + title: string; + subtitle: string; + count?: number; + c: any; +}> = ({ icon, title, subtitle, count, c }) => ( + + {icon} + + + + {title} + + {count !== undefined && ( + + )} + + {subtitle} + + +); diff --git a/frontend/src/app/pages/Commands/ShortcutsSection.tsx b/frontend/src/app/pages/Commands/ShortcutsSection.tsx new file mode 100644 index 00000000..92311e10 --- /dev/null +++ b/frontend/src/app/pages/Commands/ShortcutsSection.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import KeyboardIcon from '@mui/icons-material/Keyboard'; +import { KeyBadge, SectionHeader } from './CommandsHelpers'; +import { Shortcut, SHORTCUTS } from './commandsTypes'; + +interface ShortcutsSectionProps { + navShortcuts: Shortcut[]; + actionShortcuts: Shortcut[]; + c: any; +} + +const ShortcutsSection: React.FC = ({ navShortcuts, actionShortcuts, c }) => ( + + } + title="Keyboard Shortcuts" + subtitle="Press ? anywhere to see the quick-reference dialog" + count={SHORTCUTS.length} + c={c} + /> + + + + + Navigation + + + {navShortcuts.map((s) => ( + + + {s.description} + + + + ))} + + + + + + Actions + + + {actionShortcuts.map((s) => ( + + + {s.description} + + + + ))} + + + + +); + +export default ShortcutsSection; diff --git a/frontend/src/app/pages/Commands/SlashCommandsSection.tsx b/frontend/src/app/pages/Commands/SlashCommandsSection.tsx new file mode 100644 index 00000000..63a26b16 --- /dev/null +++ b/frontend/src/app/pages/Commands/SlashCommandsSection.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import DescriptionIcon from '@mui/icons-material/Description'; +import PsychologyIcon from '@mui/icons-material/Psychology'; +import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import { SectionHeader } from './CommandsHelpers'; +import { SlashCommand } from './commandsTypes'; + +interface SlashCommandsSectionProps { + slashCommands: SlashCommand[]; + modesMap: Record; + c: any; +} + +const SlashCommandsSection: React.FC = ({ slashCommands, modesMap, c }) => ( + + } + title="Slash Commands" + subtitle="Type / in chat to invoke templates, skills, and modes" + count={slashCommands.length} + c={c} + /> + + {slashCommands.length === 0 ? ( + + + + No slash commands yet. Create templates, skills, or modes to see them here. + + + ) : ( + + {slashCommands.map((cmd) => ( + + + {cmd.type === 'template' ? ( + + ) : cmd.type === 'mode' ? ( + + ) : ( + + )} + + + /{cmd.command} + + + + {cmd.description} + + + ))} + + )} + +); + +export default SlashCommandsSection; diff --git a/frontend/src/app/pages/Commands/commandsTypes.ts b/frontend/src/app/pages/Commands/commandsTypes.ts new file mode 100644 index 00000000..19143294 --- /dev/null +++ b/frontend/src/app/pages/Commands/commandsTypes.ts @@ -0,0 +1,33 @@ +import React from 'react'; + +export interface SlashCommand { + id: string; + type: 'template' | 'skill' | 'mode'; + name: string; + description: string; + command: string; +} + +export interface AtCommand { + prefix: string; + label: string; + description: string; + icon: React.ReactNode; + source: string; + isChild?: boolean; +} + +export interface Shortcut { + key: string; + description: string; + category: 'navigation' | 'action'; +} + +export const SHORTCUTS: Shortcut[] = [ + { key: 'd', description: 'Go to Dashboard', category: 'navigation' }, + { key: 't', description: 'Go to Templates', category: 'navigation' }, + { key: '1-9', description: 'Open agent by position', category: 'navigation' }, + { key: 'Shift+A', description: 'Approve all pending', category: 'action' }, + { key: 'Shift+D', description: 'Deny all pending', category: 'action' }, + { key: '?', description: 'Show shortcuts dialog', category: 'navigation' }, +]; diff --git a/frontend/src/app/pages/Commands/hooks/useCommands.tsx b/frontend/src/app/pages/Commands/hooks/useCommands.tsx new file mode 100644 index 00000000..0429b0cf --- /dev/null +++ b/frontend/src/app/pages/Commands/hooks/useCommands.tsx @@ -0,0 +1,168 @@ +import { useEffect, useMemo } from 'react'; +import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; +import LanguageIcon from '@mui/icons-material/Language'; +import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; +import ViewQuiltOutlinedIcon from '@mui/icons-material/ViewQuiltOutlined'; +import { useAppSelector, useAppDispatch } from '@/shared/hooks'; +import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice'; +import { getToolGroupIcon } from '@/app/components/CommandPicker'; +import { fetchOutputs } from '@/shared/state/outputsSlice'; +import { fetchTemplates } from '@/shared/state/templatesSlice'; +import { fetchSkills } from '@/shared/state/skillsSlice'; +import { fetchModes } from '@/shared/state/modesSlice'; +import { SlashCommand, AtCommand, SHORTCUTS } from '../commandsTypes'; + +export function useCommands() { + const dispatch = useAppDispatch(); + const templates = useAppSelector((state) => state.templates.items); + const skills = useAppSelector((state) => state.skills.items); + const modesMap = useAppSelector((state) => state.modes.items); + const builtinTools = useAppSelector((state) => state.tools.builtinTools); + const customTools = useAppSelector((state) => state.tools.items); + const outputItems = useAppSelector((state) => state.outputs.items); + + const templatesLoaded = useAppSelector((state) => state.templates.loaded); + const skillsLoaded = useAppSelector((state) => state.skills.loaded); + const modesLoaded = useAppSelector((state) => state.modes.loaded); + const builtinLoaded = useAppSelector((state) => state.tools.builtinLoaded); + const toolsLoaded = useAppSelector((state) => state.tools.loaded); + const outputsLoaded = useAppSelector((state) => state.outputs.loaded); + + useEffect(() => { + if (!templatesLoaded) dispatch(fetchTemplates()); + if (!skillsLoaded) dispatch(fetchSkills()); + if (!modesLoaded) dispatch(fetchModes()); + if (!builtinLoaded) dispatch(fetchBuiltinTools()); + if (!toolsLoaded) dispatch(fetchTools()); + if (!outputsLoaded) dispatch(fetchOutputs()); + }, [dispatch, templatesLoaded, skillsLoaded, modesLoaded, builtinLoaded, toolsLoaded, outputsLoaded]); + + const slashCommands: SlashCommand[] = useMemo(() => [ + ...Object.values(templates).map((t) => ({ + id: t.id, + type: 'template' as const, + name: t.name, + description: t.description || `Template with ${t.fields.length} fields`, + command: t.name.toLowerCase().replace(/\s+/g, '-'), + })), + ...Object.values(skills).map((s) => ({ + id: s.id, + type: 'skill' as const, + name: s.name, + description: s.description || 'Skill', + command: s.command || s.id, + })), + ...Object.values(modesMap).map((m) => ({ + id: m.id, + type: 'mode' as const, + name: m.name, + description: m.description || 'Switch to this mode', + command: m.name.toLowerCase().replace(/\s+/g, '-'), + })), + ], [templates, skills, modesMap]); + + const atCommands: AtCommand[] = useMemo(() => { + const items: AtCommand[] = [ + { prefix: '@file', label: 'File', description: 'Attach a file or folder as context', icon: , source: 'builtin' }, + ]; + + const hasWebSearch = builtinTools.some((t) => t.name === 'WebSearch' && t.deferred); + const hasWebFetch = builtinTools.some((t) => t.name === 'WebFetch' && t.deferred); + if (hasWebSearch || hasWebFetch) { + items.push({ + prefix: '@web', + label: 'Web', + description: 'Search the web and fetch URLs', + icon: , + source: 'builtin', + }); + } + + for (const tool of Object.values(customTools)) { + if (!tool.mcp_config || Object.keys(tool.mcp_config).length === 0) continue; + const services = tool.tool_permissions?._services as Record | undefined; + if (!services) continue; + const perms = tool.tool_permissions as Record; + const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record; + + const enabledServices: { name: string }[] = []; + for (const [serviceName, serviceTools] of Object.entries(services)) { + const allToolNames = [...(serviceTools.read || []), ...(serviceTools.write || [])]; + const enabled = allToolNames.filter((name) => perms[name] !== 'deny'); + if (enabled.length > 0) enabledServices.push({ name: serviceName }); + } + + if (enabledServices.length === 0) continue; + + const groupEntries = Object.entries(serviceGroups); + const emittedServices = new Set(); + + for (const [groupName, groupServiceNames] of groupEntries) { + const groupCmd = groupName.toLowerCase().replace(/\s+/g, '-'); + const groupServices = enabledServices.filter((s) => groupServiceNames.includes(s.name)); + if (groupServices.length === 0) continue; + groupServices.forEach((s) => emittedServices.add(s.name)); + + const groupIcon = getToolGroupIcon(groupName, 18); + if (groupServices.length >= 2) { + items.push({ + prefix: `@${groupCmd}`, + label: groupName, + description: `Use all ${groupName} actions`, + icon: groupIcon, + source: tool.name, + }); + for (const svc of groupServices) { + items.push({ + prefix: `@${groupCmd}/${svc.name.toLowerCase().replace(/\s+/g, '-')}`, + label: svc.name, + description: `Use ${svc.name} actions from ${tool.name}`, + icon: groupIcon, + source: tool.name, + isChild: true, + }); + } + } else { + const svc = groupServices[0]; + items.push({ + prefix: `@${svc.name.toLowerCase().replace(/\s+/g, '-')}`, + label: svc.name, + description: `Use ${svc.name} actions from ${tool.name}`, + icon: groupIcon, + source: tool.name, + }); + } + } + + for (const svc of enabledServices) { + if (emittedServices.has(svc.name)) continue; + items.push({ + prefix: `@${svc.name.toLowerCase().replace(/\s+/g, '-')}`, + label: svc.name, + description: `Use ${svc.name} actions from ${tool.name}`, + icon: , + source: tool.name, + }); + } + } + + for (const out of Object.values(outputItems)) { + if (out.permission === 'deny') continue; + const cmd = out.name.toLowerCase().replace(/\s+/g, '-'); + items.push({ + prefix: `@${cmd}`, + label: out.name, + description: out.description || `Render ${out.name} view`, + icon: , + source: 'view', + }); + } + + return items; + }, [builtinTools, customTools, outputItems]); + + const navShortcuts = SHORTCUTS.filter((s) => s.category === 'navigation'); + const actionShortcuts = SHORTCUTS.filter((s) => s.category === 'action'); + + return { slashCommands, atCommands, modesMap, navShortcuts, actionShortcuts }; +} diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx index 43e1616f..6fb5efe6 100644 --- a/frontend/src/app/pages/Dashboard/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx @@ -3,182 +3,26 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Chip from '@mui/material/Chip'; import IconButton from '@mui/material/IconButton'; -import Button from '@mui/material/Button'; import Tooltip from '@mui/material/Tooltip'; -import CheckIcon from '@mui/icons-material/Check'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import CancelIcon from '@mui/icons-material/Cancel'; import CloseIcon from '@mui/icons-material/Close'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import TerminalIcon from '@mui/icons-material/Terminal'; import { motion } from 'framer-motion'; -import { - AgentSession, - handleApproval, - toggleExpandSession, - collapseSession, - closeSession, -} from '@/shared/state/agentsSlice'; -import { - setCardPosition, - setCardSize, - fadeGlowingAgentCard, - clearGlowingAgentCard, - removeCard, -} from '@/shared/state/dashboardLayoutSlice'; +import { AgentSession, toggleExpandSession, collapseSession, closeSession } from '@/shared/state/agentsSlice'; +import { setCardPosition, setCardSize, fadeGlowingAgentCard, clearGlowingAgentCard, removeCard } from '@/shared/state/dashboardLayoutSlice'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { QuestionForm } from '@/app/pages/AgentChat/ApprovalBar'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; -import { parseMcpToolName, getMcpShortAction } from '@/app/pages/AgentChat/ToolCallBubble'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useOverlayScrollPassthrough } from './useOverlayScrollPassthrough'; - -// --------------------------------------------------------------------------- -// Helper components & functions (unchanged) -// --------------------------------------------------------------------------- - -const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => { - if (service === 'gmail') { - return ( - - - - - - - - - ); - } - if (service === 'calendar') { - return ( - - - - 31 - - ); - } - if (service === 'drive' || service === 'sheets') { - return ( - - - - - - - ); - } - return null; -}; - -function formatDuration(createdAt: string, closedAt?: string | null, status?: string): string { - const start = new Date(createdAt).getTime(); - const end = (closedAt ? new Date(closedAt).getTime() : null) - || (status === 'running' || status === 'waiting_approval' ? Date.now() : Date.now()); - const seconds = Math.max(0, Math.floor((end - start) / 1000)); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ${seconds % 60}s`; - const hours = Math.floor(minutes / 60); - return `${hours}h ${minutes % 60}m`; -} - -function summarizeToolInput(toolName: string, toolInput: Record): string { - const mcp = parseMcpToolName(toolName); - if (mcp.isMcp) { - const keys = Object.keys(toolInput || {}); - if (keys.length === 0) return ''; - if (keys.length === 1) { - const v = toolInput[keys[0]]; - const s = typeof v === 'string' ? v : JSON.stringify(v); - return s.length > 60 ? s.slice(0, 60) + '…' : s; - } - return keys.slice(0, 3).map((k) => { - const v = toolInput[k]; - const s = typeof v === 'string' ? v : JSON.stringify(v); - return `${k}: ${s.length > 30 ? s.slice(0, 30) + '…' : s}`; - }).join(' '); - } - switch (toolName) { - case 'Bash': - return toolInput.command || '(command)'; - case 'Read': - return toolInput.file_path || toolInput.path || '(file)'; - case 'Write': - case 'Edit': - return toolInput.file_path || toolInput.path || '(file)'; - case 'Grep': - return `/${toolInput.pattern || ''}/${toolInput.path ? ` in ${toolInput.path}` : ''}`; - case 'Glob': - return toolInput.glob_pattern || toolInput.pattern || '(pattern)'; - case 'AskUserQuestion': { - const questions = toolInput.questions; - if (Array.isArray(questions) && questions.length > 0) { - return questions[0].question || questions[0].prompt || questions[0].text || 'Question pending'; - } - return 'Question pending'; - } - default: { - return toolInput.command || toolInput.file_path || toolInput.path || toolInput.query - || JSON.stringify(toolInput).slice(0, 60); - } - } -} - -function getToolDisplayName(toolName: string): string { - const mcp = parseMcpToolName(toolName); - if (mcp.isMcp) return mcp.displayName; - return toolName; -} - -// --------------------------------------------------------------------------- -// Resize handle definitions -// --------------------------------------------------------------------------- - -type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; - -const EDGE_THICKNESS = 6; -const CORNER_SIZE = 14; - -const CURSOR_MAP: Record = { - n: 'ns-resize', - s: 'ns-resize', - e: 'ew-resize', - w: 'ew-resize', - nw: 'nwse-resize', - se: 'nwse-resize', - ne: 'nesw-resize', - sw: 'nesw-resize', -}; - -const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ - { dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } }, - { dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } }, - { dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } }, - { dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } }, - { dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, - { dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, - { dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, - { dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, -]; - -// --------------------------------------------------------------------------- -// AgentCard -// --------------------------------------------------------------------------- +import { type ResizeDir, DRAG_THRESHOLD, CURSOR_MAP, HANDLE_DEFS } from './cardLayoutConstants'; +import CardGlowOverlay from './CardGlowOverlay'; +import AgentCardCollapsed from './AgentCardCollapsed'; +import { formatDuration, getStatusColors, getPreviewContent } from './agentCardUtils'; interface Props { - session: AgentSession; - expanded: boolean; - cardX: number; - cardY: number; - cardWidth: number; - cardHeight: number; - zoom?: number; - spawnFrom?: { x: number; y: number; type?: 'branch' }; - exitTarget?: { x: number; y: number }; - isSelected?: boolean; - isHighlighted?: boolean; + session: AgentSession; expanded: boolean; + cardX: number; cardY: number; cardWidth: number; cardHeight: number; + zoom?: number; spawnFrom?: { x: number; y: number; type?: 'branch' }; + exitTarget?: { x: number; y: number }; isSelected?: boolean; isHighlighted?: boolean; multiDragDelta?: { dx: number; dy: number } | null; onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void; onDragStart?: (id: string, type: 'agent' | 'view') => void; @@ -186,25 +30,16 @@ interface Props { onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void; onBranch?: (sourceSessionId: string, newSessionId: string) => void; onMeasuredHeight?: (sessionId: string, height: number) => void; - snapColumn?: { x: number; width: number }; - autoFocusInput?: boolean; - cardZOrder?: number; - onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void; - isFocused?: boolean; - onFocusRequest?: (sessionId: string) => void; - onFocusExit?: () => void; + snapColumn?: { x: number; width: number }; autoFocusInput?: boolean; + cardZOrder?: number; onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void; + isFocused?: boolean; onFocusRequest?: (sessionId: string) => void; onFocusExit?: () => void; } -const MIN_W = 480; -const MIN_H = 120; -const EXPANDED_OVERLAY_H = 620; - +const MIN_W = 480, MIN_H = 120, EXPANDED_OVERLAY_H = 620; const SPAWN_SPRING = { type: 'spring' as const, stiffness: 400, damping: 28, mass: 0.6 }; const BRANCH_SPRING = { type: 'spring' as const, stiffness: 300, damping: 26, mass: 0.8 }; const EXIT_SPRING = { type: 'spring' as const, stiffness: 350, damping: 30, mass: 0.7 }; -const GLOW_FADE_MS = 2500; - -const SNAP_THRESHOLD = 60; +const GLOW_FADE_MS = 2500, SNAP_THRESHOLD = 60; const AgentCard: React.FC = ({ session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom, exitTarget, @@ -216,845 +51,195 @@ const AgentCard: React.FC = ({ const dispatch = useAppDispatch(); const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); - const cardBoxRef = useRef(null); useEffect(() => { const el = cardBoxRef.current; if (!el || !onMeasuredHeight) return; const ro = new ResizeObserver((entries) => { - for (const entry of entries) { - onMeasuredHeight(session.id, entry.contentRect.height); - } + for (const entry of entries) onMeasuredHeight(session.id, entry.contentRect.height); }); ro.observe(el); return () => ro.disconnect(); }, [session.id, onMeasuredHeight]); - - // ---- Glow state (for branched cards) ---- const glowEntry = useAppSelector((s) => s.dashboardLayout.glowingAgentCards[session.id]); const isGlowingRedux = !!glowEntry; const glowFading = glowEntry?.fading ?? false; const glowFadeTimer = useRef | null>(null); - const dismissGlow = useCallback(() => { if (!isGlowingRedux || glowFading) return; dispatch(fadeGlowingAgentCard(session.id)); - glowFadeTimer.current = setTimeout(() => { - dispatch(clearGlowingAgentCard(session.id)); - }, GLOW_FADE_MS + 300); + glowFadeTimer.current = setTimeout(() => dispatch(clearGlowingAgentCard(session.id)), GLOW_FADE_MS + 300); }, [isGlowingRedux, glowFading, dispatch, session.id]); - - useEffect(() => () => { - if (glowFadeTimer.current) clearTimeout(glowFadeTimer.current); - }, []); - - const accentColor = c.accent.primary; - const accentHover = c.accent.hover; - - const STATUS_COLORS: Record = { - running: { color: c.status.success, bg: c.status.successBg }, - waiting_approval: { color: c.status.warning, bg: c.status.warningBg }, - completed: { color: c.text.tertiary, bg: c.bg.secondary }, - error: { color: c.status.error, bg: c.status.errorBg }, - stopped: { color: c.text.tertiary, bg: c.bg.secondary }, - draft: { color: c.accent.primary, bg: c.bg.secondary }, - }; - + useEffect(() => () => { if (glowFadeTimer.current) clearTimeout(glowFadeTimer.current); }, []); + const accentColor = c.accent.primary, accentHover = c.accent.hover; + const statusStyle = getStatusColors(c)[session.status] || { color: c.text.tertiary, bg: c.bg.secondary }; const [, setTick] = useState(0); const isDraft = session.status === 'draft'; - - // ---- Drag via header (pointer events) ---- - const DRAG_THRESHOLD = 3; const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); const [isDragging, setIsDragging] = useState(false); const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null); - const didDrag = useRef(false); - const justDraggedRef = useRef(false); - + const didDrag = useRef(false); const justDraggedRef = useRef(false); const handleDragPointerDown = useCallback((e: React.PointerEvent) => { if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); + e.preventDefault(); e.stopPropagation(); dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY }; - didDrag.current = false; - setIsDragging(true); + didDrag.current = false; setIsDragging(true); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); onDragStart?.(session.id, 'agent'); }, [cardX, cardY, onDragStart, session.id]); - const handleDragPointerMove = useCallback((e: React.PointerEvent) => { if (!dragState.current) return; - const rawDx = e.clientX - dragState.current.startX; - const rawDy = e.clientY - dragState.current.startY; + const rawDx = e.clientX - dragState.current.startX, rawDy = e.clientY - dragState.current.startY; if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return; didDrag.current = true; - const dx = rawDx / zoom; - const dy = rawDy / zoom; - setLocalDragPos({ - x: dragState.current.origX + dx, - y: dragState.current.origY + dy, - }); + const dx = rawDx / zoom, dy = rawDy / zoom; + setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy }); onDragMove?.(dx, dy); }, [zoom, onDragMove]); - const handleDragPointerUp = useCallback((e: React.PointerEvent) => { if (!dragState.current) return; - const dx = (e.clientX - dragState.current.startX) / zoom; - const dy = (e.clientY - dragState.current.startY) / zoom; + const dx = (e.clientX - dragState.current.startX) / zoom, dy = (e.clientY - dragState.current.startY) / zoom; if (didDrag.current) { - let finalX = dragState.current.origX + dx; - const finalY = dragState.current.origY + dy; - + let finalX = dragState.current.origX + dx; const finalY = dragState.current.origY + dy; if (snapColumn && Math.abs(finalX - snapColumn.x) < SNAP_THRESHOLD) { finalX = snapColumn.x; dispatch(setCardSize({ sessionId: session.id, width: snapColumn.width, height: cardHeight })); } - dispatch(setCardPosition({ sessionId: session.id, x: finalX, y: finalY })); justDraggedRef.current = true; requestAnimationFrame(() => { justDraggedRef.current = false; }); } onDragEnd?.(dx, dy, didDrag.current); - dragState.current = null; - didDrag.current = false; - setLocalDragPos(null); - setIsDragging(false); + dragState.current = null; didDrag.current = false; setLocalDragPos(null); setIsDragging(false); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); }, [zoom, dispatch, session.id, onDragEnd, snapColumn, cardHeight]); - - // ---- Unified edge / corner resize ---- - const resizeRef = useRef<{ - dir: ResizeDir; - startX: number; - startY: number; - origX: number; - origY: number; - origW: number; - origH: number; - } | null>(null); + const resizeRef = useRef<{ dir: ResizeDir; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null); const [isResizing, setIsResizing] = useState(false); const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null); - - const handleResizeDown = useCallback( - (dir: ResizeDir) => (e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - const effectiveW = Math.max(cardWidth, MIN_W); - const effectiveH = expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : cardHeight; - resizeRef.current = { - dir, - startX: e.clientX, - startY: e.clientY, - origX: cardX, - origY: cardY, - origW: effectiveW, - origH: effectiveH, - }; - setIsResizing(true); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, - [cardX, cardY, cardWidth, cardHeight, expanded], - ); - - const computeResize = useCallback( - (e: React.PointerEvent) => { - if (!resizeRef.current) return null; - const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current; - const dx = (e.clientX - startX) / zoom; - const dy = (e.clientY - startY) / zoom; - - let newX = origX, newY = origY, newW = origW, newH = origH; - - if (dir.includes('e')) newW = origW + dx; - if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; } - if (dir.includes('s')) newH = origH + dy; - if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; } - - if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; } - if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; } - - return { x: newX, y: newY, w: newW, h: newH }; - }, - [zoom], - ); - - const handleResizeMove = useCallback( - (e: React.PointerEvent) => { - const result = computeResize(e); - if (result) setLocalResize(result); - }, - [computeResize], - ); - + const handleResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); e.stopPropagation(); + const effectiveW = Math.max(cardWidth, MIN_W), effectiveH = expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : cardHeight; + resizeRef.current = { dir, startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, origW: effectiveW, origH: effectiveH }; + setIsResizing(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, [cardX, cardY, cardWidth, cardHeight, expanded]); + const computeResize = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return null; + const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current; + const dx = (e.clientX - startX) / zoom, dy = (e.clientY - startY) / zoom; + let newX = origX, newY = origY, newW = origW, newH = origH; + if (dir.includes('e')) newW = origW + dx; + if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; } + if (dir.includes('s')) newH = origH + dy; + if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; } + if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; } + if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; } + return { x: newX, y: newY, w: newW, h: newH }; + }, [zoom]); + const handleResizeMove = useCallback((e: React.PointerEvent) => { const r = computeResize(e); if (r) setLocalResize(r); }, [computeResize]); const handleResizeUp = useCallback((e: React.PointerEvent) => { if (!resizeRef.current) return; - const result = computeResize(e); - if (result) { - dispatch(setCardPosition({ sessionId: session.id, x: result.x, y: result.y })); - dispatch(setCardSize({ sessionId: session.id, width: result.w, height: result.h })); - } - resizeRef.current = null; - setLocalResize(null); - setIsResizing(false); + const r = computeResize(e); + if (r) { dispatch(setCardPosition({ sessionId: session.id, x: r.x, y: r.y })); dispatch(setCardSize({ sessionId: session.id, width: r.w, height: r.h })); } + resizeRef.current = null; setLocalResize(null); setIsResizing(false); (e.target as HTMLElement).releasePointerCapture(e.pointerId); }, [computeResize, dispatch, session.id]); - const handleRemove = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - dispatch(collapseSession(session.id)); - dispatch(removeCard(session.id)); - if (glowEntry) { - setTimeout(() => { - dispatch(clearGlowingAgentCard(session.id)); - }, 500); - } else { - dispatch(closeSession({ sessionId: session.id })); - } + e.stopPropagation(); e.preventDefault(); + dispatch(collapseSession(session.id)); dispatch(removeCard(session.id)); + if (glowEntry) setTimeout(() => dispatch(clearGlowingAgentCard(session.id)), 500); + else dispatch(closeSession({ sessionId: session.id })); }; - - useEffect(() => { if (session.status === 'running' || session.status === 'waiting_approval') { const interval = setInterval(() => setTick((t) => t + 1), 1000); return () => clearInterval(interval); } }, [session.status]); - - const lastMessage = session.messages[session.messages.length - 1]; - const isStreaming = !!session.streamingMessage; - const previewContent = isStreaming - ? (session.streamingMessage!.role === 'tool_call' - ? `[${getToolDisplayName(session.streamingMessage!.tool_name || '')}] ${session.streamingMessage!.content}` - : session.streamingMessage!.content - ).slice(0, 120) - : lastMessage && typeof lastMessage.content === 'string' - ? lastMessage.content.slice(0, 120) - : ''; + const { content: previewContent, isStreaming } = getPreviewContent(session); const hasPending = session.pending_approvals.length > 0; - const pendingReq = session.pending_approvals[0]; - const statusStyle = STATUS_COLORS[session.status] || { color: c.text.tertiary, bg: c.bg.secondary }; - const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); - const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0; const activeX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx); const activeY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy); - const activeW = localResize?.w ?? cardWidth; - const activeH = localResize?.h ?? cardHeight; - + const activeW = localResize?.w ?? cardWidth, activeH = localResize?.h ?? cardHeight; const isBranchSpawn = spawnFrom?.type === 'branch'; const spawnInitial = spawnFrom - ? isBranchSpawn - ? { opacity: 0.5, scale: 0.92, left: spawnFrom.x, top: spawnFrom.y } - : { opacity: 0, scale: 0.3, left: spawnFrom.x, top: spawnFrom.y } + ? isBranchSpawn ? { opacity: 0.5, scale: 0.92, left: spawnFrom.x, top: spawnFrom.y } : { opacity: 0, scale: 0.3, left: spawnFrom.x, top: spawnFrom.y } : false; - const spawnTransition = noTransition || !spawnFrom - ? { duration: 0 } - : isBranchSpawn - ? { left: BRANCH_SPRING, top: BRANCH_SPRING, scale: BRANCH_SPRING, opacity: { duration: 0.25 } } - : { left: SPAWN_SPRING, top: SPAWN_SPRING, scale: SPAWN_SPRING, opacity: { duration: 0.12 } }; - + const spawnTransition = noTransition || !spawnFrom ? { duration: 0 } + : isBranchSpawn ? { left: BRANCH_SPRING, top: BRANCH_SPRING, scale: BRANCH_SPRING, opacity: { duration: 0.25 } } + : { left: SPAWN_SPRING, top: SPAWN_SPRING, scale: SPAWN_SPRING, opacity: { duration: 0.12 } }; const exitAnimation = exitTarget - ? { - opacity: 0, - scale: 0.3, - left: exitTarget.x, - top: exitTarget.y, - transition: { left: EXIT_SPRING, top: EXIT_SPRING, scale: EXIT_SPRING, opacity: { duration: 0.2 } }, - } + ? { opacity: 0, scale: 0.3, left: exitTarget.x, top: exitTarget.y, transition: { left: EXIT_SPRING, top: EXIT_SPRING, scale: EXIT_SPRING, opacity: { duration: 0.2 } } } : { opacity: 0, scale: 0.85, transition: { duration: 0.2 } }; if (isFocused) { - // Focus mode: render as a simple box filling its container (outside canvas transform) return ( - - {/* Header with close button */} - { e.stopPropagation(); onFocusExit?.(); }} - sx={{ - display: 'flex', alignItems: 'center', justifyContent: 'space-between', - mb: 1, flexShrink: 0, cursor: 'default', - }} - > + + { e.stopPropagation(); onFocusExit?.(); }} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, flexShrink: 0, cursor: 'default' }}> - - {session.name || 'Agent'} - + {session.name || 'Agent'} {session.model} · {formatDuration(session.created_at, undefined, session.status)} - onFocusExit?.()} sx={{ color: c.text.ghost }}> - - - - {/* Chat fills remaining space */} - - + onFocusExit?.()} sx={{ color: c.text.ghost }}> + ); } return ( - onBringToFront?.(session.id, 'agent')} - style={{ - position: 'absolute', - zIndex: isDragging || isResizing ? 999999 : cardZOrder, - }} - > - { - if (justDraggedRef.current) return; - if (!isSelected && !e.shiftKey) { - dispatch(toggleExpandSession(session.id)); - } - onCardSelect?.(session.id, 'agent', e.shiftKey); - }} + onBringToFront?.(session.id, 'agent')} style={{ position: 'absolute', zIndex: isDragging || isResizing ? 999999 : cardZOrder }}> + { if (justDraggedRef.current) return; if (!isSelected && !e.shiftKey) dispatch(toggleExpandSession(session.id)); onCardSelect?.(session.id, 'agent', e.shiftKey); }} sx={{ - position: 'relative', - width: localResize ? activeW : Math.max(cardWidth, MIN_W), - height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'), + position: 'relative', width: localResize ? activeW : Math.max(cardWidth, MIN_W), height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'), bgcolor: c.bg.surface, - border: isHighlighted - ? `2px solid ${c.accent.primary}` - : (isGlowingRedux && !glowFading) - ? `2px solid ${accentColor}` - : isSelected - ? '2px solid #3b82f6' - : hasPending && !expanded - ? `1px solid ${c.status.warning}` - : expanded - ? `1px solid ${c.border.strong}` - : `1px solid ${c.border.subtle}`, - borderRadius: 3, - p: 2, - cursor: expanded ? 'default' : 'pointer', - transition: noTransition - ? 'none' - : glowFading - ? `border ${GLOW_FADE_MS}ms ease-out, box-shadow ${GLOW_FADE_MS}ms ease-out` - : c.transition, - boxShadow: isHighlighted - ? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15` - : (isGlowingRedux && !glowFading) - ? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15` - : isDragging - ? c.shadow.lg - : isSelected - ? `0 0 0 1px #3b82f6, ${c.shadow.md}` - : expanded - ? c.shadow.md - : c.shadow.sm, - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - ...(isHighlighted && { - animation: 'card-highlight-pulse 2s ease-out forwards', - '@keyframes card-highlight-pulse': { - '0%': { - boxShadow: `0 0 0 3px ${c.accent.primary}70, 0 0 24px ${c.accent.primary}50, 0 0 48px ${c.accent.primary}25`, - }, - '25%': { - boxShadow: `0 0 0 4px ${c.accent.primary}55, 0 0 30px ${c.accent.primary}40, 0 0 56px ${c.accent.primary}20`, - }, - '50%': { - boxShadow: `0 0 0 3px ${c.accent.primary}45, 0 0 22px ${c.accent.primary}30, 0 0 44px ${c.accent.primary}15`, - }, - '75%': { - boxShadow: `0 0 0 2px ${c.accent.primary}25, 0 0 14px ${c.accent.primary}18, 0 0 28px ${c.accent.primary}08`, - }, - '100%': { - boxShadow: c.shadow.sm, - }, - }, - }), - ...(!isHighlighted && isGlowingRedux && !glowFading && { - animation: 'agent-card-glow-pulse 2s ease-in-out infinite', - '@keyframes agent-card-glow-pulse': { - '0%, 100%': { - boxShadow: `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15`, - }, - '50%': { - boxShadow: `0 0 0 3px ${accentColor}60, 0 0 28px ${accentColor}45, 0 0 56px ${accentColor}25`, - }, - }, - }), - ...(!isHighlighted && !(isGlowingRedux && !glowFading) && !expanded && !isDragging && !isSelected && { - '&:hover': { - boxShadow: c.shadow.md, - borderColor: hasPending ? c.status.warning : c.border.strong, - }, - }), - }} - > - {/* Glow overlays for branched cards */} - {isGlowingRedux && ( - - {/* Rotating conic gradient border */} - - {/* Top edge shimmer */} - - {/* Inner shadow overlay */} - - - )} - - {/* Resize handles: 4 edges + 4 corners (hidden in focus mode) */} + border: isHighlighted ? `2px solid ${c.accent.primary}` : (isGlowingRedux && !glowFading) ? `2px solid ${accentColor}` : isSelected ? '2px solid #3b82f6' : hasPending && !expanded ? `1px solid ${c.status.warning}` : expanded ? `1px solid ${c.border.strong}` : `1px solid ${c.border.subtle}`, + borderRadius: 3, p: 2, cursor: expanded ? 'default' : 'pointer', + transition: noTransition ? 'none' : glowFading ? `border ${GLOW_FADE_MS}ms ease-out, box-shadow ${GLOW_FADE_MS}ms ease-out` : c.transition, + boxShadow: isHighlighted ? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15` : (isGlowingRedux && !glowFading) ? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15` : isDragging ? c.shadow.lg : isSelected ? `0 0 0 1px #3b82f6, ${c.shadow.md}` : expanded ? c.shadow.md : c.shadow.sm, + display: 'flex', flexDirection: 'column', overflow: 'hidden', + ...(isHighlighted && { animation: 'card-highlight-pulse 2s ease-out forwards', '@keyframes card-highlight-pulse': { '0%': { boxShadow: `0 0 0 3px ${c.accent.primary}70, 0 0 24px ${c.accent.primary}50, 0 0 48px ${c.accent.primary}25` }, '25%': { boxShadow: `0 0 0 4px ${c.accent.primary}55, 0 0 30px ${c.accent.primary}40, 0 0 56px ${c.accent.primary}20` }, '50%': { boxShadow: `0 0 0 3px ${c.accent.primary}45, 0 0 22px ${c.accent.primary}30, 0 0 44px ${c.accent.primary}15` }, '75%': { boxShadow: `0 0 0 2px ${c.accent.primary}25, 0 0 14px ${c.accent.primary}18, 0 0 28px ${c.accent.primary}08` }, '100%': { boxShadow: c.shadow.sm } } }), + ...(!isHighlighted && isGlowingRedux && !glowFading && { animation: 'agent-card-glow-pulse 2s ease-in-out infinite', '@keyframes agent-card-glow-pulse': { '0%, 100%': { boxShadow: `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15` }, '50%': { boxShadow: `0 0 0 3px ${accentColor}60, 0 0 28px ${accentColor}45, 0 0 56px ${accentColor}25` } } }), + ...(!isHighlighted && !(isGlowingRedux && !glowFading) && !expanded && !isDragging && !isSelected && { '&:hover': { boxShadow: c.shadow.md, borderColor: hasPending ? c.status.warning : c.border.strong } }), + }}> + {isGlowingRedux && } {!isFocused && HANDLE_DEFS.map(({ dir, sx }) => ( - e.stopPropagation()} - sx={{ - position: 'absolute', - ...sx, - cursor: CURSOR_MAP[dir], - zIndex: 20, - userSelect: 'none', - touchAction: 'none', - }} - /> + e.stopPropagation()} sx={{ position: 'absolute', ...sx, cursor: CURSOR_MAP[dir], zIndex: 20, userSelect: 'none', touchAction: 'none' }} /> ))} - - {/* Selection overlay – blocks click interaction while selected, enabling drag from anywhere */} {isSelected && ( - { - if (justDraggedRef.current) return; - onCardSelect?.(session.id, 'agent', e.shiftKey); - }} - sx={{ - position: 'absolute', - inset: 0, - zIndex: 15, - cursor: isDragging ? 'grabbing' : 'grab', - touchAction: 'none', - }} - /> + { if (justDraggedRef.current) return; onCardSelect?.(session.id, 'agent', e.shiftKey); }} + sx={{ position: 'absolute', inset: 0, zIndex: 15, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none' }} /> )} - - {/* Drag zone: header + metadata – entire region above separator is draggable */} - { - e.stopPropagation(); - onFocusRequest?.(session.id); - }} - sx={{ - position: 'relative', - zIndex: 16, - mx: -2, - mt: -2, - px: 2, - pt: 2, - pb: 1.5, - cursor: isFocused ? 'default' : isDragging ? 'grabbing' : 'grab', - touchAction: 'none', - userSelect: 'none', - flexShrink: 0, - }} - > - - - + { e.stopPropagation(); onFocusRequest?.(session.id); }} sx={{ position: 'relative', zIndex: 16, mx: -2, mt: -2, px: 2, pt: 2, pb: 1.5, cursor: isFocused ? 'default' : isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none', flexShrink: 0 }}> + + + + {session.name} + - - - {session.name} - - - - e.stopPropagation()} - sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }} - > - - e.stopPropagation()} - sx={{ - color: c.text.ghost, - p: 0.5, - '&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` }, - }} - > - - - + e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}> + e.stopPropagation()} sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` } }}> - - {/* Metadata row */} - - - {session.model} - - - {session.mode} - - - {formatDuration(session.created_at, (session as any).closed_at, session.status)} - - {session.cost_usd > 0 && hasApiKey && ( - - ${session.cost_usd.toFixed(4)} - - )} + + {session.model} + {session.mode} + {formatDuration(session.created_at, (session as any).closed_at, session.status)} + {session.cost_usd > 0 && hasApiKey && ${session.cost_usd.toFixed(4)}} - - {/* Expanded: inline chat fills remaining space */} {expanded && ( - e.stopPropagation()} - sx={{ - mx: -2, - mb: -2, - flex: 1, - minHeight: 0, - borderTop: `1px solid ${c.border.subtle}`, - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - }} - > - dispatch(collapseSession(session.id))} - embedded - autoFocus={autoFocusInput} - isGlowing={isGlowingRedux && !glowFading} - onDismissGlow={dismissGlow} - onBranch={onBranch ? (newId: string) => onBranch(session.id, newId) : undefined} - /> + e.stopPropagation()} sx={{ mx: -2, mb: -2, flex: 1, minHeight: 0, borderTop: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> + dispatch(collapseSession(session.id))} embedded autoFocus={autoFocusInput} isGlowing={isGlowingRedux && !glowFading} onDismissGlow={dismissGlow} onBranch={onBranch ? (newId: string) => onBranch(session.id, newId) : undefined} /> )} - - {/* Collapsed: preview + approval */} - {!expanded && ( - <> - {previewContent && ( - - {isStreaming && ( - - )} - - {previewContent} - - - )} - - {hasPending && pendingReq && pendingReq.tool_name === 'AskUserQuestion' ? ( - e.stopPropagation()}> - - dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput })) - } - onDeny={(requestId) => - dispatch(handleApproval({ requestId, behavior: 'deny' })) - } - /> - - ) : hasPending ? ( - e.stopPropagation()} sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> - {pendingReq && ( - - - {(() => { - const mcp = parseMcpToolName(pendingReq.tool_name); - if (mcp.isMcp && mcp.service) return ; - return ; - })()} - - - {getToolDisplayName(pendingReq.tool_name)} - - - {summarizeToolInput(pendingReq.tool_name, pendingReq.tool_input)} - - - - {session.pending_approvals.length === 1 && ( - - - dispatch(handleApproval({ requestId: pendingReq.id, behavior: 'allow' }))} - sx={{ color: c.status.success }} - > - - - - - dispatch(handleApproval({ requestId: pendingReq.id, behavior: 'deny' }))} - sx={{ color: c.status.error }} - > - - - - - )} - - )} - {session.pending_approvals.length > 1 && ( - - - {session.pending_approvals.length} pending approvals - - - - - )} - - ) : null} - - )} + {!expanded && } ); diff --git a/frontend/src/app/pages/Dashboard/AgentCardCollapsed.tsx b/frontend/src/app/pages/Dashboard/AgentCardCollapsed.tsx new file mode 100644 index 00000000..8e0f1cb0 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/AgentCardCollapsed.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import Tooltip from '@mui/material/Tooltip'; +import CheckIcon from '@mui/icons-material/Check'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import CancelIcon from '@mui/icons-material/Cancel'; +import CloseIcon from '@mui/icons-material/Close'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import { AgentSession, handleApproval } from '@/shared/state/agentsSlice'; +import { useAppDispatch } from '@/shared/hooks'; +import { QuestionForm } from '@/app/pages/AgentChat/ApprovalBar'; +import { parseMcpToolName } from '@/app/pages/AgentChat/ToolCallBubble'; +import GoogleServiceIcon from '@/app/components/GoogleServiceIcon'; +import { summarizeToolInput, getToolDisplayName } from './agentCardUtils'; + +interface AgentCardCollapsedProps { + session: AgentSession; + previewContent: string; + isStreaming: boolean; + hasPending: boolean; + statusStyle: { color: string; bg: string }; + c: Record; +} + +const AgentCardCollapsed: React.FC = ({ + session, + previewContent, + isStreaming, + hasPending, + statusStyle, + c, +}) => { + const dispatch = useAppDispatch(); + const pendingReq = session.pending_approvals[0]; + + return ( + <> + {previewContent && ( + + {isStreaming && ( + + )} + + {previewContent} + + + )} + + {hasPending && pendingReq && pendingReq.tool_name === 'AskUserQuestion' ? ( + e.stopPropagation()}> + + dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput })) + } + onDeny={(requestId) => + dispatch(handleApproval({ requestId, behavior: 'deny' })) + } + /> + + ) : hasPending ? ( + e.stopPropagation()} sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> + {pendingReq && ( + + + {(() => { + const mcp = parseMcpToolName(pendingReq.tool_name); + if (mcp.isMcp && mcp.service) return ; + return ; + })()} + + + {getToolDisplayName(pendingReq.tool_name)} + + + {summarizeToolInput(pendingReq.tool_name, pendingReq.tool_input)} + + + + {session.pending_approvals.length === 1 && ( + + + dispatch(handleApproval({ requestId: pendingReq.id, behavior: 'allow' }))} + sx={{ color: c.status.success }} + > + + + + + dispatch(handleApproval({ requestId: pendingReq.id, behavior: 'deny' }))} + sx={{ color: c.status.error }} + > + + + + + )} + + )} + {session.pending_approvals.length > 1 && ( + + + {session.pending_approvals.length} pending approvals + + + + + )} + + ) : null} + + ); +}; + +export default React.memo(AgentCardCollapsed); diff --git a/frontend/src/app/pages/Dashboard/BrowserActionOverlay.tsx b/frontend/src/app/pages/Dashboard/BrowserActionOverlay.tsx new file mode 100644 index 00000000..92c38a6a --- /dev/null +++ b/frontend/src/app/pages/Dashboard/BrowserActionOverlay.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CircularProgress from '@mui/material/CircularProgress'; +import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; +import { getActionLabel } from '@/shared/browserCommandHandler'; + +interface BrowserActionOverlayProps { + agentAction: string | null; + lastAction: string | null; + actionSeq: number; + coords?: { xPercent: number; yPercent: number }; + accentColor: string; + accentRgb: string; + showGlow: boolean; + agentActive: boolean; + browserId: string; + showFrostedOverlay: boolean; +} + +const BrowserActionOverlay: React.FC = ({ + agentAction, lastAction, actionSeq, coords, accentColor, accentRgb, + showGlow, agentActive, browserId, showFrostedOverlay, +}) => ( + <> + {(agentAction === 'screenshot' || lastAction === 'screenshot') && ( + + )} + + {agentAction === 'get_text' && ( + + )} + + {(agentAction === 'click' || lastAction === 'click') && ( + + )} + + {agentAction === 'type' && ( + + {[0, 1, 2].map((i) => ( + + ))} + + )} + + {showGlow && !agentActive && ( + + )} + + {showFrostedOverlay && ( + + + + + + {getActionLabel(agentAction ?? '')} + + + + )} + +); + +export default BrowserActionOverlay; diff --git a/frontend/src/app/pages/Dashboard/BrowserAgentOverlay.tsx b/frontend/src/app/pages/Dashboard/BrowserAgentOverlay.tsx index c780cfa6..b69d4f17 100644 --- a/frontend/src/app/pages/Dashboard/BrowserAgentOverlay.tsx +++ b/frontend/src/app/pages/Dashboard/BrowserAgentOverlay.tsx @@ -9,9 +9,11 @@ import OpenInFullIcon from '@mui/icons-material/OpenInFull'; import CloseFullscreenIcon from '@mui/icons-material/CloseFullscreen'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import { AgentSession, AgentMessage, stopAgent } from '@/shared/state/agentsSlice'; +import { AgentSession, stopAgent } from '@/shared/state/agentsSlice'; import { useAppDispatch } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { summarizeMessage } from './browserAgentOverlayUtils'; +import OverlayActionLog from './OverlayActionLog'; interface Props { session: AgentSession; @@ -19,42 +21,9 @@ interface Props { browserHeight: number; } -function summarizeMessage(msg: AgentMessage): { type: 'thought' | 'action' | 'result' | 'skip'; text: string } { - if (msg.role === 'assistant' && typeof msg.content === 'string') { - const trimmed = msg.content.trim(); - if (!trimmed) return { type: 'skip', text: '' }; - return { type: 'thought', text: trimmed }; - } - - if (msg.role === 'tool_call') { - const content = typeof msg.content === 'string' ? (() => { try { return JSON.parse(msg.content); } catch { return {}; } })() : msg.content; - const tool = content?.tool || content?.name || '?'; - const input = content?.input || {}; - let brief = ''; - switch (tool) { - case 'BrowserNavigate': brief = `Navigate → ${input.url || '...'}`; break; - case 'BrowserClick': brief = `Click ${input.selector || '...'}`; break; - case 'BrowserType': brief = `Type "${(input.text || '').slice(0, 30)}${(input.text || '').length > 30 ? '…' : ''}" into ${input.selector || '...'}`; break; - case 'BrowserScreenshot': brief = 'Screenshot'; break; - case 'BrowserGetText': brief = 'Read page text'; break; - case 'BrowserGetElements': brief = `Inspect elements${input.selector ? ` (${input.selector})` : ''}`; break; - case 'BrowserEvaluate': brief = `Evaluate JS`; break; - default: brief = tool; - } - return { type: 'action', text: brief }; - } - - if (msg.role === 'tool_result') { - return { type: 'result', text: '' }; - } - - return { type: 'skip', text: '' }; -} - const BrowserAgentOverlay: React.FC = ({ session, browserWidth, browserHeight }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const scrollRef = useRef(null); const [expanded, setExpanded] = useState(false); const [confirmStop, setConfirmStop] = useState(false); const [fadeOut, setFadeOut] = useState(false); @@ -90,12 +59,6 @@ const BrowserAgentOverlay: React.FC = ({ session, browserWidth, browserHe return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; }, [fadeOut]); - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [session.messages.length, session.streamingMessage]); - const handleStop = useCallback(() => { if (!confirmStop) { setConfirmStop(true); @@ -160,7 +123,6 @@ const BrowserAgentOverlay: React.FC = ({ session, browserWidth, browserHe }, }} > - {/* Header */} = ({ session, browserWidth, browserHe )} - {/* Body — scrollable action log */} - - {entries.length === 0 && isRunning && ( - - Starting... - - )} - - {entries.map((entry, i) => ( - - {entry.type === 'thought' ? ( - <> - - - {entry.text} - - - ) : ( - <> - - - {entry.text} - - - )} - - ))} - + ); }; diff --git a/frontend/src/app/pages/Dashboard/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/BrowserCard.tsx index a50c7d31..8f9d514e 100644 --- a/frontend/src/app/pages/Dashboard/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/BrowserCard.tsx @@ -1,111 +1,35 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import InputBase from '@mui/material/InputBase'; -import LinearProgress from '@mui/material/LinearProgress'; -import CircularProgress from '@mui/material/CircularProgress'; -import LanguageIcon from '@mui/icons-material/Language'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import CloseIcon from '@mui/icons-material/Close'; -import AddIcon from '@mui/icons-material/Add'; -import LockIcon from '@mui/icons-material/Lock'; -import SearchIcon from '@mui/icons-material/Search'; -import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; -import { - setBrowserCardPosition, - setBrowserCardSize, - removeBrowserCard, - addBrowserTab, - removeBrowserTab, - setActiveBrowserTab, - updateBrowserTabUrl, - updateBrowserTabTitle, - updateBrowserTabFavicon, - reorderBrowserTab, - type BrowserTab, -} from '@/shared/state/dashboardLayoutSlice'; +import { setBrowserCardPosition, setBrowserCardSize, updateBrowserTabUrl, type BrowserTab } from '@/shared/state/dashboardLayoutSlice'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { - registerWebview, - unregisterWebview, - setActiveTab as setRegistryActiveTab, - type BrowserWebview, -} from '@/shared/browserRegistry'; import { useBrowserActivity } from '@/shared/useBrowserActivity'; -import { getActionLabel } from '@/shared/browserCommandHandler'; import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl'; import BrowserAgentOverlay from './BrowserAgentOverlay'; import { useOverlayScrollPassthrough } from './useOverlayScrollPassthrough'; import { useElementSelection } from '@/app/components/ElementSelectionContext'; +import { type ResizeDir, CURSOR_MAP, HANDLE_DEFS, DRAG_THRESHOLD } from './cardLayoutConstants'; +import { useWebviewLifecycle, isElectron, chromeUserAgent, webviewPreloadPath, type TabLocalState, type WebviewElement } from './hooks/useWebviewLifecycle'; +import BrowserTabBar from './BrowserTabBar'; +import BrowserNavBar from './BrowserNavBar'; +import BrowserActionOverlay from './BrowserActionOverlay'; -type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; - -const EDGE_THICKNESS = 6; -const CORNER_SIZE = 14; -const MIN_W = 400; -const MIN_H = 300; - -const CURSOR_MAP: Record = { - n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize', - nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize', -}; - -const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ - { dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } }, - { dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } }, - { dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } }, - { dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } }, - { dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, - { dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, - { dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, - { dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, -]; - -const isElectron = navigator.userAgent.includes('Electron'); - -const chromeUserAgent = navigator.userAgent - .replace(/\s*Electron\/\S+/, '') - .replace(/\s*OpenSwarm\/\S+/, ''); - -const webviewPreloadPath: string | undefined = isElectron - ? (window as any).openswarm?.getWebviewPreloadPath?.() - : undefined; - -type WebviewElement = BrowserWebview; - -interface TabLocalState { - loading: boolean; - canGoBack: boolean; - canGoForward: boolean; -} +export type { TabLocalState, WebviewElement }; +const MIN_W = 400, MIN_H = 300; interface Props { - browserId: string; - tabs: BrowserTab[]; - activeTabId: string; - cardX: number; - cardY: number; - cardWidth: number; - cardHeight: number; - zoom?: number; - cmdHeld?: boolean; - isSelected?: boolean; - isHighlighted?: boolean; + browserId: string; tabs: BrowserTab[]; activeTabId: string; + cardX: number; cardY: number; cardWidth: number; cardHeight: number; + zoom?: number; cmdHeld?: boolean; isSelected?: boolean; isHighlighted?: boolean; multiDragDelta?: { dx: number; dy: number } | null; onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => void; onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void; onDragMove?: (dx: number, dy: number) => void; onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void; - cardZOrder?: number; - onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void; + cardZOrder?: number; onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void; } - const BrowserCard: React.FC = ({ browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, cmdHeld = false, isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd, @@ -114,1143 +38,206 @@ const BrowserCard: React.FC = ({ const c = useClaudeTokens(); const dispatch = useAppDispatch(); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); - const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage); const elementSelectionCtx = useElementSelection(); const isElementSelectMode = elementSelectionCtx?.selectMode ?? false; - const browserAgentSession = useAppSelector((state) => { - const sessions = state.agents.sessions; - const matches = Object.values(sessions).filter( + const matches = Object.values(state.agents.sessions).filter( (s) => s.browser_id === browserId && s.mode === 'browser-agent' - && (s.status === 'running' || s.status === 'completed' || s.status === 'error' || s.status === 'stopped'), - ); + && ['running', 'completed', 'error', 'stopped'].includes(s.status)); return matches.find((s) => s.status === 'running') ?? matches[matches.length - 1] ?? null; }); - const activity = useBrowserActivity(browserId); - const agentRunning = browserAgentSession?.status === 'running'; - const agentActive = activity.active || agentRunning; - const agentAction = activity.action; - const lastAction = activity.lastAction; - + const agentActive = activity.active || browserAgentSession?.status === 'running'; + const { action: agentAction, lastAction } = activity; const [tabLocalStates, setTabLocalStates] = useState>({}); const updateTabLocal = useCallback((tabId: string, update: Partial) => { - setTabLocalStates((prev) => ({ - ...prev, - [tabId]: { - loading: false, - canGoBack: false, - canGoForward: false, - ...prev[tabId], - ...update, - }, - })); + setTabLocalStates((prev) => ({ ...prev, [tabId]: { loading: false, canGoBack: false, canGoForward: false, ...prev[tabId], ...update } })); }, []); - const activeTab = tabs.find((t) => t.id === activeTabId); - const activeUrl = activeTab?.url || ''; - const activeTitle = activeTab?.title || ''; + const activeUrl = activeTab?.url || '', activeTitle = activeTab?.title || ''; const activeLocal = tabLocalStates[activeTabId] || { loading: false, canGoBack: false, canGoForward: false }; - const [urlBarValue, setUrlBarValue] = useState(activeUrl); - useEffect(() => { - setUrlBarValue(activeUrl); - }, [activeUrl, activeTabId]); - - // ---- Webview ref management ---- - const webviewMap = useRef>(new Map()); - const initializedTabs = useRef(new Set()); - const tabBarRef = useRef(null); - - useEffect(() => { - setRegistryActiveTab(browserId, activeTabId); - }, [browserId, activeTabId]); - - const tabIdKey = tabs.map((t) => t.id).join(','); - useEffect(() => { - if (!isElectron) return; - const cleanups: (() => void)[] = []; - - for (const tab of tabs) { - const wv = webviewMap.current.get(tab.id); - if (!wv) continue; - const tabId = tab.id; - - registerWebview(browserId, tabId, wv); - - if (!initializedTabs.current.has(tabId)) { - initializedTabs.current.add(tabId); - const targetUrl = tab.url; - const doLoad = () => { - wv.loadURL(targetUrl).catch(() => {}); - }; - wv.addEventListener('dom-ready', doLoad, { once: true }); - cleanups.push(() => wv.removeEventListener('dom-ready', doLoad)); - } - - const onNavigate = () => { - const newUrl = wv.getURL(); - dispatch(updateBrowserTabUrl({ browserId, tabId, url: newUrl })); - updateTabLocal(tabId, { - canGoBack: wv.canGoBack(), - canGoForward: wv.canGoForward(), - }); - }; - - const onTitleUpdate = () => { - dispatch(updateBrowserTabTitle({ browserId, tabId, title: wv.getTitle() })); - }; - - const onLoadStart = () => updateTabLocal(tabId, { loading: true }); - const onLoadStop = () => { - updateTabLocal(tabId, { loading: false }); - onNavigate(); - onTitleUpdate(); - }; - - const onFaviconUpdate = (e: any) => { - const favicons = e.favicons || (e.detail && e.detail.favicons); - if (favicons?.[0]) { - dispatch(updateBrowserTabFavicon({ browserId, tabId, favicon: favicons[0] })); - } - }; - - wv.addEventListener('did-navigate', onNavigate); - wv.addEventListener('did-navigate-in-page', onNavigate); - wv.addEventListener('page-title-updated', onTitleUpdate); - wv.addEventListener('did-start-loading', onLoadStart); - wv.addEventListener('did-stop-loading', onLoadStop); - wv.addEventListener('page-favicon-updated', onFaviconUpdate); - - cleanups.push(() => { - unregisterWebview(browserId, tabId); - wv.removeEventListener('did-navigate', onNavigate); - wv.removeEventListener('did-navigate-in-page', onNavigate); - wv.removeEventListener('page-title-updated', onTitleUpdate); - wv.removeEventListener('did-start-loading', onLoadStart); - wv.removeEventListener('did-stop-loading', onLoadStop); - wv.removeEventListener('page-favicon-updated', onFaviconUpdate); - }); - } - - return () => cleanups.forEach((fn) => fn()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tabIdKey, browserId, dispatch, updateTabLocal]); - - // ---- Navigation (active tab) ---- + useEffect(() => { setUrlBarValue(activeUrl); }, [activeUrl, activeTabId]); + const webviewMap = useWebviewLifecycle(browserId, tabs, activeTabId, updateTabLocal); const navigate = useCallback((targetUrl: string) => { const finalUrl = resolveInput(targetUrl); setUrlBarValue(finalUrl); const wv = webviewMap.current.get(activeTabId); - if (isElectron && wv) { - wv.loadURL(finalUrl).catch((err: Error) => { - if (!err.message?.includes('ERR_ABORTED')) console.error('Navigation failed:', err); - }); - } + if (isElectron && wv) wv.loadURL(finalUrl).catch((err: Error) => { if (!err.message?.includes('ERR_ABORTED')) console.error('Navigation failed:', err); }); dispatch(updateBrowserTabUrl({ browserId, tabId: activeTabId, url: finalUrl })); }, [browserId, activeTabId, dispatch]); - - const handleUrlKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - navigate(urlBarValue); - } - }, [navigate, urlBarValue]); - - const handleBack = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - webviewMap.current.get(activeTabId)?.goBack(); - }, [activeTabId]); - - const handleForward = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - webviewMap.current.get(activeTabId)?.goForward(); - }, [activeTabId]); - - const handleRefresh = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - webviewMap.current.get(activeTabId)?.reload(); - }, [activeTabId]); - - const handleRemove = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - dispatch(removeBrowserCard(browserId)); - }, [dispatch, browserId]); - - // ---- Tab management ---- - const handleAddTab = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - dispatch(addBrowserTab({ browserId, url: browserHomepage })); - }, [dispatch, browserId, browserHomepage]); - - const handleCloseTab = useCallback((tabId: string, e: React.MouseEvent) => { - e.stopPropagation(); - dispatch(removeBrowserTab({ browserId, tabId })); - }, [dispatch, browserId]); - - const handleSwitchTab = useCallback((tabId: string) => { - dispatch(setActiveBrowserTab({ browserId, tabId })); - }, [dispatch, browserId]); - - // ---- Tab drag reorder ---- - const tabDragRef = useRef<{ - tabId: string; - startX: number; - isDragging: boolean; - } | null>(null); - const swapCooldown = useRef(false); - const [dragTabId, setDragTabId] = useState(null); - const [dragTabOffset, setDragTabOffset] = useState(0); - - const handleTabPointerDown = useCallback((e: React.PointerEvent) => { - e.stopPropagation(); - const tabId = (e.currentTarget as HTMLElement).getAttribute('data-tab-id'); - if (!tabId) return; - tabDragRef.current = { tabId, startX: e.clientX, isDragging: false }; - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - }, []); - - const handleTabPointerMove = useCallback((e: React.PointerEvent) => { - const drag = tabDragRef.current; - if (!drag) return; - const dx = e.clientX - drag.startX; - if (!drag.isDragging && Math.abs(dx) < 5) return; - drag.isDragging = true; - setDragTabId(drag.tabId); - setDragTabOffset(dx); - - if (swapCooldown.current) return; - const bar = tabBarRef.current; - if (!bar) return; - - const draggedEl = bar.querySelector(`[data-tab-id="${drag.tabId}"]`) as HTMLElement | null; - if (!draggedEl) return; - const rect = draggedEl.getBoundingClientRect(); - const center = rect.left + rect.width / 2 + dx; - const currentIdx = tabs.findIndex((t) => t.id === drag.tabId); - - if (currentIdx < tabs.length - 1) { - const nextId = tabs[currentIdx + 1].id; - const nextEl = bar.querySelector(`[data-tab-id="${nextId}"]`) as HTMLElement | null; - if (nextEl) { - const nr = nextEl.getBoundingClientRect(); - if (center > nr.left + nr.width / 2) { - dispatch(reorderBrowserTab({ browserId, tabId: drag.tabId, toIndex: currentIdx + 1 })); - drag.startX = e.clientX; - setDragTabOffset(0); - swapCooldown.current = true; - requestAnimationFrame(() => { swapCooldown.current = false; }); - } - } - } - - if (currentIdx > 0) { - const prevId = tabs[currentIdx - 1].id; - const prevEl = bar.querySelector(`[data-tab-id="${prevId}"]`) as HTMLElement | null; - if (prevEl) { - const pr = prevEl.getBoundingClientRect(); - if (center < pr.left + pr.width / 2) { - dispatch(reorderBrowserTab({ browserId, tabId: drag.tabId, toIndex: currentIdx - 1 })); - drag.startX = e.clientX; - setDragTabOffset(0); - swapCooldown.current = true; - requestAnimationFrame(() => { swapCooldown.current = false; }); - } - } - } - }, [tabs, browserId, dispatch]); - - const handleTabPointerUp = useCallback((e: React.PointerEvent) => { - const drag = tabDragRef.current; - if (!drag) return; - if (!drag.isDragging) { - handleSwitchTab(drag.tabId); - } - tabDragRef.current = null; - setDragTabId(null); - setDragTabOffset(0); - (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); - }, [handleSwitchTab]); - - // ---- Card drag via tab bar background ---- - const DRAG_THRESHOLD = 3; + const handleUrlKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); navigate(urlBarValue); } }, [navigate, urlBarValue]); + const handleBack = useCallback((e: React.MouseEvent) => { e.stopPropagation(); webviewMap.current.get(activeTabId)?.goBack(); }, [activeTabId]); + const handleForward = useCallback((e: React.MouseEvent) => { e.stopPropagation(); webviewMap.current.get(activeTabId)?.goForward(); }, [activeTabId]); + const handleRefresh = useCallback((e: React.MouseEvent) => { e.stopPropagation(); webviewMap.current.get(activeTabId)?.reload(); }, [activeTabId]); const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); const [isDragging, setIsDragging] = useState(false); const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null); - const didDrag = useRef(false); - const justDraggedRef = useRef(false); - + const didDrag = useRef(false), justDraggedRef = useRef(false); const handleDragPointerDown = useCallback((e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); + if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY }; - didDrag.current = false; - setIsDragging(true); - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - onDragStart?.(browserId, 'browser'); + didDrag.current = false; setIsDragging(true); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); onDragStart?.(browserId, 'browser'); }, [cardX, cardY, onDragStart, browserId]); - const handleDragPointerMove = useCallback((e: React.PointerEvent) => { if (!dragState.current) return; - const rawDx = e.clientX - dragState.current.startX; - const rawDy = e.clientY - dragState.current.startY; + const rawDx = e.clientX - dragState.current.startX, rawDy = e.clientY - dragState.current.startY; if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return; didDrag.current = true; - const dx = rawDx / zoom; - const dy = rawDy / zoom; - setLocalDragPos({ - x: dragState.current.origX + dx, - y: dragState.current.origY + dy, - }); - onDragMove?.(dx, dy); + const dx = rawDx / zoom, dy = rawDy / zoom; + setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy }); onDragMove?.(dx, dy); }, [zoom, onDragMove]); - const handleDragPointerUp = useCallback((e: React.PointerEvent) => { if (!dragState.current) return; - const dx = (e.clientX - dragState.current.startX) / zoom; - const dy = (e.clientY - dragState.current.startY) / zoom; + const dx = (e.clientX - dragState.current.startX) / zoom, dy = (e.clientY - dragState.current.startY) / zoom; if (didDrag.current) { - dispatch(setBrowserCardPosition({ - browserId, - x: dragState.current.origX + dx, - y: dragState.current.origY + dy, - })); - justDraggedRef.current = true; - requestAnimationFrame(() => { justDraggedRef.current = false; }); + dispatch(setBrowserCardPosition({ browserId, x: dragState.current.origX + dx, y: dragState.current.origY + dy })); + justDraggedRef.current = true; requestAnimationFrame(() => { justDraggedRef.current = false; }); } onDragEnd?.(dx, dy, didDrag.current); - dragState.current = null; - didDrag.current = false; - setLocalDragPos(null); - setIsDragging(false); + dragState.current = null; didDrag.current = false; setLocalDragPos(null); setIsDragging(false); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); }, [zoom, dispatch, browserId, onDragEnd]); - - // ---- Resize ---- - const resizeRef = useRef<{ - dir: ResizeDir; startX: number; startY: number; - origX: number; origY: number; origW: number; origH: number; - } | null>(null); + const resizeRef = useRef<{ dir: ResizeDir; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null); const [isResizing, setIsResizing] = useState(false); const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null); - - const handleResizeDown = useCallback( - (dir: ResizeDir) => (e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - resizeRef.current = { - dir, startX: e.clientX, startY: e.clientY, - origX: cardX, origY: cardY, origW: cardWidth, origH: cardHeight, - }; - setIsResizing(true); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, - [cardX, cardY, cardWidth, cardHeight], - ); - - const computeResize = useCallback( - (e: React.PointerEvent) => { - if (!resizeRef.current) return null; - const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current; - const dx = (e.clientX - startX) / zoom; - const dy = (e.clientY - startY) / zoom; - let newX = origX, newY = origY, newW = origW, newH = origH; - if (dir.includes('e')) newW = origW + dx; - if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; } - if (dir.includes('s')) newH = origH + dy; - if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; } - if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; } - if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; } - return { x: newX, y: newY, w: newW, h: newH }; - }, - [zoom], - ); - - const handleResizeMove = useCallback( - (e: React.PointerEvent) => { - const result = computeResize(e); - if (result) setLocalResize(result); - }, - [computeResize], - ); - + const handleResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => { + if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); + resizeRef.current = { dir, startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, origW: cardWidth, origH: cardHeight }; + setIsResizing(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, [cardX, cardY, cardWidth, cardHeight]); + const computeResize = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return null; + const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current; + const dx = (e.clientX - startX) / zoom, dy = (e.clientY - startY) / zoom; + let newX = origX, newY = origY, newW = origW, newH = origH; + if (dir.includes('e')) newW = origW + dx; + if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; } + if (dir.includes('s')) newH = origH + dy; + if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; } + if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; } + if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; } + return { x: newX, y: newY, w: newW, h: newH }; + }, [zoom]); + const handleResizeMove = useCallback((e: React.PointerEvent) => { const r = computeResize(e); if (r) setLocalResize(r); }, [computeResize]); const handleResizeUp = useCallback((e: React.PointerEvent) => { if (!resizeRef.current) return; - const result = computeResize(e); - if (result) { - dispatch(setBrowserCardPosition({ browserId, x: result.x, y: result.y })); - dispatch(setBrowserCardSize({ browserId, width: result.w, height: result.h })); - } - resizeRef.current = null; - setLocalResize(null); - setIsResizing(false); + const r = computeResize(e); + if (r) { dispatch(setBrowserCardPosition({ browserId, x: r.x, y: r.y })); dispatch(setBrowserCardSize({ browserId, width: r.w, height: r.h })); } + resizeRef.current = null; setLocalResize(null); setIsResizing(false); (e.target as HTMLElement).releasePointerCapture(e.pointerId); }, [computeResize, dispatch, browserId]); - - // ---- Display calculations ---- - const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; - const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0; - const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx); - const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy); - const displayW = localResize?.w ?? cardWidth; - const displayH = localResize?.h ?? cardHeight; + const md = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta : null; + const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + (md?.dx ?? 0)); + const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + (md?.dy ?? 0)); + const displayW = localResize?.w ?? cardWidth, displayH = localResize?.h ?? cardHeight; const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); - - const isSecure = activeUrl.startsWith('https://'); - const isSearch = isGoogleSearch(activeUrl); - - const accentColor = c.accent.primary; - const accentHover = c.accent.hover; + const isSecure = activeUrl.startsWith('https://'), isSearch = isGoogleSearch(activeUrl); + const accentColor = c.accent.primary, accentHover = c.accent.hover; const accentRgb = accentColor.replace('#', '').match(/.{2}/g)?.map(h => parseInt(h, 16)).join(',') || '189,100,57'; - - // ---- Glow state ---- const glowingBrowserCards = useAppSelector((s) => s.dashboardLayout.glowingBrowserCards); - const isGlowingFromRedux = !!glowingBrowserCards[browserId]; - - const showGlow = isGlowingFromRedux; - - const agentBorder = isHighlighted - ? `2px solid ${c.accent.primary}` - : agentActive - ? `2px solid ${accentColor}` - : showGlow - ? `2px solid ${accentColor}` - : isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`; - - const innerGlow = showGlow && !agentActive - ? `, inset 0 0 30px ${accentColor}25, inset 0 0 60px ${accentColor}10` - : ''; - - const agentShadow = isHighlighted - ? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15` - : agentActive - ? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15` - : showGlow - ? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15${innerGlow}` - : isDragging || isResizing - ? c.shadow.lg - : isSelected - ? `0 0 0 1px #3b82f6, ${c.shadow.md}` - : c.shadow.md; + const showGlow = !!glowingBrowserCards[browserId]; + const agentBorder = isHighlighted ? `2px solid ${c.accent.primary}` : agentActive ? `2px solid ${accentColor}` : showGlow ? `2px solid ${accentColor}` : isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`; + const innerGlow = showGlow && !agentActive ? `, inset 0 0 30px ${accentColor}25, inset 0 0 60px ${accentColor}10` : ''; + const agentShadow = isHighlighted ? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15` + : agentActive ? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15` + : showGlow ? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15${innerGlow}` + : (isDragging || isResizing) ? c.shadow.lg : isSelected ? `0 0 0 1px #3b82f6, ${c.shadow.md}` : c.shadow.md; return ( - onBringToFront?.(browserId, 'browser')} - onClick={(e: React.MouseEvent) => { - if (justDraggedRef.current) return; - onCardSelect?.(browserId, 'browser', e.shiftKey); - }} + onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; onCardSelect?.(browserId, 'browser', e.shiftKey); }} sx={{ - position: 'absolute', - left: displayX, - top: displayY, - width: displayW, - height: displayH, - borderRadius: `${c.radius.lg}px`, - border: agentBorder, - bgcolor: c.bg.surface, - boxShadow: agentShadow, - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', + position: 'absolute', left: displayX, top: displayY, width: displayW, height: displayH, + borderRadius: `${c.radius.lg}px`, border: agentBorder, bgcolor: c.bg.surface, boxShadow: agentShadow, + overflow: 'hidden', display: 'flex', flexDirection: 'column', zIndex: (isDragging || isResizing) ? 999999 : cardZOrder, transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease', '&:hover .resize-handle': { opacity: 1 }, ...(isHighlighted && { animation: 'card-highlight-pulse 2s ease-out forwards', '@keyframes card-highlight-pulse': { - '0%': { - boxShadow: `0 0 0 3px ${c.accent.primary}70, 0 0 24px ${c.accent.primary}50, 0 0 48px ${c.accent.primary}25`, - }, - '25%': { - boxShadow: `0 0 0 4px ${c.accent.primary}55, 0 0 30px ${c.accent.primary}40, 0 0 56px ${c.accent.primary}20`, - }, - '50%': { - boxShadow: `0 0 0 3px ${c.accent.primary}45, 0 0 22px ${c.accent.primary}30, 0 0 44px ${c.accent.primary}15`, - }, - '75%': { - boxShadow: `0 0 0 2px ${c.accent.primary}25, 0 0 14px ${c.accent.primary}18, 0 0 28px ${c.accent.primary}08`, - }, - '100%': { - boxShadow: c.shadow.md, - }, + '0%': { boxShadow: `0 0 0 3px ${c.accent.primary}70, 0 0 24px ${c.accent.primary}50, 0 0 48px ${c.accent.primary}25` }, + '25%': { boxShadow: `0 0 0 4px ${c.accent.primary}55, 0 0 30px ${c.accent.primary}40, 0 0 56px ${c.accent.primary}20` }, + '50%': { boxShadow: `0 0 0 3px ${c.accent.primary}45, 0 0 22px ${c.accent.primary}30, 0 0 44px ${c.accent.primary}15` }, + '75%': { boxShadow: `0 0 0 2px ${c.accent.primary}25, 0 0 14px ${c.accent.primary}18, 0 0 28px ${c.accent.primary}08` }, + '100%': { boxShadow: c.shadow.md }, }, }), ...(!isHighlighted && (agentActive || showGlow) && { animation: `agent-glow-${browserId} 2s ease-in-out infinite`, [`@keyframes agent-glow-${browserId}`]: { - '0%, 100%': { - boxShadow: `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15${innerGlow}`, - }, - '50%': { - boxShadow: `0 0 0 3px ${accentColor}60, 0 0 28px ${accentColor}45, 0 0 56px ${accentColor}25${innerGlow}`, - }, + '0%, 100%': { boxShadow: `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15${innerGlow}` }, + '50%': { boxShadow: `0 0 0 3px ${accentColor}60, 0 0 28px ${accentColor}45, 0 0 56px ${accentColor}25${innerGlow}` }, }, }), }} > - {/* Selection overlay – blocks click interaction while selected, enabling drag from anywhere */} {isSelected && ( - { - if (justDraggedRef.current) return; - onCardSelect?.(browserId, 'browser', e.shiftKey); - }} - sx={{ - position: 'absolute', - inset: 0, - zIndex: 15, - cursor: isDragging ? 'grabbing' : 'grab', - touchAction: 'none', - }} + onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; onCardSelect?.(browserId, 'browser', e.shiftKey); }} + sx={{ position: 'absolute', inset: 0, zIndex: 15, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none' }} /> )} - - {/* Rotating gradient border glow for element selection / streaming */} {showGlow && !agentActive && ( - + )} - - {/* Animated border glow (top edge overlay) */} - {agentActive && ( - - )} - - {/* ====== Tab bar / drag handle ====== */} - - {/* Scrollable tab strip */} - - {tabs.map((tab) => { - const isActive = tab.id === activeTabId; - const isBeingDragged = tab.id === dragTabId; - const tls = tabLocalStates[tab.id]; - - return ( - - {/* Favicon / loading spinner */} - - {tls?.loading ? ( - - ) : tab.favicon ? ( - { e.target.style.display = 'none'; }} - /> - ) : ( - - )} - - - {/* Title */} - - {tab.title || 'New Tab'} - - - {/* Close tab */} - handleCloseTab(tab.id, e)} - onPointerDown={(e: React.PointerEvent) => e.stopPropagation()} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 16, - height: 16, - borderRadius: '4px', - flexShrink: 0, - opacity: isActive ? 0.6 : 0, - cursor: 'pointer', - transition: 'opacity 0.15s, background 0.15s', - '&:hover': { bgcolor: `${c.text.muted}25`, opacity: 1 }, - }} - > - - - - ); - })} - - {/* Add tab (+) button */} - e.stopPropagation()} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 28, - flexShrink: 0, - cursor: 'pointer', - borderRadius: '4px', - mx: 0.25, - my: 0.5, - transition: 'background 0.15s', - '&:hover': { bgcolor: `${c.text.muted}15` }, - }} - > - - - - - {/* Right side controls */} - - {/* Agent activity badge */} - {agentActive && ( - - - - AI - - - )} - - - e.stopPropagation()} - sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }} - > - - - - - - - {/* ====== Navigation bar ====== */} - - - - e.stopPropagation()} - disabled={!activeLocal.canGoBack} - sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} - > - - - - - - - - e.stopPropagation()} - disabled={!activeLocal.canGoForward} - sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} - > - - - - - - - e.stopPropagation()} - sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} - > - - - - - {/* URL bar */} - - {isSearch ? ( - - ) : isSecure ? ( - - ) : null} - setUrlBarValue(e.target.value)} - onKeyDown={handleUrlKeyDown} - onPointerDown={(e) => e.stopPropagation()} - onFocus={(e) => (e.target as HTMLInputElement).select()} - placeholder="Search Google or enter URL..." - sx={{ - flex: 1, - fontSize: '0.74rem', - fontFamily: c.font.mono, - color: c.text.secondary, - py: 0, - '& input': { py: '2px' }, - '& input::placeholder': { color: c.text.ghost, opacity: 1 }, - }} - /> - - - - {/* Loading indicator */} - {(activeLocal.loading || (agentActive && agentAction === 'navigate')) && ( - - )} - - {/* ====== Browser body — multiple webviews stacked ====== */} + {agentActive && } + + - {isElementSelectMode && ( - - )} - {cmdHeld && !isSelected && ( - - )} - {isElectron ? ( - tabs.map((tab) => ( - { - if (el) webviewMap.current.set(tab.id, el as unknown as WebviewElement); - else webviewMap.current.delete(tab.id); - }} - data-tab-id={tab.id} - src="about:blank" - allowpopups="true" - useragent={chromeUserAgent} - {...(webviewPreloadPath ? { preload: webviewPreloadPath } : {})} - webpreferences="plugins=yes, autoplayPolicy=no-user-gesture-required" - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - border: 'none', - visibility: tab.id === activeTabId ? 'visible' : 'hidden', - zIndex: tab.id === activeTabId ? 1 : 0, - }} - /> - )) - ) : ( + {isElementSelectMode && } + {cmdHeld && !isSelected && } + {isElectron ? tabs.map((tab) => ( + { if (el) webviewMap.current.set(tab.id, el as unknown as WebviewElement); else webviewMap.current.delete(tab.id); }} + data-tab-id={tab.id} src="about:blank" allowpopups="true" useragent={chromeUserAgent} + {...(webviewPreloadPath ? { preload: webviewPreloadPath } : {})} + webpreferences="plugins=yes, autoplayPolicy=no-user-gesture-required" + style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none', + visibility: tab.id === activeTabId ? 'visible' : 'hidden', zIndex: tab.id === activeTabId ? 1 : 0 }} + /> + )) : ( -