[Haik]: Agentic refactor 4. Frontend Cleanup

This commit is contained in:
haikdc
2026-03-30 04:45:29 -07:00
parent 781003227d
commit 21fe641a46
194 changed files with 21229 additions and 24900 deletions
+2 -123
View File
@@ -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();
+123
View File
@@ -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',
},
},
},
},
});
}
+7 -299
View File
@@ -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 }) => (
<SvgIcon sx={sx} viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</SvgIcon>
);
export { getToolGroupIcon } from './CommandPickerIcons';
export type { CommandPickerItem } from './commandPickerTypes';
const GoogleIcon: React.FC<{ sx?: object }> = ({ sx }) => (
<SvgIcon sx={sx} viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</SvgIcon>
);
const RedditIcon: React.FC<{ sx?: object }> = ({ sx }) => (
<SvgIcon sx={sx} viewBox="0 0 24 24">
<path d="M14.238 15.348c.085.084.085.221 0 .306-.465.462-1.194.687-2.231.687l-.008-.002-.008.002c-1.036 0-1.766-.225-2.231-.688-.085-.084-.085-.221 0-.305.084-.084.222-.084.307 0 .379.377 1.008.561 1.924.561l.008.002.008-.002c.915 0 1.544-.184 1.924-.561.085-.084.223-.084.307 0zm-3.44-2.418c0-.507-.414-.919-.922-.919-.509 0-.922.412-.922.919 0 .506.414.918.922.918.508 0 .922-.412.922-.918zm4.04-.919c-.509 0-.922.412-.922.919 0 .506.414.918.922.918.508 0 .922-.412.922-.918 0-.507-.414-.919-.922-.919zM12 2C6.478 2 2 6.477 2 12c0 5.522 4.478 10 10 10s10-4.478 10-10c0-5.523-4.478-10-10-10zm5.8 11.333c.02.14.03.283.03.428 0 2.19-2.547 3.964-5.69 3.964-3.142 0-5.69-1.774-5.69-3.964 0-.145.01-.288.03-.428A1.588 1.588 0 0 1 5.6 12c0-.881.716-1.596 1.599-1.596.424 0 .808.17 1.09.443 1.07-.742 2.554-1.22 4.19-1.284l.782-3.674a.11.11 0 0 1 .13-.083l2.603.556a1.132 1.132 0 0 1 2.154.481 1.134 1.134 0 0 1-1.132 1.133 1.132 1.132 0 0 1-1.105-.896l-2.318-.495-.69 3.248c1.6.08 3.046.56 4.094 1.29.283-.278.67-.45 1.099-.45.882 0 1.599.715 1.599 1.596 0 .56-.29 1.05-.726 1.334z" />
</SvgIcon>
);
const TOOL_GROUP_ICONS: Record<string, React.FC<{ sx?: object }>> = {
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 <Icon sx={{ fontSize: size }} />;
return <BuildOutlinedIcon sx={{ fontSize: size }} />;
}
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<string, React.ComponentType<{ sx?: object }>> = {
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)}
<span style={{ color, fontWeight: 600 }}>{text.slice(idx, idx + query.length)}</span>
{text.slice(idx + query.length)}
</>
);
}
const CommandPicker: React.FC<Props> = ({ trigger, filter, onSelect, onClose, visible }) => {
const CommandPicker: React.FC<CommandPickerProps> = ({ 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<HTMLDivElement>(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: <DescriptionIcon sx={{ fontSize: 15 }} />,
}));
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: <PsychologyIcon sx={{ fontSize: 15 }} />,
}));
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: <IconComp sx={{ fontSize: 15 }} />,
};
});
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: <InsertDriveFileOutlinedIcon sx={{ fontSize: 15 }} />,
},
];
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: <LanguageIcon sx={{ fontSize: 15 }} />,
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<string, { read: string[]; write: string[] }> | undefined;
if (!services) continue;
const perms = tool.tool_permissions as Record<string, any>;
const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record<string, string[]>;
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<string>();
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: <BuildOutlinedIcon sx={{ fontSize: 15 }} />,
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: <ViewQuiltOutlinedIcon sx={{ fontSize: 15 }} />,
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) {
@@ -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 }) => (
<SvgIcon sx={sx} viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</SvgIcon>
);
export const GoogleIcon: React.FC<{ sx?: object }> = ({ sx }) => (
<SvgIcon sx={sx} viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</SvgIcon>
);
export const RedditIcon: React.FC<{ sx?: object }> = ({ sx }) => (
<SvgIcon sx={sx} viewBox="0 0 24 24">
<path d="M14.238 15.348c.085.084.085.221 0 .306-.465.462-1.194.687-2.231.687l-.008-.002-.008.002c-1.036 0-1.766-.225-2.231-.688-.085-.084-.085-.221 0-.305.084-.084.222-.084.307 0 .379.377 1.008.561 1.924.561l.008.002.008-.002c.915 0 1.544-.184 1.924-.561.085-.084.223-.084.307 0zm-3.44-2.418c0-.507-.414-.919-.922-.919-.509 0-.922.412-.922.919 0 .506.414.918.922.918.508 0 .922-.412.922-.918zm4.04-.919c-.509 0-.922.412-.922.919 0 .506.414.918.922.918.508 0 .922-.412.922-.918 0-.507-.414-.919-.922-.919zM12 2C6.478 2 2 6.477 2 12c0 5.522 4.478 10 10 10s10-4.478 10-10c0-5.523-4.478-10-10-10zm5.8 11.333c.02.14.03.283.03.428 0 2.19-2.547 3.964-5.69 3.964-3.142 0-5.69-1.774-5.69-3.964 0-.145.01-.288.03-.428A1.588 1.588 0 0 1 5.6 12c0-.881.716-1.596 1.599-1.596.424 0 .808.17 1.09.443 1.07-.742 2.554-1.22 4.19-1.284l.782-3.674a.11.11 0 0 1 .13-.083l2.603.556a1.132 1.132 0 0 1 2.154.481 1.134 1.134 0 0 1-1.132 1.133 1.132 1.132 0 0 1-1.105-.896l-2.318-.495-.69 3.248c1.6.08 3.046.56 4.094 1.29.283-.278.67-.45 1.099-.45.882 0 1.599.715 1.599 1.596 0 .56-.29 1.05-.726 1.334z" />
</SvgIcon>
);
const TOOL_GROUP_ICONS: Record<string, React.FC<{ sx?: object }>> = {
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 <Icon sx={{ fontSize: size }} />;
return <BuildOutlinedIcon sx={{ fontSize: size }} />;
}
+11 -149
View File
@@ -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<DirectoryBrowserProps> = ({ open, onClose, onSe
onClose();
};
const pathSegments = browseData?.current.split('/').filter(Boolean) ?? [];
const hasEntries = (browseData?.directories.length ?? 0) + (browseData?.files.length ?? 0) > 0;
return (
<Dialog
open={open}
@@ -167,141 +154,16 @@ const DirectoryBrowser: React.FC<DirectoryBrowserProps> = ({ open, onClose, onSe
</Button>
</Box>
{browseData && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton
size="small"
onClick={handleGoUp}
disabled={!browseData.parent}
sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}
>
<ArrowUpwardIcon sx={{ fontSize: 18 }} />
</IconButton>
<Breadcrumbs
separator="/"
sx={{
'& .MuiBreadcrumbs-separator': { color: c.text.ghost, mx: 0.25 },
flex: 1,
overflow: 'hidden',
}}
>
<Link
component="button"
underline="hover"
onClick={() => browse('/')}
sx={{ color: c.text.tertiary, fontSize: '0.78rem' }}
>
/
</Link>
{pathSegments.map((seg, i) => {
const fullPath = '/' + pathSegments.slice(0, i + 1).join('/');
const isLast = i === pathSegments.length - 1;
return isLast ? (
<Typography key={fullPath} sx={{ color: c.text.primary, fontSize: '0.78rem', fontWeight: 500 }}>
{seg}
</Typography>
) : (
<Link
key={fullPath}
component="button"
underline="hover"
onClick={() => browse(fullPath)}
sx={{ color: c.text.tertiary, fontSize: '0.78rem' }}
>
{seg}
</Link>
);
})}
</Breadcrumbs>
</Box>
)}
{error && (
<Typography sx={{ color: c.status.error, fontSize: '0.82rem', px: 1 }}>
{error}
</Typography>
)}
<Box sx={{
flex: 1,
overflow: 'auto',
border: `1px solid ${c.border.subtle}`,
borderRadius: 2,
bgcolor: c.bg.page,
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={24} sx={{ color: c.accent.primary }} />
</Box>
) : !hasEntries ? (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.85rem' }}>
Empty directory
</Typography>
</Box>
) : (
<List dense disablePadding>
{browseData?.directories.map((dir) => (
<ListItemButton
key={`d-${dir}`}
selected={selected?.name === dir && selected.type === 'directory'}
onDoubleClick={() => 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` },
}}
>
<ListItemIcon sx={{ minWidth: 32, color: c.accent.primary }}>
<FolderIcon sx={{ fontSize: 18 }} />
</ListItemIcon>
<ListItemText
primary={dir}
primaryTypographyProps={{ sx: { fontSize: '0.84rem', color: c.text.primary } }}
/>
</ListItemButton>
))}
{browseData?.files.map((file) => (
<ListItemButton
key={`f-${file}`}
selected={selected?.name === file && selected.type === 'file'}
onClick={() =>
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` },
}}
>
<ListItemIcon sx={{ minWidth: 32, color: c.text.muted }}>
<InsertDriveFileOutlinedIcon sx={{ fontSize: 17 }} />
</ListItemIcon>
<ListItemText
primary={file}
primaryTypographyProps={{ sx: { fontSize: '0.84rem', color: c.text.secondary } }}
/>
</ListItemButton>
))}
</List>
)}
</Box>
<DirectoryFileList
browseData={browseData}
selected={selected}
loading={loading}
error={error}
onBrowse={browse}
onNavigate={handleNavigate}
onGoUp={handleGoUp}
onSelect={setSelected}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'space-between' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem', pl: 1 }}>
@@ -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<DirectoryFileListProps> = ({
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 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton
size="small"
onClick={onGoUp}
disabled={!browseData.parent}
sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}
>
<ArrowUpwardIcon sx={{ fontSize: 18 }} />
</IconButton>
<Breadcrumbs
separator="/"
sx={{
'& .MuiBreadcrumbs-separator': { color: c.text.ghost, mx: 0.25 },
flex: 1,
overflow: 'hidden',
}}
>
<Link
component="button"
underline="hover"
onClick={() => onBrowse('/')}
sx={{ color: c.text.tertiary, fontSize: '0.78rem' }}
>
/
</Link>
{pathSegments.map((seg, i) => {
const fullPath = '/' + pathSegments.slice(0, i + 1).join('/');
const isLast = i === pathSegments.length - 1;
return isLast ? (
<Typography key={fullPath} sx={{ color: c.text.primary, fontSize: '0.78rem', fontWeight: 500 }}>
{seg}
</Typography>
) : (
<Link
key={fullPath}
component="button"
underline="hover"
onClick={() => onBrowse(fullPath)}
sx={{ color: c.text.tertiary, fontSize: '0.78rem' }}
>
{seg}
</Link>
);
})}
</Breadcrumbs>
</Box>
)}
{error && (
<Typography sx={{ color: c.status.error, fontSize: '0.82rem', px: 1 }}>
{error}
</Typography>
)}
<Box sx={{
flex: 1,
overflow: 'auto',
border: `1px solid ${c.border.subtle}`,
borderRadius: 2,
bgcolor: c.bg.page,
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={24} sx={{ color: c.accent.primary }} />
</Box>
) : !hasEntries ? (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.85rem' }}>
Empty directory
</Typography>
</Box>
) : (
<List dense disablePadding>
{browseData?.directories.map((dir) => (
<ListItemButton
key={`d-${dir}`}
selected={selected?.name === dir && selected.type === 'directory'}
onDoubleClick={() => 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` },
}}
>
<ListItemIcon sx={{ minWidth: 32, color: c.accent.primary }}>
<FolderIcon sx={{ fontSize: 18 }} />
</ListItemIcon>
<ListItemText
primary={dir}
primaryTypographyProps={{ sx: { fontSize: '0.84rem', color: c.text.primary } }}
/>
</ListItemButton>
))}
{browseData?.files.map((file) => (
<ListItemButton
key={`f-${file}`}
selected={selected?.name === file && selected.type === 'file'}
onClick={() =>
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` },
}}
>
<ListItemIcon sx={{ minWidth: 32, color: c.text.muted }}>
<InsertDriveFileOutlinedIcon sx={{ fontSize: 17 }} />
</ListItemIcon>
<ListItemText
primary={file}
primaryTypographyProps={{ sx: { fontSize: '0.84rem', color: c.text.secondary } }}
/>
</ListItemButton>
))}
</List>
)}
</Box>
</>
);
};
export default DirectoryFileList;
@@ -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<string, { label: string; tokenKey?: string }> = {
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<typeof useClaudeTokens> }> = ({ 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 (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: color,
flexShrink: 0,
opacity: 0.8,
...(isActive && {
animation: 'islandPulse 2s ease-in-out infinite',
'@keyframes islandPulse': {
'0%, 100%': { opacity: 0.8, transform: 'scale(1)' },
'50%': { opacity: 0.4, transform: 'scale(1.3)' },
},
}),
}}
/>
);
};
const AgentStatusRow: React.FC<{
agent: TrackedAgent;
c: ReturnType<typeof useClaudeTokens>;
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 (
<Box
onClick={() => 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,
}}
>
<StatusDot status={agent.status} c={c} />
<Typography
sx={{
fontSize: '0.78rem',
fontWeight: 500,
color: c.text.secondary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{agent.name}
</Typography>
<Typography
sx={{
fontSize: '0.6rem',
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.04em',
flexShrink: 0,
}}
>
{cfg.label}
</Typography>
{isActive ? (
<Tooltip title="Stop agent" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onStop(agent.id); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.border.subtle } }}
>
<StopCircleOutlinedIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Dismiss" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onDismiss(agent.id); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { bgcolor: c.border.subtle } }}
>
<CloseIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
</Box>
);
};
// ---------------------------------------------------------------------------
// Compact activity indicator — subtle breathing dot
// ---------------------------------------------------------------------------
const ActivityIndicator: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = ({ c }) => (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: c.text.tertiary,
flexShrink: 0,
animation: 'subtlePulse 2.2s ease-in-out infinite',
'@keyframes subtlePulse': {
'0%, 100%': { opacity: 0.6, transform: 'scale(1)' },
'50%': { opacity: 1, transform: 'scale(1.15)' },
},
}}
/>
);
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const DynamicIsland: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const islandRef = useRef<HTMLDivElement>(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<string, any>) => {
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' && <style>{glowKeyframes}</style>}
<motion.div
ref={islandRef}
layout
transition={islandState === 'expanded' ? SPRING_LAYOUT : SPRING_BOUNCE}
style={{
position: 'absolute',
left: '50%',
top: 6,
x: '-50%',
zIndex: 9999,
width: islandWidth,
borderRadius: islandBorderRadius,
cursor: islandState === 'expanded' ? 'default' : 'pointer',
// @ts-expect-error -- vendor prefix
WebkitAppRegion: 'no-drag',
}}
onClick={islandState !== 'expanded' && islandState !== 'compact-actionable' ? handleIslandClick : undefined}
>
<motion.div
layout
transition={SPRING_LAYOUT}
style={{
background: c.bg.secondary,
border: islandState === 'compact-actionable'
? `1px solid ${c.status.warning}`
: `0.5px solid ${c.border.medium}`,
borderRadius: islandBorderRadius,
boxShadow: islandState === 'compact-actionable'
? `0 0 8px 1px ${c.status.warning}40`
: shadow,
overflow: 'hidden',
animation: islandState === 'compact-actionable'
? 'approvalGlow 2.5s ease-in-out infinite'
: 'none',
}}
>
<AnimatePresence mode="wait">
{islandState === 'idle' && (
<IdlePill key="idle" c={c} />
)}
{islandState === 'compact' && (
<CompactPill
key="compact"
c={c}
text={compactText}
activeCount={activeAgents.length}
hasApprovals={hasApprovals}
/>
)}
{islandState === 'compact-actionable' && oldestNonQuestionApproval && (
<CompactActionablePill
key="compact-actionable"
c={c}
request={oldestNonQuestionApproval}
remainingCount={nonQuestionApprovalCount}
onApprove={onApprove}
onDeny={onDeny}
onExpand={() => setUserExpanded(true)}
/>
)}
{islandState === 'expanded' && (
<ExpandedCard
key="expanded"
c={c}
groups={groups}
totalApprovals={totalApprovals}
activeAgents={activeAgents}
finishedAgents={finishedAgents}
hasApprovals={hasApprovals}
hasAgents={hasAgents}
onApprove={onApprove}
onDeny={onDeny}
onStopAgent={onStopAgent}
onDismissAgent={onDismissAgent}
onNavigateToDashboard={onNavigateToDashboard}
onClearAllFinished={onClearAllFinished}
onCollapse={() => setUserExpanded(false)}
/>
)}
</AnimatePresence>
</motion.div>
</motion.div>
</>
);
};
// ---------------------------------------------------------------------------
// Idle pill — disabled search bar
// ---------------------------------------------------------------------------
const IdlePill: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = ({ c }) => (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.2 }}
>
<Tooltip title="Coming soon" arrow placement="bottom">
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
height: 24,
userSelect: 'none',
cursor: 'default',
}}
>
<SearchIcon sx={{ fontSize: 13, color: c.text.ghost, flexShrink: 0 }} />
<Typography
sx={{
color: c.text.ghost,
fontSize: '0.66rem',
fontWeight: 400,
lineHeight: 1,
whiteSpace: 'nowrap',
}}
>
Search...
</Typography>
</Box>
</Tooltip>
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact pill
// ---------------------------------------------------------------------------
const CompactPill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
text: string;
activeCount: number;
hasApprovals: boolean;
}> = ({ c, text, activeCount, hasApprovals }) => (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={SPRING_BOUNCE}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
height: 24,
userSelect: 'none',
}}
>
<ActivityIndicator c={c} />
<Typography
sx={{
fontSize: '0.68rem',
fontWeight: 500,
color: c.text.tertiary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{text}
</Typography>
{hasApprovals && (
<Box
sx={{
width: 4,
height: 4,
borderRadius: '50%',
bgcolor: c.accent.primary,
flexShrink: 0,
opacity: 0.8,
}}
/>
)}
</Box>
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact-actionable pill — single approval with icon + name + approve/deny
// ---------------------------------------------------------------------------
const CompactActionablePill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
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 (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={SPRING_BOUNCE}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 0.5,
height: 24,
userSelect: 'none',
}}
>
<Box
sx={{
width: 16,
height: 16,
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
color: c.text.tertiary,
'& svg': { width: 12, height: 12 },
}}
>
{icon}
</Box>
<Typography
sx={{
fontSize: '0.68rem',
fontWeight: 600,
color: c.text.secondary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{parsed.displayName}
</Typography>
{remainingCount > 1 && (
<Typography
sx={{
fontSize: '0.6rem',
fontWeight: 600,
color: c.text.ghost,
flexShrink: 0,
}}
>
+{remainingCount - 1}
</Typography>
)}
<Tooltip title="Approve" arrow>
<IconButton
size="small"
onClick={(e) => { 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)' },
}}
>
<CheckIcon sx={{ fontSize: 11 }} />
</IconButton>
</Tooltip>
<Tooltip title="Deny" arrow>
<IconButton
size="small"
onClick={(e) => { 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` },
}}
>
<CloseIcon sx={{ fontSize: 11 }} />
</IconButton>
</Tooltip>
<Tooltip title="Show details" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onExpand(); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }}
>
<ExpandMoreIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
</motion.div>
);
};
// ---------------------------------------------------------------------------
// Expanded card
// ---------------------------------------------------------------------------
const ExpandedCard: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
groups: SessionApprovalGroup[];
totalApprovals: number;
activeAgents: TrackedAgent[];
finishedAgents: TrackedAgent[];
hasApprovals: boolean;
hasAgents: boolean;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => 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 (
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.18 }}
>
{/* Header */}
<Box
onClick={!hasApprovals ? onCollapse : undefined}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
cursor: hasApprovals ? 'default' : 'pointer',
userSelect: 'none',
borderBottom: `0.5px solid ${c.border.subtle}`,
'&:hover': !hasApprovals ? { bgcolor: c.border.subtle } : {},
transition: 'background-color 0.15s',
}}
>
<Typography
sx={{
fontSize: '0.76rem',
fontWeight: 600,
color: c.text.muted,
flex: 1,
}}
>
{headerTitle}
</Typography>
{badgeCount > 0 && (
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 600,
color: c.text.ghost,
flexShrink: 0,
}}
>
{badgeCount}
</Typography>
)}
{!hasApprovals && (
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onCollapse(); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }}
>
<CloseIcon sx={{ fontSize: 13 }} />
</IconButton>
)}
</Box>
{/* Content */}
<Box
sx={{
overflow: 'auto',
maxHeight: 'min(420px, calc(100vh - 100px))',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{hasApprovals && (
<Box sx={{ py: 1 }}>
{hasAgents && (
<Typography
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
px: 2,
pb: 0.5,
}}
>
Approvals
</Typography>
)}
{groups.map((group) => (
<Box key={group.sessionId} sx={{ mb: 1, '&:last-child': { mb: 0 } }}>
{groups.length > 1 && (
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.04em',
px: 2,
py: 0.5,
}}
>
{group.sessionName}
</Typography>
)}
{group.approvals.length > 1 ? (
<BatchApprovalBar
requests={group.approvals}
onApprove={onApprove}
onDeny={onDeny}
/>
) : (
group.approvals.map((req) => (
<ApprovalBar
key={req.id}
request={req}
onApprove={onApprove}
onDeny={onDeny}
/>
))
)}
</Box>
))}
</Box>
)}
{hasApprovals && hasAgents && (
<Box sx={{ mx: 2, borderTop: `0.5px solid ${c.border.subtle}` }} />
)}
{hasAgents && (
<Box sx={{ py: 0.75 }}>
{hasApprovals && (
<Typography
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
px: 2,
pb: 0.5,
pt: 0.25,
}}
>
Agents
</Typography>
)}
{activeAgents.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
{finishedAgents.length > 0 && (
<>
{activeAgents.length > 0 && (
<Box sx={{ mx: 2, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
)}
<Box
onClick={() => 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',
}}
>
<Typography
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
flex: 1,
}}
>
Completed ({finishedAgents.length})
</Typography>
<Typography
component="span"
onClick={(e: React.MouseEvent) => { 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
</Typography>
<IconButton size="small" sx={{ p: 0, color: c.text.ghost }}>
{completedExpanded
? <ExpandLessIcon sx={{ fontSize: 14 }} />
: <ExpandMoreIcon sx={{ fontSize: 14 }} />}
</IconButton>
</Box>
<Collapse in={completedExpanded}>
{finishedAgents.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
</Collapse>
</>
)}
</Box>
)}
</Box>
</motion.div>
);
};
export default DynamicIsland;
@@ -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 (
<Box
onClick={() => 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,
}}
>
<StatusDot status={agent.status} c={c} />
<Typography
sx={{
fontSize: '0.78rem',
fontWeight: 500,
color: c.text.secondary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{agent.name}
</Typography>
<Typography
sx={{
fontSize: '0.6rem',
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.04em',
flexShrink: 0,
}}
>
{cfg.label}
</Typography>
{isActive ? (
<Tooltip title="Stop agent" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onStop(agent.id); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.border.subtle } }}
>
<StopCircleOutlinedIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Dismiss" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onDismiss(agent.id); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { bgcolor: c.border.subtle } }}
>
<CloseIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
</Box>
);
};
@@ -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 (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={SPRING_BOUNCE}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 0.5,
height: 24,
userSelect: 'none',
}}
>
<Box
sx={{
width: 16,
height: 16,
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
color: c.text.tertiary,
'& svg': { width: 12, height: 12 },
}}
>
{icon}
</Box>
<Typography
sx={{
fontSize: '0.68rem',
fontWeight: 600,
color: c.text.secondary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{parsed.displayName}
</Typography>
{remainingCount > 1 && (
<Typography
sx={{
fontSize: '0.6rem',
fontWeight: 600,
color: c.text.ghost,
flexShrink: 0,
}}
>
+{remainingCount - 1}
</Typography>
)}
<Tooltip title="Approve" arrow>
<IconButton
size="small"
onClick={(e) => { 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)' },
}}
>
<CheckIcon sx={{ fontSize: 11 }} />
</IconButton>
</Tooltip>
<Tooltip title="Deny" arrow>
<IconButton
size="small"
onClick={(e) => { 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` },
}}
>
<CloseIcon sx={{ fontSize: 11 }} />
</IconButton>
</Tooltip>
<Tooltip title="Show details" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onExpand(); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }}
>
<ExpandMoreIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
</motion.div>
);
};
@@ -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 }) => (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={SPRING_BOUNCE}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
height: 24,
userSelect: 'none',
}}
>
<ActivityIndicator c={c} />
<Typography
sx={{
fontSize: '0.68rem',
fontWeight: 500,
color: c.text.tertiary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{text}
</Typography>
{hasApprovals && (
<Box
sx={{
width: 4,
height: 4,
borderRadius: '50%',
bgcolor: c.accent.primary,
flexShrink: 0,
opacity: 0.8,
}}
/>
)}
</Box>
</motion.div>
);
@@ -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 && (
<Box sx={{ mx: 2, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
)}
<Box
onClick={() => 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',
}}
>
<Typography
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
flex: 1,
}}
>
Completed ({finishedAgents.length})
</Typography>
<Typography
component="span"
onClick={(e: React.MouseEvent) => { 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
</Typography>
<IconButton size="small" sx={{ p: 0, color: c.text.ghost }}>
{completedExpanded
? <ExpandLessIcon sx={{ fontSize: 14 }} />
: <ExpandMoreIcon sx={{ fontSize: 14 }} />}
</IconButton>
</Box>
<Collapse in={completedExpanded}>
{finishedAgents.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
</Collapse>
</>
);
};
@@ -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<HTMLDivElement>(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' && <style>{glowKeyframes}</style>}
<motion.div
ref={islandRef}
layout
transition={islandState === 'expanded' ? SPRING_LAYOUT : SPRING_BOUNCE}
style={{
position: 'absolute',
left: '50%',
top: 6,
x: '-50%',
zIndex: 9999,
width: islandWidth,
borderRadius: islandBorderRadius,
cursor: islandState === 'expanded' ? 'default' : 'pointer',
// @ts-expect-error -- vendor prefix
WebkitAppRegion: 'no-drag',
}}
onClick={islandState !== 'expanded' && islandState !== 'compact-actionable' ? handleIslandClick : undefined}
>
<motion.div
layout
transition={SPRING_LAYOUT}
style={{
background: c.bg.secondary,
border: islandState === 'compact-actionable'
? `1px solid ${c.status.warning}`
: `0.5px solid ${c.border.medium}`,
borderRadius: islandBorderRadius,
boxShadow: islandState === 'compact-actionable'
? `0 0 8px 1px ${c.status.warning}40`
: shadow,
overflow: 'hidden',
animation: islandState === 'compact-actionable'
? 'approvalGlow 2.5s ease-in-out infinite'
: 'none',
}}
>
<AnimatePresence mode="wait">
{islandState === 'idle' && (
<IdlePill key="idle" c={c} />
)}
{islandState === 'compact' && (
<CompactPill
key="compact"
c={c}
text={compactText}
activeCount={activeAgents.length}
hasApprovals={hasApprovals}
/>
)}
{islandState === 'compact-actionable' && oldestNonQuestionApproval && (
<CompactActionablePill
key="compact-actionable"
c={c}
request={oldestNonQuestionApproval}
remainingCount={nonQuestionApprovalCount}
onApprove={onApprove}
onDeny={onDeny}
onExpand={() => setUserExpanded(true)}
/>
)}
{islandState === 'expanded' && (
<ExpandedCard
key="expanded"
c={c}
groups={groups}
totalApprovals={totalApprovals}
activeAgents={activeAgents}
finishedAgents={finishedAgents}
hasApprovals={hasApprovals}
hasAgents={hasAgents}
onApprove={onApprove}
onDeny={onDeny}
onStopAgent={onStopAgent}
onDismissAgent={onDismissAgent}
onNavigateToDashboard={onNavigateToDashboard}
onClearAllFinished={onClearAllFinished}
onCollapse={() => setUserExpanded(false)}
/>
)}
</AnimatePresence>
</motion.div>
</motion.div>
</>
);
};
export default DynamicIsland;
@@ -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<string, any>) => 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 (
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.18 }}
>
{/* Header */}
<Box
onClick={!hasApprovals ? onCollapse : undefined}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
cursor: hasApprovals ? 'default' : 'pointer',
userSelect: 'none',
borderBottom: `0.5px solid ${c.border.subtle}`,
'&:hover': !hasApprovals ? { bgcolor: c.border.subtle } : {},
transition: 'background-color 0.15s',
}}
>
<Typography
sx={{
fontSize: '0.76rem',
fontWeight: 600,
color: c.text.muted,
flex: 1,
}}
>
{headerTitle}
</Typography>
{badgeCount > 0 && (
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 600,
color: c.text.ghost,
flexShrink: 0,
}}
>
{badgeCount}
</Typography>
)}
{!hasApprovals && (
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onCollapse(); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }}
>
<CloseIcon sx={{ fontSize: 13 }} />
</IconButton>
)}
</Box>
{/* Content */}
<Box
sx={{
overflow: 'auto',
maxHeight: 'min(420px, calc(100vh - 100px))',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{hasApprovals && (
<Box sx={{ py: 1 }}>
{hasAgents && (
<Typography
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
px: 2,
pb: 0.5,
}}
>
Approvals
</Typography>
)}
{groups.map((group) => (
<Box key={group.sessionId} sx={{ mb: 1, '&:last-child': { mb: 0 } }}>
{groups.length > 1 && (
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.04em',
px: 2,
py: 0.5,
}}
>
{group.sessionName}
</Typography>
)}
{group.approvals.length > 1 ? (
<BatchApprovalBar
requests={group.approvals}
onApprove={onApprove}
onDeny={onDeny}
/>
) : (
group.approvals.map((req) => (
<ApprovalBar
key={req.id}
request={req}
onApprove={onApprove}
onDeny={onDeny}
/>
))
)}
</Box>
))}
</Box>
)}
{hasApprovals && hasAgents && (
<Box sx={{ mx: 2, borderTop: `0.5px solid ${c.border.subtle}` }} />
)}
{hasAgents && (
<Box sx={{ py: 0.75 }}>
{hasApprovals && (
<Typography
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
px: 2,
pb: 0.5,
pt: 0.25,
}}
>
Agents
</Typography>
)}
{activeAgents.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
<CompletedAgentsList
c={c}
finishedAgents={finishedAgents}
showDivider={activeAgents.length > 0}
onStopAgent={onStopAgent}
onDismissAgent={onDismissAgent}
onNavigateToDashboard={onNavigateToDashboard}
onClearAllFinished={onClearAllFinished}
/>
</Box>
)}
</Box>
</motion.div>
);
};
@@ -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 }) => (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.2 }}
>
<Tooltip title="Coming soon" arrow placement="bottom">
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
height: 24,
userSelect: 'none',
cursor: 'default',
}}
>
<SearchIcon sx={{ fontSize: 13, color: c.text.ghost, flexShrink: 0 }} />
<Typography
sx={{
color: c.text.ghost,
fontSize: '0.66rem',
fontWeight: 400,
lineHeight: 1,
whiteSpace: 'nowrap',
}}
>
Search...
</Typography>
</Box>
</Tooltip>
</motion.div>
);
@@ -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 (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: color,
flexShrink: 0,
opacity: 0.8,
...(isActive && {
animation: 'islandPulse 2s ease-in-out infinite',
'@keyframes islandPulse': {
'0%, 100%': { opacity: 0.8, transform: 'scale(1)' },
'50%': { opacity: 0.4, transform: 'scale(1.3)' },
},
}),
}}
/>
);
};
export const ActivityIndicator: React.FC<{ c: ClaudeTokens }> = ({ c }) => (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: c.text.tertiary,
flexShrink: 0,
animation: 'subtlePulse 2.2s ease-in-out infinite',
'@keyframes subtlePulse': {
'0%, 100%': { opacity: 0.6, transform: 'scale(1)' },
'50%': { opacity: 1, transform: 'scale(1.15)' },
},
}}
/>
);
@@ -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<HTMLDivElement | null>,
) {
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<string, any>) => {
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,
};
}
@@ -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,
};
}
@@ -0,0 +1 @@
export { default } from './DynamicIsland';
@@ -0,0 +1,30 @@
import type { ApprovalRequest, AgentSession } from '@/shared/state/agentsSlice';
import type { useClaudeTokens } from '@/shared/styles/ThemeContext';
export type ClaudeTokens = ReturnType<typeof useClaudeTokens>;
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<string, { label: string; tokenKey?: string }> = {
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 };
@@ -0,0 +1,38 @@
import React from 'react';
const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => {
if (service === 'gmail') {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
<path d="M2 6.5V18a2 2 0 002 2h1V8l-3-1.5z" fill="#4285F4"/>
<path d="M22 6.5V18a2 2 0 01-2 2h-1V8l3-1.5z" fill="#34A853"/>
<path d="M5 8v12h2V10.2L12 14l5-3.8V20h2V8l-7 5.25L5 8z" fill="#EA4335"/>
<path d="M4 4a2 2 0 00-2 2.5L5 8V4H4z" fill="#4285F4"/>
<path d="M20 4a2 2 0 012 2.5L19 8V4h1z" fill="#FBBC04"/>
<path d="M19 4H5v4l7 5.25L19 8V4z" fill="#EA4335"/>
</svg>
);
}
if (service === 'calendar') {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ flexShrink: 0 }}>
<rect x="3" y="3" width="18" height="18" rx="2" fill="#fff" stroke="#4285F4" strokeWidth="1.5"/>
<rect x="3" y="3" width="18" height="6" rx="2" fill="#4285F4"/>
<text x="12" y="17.5" textAnchor="middle" fontSize="9" fontWeight="700" fill="#4285F4" fontFamily="sans-serif">31</text>
</svg>
);
}
if (service === 'drive' || service === 'sheets') {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ flexShrink: 0 }}>
<path d="M8 2l7 12H1L8 2z" fill="#FBBC04"/>
<path d="M15 2l7 12h-7L8 2h7z" fill="#34A853"/>
<path d="M1 14h14l-3.5 6H4.5L1 14z" fill="#4285F4"/>
<path d="M15 14h7l-3.5 6h-7L15 14z" fill="#EA4335"/>
</svg>
);
}
return null;
};
export default GoogleServiceIcon;
File diff suppressed because it is too large Load Diff
@@ -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: <DescriptionIcon /> },
{ label: 'Skills', path: '/skills', icon: <PsychologyIcon /> },
{ label: 'Actions', path: '/actions', icon: <BuildIcon /> },
{ label: 'Modes', path: '/modes', icon: <TuneIcon /> },
];
const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path));
interface SidebarProps { showUpdateDot: boolean }
const Sidebar: React.FC<SidebarProps> = ({ 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<string | null>(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 (
<>
<Box sx={{ flex: 1, overflow: 'auto', pt: 0.5, '&::-webkit-scrollbar': { width: 0 } }}>
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton onClick={handleDashClick} sx={sectionSx(isDashRoute)}>
<ListItemIcon sx={{ color: isDashRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
<DashboardIcon sx={{ fontSize: 20 }} />
</ListItemIcon>
<ListItemText primary="Dashboards" sx={sectionTextSx(isDashRoute)} />
<Tooltip title="New dashboard" placement="right">
<IconButton size="small" onClick={handleCreateDash} sx={addBtnSx}>
<AddIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
{dashboardList.length > 0 && <ExpandMoreIcon sx={chevronSx(dashExpanded)} />}
</ListItemButton>
<Collapse in={dashExpanded && dashboardList.length > 0} timeout={200}>
<Box sx={scrollSx}>
{dashboardList.map((entry) => {
const isActive = activeDashId === entry.id;
const isRen = renamingId === entry.id;
return (
<Box key={entry.id} onClick={() => handleDashItemClick(entry.id)}
sx={{ ...subItemSx(isActive), py: isRen ? 0.25 : 0.5, cursor: isRen ? 'default' : 'pointer' }}>
{isRen ? (
<InputBase autoFocus value={renameValue}
onChange={(e) => 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' } }}
/>
) : (
<Typography onDoubleClick={(e) => { e.stopPropagation(); handleStartRename(entry.id, entry.name); }}
sx={subTextSx(isActive)}>
{entry.name}
</Typography>
)}
</Box>
);
})}
</Box>
</Collapse>
</Box>
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton onClick={() => {
if (isCustomRoute) setCustomExpanded((p) => !p);
else { navigate('/customization'); setCustomExpanded(true); }
}} sx={sectionSx(isCustomRoute)}>
<ListItemIcon sx={{ color: isCustomRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
<ExtensionIcon sx={{ fontSize: 20 }} />
</ListItemIcon>
<ListItemText primary="Customization" sx={sectionTextSx(isCustomRoute)} />
<ExpandMoreIcon sx={chevronSx(customExpanded)} />
</ListItemButton>
<Collapse in={customExpanded} timeout={200}>
<Box sx={{ ml: 2, mt: 0.25, mb: 0.5, borderLeft: `1px solid ${c.border.medium}` }}>
{CUSTOMIZATION_ITEMS.map((item) => (
<NavLink key={item.path} to={item.path} style={{ textDecoration: 'none', color: 'inherit' }}>
{({ isActive }) => (
<Box sx={subItemSx(isActive)}>
<Typography sx={subTextSx(isActive)}>{item.label}</Typography>
</Box>
)}
</NavLink>
))}
</Box>
</Collapse>
</Box>
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton onClick={handleAppsClick} sx={sectionSx(isAppsRoute)}>
<ListItemIcon sx={{ color: isAppsRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
<ViewQuiltIcon sx={{ fontSize: 20 }} />
</ListItemIcon>
<ListItemText primary="Apps" sx={sectionTextSx(isAppsRoute)} />
<Tooltip title="New app" placement="right">
<IconButton size="small" onClick={handleCreateApp} sx={addBtnSx}>
<AddIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
{appsList.length > 0 && <ExpandMoreIcon sx={chevronSx(appsExpanded)} />}
</ListItemButton>
<Collapse in={appsExpanded && appsList.length > 0} timeout={200}>
<Box sx={scrollSx}>
{appsList.map((app) => {
const isActive = activeAppId === app.id;
return (
<Box key={app.id} onClick={() => navigate(`/apps/${app.id}`)} sx={subItemSx(isActive)}>
<Typography sx={subTextSx(isActive)}>{app.name}</Typography>
</Box>
);
})}
</Box>
</Collapse>
</Box>
</Box>
<Box sx={{ px: 1, py: 1, borderTop: `0.5px solid ${c.border.subtle}` }}>
<ListItemButton onClick={() => dispatch(openSettingsModal())} sx={{
borderRadius: 1.5, py: 0.6, px: 1.25,
'&:hover': { bgcolor: `${c.text.tertiary}0A` }, transition: 'background-color 0.15s',
}}>
<ListItemIcon sx={{ color: c.text.tertiary, minWidth: 32, position: 'relative' }}>
<SettingsIcon sx={{ fontSize: 20 }} />
{showUpdateDot && (
<Box sx={{ position: 'absolute', top: 2, right: 10, width: 7, height: 7,
borderRadius: '50%', bgcolor: c.accent.primary, border: `1.5px solid ${c.bg.secondary}` }} />
)}
</ListItemIcon>
<ListItemText primary="Settings" sx={{
'& .MuiListItemText-primary': { color: c.text.muted, fontSize: '0.82rem', fontWeight: 400 },
}} />
</ListItemButton>
</Box>
</>
);
};
export default Sidebar;
@@ -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<TitleBarProps> = ({ 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 (
<Box sx={{
height: 38, flexShrink: 0, bgcolor: c.bg.secondary,
borderBottom: `0.5px solid ${c.border.medium}`,
display: 'flex', alignItems: 'center', position: 'relative',
overflow: 'visible', WebkitAppRegion: 'drag', userSelect: 'none',
pl: '78px', gap: 0.25,
}}>
<Tooltip title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
<IconButton size="small" onClick={onToggleSidebar} sx={navBtnSx}>
<ViewSidebarOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Back">
<IconButton size="small" onClick={() => navigate(-1)} sx={navBtnSx}>
<ArrowBackOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Forward">
<IconButton size="small" onClick={() => navigate(1)} sx={navBtnSx}>
<ArrowForwardOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<DynamicIsland />
<Box sx={{ flex: 1 }} />
<Box sx={{
display: 'flex', alignItems: 'center', gap: 0.75, pr: 1.5,
WebkitAppRegion: 'no-drag',
}}>
<Box component="img" src="./logo.png" alt="OpenSwarm"
sx={{ width: 16, height: 16, borderRadius: 0.5, opacity: 0.6 }} />
<Typography sx={{
color: c.text.tertiary, fontSize: '0.72rem', fontWeight: 500,
letterSpacing: 0.3, lineHeight: 1,
}}>
OpenSwarm
</Typography>
</Box>
</Box>
);
};
export default TitleBar;
@@ -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<UpdateBannerProps> = ({
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 (
<Box sx={{
display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 0.5,
bgcolor: `${c.accent.primary}14`, borderBottom: `1px solid ${c.accent.primary}30`,
flexShrink: 0,
}}>
<SystemUpdateAltIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
<Typography sx={{
fontSize: '0.8rem', color: c.text.secondary, flex: 1,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
{updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`}
</Typography>
{updateStatus === 'downloading' && (
<LinearProgress variant="determinate" value={downloadPercent} sx={{
width: 120, height: 3, flexShrink: 0, borderRadius: 2,
bgcolor: `${c.accent.primary}20`,
'& .MuiLinearProgress-bar': { bgcolor: c.accent.primary, borderRadius: 2 },
}} />
)}
{updateStatus === 'downloading' && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, flexShrink: 0 }}>
{Math.round(downloadPercent)}%
</Typography>
)}
{updateStatus === 'available' && (
<Button size="small" variant="contained" onClick={onDownload} sx={actionBtnSx}>
Download
</Button>
)}
{updateStatus === 'downloaded' && (
<Button size="small" variant="contained" onClick={onInstall} sx={actionBtnSx}>
Restart & Update
</Button>
)}
<IconButton size="small" onClick={onDismiss}
sx={{ color: c.text.tertiary, p: 0.25, flexShrink: 0, '&:hover': { color: c.text.secondary } }}>
<CloseIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
);
};
export default UpdateBanner;
@@ -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 };
}
@@ -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<string | null>(() => {
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,
};
}
@@ -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]);
}
+22 -402
View File
@@ -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<string | null>(null);
const [nineRouterReady, setNineRouterReady] = useState<boolean | null>(null);
const [connectedTools, setConnectedTools] = useState<Set<string>>(new Set());
const pollTimerRef = useRef<any>(null);
const msgHandlerRef = useRef<any>(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' ? (
<>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Connect Your Accounts
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 0.5, textAlign: 'center' }}>
10+ tools already active with no setup needed
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, mb: 3, textAlign: 'center' }}>
Connect services below for even more capabilities
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
{ONBOARDING_TOOL_INTEGRATIONS.map((ig) => {
const isConnected = connectedTools.has(ig.name);
const isConnecting = connecting === ig.name;
return (
<Box
key={ig.name}
onClick={() => !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` } }),
}}
>
<Box>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{ig.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{ig.desc}</Typography>
</Box>
{isConnected ? (
<CheckCircleIcon sx={{ fontSize: 18, color: ig.color }} />
) : (
<Typography sx={{ fontSize: '0.68rem', color: isConnecting ? ig.color : c.text.tertiary }}>
{isConnecting ? 'Connecting...' : 'Connect \u2192'}
</Typography>
)}
</Box>
);
})}
</Box>
<Button
onClick={dismiss}
fullWidth
variant={connectedTools.size > 0 ? 'contained' : 'text'}
sx={{
textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px`,
...(connectedTools.size > 0
? { bgcolor: c.accent.primary, color: '#fff', '&:hover': { bgcolor: c.accent.hover } }
: { color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }),
}}
>
{connectedTools.size > 0 ? 'Done' : 'Skip for now'}
</Button>
</>
<ToolsStep
connecting={connecting}
connectedTools={connectedTools}
onToolConnect={handleToolConnect}
onDismiss={dismiss}
/>
) : (
<>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Welcome to OpenSwarm
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 3, textAlign: 'center' }}>
Connect an AI model to get started
</Typography>
{/* Subscription options */}
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Use your existing subscription
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
{SUBSCRIPTION_PROVIDERS.map((p) => (
<Box
key={p.id}
onClick={() => !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` } }),
}}
>
<Box>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{p.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{p.desc}</Typography>
</Box>
<Typography sx={{ fontSize: '0.68rem', color: p.preview ? c.text.ghost : connecting === p.id ? c.accent.primary : !nineRouterReady ? c.text.ghost : c.text.tertiary, fontStyle: p.preview ? 'italic' : 'normal' }}>
{p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : !nineRouterReady && nineRouterReady !== false ? 'Starting...' : 'Connect \u2192'}
</Typography>
</Box>
))}
</Box>
{/* API key option */}
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Or use an API key
</Typography>
<Box
onClick={handleApiKey}
sx={{
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
cursor: 'pointer', mb: 2.5,
'&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` },
}}
>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
I have an API key
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>
Go to Settings &rarr; Models to enter your key
</Typography>
</Box>
{/* Skip */}
<Button
onClick={handleSkip}
fullWidth
sx={{ textTransform: 'none', fontSize: '0.72rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
>
Skip for now
</Button>
</>
<ProviderStep
connecting={connecting}
nineRouterReady={nineRouterReady}
onConnect={handleConnect}
onApiKey={handleApiKey}
onSkip={handleSkip}
/>
)}
</Box>
</Modal>
@@ -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<ProviderStepProps> = ({
connecting, nineRouterReady, onConnect, onApiKey, onSkip,
}) => {
const c = useClaudeTokens();
return (
<>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Welcome to OpenSwarm
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 3, textAlign: 'center' }}>
Connect an AI model to get started
</Typography>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Use your existing subscription
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
{SUBSCRIPTION_PROVIDERS.map((p) => (
<Box
key={p.id}
onClick={() => !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` } }),
}}
>
<Box>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{p.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{p.desc}</Typography>
</Box>
<Typography sx={{ fontSize: '0.68rem', color: p.preview ? c.text.ghost : connecting === p.id ? c.accent.primary : !nineRouterReady ? c.text.ghost : c.text.tertiary, fontStyle: p.preview ? 'italic' : 'normal' }}>
{p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : !nineRouterReady && nineRouterReady !== false ? 'Starting...' : 'Connect \u2192'}
</Typography>
</Box>
))}
</Box>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Or use an API key
</Typography>
<Box
onClick={onApiKey}
sx={{
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
cursor: 'pointer', mb: 2.5,
'&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` },
}}
>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
I have an API key
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>
Go to Settings &rarr; Models to enter your key
</Typography>
</Box>
<Button
onClick={onSkip}
fullWidth
sx={{ textTransform: 'none', fontSize: '0.72rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
>
Skip for now
</Button>
</>
);
};
export default ProviderStep;
+19 -251
View File
@@ -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<RichPromptEditorProps> = ({
value,
onChange,
label = '',
placeholder = '',
minRows = 3,
maxRows = 8,
}) => {
const c = useClaudeTokens();
const editorRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState(false);
const [hasContent, setHasContent] = useState(false);
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
const attachedSkillsRef = useRef(attachedSkills);
attachedSkillsRef.current = attachedSkills;
const removeSkillPillRef = useRef<(id: string) => void>(() => {});
const [picker, setPicker] = useState<TriggerState>(EMPTY_TRIGGER);
const [pickerRect, setPickerRect] = useState<DOMRect | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<PromptTemplate | null>(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<string | null>(null);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
if (value === lastEmittedRef.current) return;
lastEmittedRef.current = value;
if (/\{\{skill:.+?\}\}/.test(value)) {
const skillsByName: Record<string, AttachedSkill> = {};
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<string, AttachedSkill> = {};
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<RichPromptEditorProps> = (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 (
<Box ref={wrapperRef} sx={{ position: 'relative' }}>
@@ -292,7 +60,7 @@ const RichPromptEditor: React.FC<RichPromptEditorProps> = ({
cursor: 'text',
}}
>
{label && (
{props.label && (
<Typography
component="label"
sx={{
@@ -311,11 +79,11 @@ const RichPromptEditor: React.FC<RichPromptEditorProps> = ({
zIndex: 1,
}}
>
{label}
{props.label}
</Typography>
)}
<Box sx={{ px: 1.75, pt: label ? 2 : 1.25, pb: 1.25, position: 'relative' }}>
<Box sx={{ px: 1.75, pt: props.label ? 2 : 1.25, pb: 1.25, position: 'relative' }}>
<div
ref={editorRef}
contentEditable
@@ -346,7 +114,7 @@ const RichPromptEditor: React.FC<RichPromptEditorProps> = ({
<div
style={{
position: 'absolute',
top: label ? 16 : 10,
top: props.label ? 16 : 10,
left: 14,
right: 14,
color: c.text.tertiary,
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
import { Box, Typography, Button } from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ONBOARDING_TOOL_INTEGRATIONS, ToolIntegration } from './onboardingConstants';
interface ToolsStepProps {
connecting: string | null;
connectedTools: Set<string>;
onToolConnect: (integration: ToolIntegration) => void;
onDismiss: () => void;
}
const ToolsStep: React.FC<ToolsStepProps> = ({
connecting, connectedTools, onToolConnect, onDismiss,
}) => {
const c = useClaudeTokens();
return (
<>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Connect Your Accounts
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 0.5, textAlign: 'center' }}>
10+ tools already active with no setup needed
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, mb: 3, textAlign: 'center' }}>
Connect services below for even more capabilities
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
{ONBOARDING_TOOL_INTEGRATIONS.map((ig) => {
const isConnected = connectedTools.has(ig.name);
const isConnecting = connecting === ig.name;
return (
<Box
key={ig.name}
onClick={() => !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` } }),
}}
>
<Box>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{ig.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{ig.desc}</Typography>
</Box>
{isConnected ? (
<CheckCircleIcon sx={{ fontSize: 18, color: ig.color }} />
) : (
<Typography sx={{ fontSize: '0.68rem', color: isConnecting ? ig.color : c.text.tertiary }}>
{isConnecting ? 'Connecting...' : 'Connect \u2192'}
</Typography>
)}
</Box>
);
})}
</Box>
<Button
onClick={onDismiss}
fullWidth
variant={connectedTools.size > 0 ? 'contained' : 'text'}
sx={{
textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px`,
...(connectedTools.size > 0
? { bgcolor: c.accent.primary, color: '#fff', '&:hover': { bgcolor: c.accent.hover } }
: { color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }),
}}
>
{connectedTools.size > 0 ? 'Done' : 'Skip for now'}
</Button>
</>
);
};
export default ToolsStep;
@@ -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<string, React.ComponentType<{ sx?: object }>> = {
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)}
<span style={{ color, fontWeight: 600 }}>{text.slice(idx, idx + query.length)}</span>
{text.slice(idx + query.length)}
</>
);
}
@@ -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<string, string> = {
'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, any>): 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<string, any> = {};
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<string, string>,
): DragPreviewElement[] {
const allSelectables = document.querySelectorAll(DRAG_SELECTOR);
const preview: DragPreviewElement[] = [];
const seen = new Set<string>();
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<string, any> = {};
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<string, string>,
addElement: (el: SelectedElement) => void,
removeElement: (id: string) => void,
): void {
const allSelectables = document.querySelectorAll(DRAG_SELECTOR);
const processed = new Set<string>();
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));
}
}
});
}
@@ -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];
@@ -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;
@@ -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: <DescriptionIcon sx={{ fontSize: 15 }} />,
}));
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: <PsychologyIcon sx={{ fontSize: 15 }} />,
}));
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: <IconComp sx={{ fontSize: 15 }} />,
};
});
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: <InsertDriveFileOutlinedIcon sx={{ fontSize: 15 }} />,
},
];
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: <LanguageIcon sx={{ fontSize: 15 }} />,
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<string, { read: string[]; write: string[] }> | undefined;
if (!services) continue;
const perms = tool.tool_permissions as Record<string, any>;
const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record<string, string[]>;
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<string>();
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: <BuildOutlinedIcon sx={{ fontSize: 15 }} />,
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: <ViewQuiltOutlinedIcon sx={{ fontSize: 15 }} />,
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 };
}
@@ -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<string, string> = {
'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, any>): 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<string, any> = {};
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<string>();
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<string, any> = {};
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<string>();
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;
@@ -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<string | null>(null);
const [nineRouterReady, setNineRouterReady] = useState<boolean | null>(null);
const [connectedTools, setConnectedTools] = useState<Set<string>>(new Set());
const pollTimerRef = useRef<any>(null);
const msgHandlerRef = useRef<any>(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,
};
}
@@ -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<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState(false);
const [hasContent, setHasContent] = useState(false);
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
const attachedSkillsRef = useRef(attachedSkills);
attachedSkillsRef.current = attachedSkills;
const removeSkillPillRef = useRef<(id: string) => void>(() => {});
const [picker, setPicker] = useState<TriggerState>(EMPTY_TRIGGER);
const [pickerRect, setPickerRect] = useState<DOMRect | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<PromptTemplate | null>(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<string | null>(null);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
if (value === lastEmittedRef.current) return;
lastEmittedRef.current = value;
if (/\{\{skill:.+?\}\}/.test(value)) {
const skillsByName: Record<string, AttachedSkill> = {};
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<string, AttachedSkill> = {};
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,
};
}
@@ -0,0 +1,122 @@
import { useCallback, MutableRefObject } from 'react';
import { API_BASE } from '@/shared/config';
interface UseSubscriptionConnectParams {
pollTimerRef: MutableRefObject<any>;
msgHandlerRef: MutableRefObject<any>;
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;
}
File diff suppressed because it is too large Load Diff
@@ -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<HTMLDivElement | null>, 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<HTMLDivElement>(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<ToolCallBubbleProps> = ({ 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 (
<Box ref={s.bubbleRef} {...s.selectAttrs} sx={{ maxWidth: '85%', my: 0.5 }}>
<Box sx={{ '--glow-rgb': s.accentRgb, bgcolor: s.c.bg.elevated, border: `1px solid ${isPending ? s.c.accent.primary : s.isDenied ? s.c.status.error + '60' : s.c.border.subtle}`, borderRadius: 2, overflow: 'hidden', animation: isPending ? 'border-glow 2s ease-in-out infinite' : 'none', transition: 'border-color 0.3s, box-shadow 0.3s' } as any}>
<Box onClick={s.toggle} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: 0.75, cursor: hasResponse ? 'pointer' : 'default', '&:hover': hasResponse ? { bgcolor: 'rgba(0,0,0,0.02)' } : {} }}>
<CallSplitIcon sx={{ fontSize: 15, color: s.c.accent.primary, flexShrink: 0 }} />
<Typography sx={{ color: s.c.accent.primary, fontSize: '0.8rem', fontWeight: 600, flexShrink: 0 }}>InvokeAgent</Typography>
<Box sx={{ display: 'inline-flex', alignItems: 'center', bgcolor: `${s.c.accent.primary}14`, borderRadius: 1, px: 0.75, py: 0.15, maxWidth: 180, overflow: 'hidden' }}>
<Typography noWrap sx={{ fontSize: '0.72rem', fontWeight: 500, color: s.c.text.secondary, fontFamily: s.c.font.sans }}>{agentName}</Typography>
</Box>
{!hasResponse && !s.showTimer && <Box sx={{ flex: 1 }} />}
{hasResponse && responsePreview && !s.expanded && <Typography noWrap sx={{ flex: 1, minWidth: 0, fontSize: '0.73rem', color: s.c.text.tertiary, fontFamily: s.c.font.sans }}>{responsePreview.slice(0, 100)}{responsePreview.length > 100 ? '…' : ''}</Typography>}
{s.expanded && <Box sx={{ flex: 1 }} />}
{s.isDenied && <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3 }}><BlockIcon sx={{ fontSize: 13, color: s.c.status.error }} /><Typography sx={{ color: s.c.status.error, fontSize: '0.7rem', fontWeight: 500 }}>denied</Typography></Box>}
{hasResponse && !s.isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{s.isError ? <ErrorOutlineIcon sx={{ fontSize: 13, color: s.c.status.error }} /> : <CheckCircleOutlineIcon sx={{ fontSize: 13, color: s.c.status.success }} />}
{s.resultElapsedMs != null && <Typography sx={{ fontSize: '0.65rem', fontFamily: s.c.font.mono, color: s.c.text.tertiary }}>{formatElapsed(s.resultElapsedMs)}</Typography>}
{costLabel && <Typography sx={{ fontSize: '0.63rem', fontFamily: s.c.font.mono, color: s.c.text.tertiary }}>{costLabel}</Typography>}
</Box>
)}
{s.showTimer && <ElapsedTimer startTime={call.timestamp} />}
{invokedSessionId && <Tooltip title="Reveal on dashboard" arrow><IconButton size="small" onClick={handleRevealAgent} sx={{ color: s.c.accent.primary, p: 0.25, flexShrink: 0, '&:hover': { bgcolor: `${s.c.accent.primary}18` } }}><CallSplitIcon sx={{ fontSize: 15, transform: 'rotate(180deg)' }} /></IconButton></Tooltip>}
{hasResponse && <IconButton size="small" sx={{ color: s.c.text.tertiary, p: 0.25, flexShrink: 0 }}>{s.expanded ? <ExpandLessIcon sx={{ fontSize: 18 }} /> : <ExpandMoreIcon sx={{ fontSize: 18 }} />}</IconButton>}
</Box>
<Collapse in={s.expanded && hasResponse}>
<Box sx={mdSx(s.c)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: ({ children, ...props }) => <a {...props}>{children}</a> }}>{responsePreview}</ReactMarkdown>
</Box>
</Collapse>
</Box>
</Box>
);
};
export const CreateAgentBubble: React.FC<ToolCallBubbleProps> = ({ 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 (
<Box ref={s.bubbleRef} {...s.selectAttrs} sx={{ maxWidth: '85%', my: 0.5 }}>
<Box sx={{ '--glow-rgb': s.accentRgb, bgcolor: s.c.bg.elevated, border: `1px solid ${isPending ? s.c.accent.primary : s.isDenied ? s.c.status.error + '60' : s.c.border.subtle}`, borderRadius: 2, overflow: 'hidden', animation: isPending ? 'border-glow 2s ease-in-out infinite' : 'none', transition: 'border-color 0.3s, box-shadow 0.3s' } as any}>
<Box onClick={s.toggle} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: 0.75, cursor: hasResponse ? 'pointer' : 'default', '&:hover': hasResponse ? { bgcolor: 'rgba(0,0,0,0.02)' } : {} }}>
<CallSplitIcon sx={{ fontSize: 15, color: s.c.accent.primary, flexShrink: 0 }} />
<Typography sx={{ color: s.c.accent.primary, fontSize: '0.8rem', fontWeight: 600, flexShrink: 0 }}>CreateAgent</Typography>
<Box sx={{ display: 'inline-flex', alignItems: 'center', bgcolor: `${s.c.accent.primary}14`, borderRadius: 1, px: 0.75, py: 0.15, maxWidth: 180, overflow: 'hidden' }}>
<Typography noWrap sx={{ fontSize: '0.72rem', fontWeight: 500, color: s.c.text.secondary, fontFamily: s.c.font.sans }}>{taskLabel}</Typography>
</Box>
{!hasResponse && !s.showTimer && <Box sx={{ flex: 1 }} />}
{hasResponse && createAgentResponse && !s.expanded && <Typography noWrap sx={{ flex: 1, minWidth: 0, fontSize: '0.73rem', color: s.c.text.tertiary, fontFamily: s.c.font.sans }}>{createAgentResponse.slice(0, 100)}{createAgentResponse.length > 100 ? '…' : ''}</Typography>}
{s.expanded && <Box sx={{ flex: 1 }} />}
{s.isDenied && <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3 }}><BlockIcon sx={{ fontSize: 13, color: s.c.status.error }} /><Typography sx={{ color: s.c.status.error, fontSize: '0.7rem', fontWeight: 500 }}>denied</Typography></Box>}
{hasResponse && !s.isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{s.isError ? <ErrorOutlineIcon sx={{ fontSize: 13, color: s.c.status.error }} /> : <CheckCircleOutlineIcon sx={{ fontSize: 13, color: s.c.status.success }} />}
{s.resultElapsedMs != null && <Typography sx={{ fontSize: '0.65rem', fontFamily: s.c.font.mono, color: s.c.text.tertiary }}>{formatElapsed(s.resultElapsedMs)}</Typography>}
</Box>
)}
{s.showTimer && <ElapsedTimer startTime={call.timestamp} />}
{createAgentSessionId && <Tooltip title="Reveal on dashboard" arrow><IconButton size="small" onClick={handleRevealAgent} sx={{ color: s.c.accent.primary, p: 0.25, flexShrink: 0, '&:hover': { bgcolor: `${s.c.accent.primary}18` } }}><CallSplitIcon sx={{ fontSize: 15, transform: 'rotate(180deg)' }} /></IconButton></Tooltip>}
{hasResponse && <IconButton size="small" sx={{ color: s.c.text.tertiary, p: 0.25, flexShrink: 0 }}>{s.expanded ? <ExpandLessIcon sx={{ fontSize: 18 }} /> : <ExpandMoreIcon sx={{ fontSize: 18 }} />}</IconButton>}
</Box>
<Collapse in={s.expanded && hasResponse}>
<Box sx={mdSx(s.c)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: ({ children, ...props }) => <a {...props}>{children}</a> }}>{createAgentResponse}</ReactMarkdown>
</Box>
</Collapse>
</Box>
</Box>
);
};
File diff suppressed because it is too large Load Diff
@@ -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 (
<>
<style>{streamingCursorKeyframes}</style>
<span
style={{
display: 'inline-block',
width: 2,
height: '1em',
background: c.accent.primary,
marginLeft: 2,
verticalAlign: 'text-bottom',
animation: 'blink-cursor 0.8s step-end infinite',
}}
/>
</>
);
};
interface Props {
rawText: string;
isStreaming?: boolean;
}
const AssistantBubbleContent: React.FC<Props> = ({ rawText, isStreaming }) => {
const c = useClaudeTokens();
return (
<Box
sx={{
color: c.text.secondary,
fontSize: '0.875rem',
lineHeight: 1.7,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
'& p': { m: 0, mb: 1, '&:last-child': { mb: 0 } },
'& pre': {
bgcolor: c.bg.secondary,
borderRadius: 1.5,
p: 1.5,
overflow: 'auto',
fontSize: '0.8rem',
fontFamily: c.font.mono,
border: `1px solid ${c.border.subtle}`,
'&::-webkit-scrollbar': { height: 5, width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
},
'& code': {
bgcolor: c.bg.secondary,
px: 0.5,
py: 0.25,
borderRadius: 0.5,
fontSize: '0.8rem',
fontFamily: c.font.mono,
},
'& pre code': { bgcolor: 'transparent', p: 0 },
'& table': {
width: '100%',
borderCollapse: 'collapse',
my: 1.5,
fontSize: '0.82rem',
border: `1px solid ${c.border.subtle}`,
borderRadius: 1,
overflow: 'hidden',
},
'& thead': {
bgcolor: c.bg.secondary,
},
'& th': {
textAlign: 'left',
fontWeight: 600,
color: c.text.primary,
px: 1.5,
py: 0.75,
borderBottom: `1.5px solid ${c.border.medium}`,
whiteSpace: 'nowrap',
},
'& td': {
px: 1.5,
py: 0.6,
borderBottom: `0.5px solid ${c.border.subtle}`,
verticalAlign: 'top',
},
'& tr:last-child td': {
borderBottom: 'none',
},
'& tbody tr:hover': {
bgcolor: `${c.bg.secondary}80`,
},
'& ul, & ol': { pl: 2.5, mb: 1 },
'& li': { mb: 0.25 },
'& a': { color: c.accent.primary },
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
),
}}
>{rawText}</ReactMarkdown>
{isStreaming && <StreamingCursor />}
</Box>
);
};
export default AssistantBubbleContent;
@@ -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: <AdsClickIcon sx={{ fontSize: 13 }} />,
color: '#3b82f6',
label: `${elements.length} element${elements.length > 1 ? 's' : ''} selected`,
chips: elements.map((el) => ({
label: el.label,
tooltip: el.selector,
icon: <AdsClickIcon sx={{ fontSize: 12 }} />,
})),
});
}
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: <FolderOutlinedIcon sx={{ fontSize: 13 }} />,
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'
? <FolderOutlinedIcon sx={{ fontSize: 12 }} />
: <InsertDriveFileOutlinedIcon sx={{ fontSize: 12 }} />,
};
}),
});
}
const skills = message.attached_skills;
if (skills && skills.length > 0) {
groups.push({
key: 'skills',
icon: <PsychologyOutlinedIcon sx={{ fontSize: 13 }} />,
color: SKILL_COLOR,
label: `${skills.length} skill${skills.length > 1 ? 's' : ''}`,
chips: skills.map((s) => ({
label: s.name,
icon: <PsychologyOutlinedIcon sx={{ fontSize: 12 }} />,
})),
});
}
const forcedTools = message.forced_tools;
if (forcedTools && forcedTools.length > 0) {
groups.push({
key: 'tools',
icon: <BuildOutlinedIcon sx={{ fontSize: 13 }} />,
color: '#f59e0b',
label: `${forcedTools.length} action${forcedTools.length > 1 ? 's' : ''} requested`,
chips: forcedTools.map((t) => ({
label: t,
icon: <BuildOutlinedIcon sx={{ fontSize: 12 }} />,
})),
});
}
return groups;
}
const AttachedContextSection: React.FC<{
elements: ParsedElement[];
message: AgentMessage;
c: ReturnType<typeof useClaudeTokens>;
}> = ({ elements, message, c }) => {
const [expanded, setExpanded] = useState(false);
const groups = useMemo(() => buildContextGroups(elements, message), [elements, message]);
if (groups.length === 0) return null;
return (
<Box sx={{ mt: 1, pt: 0.75, borderTop: `1px solid ${c.border.subtle}` }}>
<Box
onClick={() => setExpanded(!expanded)}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
mb: 0.5,
'&:hover': { opacity: 0.8 },
}}
>
{groups.map((g) => (
<Box key={g.key} sx={{ color: g.color, display: 'inline-flex', alignItems: 'center' }}>
{g.icon}
</Box>
))}
<Typography sx={{ fontSize: '0.7rem', fontWeight: 600, color: c.text.muted }}>
{groups.map((g) => g.label).join(' · ')}
</Typography>
<ExpandMoreIcon
sx={{
fontSize: 14,
color: c.text.tertiary,
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: '0.15s',
}}
/>
</Box>
<Collapse in={expanded}>
{groups.map((g) => (
<Box key={g.key} sx={{ mt: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', fontWeight: 600, color: g.color, textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.25 }}>
{g.label}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{g.chips.map((chip, i) => (
<Tooltip key={i} title={chip.tooltip || chip.label} arrow placement="top"
slotProps={{ tooltip: { sx: { fontFamily: c.font.mono, fontSize: '0.68rem', maxWidth: 400 } } }}
>
<Chip
icon={chip.icon as React.ReactElement}
label={chip.label}
size="small"
sx={{
bgcolor: `${g.color}18`,
color: g.color,
fontSize: '0.68rem',
fontFamily: c.font.mono,
height: 22,
'& .MuiChip-icon': { color: g.color },
}}
/>
</Tooltip>
))}
</Box>
</Box>
))}
</Collapse>
</Box>
);
};
export default AttachedContextSection;
@@ -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<Props> = ({
contextPaths, onRemoveContextPath, copiedPathIdx, onCopyPath,
forcedTools, onRemoveForcedTool,
selectedElements, onRemoveElement,
hasImages, c,
}) => (
<>
{contextPaths.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, px: 1.5, pt: hasImages ? 0.25 : 1, pb: 0 }}>
{contextPaths.map((cp, idx) => {
const label = cp.path.split('/').filter(Boolean).slice(-2).join('/');
return (
<Tooltip key={`${cp.path}-${idx}`} title={copiedPathIdx === idx ? 'Copied!' : cp.path}
arrow placement="top"
slotProps={{ tooltip: { sx: { fontFamily: c.font.mono, fontSize: '0.7rem', maxWidth: 420, wordBreak: 'break-all' } } }}>
<Chip
icon={cp.type === 'directory' ? <FolderOpenIcon sx={{ fontSize: 14 }} /> : <InsertDriveFileOutlinedIcon sx={{ fontSize: 14 }} />}
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 } },
}}
/>
</Tooltip>
);
})}
</Box>
)}
{forcedTools.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, px: 1.5, pt: (hasImages || contextPaths.length > 0) ? 0.25 : 1, pb: 0 }}>
{forcedTools.map((ft, idx) => (
<Chip key={`ft-${ft.label}-${idx}`}
icon={<>{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 } },
}}
/>
))}
</Box>
)}
{selectedElements.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, px: 1.5,
pt: (hasImages || 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 (
<Tooltip key={el.id} title={tooltipText} arrow placement="top"
slotProps={{ tooltip: { sx: { fontFamily: c.font.mono, fontSize: '0.7rem', maxWidth: 420, wordBreak: 'break-all' } } }}>
<Chip icon={<AdsClickIcon sx={{ fontSize: 14 }} />} 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' },
}}
/>
</Tooltip>
);
})}
</Box>
)}
</>
);
export default AttachmentChips;
@@ -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<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
}
export const BatchApprovalBar: React.FC<BatchApprovalBarProps> = ({ requests, onApprove, onDeny }) => {
const c = useClaudeTokens();
const [expandedGroup, setExpandedGroup] = useState<string | null>(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<string, ToolGroup>();
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{questions.map((req) => (
<QuestionForm key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
))}
{nonQuestions.length > 1 && (
<Box sx={{
mx: 2, mb: 0.5, borderRadius: 2.5, border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface, overflow: 'hidden',
}}>
<Box sx={{
display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.25,
bgcolor: c.status.warningBg, borderBottom: `1px solid ${c.border.subtle}`,
}}>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.status.warning, flex: 1 }}>
{nonQuestions.length} pending approvals
</Typography>
<Button variant="contained" size="small" startIcon={<CheckIcon />} onClick={handleApproveAll}
sx={{ bgcolor: c.status.success, '&:hover': { bgcolor: '#1e4d15' }, fontWeight: 600, fontSize: '0.78rem', textTransform: 'none', borderRadius: 1.5, px: 1.5, minHeight: 30 }}>
Approve All
</Button>
<Button variant="outlined" size="small" startIcon={<CloseIcon />} onClick={handleDenyAll}
sx={{ borderColor: c.status.error, color: c.status.error, '&:hover': { borderColor: '#8f2828', bgcolor: 'rgba(181,51,51,0.04)' }, fontWeight: 600, fontSize: '0.78rem', textTransform: 'none', borderRadius: 1.5, px: 1.5, minHeight: 30 }}>
Deny All
</Button>
</Box>
{groups.map((group) => (
<GroupRow
key={group.toolName}
group={group}
expanded={expandedGroup === group.toolName}
onToggle={() => setExpandedGroup((prev) => prev === group.toolName ? null : group.toolName)}
onApprove={onApprove}
onDeny={onDeny}
onApproveGroup={() => handleApproveGroup(group)}
onDenyGroup={() => handleDenyGroup(group)}
/>
))}
</Box>
)}
{nonQuestions.length === 1 && (
<ApprovalBar request={nonQuestions[0]} onApprove={onApprove} onDeny={onDeny} />
)}
</Box>
);
};
interface GroupRowProps {
group: ToolGroup;
expanded: boolean;
onToggle: () => void;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
onApproveGroup: () => void;
onDenyGroup: () => void;
}
const GroupRow: React.FC<GroupRowProps> = ({ group, expanded, onToggle, onApprove, onDeny, onApproveGroup, onDenyGroup }) => {
const c = useClaudeTokens();
const meta = useMcpToolMeta(group.parsed);
const accentColor = meta.integration?.color || c.status.warning;
return (
<Box sx={{ borderBottom: `1px solid ${c.border.subtle}`, '&:last-child': { borderBottom: 'none' } }}>
<Box
onClick={onToggle}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1,
cursor: 'pointer', '&:hover': { bgcolor: c.bg.secondary }, transition: 'background-color 0.1s',
}}
>
<Box sx={{
width: 26, height: 26, borderRadius: 1, bgcolor: `${accentColor}14`,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
{group.parsed.isMcp
? (meta.integration?.icon || <ExtensionIcon sx={{ fontSize: 15, color: accentColor }} />)
: getToolIcon(group.toolName)}
</Box>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: c.text.primary, flex: 1 }}>
{group.parsed.isMcp ? group.parsed.displayName : group.toolName}
</Typography>
<Chip label={`${group.requests.length}`} size="small"
sx={{ height: 20, minWidth: 24, fontSize: '0.72rem', fontWeight: 700, bgcolor: `${accentColor}18`, color: accentColor, border: 'none' }} />
{group.requests.length > 1 && (
<>
<Button variant="text" size="small"
onClick={(e) => { e.stopPropagation(); onApproveGroup(); }}
sx={{ color: c.status.success, fontWeight: 600, fontSize: '0.72rem', textTransform: 'none', minWidth: 0, px: 1, minHeight: 24 }}>
Approve {group.requests.length}
</Button>
<Button variant="text" size="small"
onClick={(e) => { e.stopPropagation(); onDenyGroup(); }}
sx={{ color: c.status.error, fontWeight: 600, fontSize: '0.72rem', textTransform: 'none', minWidth: 0, px: 1, minHeight: 24 }}>
Deny {group.requests.length}
</Button>
</>
)}
<IconButton size="small" sx={{ color: c.text.ghost, p: 0.25 }}>
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Box>
<Collapse in={expanded}>
<Box sx={{ px: 1, pb: 1, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{group.requests.map((req) => (
<ApprovalBar key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
))}
</Box>
</Collapse>
</Box>
);
};
@@ -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<Props> = ({ parentSessionId, browserId })
);
};
const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors }> = ({ entry, accentColor, fc }) => {
const c = useClaudeTokens();
if (entry.type === 'thought') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
<SmartToyOutlinedIcon
sx={{ fontSize: 10, color: fc.thoughtIcon, mt: '3px', flexShrink: 0 }}
/>
<Typography
sx={{
fontSize: '0.7rem',
color: fc.thought,
lineHeight: 1.45,
wordBreak: 'break-word',
fontFamily: c.font.mono,
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'action') {
const ActionIcon = getActionIcon(entry.actionTool);
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
<ActionIcon sx={{ fontSize: 11, color: accentColor, mt: '2px', flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.7rem',
fontFamily: c.font.mono,
color: accentColor,
lineHeight: 1.45,
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'result') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0, pl: 1.25 }}>
<Typography
sx={{
fontSize: '0.65rem',
fontFamily: c.font.mono,
color: fc.result,
lineHeight: 1.45,
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'system') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', minWidth: 0 }}>
<ErrorOutlineIcon sx={{ fontSize: 10, color: fc.errorIcon, flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.68rem',
fontFamily: c.font.mono,
color: fc.error,
lineHeight: 1.45,
}}
>
{entry.text}
</Typography>
</Box>
);
}
return null;
};
const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => {
const c = useClaudeTokens();
if (status === 'running') {
return (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: c.status.success,
animation: 'ba-feed-pulse 1.4s ease-in-out infinite',
'@keyframes ba-feed-pulse': {
'0%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'50%': { opacity: 1, transform: 'scale(1.2)' },
},
}}
/>
);
}
if (status === 'completed') {
return <CheckCircleOutlineIcon sx={{ fontSize: 10, color: c.status.success }} />;
}
if (status === 'error') {
return <ErrorOutlineIcon sx={{ fontSize: 10, color: c.status.error }} />;
}
return null;
};
export default React.memo(BrowserAgentInlineFeed);
@@ -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 (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
<SmartToyOutlinedIcon
sx={{ fontSize: 10, color: fc.thoughtIcon, mt: '3px', flexShrink: 0 }}
/>
<Typography
sx={{
fontSize: '0.7rem',
color: fc.thought,
lineHeight: 1.45,
wordBreak: 'break-word',
fontFamily: c.font.mono,
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'action') {
const ActionIcon = getActionIcon(entry.actionTool);
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
<ActionIcon sx={{ fontSize: 11, color: accentColor, mt: '2px', flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.7rem',
fontFamily: c.font.mono,
color: accentColor,
lineHeight: 1.45,
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'result') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0, pl: 1.25 }}>
<Typography
sx={{
fontSize: '0.65rem',
fontFamily: c.font.mono,
color: fc.result,
lineHeight: 1.45,
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'system') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', minWidth: 0 }}>
<ErrorOutlineIcon sx={{ fontSize: 10, color: fc.errorIcon, flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.68rem',
fontFamily: c.font.mono,
color: fc.error,
lineHeight: 1.45,
}}
>
{entry.text}
</Typography>
</Box>
);
}
return null;
};
export const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => {
const c = useClaudeTokens();
if (status === 'running') {
return (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: c.status.success,
animation: 'ba-feed-pulse 1.4s ease-in-out infinite',
'@keyframes ba-feed-pulse': {
'0%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'50%': { opacity: 1, transform: 'scale(1.2)' },
},
}}
/>
);
}
if (status === 'completed') {
return <CheckCircleOutlineIcon sx={{ fontSize: 10, color: c.status.success }} />;
}
if (status === 'error') {
return <ErrorOutlineIcon sx={{ fontSize: 10, color: c.status.error }} />;
}
return null;
};
@@ -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<ChatHeaderProps> = ({ session, isDraft, id, onClose }) => {
const c = useClaudeTokens();
const STATUS_STYLES: Record<string, { color: string; bg: string }> = {
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 (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 1.5,
borderBottom: `0.5px solid ${c.border.medium}`,
bgcolor: c.bg.surface,
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography noWrap sx={{ color: c.text.primary, fontWeight: 600 }}>{session.name}</Typography>
{!isDraft && (
<Chip
label={session.status.replace('_', ' ')}
size="small"
sx={{
bgcolor: statusStyle.bg,
color: statusStyle.color,
fontWeight: 600,
fontSize: '0.7rem',
height: 20,
}}
/>
)}
</Box>
{!isDraft && (
<Box sx={{ display: 'flex', gap: 1.5, mt: 0.25 }}>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>{session.model}</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>{session.branch_name}</Typography>
{session.cost_usd > 0 && (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
</Typography>
)}
</Box>
)}
</Box>
{!isDraft && id && <DiffViewer sessionId={id} />}
{onClose && (
<IconButton onClick={onClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<CloseIcon fontSize="small" />
</IconButton>
)}
</Box>
);
};
export default ChatHeader;
File diff suppressed because it is too large Load Diff
@@ -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 (
<Tooltip title={tooltip}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', cursor: 'default', p: 0.5 }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={trackColor} strokeWidth={strokeWidth} />
<circle
cx={size / 2} cy={size / 2} r={radius}
fill="none" stroke={accentColor} strokeWidth={strokeWidth}
strokeDasharray={circumference} strokeDashoffset={dashOffset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
</Box>
</Tooltip>
);
};
export default ContextRing;
@@ -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 (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 6, height: 6, borderRadius: '50%',
bgcolor: c.accent.primary,
animation: 'tool-pulse 1.5s ease-in-out infinite',
}}
/>
<Typography
sx={{
fontSize: '0.7rem', fontFamily: c.font.mono,
color: c.text.tertiary, minWidth: 28, textAlign: 'right',
}}
>
{display}
</Typography>
</Box>
);
};
@@ -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<string, any>; 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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, p: 1.5, pt: 1 }}>
{messages.slice(0, 5).map((msg: any, i: number) => {
const m = extractEmailFields(msg);
return (
<Box key={i} sx={{ bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, px: 1.25, py: 1, display: 'flex', flexDirection: 'column', gap: 0.4, transition: 'background-color 0.15s', '&:hover': { bgcolor: TC_HOVER } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 1 }}>
<span style={{ color: TC_HEADING, fontSize: '0.74rem', fontWeight: 600, fontFamily: c.font.sans }}>{m.subject}</span>
{m.date && <span style={{ color: TC_DIM, fontSize: '0.6rem', flexShrink: 0, fontFamily: c.font.mono }}>{m.date}</span>}
</Box>
{m.from && <span style={{ color: TC_MUTED, fontSize: '0.68rem', fontFamily: c.font.sans }}>{m.from}</span>}
{(m.snippet || m.bodyPreview) && (
<span style={{ color: TC_BODY, fontSize: '0.68rem', lineHeight: 1.45, fontFamily: c.font.sans }}>
{(m.snippet || m.bodyPreview).slice(0, 120)}{(m.snippet || m.bodyPreview).length > 120 ? '…' : ''}
</span>
)}
</Box>
);
})}
{messages.length > 5 && <span style={{ color: TC_DIM, fontSize: '0.66rem', fontStyle: 'italic', textAlign: 'center', display: 'block' }}>+{messages.length - 5} more</span>}
</Box>
);
}
return (
<Box sx={{ ...(hideSubjectHeader ? { overflow: 'hidden' } : { bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, mx: 1.5, my: 1, overflow: 'hidden' }) }}>
{!hideSubjectHeader && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: 0.85, borderBottom: `1px solid ${TC_BORDER}` }}>
{isSend ? <SendIcon sx={{ fontSize: 14, color: TC_SUCCESS, opacity: 0.8 }} /> : <EmailIcon sx={{ fontSize: 14, color: TC_ACCENT, opacity: 0.8 }} />}
<span style={{ color: TC_HEADING, fontSize: '0.78rem', fontWeight: 600, flex: 1, fontFamily: c.font.sans }}>{email.subject}</span>
</Box>
)}
<Box sx={{ px: 1.5, py: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{(email.from || email.to || email.date) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.3 }}>
{email.from && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 32, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>From</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{email.from}</span>
</Box>
)}
{email.to && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 32, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>To</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{email.to}</span>
</Box>
)}
{email.date && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 32, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Date</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{email.date}</span>
</Box>
)}
</Box>
)}
{labels.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.4, flexWrap: 'wrap', mt: 0.15 }}>
{labels.map((l: string, i: number) => (
<Box key={i} sx={{ display: 'inline-flex', alignItems: 'center', bgcolor: `${TC_ACCENT}18`, borderRadius: 0.75, px: 0.6, py: 0.1 }}>
<span style={{ fontSize: '0.56rem', color: TC_ACCENT, fontFamily: c.font.mono, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{l}</span>
</Box>
))}
</Box>
)}
{(email.snippet || email.bodyPreview) && (
<Box sx={{
mt: 0.25, pt: 0.5, borderTop: `1px solid ${TC_BORDER}`, color: TC_BODY, fontFamily: c.font.sans, fontSize: '0.7rem', lineHeight: 1.6, overflowWrap: 'anywhere', wordBreak: 'break-word',
'& p': { m: 0, mb: 0.75, '&:last-child': { mb: 0 } },
'& h1, & h2, & h3, & h4, & h5, & h6': { color: TC_HEADING, fontFamily: c.font.sans, mt: 1, mb: 0.5, '&:first-of-type': { mt: 0 } },
'& h1': { fontSize: '0.82rem' }, '& h2': { fontSize: '0.78rem' }, '& h3': { fontSize: '0.74rem' }, '& h4, & h5, & h6': { fontSize: '0.7rem' },
'& strong': { color: TC_HEADING, fontWeight: 600 }, '& em': { fontStyle: 'italic' },
'& a': { color: TC_ACCENT, 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 ${TC_BORDER}`, color: TC_MUTED, fontStyle: 'italic' },
'& code': { bgcolor: `${TC_BORDER}`, px: 0.4, py: 0.15, borderRadius: 0.5, fontSize: '0.65rem', fontFamily: c.font.mono },
'& pre': { bgcolor: `${TC_BORDER}`, borderRadius: 1, p: 1, overflow: 'auto', fontSize: '0.65rem', fontFamily: c.font.mono, m: 0, mb: 0.75 },
'& pre code': { bgcolor: 'transparent', p: 0 },
'& hr': { border: 'none', borderTop: `1px solid ${TC_BORDER}`, my: 0.75 },
'& img': { maxWidth: '100%', borderRadius: 1 },
}}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: ({ children, ...props }) => <a {...props}>{children}</a> }}>
{email.bodyPreview || email.snippet}
</ReactMarkdown>
</Box>
)}
{attachments.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.4, flexWrap: 'wrap', mt: 0.2 }}>
{attachments.map((a: any, i: number) => (
<Box key={i} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.3, bgcolor: `${TC_WARNING}15`, borderRadius: 0.75, px: 0.6, py: 0.1 }}>
<AttachFileIcon sx={{ fontSize: 9, color: TC_WARNING, opacity: 0.7 }} />
<span style={{ fontSize: '0.58rem', color: TC_WARNING, fontFamily: c.font.mono }}>{a.filename || a.name || 'attachment'}</span>
</Box>
))}
</Box>
)}
</Box>
</Box>
);
};
@@ -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<Props> = ({
images, onRemoveImage, lightboxSrc, onOpenLightbox, onCloseLightbox, c,
}) => (
<>
{images.length > 0 && (
<Box sx={{
display: 'flex', gap: 0.75, px: 1.5, pt: 1, pb: 0.5, overflowX: 'auto',
'&::-webkit-scrollbar': { height: 4 },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
}}>
{images.map((img, idx) => (
<Box key={idx} sx={{
position: 'relative', width: 56, height: 56, 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)' },
}} onClick={() => onOpenLightbox(img.preview)}>
<img src={img.preview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<IconButton size="small" onClick={(e) => { 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 },
}}>
<CloseIcon sx={{ fontSize: 10 }} />
</IconButton>
</Box>
))}
</Box>
)}
<Modal
open={!!lightboxSrc}
onClose={onCloseLightbox}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<Box onClick={onCloseLightbox} sx={{ position: 'relative', outline: 'none', maxWidth: '90vw', maxHeight: '90vh' }}>
<IconButton onClick={onCloseLightbox} 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,
}}>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
<img
src={lightboxSrc || ''} alt=""
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)', display: 'block',
}}
/>
</Box>
</Modal>
</>
);
export default ImageAttachments;
@@ -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<string, any>; 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 (
<Box sx={{ ...(hideHeader ? { overflow: 'hidden' } : { bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, mx: 1.5, my: 1, overflow: 'hidden' }) }}>
{!hideHeader && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: 0.85, borderBottom: `1px solid ${TC_BORDER}` }}>
<EventIcon sx={{ fontSize: 14, color: TC_SUCCESS, opacity: 0.8 }} />
<span style={{ color: TC_HEADING, fontSize: '0.78rem', fontWeight: 600, fontFamily: c.font.sans }}>{single.summary || '(no title)'}</span>
</Box>
)}
<Box sx={{ px: 1.5, py: 1, display: 'flex', flexDirection: 'column', gap: 0.3 }}>
{start && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 48, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Start</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{formatTimestamp(start)}</span>
</Box>
)}
{end && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 48, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>End</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{formatTimestamp(end)}</span>
</Box>
)}
{single.location && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 48, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Where</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{single.location}</span>
</Box>
)}
{single.description && (
<Box sx={{ mt: 0.3, pt: 0.5, borderTop: `1px solid ${TC_BORDER}` }}>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: c.font.sans, fontSize: '0.68rem', lineHeight: 1.5, color: TC_BODY }}>
{single.description.slice(0, 300)}{single.description.length > 300 ? '…' : ''}
</pre>
</Box>
)}
</Box>
</Box>
);
}
if (items.length > 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, p: 1.5, pt: 1 }}>
{items.slice(0, 6).map((item: any, i: number) => (
<Box key={i} sx={{ bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, px: 1.25, py: 0.75, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 1, transition: 'background-color 0.15s', '&:hover': { bgcolor: TC_HOVER } }}>
<span style={{ color: TC_HEADING, fontSize: '0.72rem', fontWeight: 500, fontFamily: c.font.sans }}>{item.summary || '(no title)'}</span>
<span style={{ color: TC_DIM, fontSize: '0.6rem', flexShrink: 0, fontFamily: c.font.mono }}>{formatTimestamp(item.start?.dateTime || item.start?.date || item.start)}</span>
</Box>
))}
{items.length > 6 && <span style={{ color: TC_DIM, fontSize: '0.64rem', fontStyle: 'italic', textAlign: 'center', display: 'block' }}>+{items.length - 6} more</span>}
</Box>
);
}
return null;
};
const DriveCard: React.FC<{ data: Record<string, any> }> = ({ 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 (
<Box sx={{ bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, mx: 1.5, my: 1, px: 1.25, py: 0.85, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<FolderIcon sx={{ fontSize: 16, color: TC_WARNING, opacity: 0.7 }} />
<Box>
<span style={{ color: TC_HEADING, fontSize: '0.73rem', fontWeight: 500, display: 'block', fontFamily: c.font.sans }}>{single.name}</span>
{single.mimeType && <span style={{ color: TC_DIM, fontSize: '0.6rem', fontFamily: c.font.mono }}>{single.mimeType}</span>}
</Box>
</Box>
);
}
if (files.length > 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1.5, pt: 1 }}>
{files.slice(0, 8).map((f: any, i: number) => (
<Box key={i} sx={{ bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, px: 1.25, py: 0.6, display: 'flex', alignItems: 'center', gap: 0.75, transition: 'background-color 0.15s', '&:hover': { bgcolor: TC_HOVER } }}>
<FolderIcon sx={{ fontSize: 13, color: TC_WARNING, opacity: 0.5 }} />
<span style={{ color: TC_HEADING, fontSize: '0.7rem', fontFamily: c.font.sans }}>{f.name || f.id}</span>
{f.mimeType && <span style={{ color: TC_DIM, fontSize: '0.58rem', flexShrink: 0, fontFamily: c.font.mono }}>{f.mimeType.split('/').pop()}</span>}
</Box>
))}
</Box>
);
}
return null;
};
const GenericMcpCard: React.FC<{ data: Record<string, any> }> = ({ data }) => {
const c = useClaudeTokens();
const { TC_DIM, TC_BODY } = useCardColors();
const entries = Object.entries(data).filter(([, v]) => v != null);
if (entries.length === 0)
return <span style={{ color: TC_DIM, fontStyle: 'italic', fontSize: '0.7rem', padding: '8px 12px', display: 'block' }}>(empty response)</span>;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.3, px: 1.5, py: 1 }}>
{entries.slice(0, 20).map(([key, val], i) => {
const isLong = typeof val === 'string' && val.length > 100;
const isObj = typeof val === 'object';
return (
<Box key={i} sx={{ fontSize: '0.7rem', display: 'flex', gap: 0.75, lineHeight: 1.5 }}>
<span style={{ color: TC_DIM, minWidth: 72, flexShrink: 0, fontWeight: 500, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.03em', paddingTop: 1 }}>{key}</span>
{isObj ? (
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: TC_BODY, fontFamily: c.font.mono, fontSize: '0.68rem' }}>{JSON.stringify(val, null, 2).slice(0, 500)}</pre>
) : isLong ? (
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: TC_BODY, fontFamily: c.font.sans, fontSize: '0.68rem' }}>{String(val).slice(0, 500)}{String(val).length > 500 ? '…' : ''}</pre>
) : (
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{String(val)}</span>
)}
</Box>
);
})}
{entries.length > 20 && <span style={{ color: TC_DIM, fontSize: '0.62rem', fontStyle: 'italic' }}>+{entries.length - 20} more fields</span>}
</Box>
);
};
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 (
<Box sx={{ p: 1 }}>
<span style={{ color: tc.STDERR_COLOR, fontSize: '0.73rem' }}>{data.error || data.message || JSON.stringify(data, null, 2)}</span>
</Box>
);
}
if (service === 'gmail') return <GmailCard data={data} action={action} hideSubjectHeader={compact} />;
if (service === 'calendar') return <CalendarCard data={data} hideHeader={compact} />;
if (service === 'drive' || service === 'sheets') return <DriveCard data={data} />;
return <GenericMcpCard data={data} />;
};
@@ -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<MessageBubbleProps> = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming }) => {
const c = useClaudeTokens();
return (
<>
<style>{streamingCursorKeyframes}</style>
<span
style={{
display: 'inline-block',
width: 2,
height: '1em',
background: c.accent.primary,
marginLeft: 2,
verticalAlign: 'text-bottom',
animation: 'blink-cursor 0.8s step-end infinite',
}}
/>
</>
);
};
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<typeof useClaudeTokens>): 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(
<Chip
key={`skill-${match.index}`}
icon={<PsychologyOutlinedIcon sx={{ fontSize: 12 }} />}
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: <AdsClickIcon sx={{ fontSize: 13 }} />,
color: '#3b82f6',
label: `${elements.length} element${elements.length > 1 ? 's' : ''} selected`,
chips: elements.map((el) => ({
label: el.label,
tooltip: el.selector,
icon: <AdsClickIcon sx={{ fontSize: 12 }} />,
})),
});
}
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: <FolderOutlinedIcon sx={{ fontSize: 13 }} />,
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'
? <FolderOutlinedIcon sx={{ fontSize: 12 }} />
: <InsertDriveFileOutlinedIcon sx={{ fontSize: 12 }} />,
};
}),
});
}
const skills = message.attached_skills;
if (skills && skills.length > 0) {
groups.push({
key: 'skills',
icon: <PsychologyOutlinedIcon sx={{ fontSize: 13 }} />,
color: SKILL_COLOR,
label: `${skills.length} skill${skills.length > 1 ? 's' : ''}`,
chips: skills.map((s) => ({
label: s.name,
icon: <PsychologyOutlinedIcon sx={{ fontSize: 12 }} />,
})),
});
}
const forcedTools = message.forced_tools;
if (forcedTools && forcedTools.length > 0) {
groups.push({
key: 'tools',
icon: <BuildOutlinedIcon sx={{ fontSize: 13 }} />,
color: '#f59e0b',
label: `${forcedTools.length} action${forcedTools.length > 1 ? 's' : ''} requested`,
chips: forcedTools.map((t) => ({
label: t,
icon: <BuildOutlinedIcon sx={{ fontSize: 12 }} />,
})),
});
}
return groups;
}
const AttachedContextSection: React.FC<{
elements: ParsedElement[];
message: AgentMessage;
c: ReturnType<typeof useClaudeTokens>;
}> = ({ elements, message, c }) => {
const [expanded, setExpanded] = useState(false);
const groups = useMemo(() => buildContextGroups(elements, message), [elements, message]);
if (groups.length === 0) return null;
return (
<Box sx={{ mt: 1, pt: 0.75, borderTop: `1px solid ${c.border.subtle}` }}>
<Box
onClick={() => setExpanded(!expanded)}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
mb: 0.5,
'&:hover': { opacity: 0.8 },
}}
>
{groups.map((g) => (
<Box key={g.key} sx={{ color: g.color, display: 'inline-flex', alignItems: 'center' }}>
{g.icon}
</Box>
))}
<Typography sx={{ fontSize: '0.7rem', fontWeight: 600, color: c.text.muted }}>
{groups.map((g) => g.label).join(' · ')}
</Typography>
<ExpandMoreIcon
sx={{
fontSize: 14,
color: c.text.tertiary,
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: '0.15s',
}}
/>
</Box>
<Collapse in={expanded}>
{groups.map((g) => (
<Box key={g.key} sx={{ mt: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', fontWeight: 600, color: g.color, textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.25 }}>
{g.label}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{g.chips.map((chip, i) => (
<Tooltip key={i} title={chip.tooltip || chip.label} arrow placement="top"
slotProps={{ tooltip: { sx: { fontFamily: c.font.mono, fontSize: '0.68rem', maxWidth: 400 } } }}
>
<Chip
icon={chip.icon as React.ReactElement}
label={chip.label}
size="small"
sx={{
bgcolor: `${g.color}18`,
color: g.color,
fontSize: '0.68rem',
fontFamily: c.font.mono,
height: 22,
'& .MuiChip-icon': { color: g.color },
}}
/>
</Tooltip>
))}
</Box>
</Box>
))}
</Collapse>
</Box>
);
};
const ImageLightbox: React.FC<{
open: boolean;
src: string;
onClose: () => void;
c: ReturnType<typeof useClaudeTokens>;
}> = ({ open, src, onClose, c }) => (
<Modal open={open} onClose={onClose} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box
onClick={onClose}
sx={{
position: 'relative',
outline: 'none',
maxWidth: '90vw',
maxHeight: '90vh',
}}
>
<IconButton
onClick={onClose}
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,
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
<img
src={src}
alt=""
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: '90vw',
maxHeight: '90vh',
borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
display: 'block',
}}
/>
</Box>
</Modal>
);
const MessageImageThumbnails: React.FC<{
images: Array<{ data: string; media_type: string }>;
c: ReturnType<typeof useClaudeTokens>;
}> = ({ images, c }) => {
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
if (images.length === 0) return null;
return (
<>
<Box sx={{ display: 'flex', gap: 0.75, mb: 1, flexWrap: 'wrap' }}>
{images.map((img, idx) => {
const src = `data:${img.media_type};base64,${img.data}`;
return (
<Box
key={idx}
onClick={() => 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)' },
}}
>
<img
src={src}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
);
})}
</Box>
<ImageLightbox
open={!!lightboxSrc}
src={lightboxSrc || ''}
onClose={() => setLightboxSrc(null)}
c={c}
/>
</>
);
};
interface Props {
message: AgentMessage;
editing?: boolean;
onSaveEdit?: (messageId: string, newContent: string) => void;
onCancelEdit?: () => void;
isStreaming?: boolean;
}
const MessageBubble: React.FC<Props> = 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<Props> = 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<Props> = React.memo(({ message, editing = false, o
}}
>
{isUser ? (
editing ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 240 }}>
<TextField
multiline
fullWidth
value={editText}
onChange={(e) => 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 },
},
}}
/>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
size="small"
onClick={handleCancelEdit}
sx={{ color: c.text.muted, fontSize: '0.75rem' }}
>
Cancel
</Button>
<Button
size="small"
variant="contained"
onClick={handleSaveEdit}
disabled={!editText.trim() || editText.trim() === rawText}
sx={{
bgcolor: c.accent.primary,
fontSize: '0.75rem',
'&:hover': { bgcolor: c.accent.hover },
}}
>
Save & Submit
</Button>
</Box>
</Box>
) : (
<Box>
{message.images && message.images.length > 0 && (
<MessageImageThumbnails images={message.images} c={c} />
)}
<Typography sx={{ color: c.text.primary, fontSize: '0.875rem', lineHeight: 1.6, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
{renderUserTextWithPills(displayText, c)}
</Typography>
<AttachedContextSection elements={selectedElements} message={message} c={c} />
</Box>
)
<UserBubbleContent
message={message}
displayText={displayText}
rawText={rawText}
selectedElements={selectedElements}
editing={editing}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
/>
) : (
<Box
sx={{
color: c.text.secondary,
fontSize: '0.875rem',
lineHeight: 1.7,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
'& p': { m: 0, mb: 1, '&:last-child': { mb: 0 } },
'& pre': {
bgcolor: c.bg.secondary,
borderRadius: 1.5,
p: 1.5,
overflow: 'auto',
fontSize: '0.8rem',
fontFamily: c.font.mono,
border: `1px solid ${c.border.subtle}`,
'&::-webkit-scrollbar': { height: 5, width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
},
'& code': {
bgcolor: c.bg.secondary,
px: 0.5,
py: 0.25,
borderRadius: 0.5,
fontSize: '0.8rem',
fontFamily: c.font.mono,
},
'& pre code': { bgcolor: 'transparent', p: 0 },
'& table': {
width: '100%',
borderCollapse: 'collapse',
my: 1.5,
fontSize: '0.82rem',
border: `1px solid ${c.border.subtle}`,
borderRadius: 1,
overflow: 'hidden',
},
'& thead': {
bgcolor: c.bg.secondary,
},
'& th': {
textAlign: 'left',
fontWeight: 600,
color: c.text.primary,
px: 1.5,
py: 0.75,
borderBottom: `1.5px solid ${c.border.medium}`,
whiteSpace: 'nowrap',
},
'& td': {
px: 1.5,
py: 0.6,
borderBottom: `0.5px solid ${c.border.subtle}`,
verticalAlign: 'top',
},
'& tr:last-child td': {
borderBottom: 'none',
},
'& tbody tr:hover': {
bgcolor: `${c.bg.secondary}80`,
},
'& ul, & ol': { pl: 2.5, mb: 1 },
'& li': { mb: 0.25 },
'& a': { color: c.accent.primary },
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
),
}}
>{rawText}</ReactMarkdown>
{isStreaming && <StreamingCursor />}
</Box>
<AssistantBubbleContent rawText={rawText} isStreaming={isStreaming} />
)}
</Box>
</Box>
@@ -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<typeof useClaudeTokens>;
}> = ({ open, src, onClose, c }) => (
<Modal open={open} onClose={onClose} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box
onClick={onClose}
sx={{
position: 'relative',
outline: 'none',
maxWidth: '90vw',
maxHeight: '90vh',
}}
>
<IconButton
onClick={onClose}
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,
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
<img
src={src}
alt=""
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: '90vw',
maxHeight: '90vh',
borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
display: 'block',
}}
/>
</Box>
</Modal>
);
interface Props {
images: Array<{ data: string; media_type: string }>;
c: ReturnType<typeof useClaudeTokens>;
}
const MessageImageThumbnails: React.FC<Props> = ({ images, c }) => {
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
if (images.length === 0) return null;
return (
<>
<Box sx={{ display: 'flex', gap: 0.75, mb: 1, flexWrap: 'wrap' }}>
{images.map((img, idx) => {
const src = `data:${img.media_type};base64,${img.data}`;
return (
<Box
key={idx}
onClick={() => 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)' },
}}
>
<img
src={src}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
);
})}
</Box>
<ImageLightbox
open={!!lightboxSrc}
src={lightboxSrc || ''}
onClose={() => setLightboxSrc(null)}
c={c}
/>
</>
);
};
export default MessageImageThumbnails;
@@ -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<QueuedMessage[]>;
queueLength: number;
setQueueLength: (len: number) => void;
children: React.ReactNode;
}
const MessageQueue: React.FC<MessageQueueProps> = ({ messageQueueRef, queueLength, setQueueLength, children }) => {
const c = useClaudeTokens();
const [queueExpanded, setQueueExpanded] = useState(false);
const [editingQueueIdx, setEditingQueueIdx] = useState<number | null>(null);
const [editingQueueText, setEditingQueueText] = useState('');
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dropTargetIdx, setDropTargetIdx] = useState<number | null>(null);
return (
<ClickAwayListener onClickAway={() => { if (queueExpanded) { setQueueExpanded(false); setEditingQueueIdx(null); } }}>
<Box>
{queueLength > 0 && (
<Box sx={{ ml: 3, mr: 1.5 }}>
<Box
onClick={() => { 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
? <KeyboardArrowDownIcon sx={{ fontSize: 12, color: c.text.tertiary }} />
: <KeyboardArrowUpIcon sx={{ fontSize: 12, color: c.text.tertiary }} />
}
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: c.text.muted, letterSpacing: 0.2 }}>
{queueLength} queued
</Typography>
<Tooltip title="Clear all">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); messageQueueRef.current = []; setQueueLength(0); setQueueExpanded(false); setEditingQueueIdx(null); }}
sx={{ p: 0.15, color: c.text.tertiary, '&:hover': { color: c.status.error } }}
>
<CloseIcon sx={{ fontSize: 10 }} />
</IconButton>
</Tooltip>
</Box>
{queueExpanded && (
<Box
sx={{
bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`,
borderBottom: 'none', borderRadius: '0 8px 0 0',
maxHeight: 240, overflowY: 'auto',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
}}
>
{messageQueueRef.current.map((msg, idx) => (
<Box
key={idx}
draggable={editingQueueIdx !== idx}
onDragStart={(e) => { 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}` } : {}),
}}
>
<Box sx={{ cursor: editingQueueIdx === idx ? 'default' : 'grab', display: 'flex', alignItems: 'center', mt: 0.3, color: c.text.ghost, '&:hover': { color: c.text.tertiary }, '&:active': { cursor: 'grabbing' } }}>
<DragIndicatorIcon sx={{ fontSize: 14 }} />
</Box>
{editingQueueIdx === idx ? (
<Box sx={{ flex: 1, display: 'flex', gap: 0.5, alignItems: 'flex-start' }}>
<TextField
multiline fullWidth size="small"
value={editingQueueText}
onChange={(e) => 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 } } }}
/>
<IconButton
size="small"
onClick={() => {
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 }}
>
<CheckIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
) : (
<Typography sx={{ flex: 1, fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.5, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', wordBreak: 'break-word' }}>
{msg.prompt}
</Typography>
)}
{editingQueueIdx !== idx && (
<Box sx={{ display: 'flex', gap: 0.25, flexShrink: 0, mt: 0.15 }}>
<Tooltip title="Edit">
<IconButton size="small" onClick={() => { setEditingQueueIdx(idx); setEditingQueueText(msg.prompt); }} sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<EditOutlinedIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
<Tooltip title="Remove">
<IconButton
size="small"
onClick={() => {
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 } }}
>
<DeleteOutlineIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
))}
</Box>
)}
</Box>
)}
{children}
</Box>
</ClickAwayListener>
);
};
export default MessageQueue;
@@ -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<string, React.ReactNode> = {
smart_toy: <SmartToyOutlinedIcon sx={{ fontSize: 14 }} />,
question_answer: <QuestionAnswerOutlinedIcon sx={{ fontSize: 14 }} />,
map: <MapOutlinedIcon sx={{ fontSize: 14 }} />,
category: <CategoryOutlinedIcon sx={{ fontSize: 14 }} />,
tune: <TuneOutlinedIcon sx={{ fontSize: 14 }} />,
};
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<HTMLInputElement | null>;
queueLength?: number;
}
const ModelModeSelector: React.FC<Props> = ({
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<HTMLElement | null>(null);
const [modelAnchor, setModelAnchor] = useState<HTMLElement | null>(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<string, Array<{ value: string; label: string; context_window: number }>> = {};
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 (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, px: 1, pb: 0.75, pt: 0 }}>
<Box onClick={(e) => 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}
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: 'inherit', lineHeight: 1 }}>{modeConf.label}</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: 'inherit', opacity: 0.7 }} />
</Box>
<Menu anchorEl={modeAnchor} open={Boolean(modeAnchor)} onClose={() => setModeAnchor(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: menuPaperProps }}>
{modesArr.map((m) => (
<MenuItem key={m.id} selected={mode === m.id} onClick={() => { onModeChange(m.id); setModeAnchor(null); }}>
<ListItemIcon sx={{ color: m.color, minWidth: 28 }}>{ICON_MAP[m.icon] || ICON_MAP.smart_toy}</ListItemIcon>
<ListItemText primary={m.name}
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: mode === m.id ? m.color : c.text.secondary } } }} />
</MenuItem>
))}
</Menu>
<Box onClick={(e) => 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',
}}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: 'inherit', lineHeight: 1 }}>
{(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: 'inherit', opacity: 0.7 }} />
</Box>
<Menu anchorEl={modelAnchor} open={Boolean(modelAnchor)} onClose={() => setModelAnchor(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: menuPaperProps }}>
{Object.entries(allModelOptions.grouped).map(([prov, models]) => [
<MenuItem key={`header-${prov}`} disabled sx={{ opacity: '0.7 !important', py: 0.5, px: 1.5, minHeight: 'auto' }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.text.tertiary }}>{prov}</Typography>
</MenuItem>,
...models.map((opt) => (
<MenuItem key={opt.value} selected={model === opt.value} onClick={() => {
onModelChange(opt.value);
if (onProviderChange) {
const provLower = prov.toLowerCase();
const providerMap: Record<string, string> = { anthropic: 'anthropic', openai: 'openai', google: 'gemini', xai: 'openrouter', meta: 'openrouter', deepseek: 'openrouter', mistral: 'openrouter', qwen: 'openrouter', cohere: 'openrouter' };
onProviderChange(providerMap[provLower] || provLower);
}
setModelAnchor(null);
}}>
<ListItemText primary={opt.label}
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }} />
</MenuItem>
)),
]).flat()}
</Menu>
<Box sx={{ flex: 1 }} />
{contextEstimate && (
<ContextRing used={contextEstimate.used} limit={contextEstimate.limit}
accentColor={c.accent.primary} trackColor={c.border.subtle} />
)}
{elementSelection && !autoRunMode && (
<Tooltip title={isMySelectMode ? 'Exit select mode' : 'Select UI element'}>
<IconButton size="small" onMouseDown={(e) => 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',
}}>
<AdsClickIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
)}
<input ref={generalFileInputRef as React.RefObject<HTMLInputElement>} 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 = '';
}} />
<Tooltip title="Attach file">
<IconButton size="small" onClick={() => generalFileInputRef.current?.click()}
sx={{ color: c.text.tertiary, p: 0.5, '&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' } }}>
<AttachFileIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
{!autoRunMode && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{hasContent && (
<Tooltip title={isRunning ? 'Queue message' : 'Send message'}>
<IconButton size="small" onClick={onSend} disabled={disabled}
sx={{ bgcolor: c.accent.primary, color: c.text.inverse, p: 0.5, width: 26, height: 26,
'&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.ghost },
transition: c.transition }}>
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
{isRunning ? (
<Tooltip title="Stop agent">
<IconButton size="small" onClick={onStop}
sx={{ bgcolor: c.status.error, color: c.text.inverse, p: 0.5, width: 26, height: 26,
'&:hover': { bgcolor: c.status.error, opacity: 0.85 }, transition: c.transition }}>
<StopIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
) : !hasContent ? (
<Tooltip title="Voice input (coming soon)">
<span><IconButton size="small" disabled
sx={{ color: c.text.tertiary, p: 0.5, '&.Mui-disabled': { color: c.text.ghost } }}>
<MicNoneOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton></span>
</Tooltip>
) : null}
</Box>
)}
</Box>
);
};
export default ModelModeSelector;
@@ -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<number, string | string[]>;
export interface QuestionFormProps {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
compact?: boolean;
}
const OTHER_KEY = '__other__';
export const QuestionForm: React.FC<QuestionFormProps> = ({ request, onApprove, onDeny, compact }) => {
const c = useClaudeTokens();
const questions: any[] = request.tool_input.questions || [];
const [answers, setAnswers] = useState<Answers>(() => {
const init: Answers = {};
questions.forEach((q: any, i: number) => {
init[i] = q.multiSelect ? [] : '';
});
return init;
});
const [otherActive, setOtherActive] = useState<Record<number, boolean>>({});
const [otherText, setOtherText] = useState<Record<number, string>>({});
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<string, string> = {};
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 (
<Box sx={{
bgcolor: c.bg.secondary, border: `1px solid ${c.accent.primary}33`,
borderRadius: compact ? 2 : 2.5, p: compact ? 1.5 : 2,
mx: compact ? 0 : 2, mb: compact ? 0 : 1,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.5 }}>
<Box sx={{ color: c.accent.primary, display: 'flex', alignItems: 'center' }}>
<QuestionAnswerIcon sx={{ fontSize: '1rem' }} />
</Box>
<Typography sx={{ color: c.accent.primary, fontWeight: 700, fontSize: '0.85rem' }}>
Agent has a question
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 2 }}>
{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 (
<Box key={i}>
{q.header && (
<Typography sx={{ color: c.text.muted, fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.25 }}>
{q.header}
</Typography>
)}
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 500, mb: 0.75 }}>
{q.question || q.prompt || q.text || '(question)'}
</Typography>
{hasOptions ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{q.options.map((opt: any) => {
const key = getOptionKey(opt);
const selected = isSelected(i, key);
return (
<Chip
key={key}
label={getOptionLabel(opt)}
size="small"
onClick={() => 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,
},
}}
/>
);
})}
<Chip
label="Other…"
size="small"
onClick={() => 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,
},
}}
/>
</Box>
{isOtherActive && (
<TextField
placeholder="Type your own answer..."
value={otherText[i] || ''}
onChange={(e) => 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 },
},
}}
/>
)}
</Box>
) : (
<TextField
placeholder="Type your answer..."
value={answers[i] || ''}
onChange={(e) => 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 },
},
}}
/>
)}
</Box>
);
})}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" startIcon={<SendIcon />} onClick={handleSubmit}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.hover || c.accent.primary, filter: 'brightness(0.9)' }, fontWeight: 600, fontSize: '0.8rem' }}>
Submit
</Button>
<Button variant="outlined" onClick={() => onDeny(request.id)}
sx={{ borderColor: c.border.strong, color: c.text.secondary, '&:hover': { borderColor: c.text.secondary, bgcolor: `${c.text.secondary}08` }, fontWeight: 600, fontSize: '0.8rem' }}>
Dismiss
</Button>
</Box>
</Box>
);
};
@@ -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<string, number> = {
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 (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<style>{thinkingDotsKeyframes}</style>
<Box
sx={{
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: '16px 16px 16px 4px',
px: 2,
py: 1.5,
boxShadow: c.shadow.sm,
display: 'flex',
alignItems: 'center',
gap: '4px',
minHeight: 36,
}}
>
{[0, 1, 2].map((i) => (
<Box
key={i}
sx={{
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: c.text.tertiary,
animation: 'thinking-bounce 1.4s infinite ease-in-out both',
animationDelay: `${i * 0.16}s`,
}}
/>
))}
</Box>
</Box>
);
};
export default ThinkingBubble;
File diff suppressed because it is too large Load Diff
@@ -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 <TerminalIcon sx={{ fontSize: '1rem' }} />;
case 'Read': return <DescriptionIcon sx={{ fontSize: '1rem' }} />;
case 'Write': case 'Edit': return <EditIcon sx={{ fontSize: '1rem' }} />;
case 'Grep': case 'Glob': return <SearchIcon sx={{ fontSize: '1rem' }} />;
case 'AskUserQuestion': return <QuestionAnswerIcon sx={{ fontSize: '1rem' }} />;
default: return <BuildIcon sx={{ fontSize: '1rem' }} />;
}
}
interface ToolPreviewProps {
request: ApprovalRequest;
tokens: ReturnType<typeof useClaudeTokens>;
}
export const CodeBlock: React.FC<{ tokens: ReturnType<typeof useClaudeTokens>; children: React.ReactNode }> = ({ tokens: c, children }) => (
<Box
component="pre"
sx={{
bgcolor: c.bg.secondary,
borderRadius: 1.5,
p: 1.5,
m: 0,
maxHeight: 150,
overflow: 'auto',
border: `1px solid ${c.border.subtle}`,
color: c.text.secondary,
fontSize: '0.75rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: c.font.mono,
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{children}
</Box>
);
const ToolPreview: React.FC<ToolPreviewProps> = ({ request, tokens: c }) => {
const { tool_name, tool_input } = request;
switch (tool_name) {
case 'Bash': {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{tool_input.description && (
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem' }}>
{tool_input.description}
</Typography>
)}
<CodeBlock tokens={c}>{tool_input.command || '(empty command)'}</CodeBlock>
</Box>
);
}
case 'Read':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionIcon sx={{ fontSize: '0.9rem', color: c.text.muted }} />
<Typography sx={{ color: c.text.secondary, fontSize: '0.8rem', fontFamily: c.font.mono }}>
{tool_input.file_path || tool_input.path || JSON.stringify(tool_input)}
</Typography>
</Box>
);
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<EditIcon sx={{ fontSize: '0.9rem', color: c.text.muted }} />
<Typography sx={{ color: c.text.secondary, fontSize: '0.8rem', fontFamily: c.font.mono }}>
{path}
</Typography>
</Box>
{content && <CodeBlock tokens={c}>{typeof content === 'string' ? content : JSON.stringify(content, null, 2)}</CodeBlock>}
</Box>
);
}
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={pattern}
size="small"
sx={{ fontFamily: c.font.mono, fontSize: '0.75rem', bgcolor: c.bg.secondary, color: c.text.secondary, border: `1px solid ${c.border.subtle}` }}
/>
{path && (
<Typography sx={{ color: c.text.muted, fontSize: '0.75rem', fontFamily: c.font.mono }}>
in {path}
</Typography>
)}
</Box>
</Box>
);
}
case 'AskUserQuestion':
return null;
default: {
const preview = tool_input.command || tool_input.file_path || tool_input.path || tool_input.query || null;
if (preview) {
return <CodeBlock tokens={c}>{preview}</CodeBlock>;
}
return <CodeBlock tokens={c}>{JSON.stringify(tool_input, null, 2)}</CodeBlock>;
}
}
};
export default ToolPreview;
@@ -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<typeof useClaudeTokens>): 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(
<Chip
key={`skill-${match.index}`}
icon={<PsychologyOutlinedIcon sx={{ fontSize: 12 }} />}
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<Props> = ({
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 240 }}>
<TextField
multiline
fullWidth
value={editText}
onChange={(e) => 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 },
},
}}
/>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
size="small"
onClick={handleCancelEdit}
sx={{ color: c.text.muted, fontSize: '0.75rem' }}
>
Cancel
</Button>
<Button
size="small"
variant="contained"
onClick={handleSaveEdit}
disabled={!editText.trim() || editText.trim() === rawText}
sx={{
bgcolor: c.accent.primary,
fontSize: '0.75rem',
'&:hover': { bgcolor: c.accent.hover },
}}
>
Save & Submit
</Button>
</Box>
</Box>
);
}
return (
<Box>
{message.images && message.images.length > 0 && (
<MessageImageThumbnails images={message.images} c={c} />
)}
<Typography sx={{ color: c.text.primary, fontSize: '0.875rem', lineHeight: 1.6, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
{renderUserTextWithPills(displayText, c)}
</Typography>
<AttachedContextSection elements={selectedElements} message={message} c={c} />
</Box>
);
};
export default UserBubbleContent;
+17 -101
View File
@@ -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<string, any>;
@@ -47,46 +45,11 @@ const ViewBubble: React.FC<Props> = ({ toolInput, toolResult, isStreaming }) =>
if (isStreaming && !hasPreview) {
return (
<Box sx={{ width: '100%', my: 1 }}>
<Box
sx={{
borderLeft: `3px solid ${outputColor}`,
borderRadius: '0 12px 12px 0',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderLeftColor: outputColor,
borderLeftWidth: 3,
borderLeftStyle: 'solid',
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
gap: 1.5,
}}
>
<Icon sx={{ fontSize: 20, color: outputColor }}>{outputIcon}</Icon>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: c.text.primary, flex: 1 }}>
{outputName}
</Typography>
<Box
sx={{
width: 16,
height: 16,
border: `2px solid ${outputColor}`,
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'output-spin 0.8s linear infinite',
'@keyframes output-spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
<Typography sx={{ fontSize: '0.75rem', color: c.text.tertiary }}>
Rendering
</Typography>
</Box>
</Box>
<StreamingPlaceholder
outputColor={outputColor}
outputIcon={outputIcon}
outputName={outputName}
/>
);
}
@@ -193,13 +156,7 @@ const ViewBubble: React.FC<Props> = ({ toolInput, toolResult, isStreaming }) =>
{/* Preview */}
{hasPreview && (
<Box
sx={{
height: 350,
position: 'relative',
overflow: 'hidden',
}}
>
<Box sx={{ height: 350, position: 'relative', overflow: 'hidden' }}>
<ViewPreview
serveUrl={serveUrl}
frontendCode={frontendCode}
@@ -219,58 +176,17 @@ const ViewBubble: React.FC<Props> = ({ toolInput, toolResult, isStreaming }) =>
</Box>
</Box>
{/* Fullscreen dialog */}
<Dialog
open={expanded}
<ViewBubbleDialog
expanded={expanded}
onClose={() => setExpanded(false)}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
height: '85vh',
display: 'flex',
flexDirection: 'column',
borderRadius: '12px',
overflow: 'hidden',
},
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2.5,
py: 1.25,
borderBottom: `1px solid ${c.border.subtle}`,
background: `linear-gradient(135deg, ${outputColor}08 0%, transparent 60%)`,
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
bgcolor: outputColor,
},
}}
>
<Icon sx={{ fontSize: 22, color: outputColor, ml: 1 }}>{outputIcon}</Icon>
<Typography sx={{ fontWeight: 700, flex: 1, fontSize: '1rem' }}>{outputName}</Typography>
<IconButton onClick={() => setExpanded(false)} size="small" sx={{ color: c.text.tertiary }}>
<CloseIcon />
</IconButton>
</Box>
<DialogContent sx={{ p: 0, flex: 1, overflow: 'hidden' }}>
<ViewPreview
serveUrl={serveUrl}
frontendCode={frontendCode}
inputData={inputData}
backendResult={backendResult}
/>
</DialogContent>
</Dialog>
outputColor={outputColor}
outputIcon={outputIcon}
outputName={outputName}
serveUrl={serveUrl}
frontendCode={frontendCode}
inputData={inputData}
backendResult={backendResult}
/>
</>
);
};
@@ -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<StreamingPlaceholderProps> = ({
outputColor, outputIcon, outputName,
}) => {
const c = useClaudeTokens();
return (
<Box sx={{ width: '100%', my: 1 }}>
<Box
sx={{
borderLeft: `3px solid ${outputColor}`,
borderRadius: '0 12px 12px 0',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderLeftColor: outputColor,
borderLeftWidth: 3,
borderLeftStyle: 'solid',
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
gap: 1.5,
}}
>
<Icon sx={{ fontSize: 20, color: outputColor }}>{outputIcon}</Icon>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: c.text.primary, flex: 1 }}>
{outputName}
</Typography>
<Box
sx={{
width: 16,
height: 16,
border: `2px solid ${outputColor}`,
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'output-spin 0.8s linear infinite',
'@keyframes output-spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
<Typography sx={{ fontSize: '0.75rem', color: c.text.tertiary }}>
Rendering
</Typography>
</Box>
</Box>
);
};
interface ViewBubbleDialogProps {
expanded: boolean;
onClose: () => void;
outputColor: string;
outputIcon: string;
outputName: string;
serveUrl?: string;
frontendCode: string;
inputData: Record<string, any>;
backendResult: any;
}
export const ViewBubbleDialog: React.FC<ViewBubbleDialogProps> = ({
expanded, onClose, outputColor, outputIcon, outputName,
serveUrl, frontendCode, inputData, backendResult,
}) => {
const c = useClaudeTokens();
return (
<Dialog
open={expanded}
onClose={onClose}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
height: '85vh',
display: 'flex',
flexDirection: 'column',
borderRadius: '12px',
overflow: 'hidden',
},
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2.5,
py: 1.25,
borderBottom: `1px solid ${c.border.subtle}`,
background: `linear-gradient(135deg, ${outputColor}08 0%, transparent 60%)`,
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
bgcolor: outputColor,
},
}}
>
<Icon sx={{ fontSize: 22, color: outputColor, ml: 1 }}>{outputIcon}</Icon>
<Typography sx={{ fontWeight: 700, flex: 1, fontSize: '1rem' }}>{outputName}</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: c.text.tertiary }}>
<CloseIcon />
</IconButton>
</Box>
<DialogContent sx={{ p: 0, flex: 1, overflow: 'hidden' }}>
<ViewPreview
serveUrl={serveUrl}
frontendCode={frontendCode}
inputData={inputData}
backendResult={backendResult}
/>
</DialogContent>
</Dialog>
);
};
@@ -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 = (
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
const RedditIcon = (
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="12" fill="#FF4500"/>
<path d="M19.5 12c0-.6-.5-1.1-1.1-1.1-.3 0-.6.1-.8.3-1-.7-2.3-1.1-3.7-1.1l.6-3 2.1.5c0 .6.5 1.1 1.1 1.1.6 0 1.1-.5 1.1-1.1 0-.6-.5-1.1-1.1-1.1-.4 0-.8.3-1 .6l-2.3-.5c-.1 0-.2 0-.2.1l-.7 3.3c-1.4 0-2.7.4-3.7 1.1-.2-.2-.5-.3-.8-.3-.6 0-1.1.5-1.1 1.1 0 .4.2.8.6 1-.1.3-.1.6-.1.9 0 2.3 2.6 4.1 5.8 4.1s5.8-1.8 5.8-4.1c0-.3 0-.6-.1-.9.4-.2.6-.6.6-1zm-9.8 1.1c0-.6.5-1.1 1.1-1.1.6 0 1.1.5 1.1 1.1 0 .6-.5 1.1-1.1 1.1-.6 0-1.1-.5-1.1-1.1zm6.2 2.9c-.8.8-2 .9-2.9.9s-2.1-.1-2.9-.9c-.1-.1-.1-.3 0-.4.1-.1.3-.1.4 0 .6.6 1.6.8 2.5.8s1.9-.2 2.5-.8c.1-.1.3-.1.4 0 .1.1.1.3 0 .4zm-.2-1.8c-.6 0-1.1-.5-1.1-1.1 0-.6.5-1.1 1.1-1.1.6 0 1.1.5 1.1 1.1 0 .6-.5 1.1-1.1 1.1z" fill="#fff"/>
</svg>
);
export const INTEGRATION_META: Record<string, IntegrationMeta> = {
'Google Workspace': { label: 'Google Workspace', color: '#4285F4', icon: GoogleIcon },
'xbird': { label: 'X / Twitter', color: '#1DA1F2', icon: <span style={{ fontSize: 14, fontWeight: 700 }}>𝕏</span> },
'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, any>): 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 '';
}
@@ -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;
}
}
@@ -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<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputHandle>(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<ReturnType<typeof createSessionWs> | null>(null);
const initialContextApplied = useRef(false);
const messageQueueRef = useRef<QueuedMessage[]>([]);
const [queueLength, setQueueLength] = useState(0);
const [editingMessageId, setEditingMessageId] = useState<string | null>(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<string, any> = { 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<string, any>) => {
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,
};
}
@@ -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<HTMLDivElement | null>; attachedSkillsRef: React.MutableRefObject<Record<string, AttachedSkill>>;
generalFileInputRef: React.RefObject<HTMLInputElement | null>;
disabled?: boolean; autoRunMode?: boolean; images: AttachedImage[]; contextPaths: ContextPath[];
forcedTools: ForcedToolGroup[]; picker: TriggerState; templates: Record<string, PromptTemplate>;
skills: Record<string, { id: string; name: string; content: string }>; ownerId: string;
elementSelection: ReturnType<typeof useElementSelection>;
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<React.SetStateAction<AttachedImage[]>>; setContextPaths: React.Dispatch<React.SetStateAction<ContextPath[]>>;
setForcedTools: React.Dispatch<React.SetStateAction<ForcedToolGroup[]>>; setPicker: React.Dispatch<React.SetStateAction<TriggerState>>;
setHasContent: React.Dispatch<React.SetStateAction<boolean>>; setAttachedSkills: React.Dispatch<React.SetStateAction<Record<string, AttachedSkill>>>;
setIsUploading: React.Dispatch<React.SetStateAction<boolean>>; setIsDragOver: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedTemplate: React.Dispatch<React.SetStateAction<PromptTemplate | null>>;
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<string, AttachedSkill> = {};
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<string, SelectedElement['semanticType']> = { agent: 'agent-card', view: 'view-card', browser: 'browser-card' };
const semanticType = semanticTypeMap[card.type];
if (!semanticType) continue;
const labelMap: Record<string, string> = { '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 };
}
@@ -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<string>();
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<string>());
const groupMetaRefinedRef = useRef(new Set<string>());
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 };
}
@@ -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;
}
@@ -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 (
<span key={i}>
<span style={{ color: tc.DIM_COLOR }}>{line.slice(0, colonIdx + 1)}</span>
<span style={{ color: tc.CMD_COLOR }}>{line.slice(colonIdx + 1)}</span>
{nl}
</span>
);
}
return <span key={i} style={{ color: tc.CMD_COLOR }}>{line}{nl}</span>;
})}
</>
);
}
if (isBashTool(toolName)) return <span style={{ color: tc.CMD_COLOR }}>{text}</span>;
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 <span key={i} style={{ color: tc.PATH_COLOR }}>{line}{nl}</span>;
if (line.startsWith('+ '))
return <span key={i} style={{ color: tc.ADD_COLOR }}>{line}{nl}</span>;
if (line.startsWith('- '))
return <span key={i} style={{ color: tc.DEL_COLOR }}>{line}{nl}</span>;
return <span key={i} style={{ color: tc.CMD_COLOR }}>{line}{nl}</span>;
})}
</>
);
}
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 <span key={i} style={{ color: tc.PATH_COLOR }}>{line}{nl}</span>;
return <span key={i} style={{ color: tc.CMD_COLOR, opacity: 0.7 }}>{line}{nl}</span>;
})}
</>
);
}
if (n === 'read' || n === 'glob' || n === 'webfetch') {
if (/^\//.test(text) || text.includes('/'))
return <span style={{ color: tc.PATH_COLOR }}>{text}</span>;
}
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 (
<span key={i}>
<span style={{ color: tc.DIM_COLOR }}>pattern: </span>
<span style={{ color: tc.WARN_COLOR }}>{line.slice(9)}</span>
{nl}
</span>
);
if (line.startsWith('path:'))
return (
<span key={i}>
<span style={{ color: tc.DIM_COLOR }}>path: </span>
<span style={{ color: tc.PATH_COLOR }}>{line.slice(6)}</span>
{nl}
</span>
);
return <span key={i} style={{ color: tc.CMD_COLOR }}>{line}{nl}</span>;
})}
</>
);
}
return <span style={{ color: tc.CMD_COLOR }}>{text}</span>;
}
export function colorizeOutput(toolName: string, text: string, tc: TermColors): React.ReactNode {
if (!text) return <span style={{ color: tc.DIM_COLOR, fontStyle: 'italic' }}>(empty)</span>;
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 <span key={i} style={{ color: tc.PATH_COLOR }}>{line}{nl}</span>;
if (n === 'grep' || n === 'ripgrep') {
const grepMatch = line.match(/^(\S+?:\d+[:-])/);
if (grepMatch) {
return (
<span key={i}>
<span style={{ color: tc.PATH_COLOR }}>{grepMatch[1]}</span>
<span style={{ color: tc.OUTPUT_COLOR }}>{line.slice(grepMatch[1].length)}</span>
{nl}
</span>
);
}
const fileHeader = line.match(/^(\S+\.\w+)$/);
if (fileHeader)
return <span key={i} style={{ color: tc.PATH_COLOR, fontWeight: 600 }}>{line}{nl}</span>;
}
if (line.startsWith('@@') && line.includes('@@'))
return <span key={i} style={{ color: tc.DIFF_HEADER_COLOR }}>{line}{nl}</span>;
if (line.startsWith('+'))
return <span key={i} style={{ color: tc.ADD_COLOR }}>{line}{nl}</span>;
if (line.startsWith('-'))
return <span key={i} style={{ color: tc.DEL_COLOR }}>{line}{nl}</span>;
if (/\b[Ee]rror\b/.test(line))
return <span key={i} style={{ color: tc.STDERR_COLOR }}>{line}{nl}</span>;
if (/\b[Ww]arning\b/.test(line))
return <span key={i} style={{ color: tc.WARN_COLOR }}>{line}{nl}</span>;
if (n === 'read') {
const lineNumMatch = line.match(/^(\s*\d+\s*[|:])/);
if (lineNumMatch) {
return (
<span key={i}>
<span style={{ color: tc.NUM_COLOR, opacity: 0.6 }}>{lineNumMatch[1]}</span>
<span style={{ color: tc.OUTPUT_COLOR }}>{line.slice(lineNumMatch[1].length)}</span>
{nl}
</span>
);
}
}
return <span key={i} style={{ color: tc.OUTPUT_COLOR }}>{line}{nl}</span>;
})}
</>
);
}
@@ -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<string, any>; 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 };
}
+8 -333
View File
@@ -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<PixelChartProps> = ({
data,
@@ -36,313 +15,11 @@ const PixelChart: React.FC<PixelChartProps> = ({
showYScale = true,
mode = 'bar',
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animRef = useRef(0);
const progressRef = useRef(0);
const hoverIdxRef = useRef(-1);
const tooltipRef = useRef<HTMLDivElement>(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 (
<Box ref={containerRef} sx={{ position: 'relative', width: '100%' }}>
@@ -352,7 +29,6 @@ const PixelChart: React.FC<PixelChartProps> = ({
onMouseLeave={handleMouseLeave}
style={{ display: 'block', width: '100%', imageRendering: 'pixelated', cursor: 'crosshair' }}
/>
{/* X-axis labels */}
{showXLabels && data.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.5, pl: `${Y_LABEL_WIDTH}px` }}>
{xLabels.map((xl) => (
@@ -373,7 +49,6 @@ const PixelChart: React.FC<PixelChartProps> = ({
))}
</Box>
)}
{/* Tooltip */}
<Box
ref={tooltipRef}
sx={{
@@ -0,0 +1,187 @@
import { ChartDrawParams } from './pixelChartTypes';
export function drawYAxis(
ctx: CanvasRenderingContext2D,
yTicks: number[],
effectiveMax: number,
h: number,
px: number,
yLabelWidth: number,
totalW: number,
formatValue: ((v: number) => 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;
}
}
}
@@ -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;
}
@@ -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<PixelChartProps, 'data'>> &
Pick<PixelChartProps, 'palette' | 'height' | 'pixelSize' | 'formatValue' | 'glow' | 'showYScale' | 'mode'>;
export function usePixelChart({
data,
palette = 'salmon',
height = 140,
pixelSize = 6,
formatValue,
glow = true,
showYScale = true,
mode = 'bar',
}: UsePixelChartProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animRef = useRef(0);
const progressRef = useRef(0);
const hoverIdxRef = useRef(-1);
const tooltipRef = useRef<HTMLDivElement>(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 };
}
@@ -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<AtCommandsSectionProps> = ({ atCommands, c }) => (
<Box>
<SectionHeader
icon={<AlternateEmailIcon sx={{ fontSize: 22 }} />}
title="@ Context Commands"
subtitle="Type @ in chat to attach context and activate actions"
count={atCommands.length}
c={c}
/>
{atCommands.length === 0 ? (
<Box
sx={{
py: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
color: c.text.ghost,
}}
>
<AlternateEmailIcon sx={{ fontSize: 36, opacity: 0.3 }} />
<Typography sx={{ fontSize: '0.85rem' }}>
No @ commands yet. Install MCP actions to see them here.
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{atCommands.map((cmd) => (
<Box
key={cmd.prefix}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
pl: cmd.isChild ? 5 : 2,
pr: 2,
py: cmd.isChild ? 0.875 : 1.25,
borderRadius: 2,
'&:hover': { bgcolor: `${c.accent.primary}06` },
transition: 'background-color 0.15s',
}}
>
<Box sx={{ color: c.accent.primary, display: 'flex', opacity: cmd.isChild ? 0.6 : 1 }}>
{cmd.icon}
</Box>
<Typography
sx={{
color: c.text.primary,
fontSize: cmd.isChild ? '0.8rem' : '0.85rem',
fontFamily: c.font.mono,
fontWeight: 500,
minWidth: 140,
}}
>
{cmd.prefix}
</Typography>
<Chip
label={cmd.source}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 600,
textTransform: 'uppercase',
bgcolor: cmd.source === 'builtin' ? `${c.accent.primary}12` : cmd.source === 'view' ? '#f472b615' : `${c.status.info}15`,
color: cmd.source === 'builtin' ? c.accent.primary : cmd.source === 'view' ? '#f472b6' : c.status.info,
}}
/>
<Typography
sx={{
color: c.text.muted,
fontSize: '0.8rem',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{cmd.description}
</Typography>
</Box>
))}
</Box>
)}
</Box>
);
export default AtCommandsSection;
+11 -543
View File
@@ -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 }) => (
<Box
sx={{
bgcolor: c.bg.secondary,
border: `1px solid ${c.border.medium}`,
borderRadius: 1.5,
px: 1.25,
py: 0.4,
display: 'inline-flex',
alignItems: 'center',
}}
>
<Typography
sx={{
color: c.accent.primary,
fontSize: '0.75rem',
fontFamily: c.font.mono,
fontWeight: 600,
lineHeight: 1,
}}
>
{keys}
</Typography>
</Box>
);
const SectionHeader: React.FC<{
icon: React.ReactNode;
title: string;
subtitle: string;
count?: number;
c: any;
}> = ({ icon, title, subtitle, count, c }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Box sx={{ color: c.accent.primary, display: 'flex', alignItems: 'center' }}>{icon}</Box>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
{title}
</Typography>
{count !== undefined && (
<Chip
label={count}
size="small"
sx={{
height: 20,
fontSize: '0.7rem',
fontWeight: 600,
bgcolor: `${c.accent.primary}15`,
color: c.accent.primary,
}}
/>
)}
</Box>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem' }}>{subtitle}</Typography>
</Box>
</Box>
);
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: <InsertDriveFileOutlinedIcon sx={{ fontSize: 18 }} />, 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: <LanguageIcon sx={{ fontSize: 18 }} />,
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<string, { read: string[]; write: string[] }> | undefined;
if (!services) continue;
const perms = tool.tool_permissions as Record<string, any>;
const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record<string, string[]>;
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<string>();
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: <BuildOutlinedIcon sx={{ fontSize: 18 }} />,
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: <ViewQuiltOutlinedIcon sx={{ fontSize: 18 }} />,
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 (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{/* Slash Commands */}
<Box>
<SectionHeader
icon={<TerminalIcon sx={{ fontSize: 22 }} />}
title="Slash Commands"
subtitle="Type / in chat to invoke templates, skills, and modes"
count={slashCommands.length}
c={c}
/>
{slashCommands.length === 0 ? (
<Box
sx={{
py: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
color: c.text.ghost,
}}
>
<TerminalIcon sx={{ fontSize: 36, opacity: 0.3 }} />
<Typography sx={{ fontSize: '0.85rem' }}>
No slash commands yet. Create templates, skills, or modes to see them here.
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{slashCommands.map((cmd) => (
<Box
key={`${cmd.type}-${cmd.id}`}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 1.25,
borderRadius: 2,
'&:hover': { bgcolor: `${c.accent.primary}06` },
transition: 'background-color 0.15s',
}}
>
<Box sx={{
color: cmd.type === 'template' ? c.accent.primary
: cmd.type === 'mode' ? (modesMap[cmd.id]?.color || c.accent.primary)
: c.status.success,
display: 'flex',
}}>
{cmd.type === 'template' ? (
<DescriptionIcon sx={{ fontSize: 18 }} />
) : cmd.type === 'mode' ? (
<SmartToyOutlinedIcon sx={{ fontSize: 18 }} />
) : (
<PsychologyIcon sx={{ fontSize: 18 }} />
)}
</Box>
<Typography
sx={{
color: c.text.primary,
fontSize: '0.85rem',
fontFamily: c.font.mono,
fontWeight: 500,
minWidth: 140,
}}
>
/{cmd.command}
</Typography>
<Chip
label={cmd.type}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 600,
textTransform: 'uppercase',
bgcolor: cmd.type === 'template' ? `${c.accent.primary}12`
: cmd.type === 'mode' ? `${modesMap[cmd.id]?.color || c.accent.primary}15`
: `${c.status.success}15`,
color: cmd.type === 'template' ? c.accent.primary
: cmd.type === 'mode' ? (modesMap[cmd.id]?.color || c.accent.primary)
: c.status.success,
}}
/>
<Typography
sx={{
color: c.text.muted,
fontSize: '0.8rem',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{cmd.description}
</Typography>
</Box>
))}
</Box>
)}
</Box>
<Box sx={{ my: 2, borderTop: `1px solid ${c.border.subtle}` }} />
{/* @ Commands */}
<Box>
<SectionHeader
icon={<AlternateEmailIcon sx={{ fontSize: 22 }} />}
title="@ Context Commands"
subtitle="Type @ in chat to attach context and activate actions"
count={atCommands.length}
c={c}
/>
{atCommands.length === 0 ? (
<Box
sx={{
py: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
color: c.text.ghost,
}}
>
<AlternateEmailIcon sx={{ fontSize: 36, opacity: 0.3 }} />
<Typography sx={{ fontSize: '0.85rem' }}>
No @ commands yet. Install MCP actions to see them here.
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{atCommands.map((cmd) => (
<Box
key={cmd.prefix}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
pl: cmd.isChild ? 5 : 2,
pr: 2,
py: cmd.isChild ? 0.875 : 1.25,
borderRadius: 2,
'&:hover': { bgcolor: `${c.accent.primary}06` },
transition: 'background-color 0.15s',
}}
>
<Box sx={{ color: c.accent.primary, display: 'flex', opacity: cmd.isChild ? 0.6 : 1 }}>
{cmd.icon}
</Box>
<Typography
sx={{
color: c.text.primary,
fontSize: cmd.isChild ? '0.8rem' : '0.85rem',
fontFamily: c.font.mono,
fontWeight: 500,
minWidth: 140,
}}
>
{cmd.prefix}
</Typography>
<Chip
label={cmd.source}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 600,
textTransform: 'uppercase',
bgcolor: cmd.source === 'builtin' ? `${c.accent.primary}12` : cmd.source === 'view' ? '#f472b615' : `${c.status.info}15`,
color: cmd.source === 'builtin' ? c.accent.primary : cmd.source === 'view' ? '#f472b6' : c.status.info,
}}
/>
<Typography
sx={{
color: c.text.muted,
fontSize: '0.8rem',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{cmd.description}
</Typography>
</Box>
))}
</Box>
)}
</Box>
<Box sx={{ my: 2, borderTop: `1px solid ${c.border.subtle}` }} />
{/* Keyboard Shortcuts */}
<Box>
<SectionHeader
icon={<KeyboardIcon sx={{ fontSize: 22 }} />}
title="Keyboard Shortcuts"
subtitle="Press ? anywhere to see the quick-reference dialog"
count={SHORTCUTS.length}
c={c}
/>
<Box sx={{ display: 'flex', gap: 4 }}>
{/* Navigation */}
<Box sx={{ flex: 1 }}>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
mb: 1.5,
px: 1,
}}
>
Navigation
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{navShortcuts.map((s) => (
<Box
key={s.key}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: 2,
'&:hover': { bgcolor: `${c.accent.primary}06` },
transition: 'background-color 0.15s',
}}
>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>
{s.description}
</Typography>
<KeyBadge keys={s.key} c={c} />
</Box>
))}
</Box>
</Box>
{/* Actions */}
<Box sx={{ flex: 1 }}>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
mb: 1.5,
px: 1,
}}
>
Actions
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{actionShortcuts.map((s) => (
<Box
key={s.key}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: 2,
'&:hover': { bgcolor: `${c.accent.primary}06` },
transition: 'background-color 0.15s',
}}
>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>
{s.description}
</Typography>
<KeyBadge keys={s.key} c={c} />
</Box>
))}
</Box>
</Box>
</Box>
</Box>
<SlashCommandsSection slashCommands={slashCommands} modesMap={modesMap} c={c} />
<Box sx={{ my: 2, borderTop: `1px solid ${c.border.subtle}` }} />
<AtCommandsSection atCommands={atCommands} c={c} />
<Box sx={{ my: 2, borderTop: `1px solid ${c.border.subtle}` }} />
<ShortcutsSection navShortcuts={navShortcuts} actionShortcuts={actionShortcuts} c={c} />
</Box>
);
};
@@ -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 }) => (
<Box
sx={{
bgcolor: c.bg.secondary,
border: `1px solid ${c.border.medium}`,
borderRadius: 1.5,
px: 1.25,
py: 0.4,
display: 'inline-flex',
alignItems: 'center',
}}
>
<Typography
sx={{
color: c.accent.primary,
fontSize: '0.75rem',
fontFamily: c.font.mono,
fontWeight: 600,
lineHeight: 1,
}}
>
{keys}
</Typography>
</Box>
);
export const SectionHeader: React.FC<{
icon: React.ReactNode;
title: string;
subtitle: string;
count?: number;
c: any;
}> = ({ icon, title, subtitle, count, c }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Box sx={{ color: c.accent.primary, display: 'flex', alignItems: 'center' }}>{icon}</Box>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
{title}
</Typography>
{count !== undefined && (
<Chip
label={count}
size="small"
sx={{
height: 20,
fontSize: '0.7rem',
fontWeight: 600,
bgcolor: `${c.accent.primary}15`,
color: c.accent.primary,
}}
/>
)}
</Box>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem' }}>{subtitle}</Typography>
</Box>
</Box>
);
@@ -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<ShortcutsSectionProps> = ({ navShortcuts, actionShortcuts, c }) => (
<Box>
<SectionHeader
icon={<KeyboardIcon sx={{ fontSize: 22 }} />}
title="Keyboard Shortcuts"
subtitle="Press ? anywhere to see the quick-reference dialog"
count={SHORTCUTS.length}
c={c}
/>
<Box sx={{ display: 'flex', gap: 4 }}>
<Box sx={{ flex: 1 }}>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
mb: 1.5,
px: 1,
}}
>
Navigation
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{navShortcuts.map((s) => (
<Box
key={s.key}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: 2,
'&:hover': { bgcolor: `${c.accent.primary}06` },
transition: 'background-color 0.15s',
}}
>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>
{s.description}
</Typography>
<KeyBadge keys={s.key} c={c} />
</Box>
))}
</Box>
</Box>
<Box sx={{ flex: 1 }}>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
mb: 1.5,
px: 1,
}}
>
Actions
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{actionShortcuts.map((s) => (
<Box
key={s.key}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: 2,
'&:hover': { bgcolor: `${c.accent.primary}06` },
transition: 'background-color 0.15s',
}}
>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>
{s.description}
</Typography>
<KeyBadge keys={s.key} c={c} />
</Box>
))}
</Box>
</Box>
</Box>
</Box>
);
export default ShortcutsSection;
@@ -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<string, { color: string }>;
c: any;
}
const SlashCommandsSection: React.FC<SlashCommandsSectionProps> = ({ slashCommands, modesMap, c }) => (
<Box>
<SectionHeader
icon={<TerminalIcon sx={{ fontSize: 22 }} />}
title="Slash Commands"
subtitle="Type / in chat to invoke templates, skills, and modes"
count={slashCommands.length}
c={c}
/>
{slashCommands.length === 0 ? (
<Box
sx={{
py: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
color: c.text.ghost,
}}
>
<TerminalIcon sx={{ fontSize: 36, opacity: 0.3 }} />
<Typography sx={{ fontSize: '0.85rem' }}>
No slash commands yet. Create templates, skills, or modes to see them here.
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{slashCommands.map((cmd) => (
<Box
key={`${cmd.type}-${cmd.id}`}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 1.25,
borderRadius: 2,
'&:hover': { bgcolor: `${c.accent.primary}06` },
transition: 'background-color 0.15s',
}}
>
<Box sx={{
color: cmd.type === 'template' ? c.accent.primary
: cmd.type === 'mode' ? (modesMap[cmd.id]?.color || c.accent.primary)
: c.status.success,
display: 'flex',
}}>
{cmd.type === 'template' ? (
<DescriptionIcon sx={{ fontSize: 18 }} />
) : cmd.type === 'mode' ? (
<SmartToyOutlinedIcon sx={{ fontSize: 18 }} />
) : (
<PsychologyIcon sx={{ fontSize: 18 }} />
)}
</Box>
<Typography
sx={{
color: c.text.primary,
fontSize: '0.85rem',
fontFamily: c.font.mono,
fontWeight: 500,
minWidth: 140,
}}
>
/{cmd.command}
</Typography>
<Chip
label={cmd.type}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 600,
textTransform: 'uppercase',
bgcolor: cmd.type === 'template' ? `${c.accent.primary}12`
: cmd.type === 'mode' ? `${modesMap[cmd.id]?.color || c.accent.primary}15`
: `${c.status.success}15`,
color: cmd.type === 'template' ? c.accent.primary
: cmd.type === 'mode' ? (modesMap[cmd.id]?.color || c.accent.primary)
: c.status.success,
}}
/>
<Typography
sx={{
color: c.text.muted,
fontSize: '0.8rem',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{cmd.description}
</Typography>
</Box>
))}
</Box>
)}
</Box>
);
export default SlashCommandsSection;
@@ -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' },
];
@@ -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: <InsertDriveFileOutlinedIcon sx={{ fontSize: 18 }} />, 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: <LanguageIcon sx={{ fontSize: 18 }} />,
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<string, { read: string[]; write: string[] }> | undefined;
if (!services) continue;
const perms = tool.tool_permissions as Record<string, any>;
const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record<string, string[]>;
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<string>();
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: <BuildOutlinedIcon sx={{ fontSize: 18 }} />,
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: <ViewQuiltOutlinedIcon sx={{ fontSize: 18 }} />,
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 };
}
File diff suppressed because it is too large Load Diff
@@ -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<string, any>;
}
const AgentCardCollapsed: React.FC<AgentCardCollapsedProps> = ({
session,
previewContent,
isStreaming,
hasPending,
statusStyle,
c,
}) => {
const dispatch = useAppDispatch();
const pendingReq = session.pending_approvals[0];
return (
<>
{previewContent && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: hasPending ? 1.5 : 0 }}>
{isStreaming && (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: c.accent.primary,
flexShrink: 0,
animation: 'pulse-dot 1.4s ease-in-out infinite',
'@keyframes pulse-dot': {
'0%, 100%': { opacity: 0.4, transform: 'scale(0.8)' },
'50%': { opacity: 1, transform: 'scale(1.2)' },
},
}}
/>
)}
<Typography
variant="body2"
sx={{
color: isStreaming ? c.text.secondary : c.text.muted,
fontSize: '0.8rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{previewContent}
</Typography>
</Box>
)}
{hasPending && pendingReq && pendingReq.tool_name === 'AskUserQuestion' ? (
<Box onClick={(e) => e.stopPropagation()}>
<QuestionForm
compact
request={pendingReq}
onApprove={(requestId, updatedInput) =>
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }))
}
onDeny={(requestId) =>
dispatch(handleApproval({ requestId, behavior: 'deny' }))
}
/>
</Box>
) : hasPending ? (
<Box onClick={(e) => e.stopPropagation()} sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{pendingReq && (
<Box
sx={{
bgcolor: c.status.warningBg,
border: `1px solid rgba(128,92,31,0.2)`,
borderRadius: 2,
p: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ minWidth: 0, flex: 1, display: 'flex', alignItems: 'center', gap: 0.75 }}>
{(() => {
const mcp = parseMcpToolName(pendingReq.tool_name);
if (mcp.isMcp && mcp.service) return <GoogleServiceIcon service={mcp.service} size={18} />;
return <TerminalIcon sx={{ fontSize: 16, color: c.status.warning, flexShrink: 0, opacity: 0.8 }} />;
})()}
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ color: c.status.warning, fontSize: '0.75rem', fontWeight: 600 }}>
{getToolDisplayName(pendingReq.tool_name)}
</Typography>
<Typography
sx={{
color: c.text.muted,
fontSize: '0.7rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{summarizeToolInput(pendingReq.tool_name, pendingReq.tool_input)}
</Typography>
</Box>
</Box>
{session.pending_approvals.length === 1 && (
<Box sx={{ display: 'flex', gap: 0.5, ml: 1 }}>
<Tooltip title="Approve">
<IconButton
size="small"
onClick={() => dispatch(handleApproval({ requestId: pendingReq.id, behavior: 'allow' }))}
sx={{ color: c.status.success }}
>
<CheckCircleIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Deny">
<IconButton
size="small"
onClick={() => dispatch(handleApproval({ requestId: pendingReq.id, behavior: 'deny' }))}
sx={{ color: c.status.error }}
>
<CancelIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
)}
{session.pending_approvals.length > 1 && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
bgcolor: c.status.warningBg,
border: `1px solid rgba(128,92,31,0.2)`,
borderRadius: 2,
px: 1.25,
py: 0.75,
}}
>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: c.status.warning, flex: 1 }}>
{session.pending_approvals.length} pending approvals
</Typography>
<Button
variant="contained"
size="small"
startIcon={<CheckIcon sx={{ fontSize: '14px !important' }} />}
onClick={() => {
for (const req of session.pending_approvals) {
if (req.tool_name !== 'AskUserQuestion') dispatch(handleApproval({ requestId: req.id, behavior: 'allow' }));
}
}}
sx={{
bgcolor: c.status.success,
'&:hover': { bgcolor: '#1e4d15' },
fontWeight: 600,
fontSize: '0.72rem',
textTransform: 'none',
borderRadius: 1.5,
px: 1.25,
py: 0.25,
minHeight: 26,
minWidth: 0,
}}
>
Approve All
</Button>
<Button
variant="outlined"
size="small"
startIcon={<CloseIcon sx={{ fontSize: '14px !important' }} />}
onClick={() => {
for (const req of session.pending_approvals) {
if (req.tool_name !== 'AskUserQuestion') dispatch(handleApproval({ requestId: req.id, behavior: 'deny' }));
}
}}
sx={{
borderColor: c.status.error,
color: c.status.error,
'&:hover': { borderColor: '#8f2828', bgcolor: 'rgba(181,51,51,0.04)' },
fontWeight: 600,
fontSize: '0.72rem',
textTransform: 'none',
borderRadius: 1.5,
px: 1.25,
py: 0.25,
minHeight: 26,
minWidth: 0,
}}
>
Deny All
</Button>
</Box>
)}
</Box>
) : null}
</>
);
};
export default React.memo(AgentCardCollapsed);
@@ -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<BrowserActionOverlayProps> = ({
agentAction, lastAction, actionSeq, coords, accentColor, accentRgb,
showGlow, agentActive, browserId, showFrostedOverlay,
}) => (
<>
{(agentAction === 'screenshot' || lastAction === 'screenshot') && (
<Box key={`flash-${actionSeq}`} sx={{
position: 'absolute', inset: 0, bgcolor: '#fff', pointerEvents: 'none', zIndex: 15,
animation: 'camera-flash 0.4s ease-out forwards',
'@keyframes camera-flash': { '0%': { opacity: 0.45 }, '100%': { opacity: 0 } },
}} />
)}
{agentAction === 'get_text' && (
<Box sx={{
position: 'absolute', left: 0, right: 0, height: '3px', zIndex: 15, pointerEvents: 'none',
background: `linear-gradient(180deg, transparent, ${accentColor}90, transparent)`,
boxShadow: `0 0 12px ${accentColor}60`,
animation: 'scan-sweep 1.5s ease-in-out infinite alternate',
'@keyframes scan-sweep': { '0%': { top: '0%' }, '100%': { top: 'calc(100% - 3px)' } },
}} />
)}
{(agentAction === 'click' || lastAction === 'click') && (
<Box key={`ripple-${actionSeq}`} sx={{
position: 'absolute',
top: `${(coords?.yPercent ?? 0.5) * 100}%`,
left: `${(coords?.xPercent ?? 0.5) * 100}%`,
width: 40, height: 40, borderRadius: '50%', border: `2px solid ${accentColor}`,
transform: 'translate(-50%, -50%)', pointerEvents: 'none', zIndex: 15,
animation: 'click-ripple 0.5s ease-out forwards',
'@keyframes click-ripple': {
'0%': { opacity: 0.8, width: 10, height: 10, borderWidth: '2px' },
'100%': { opacity: 0, width: 60, height: 60, borderWidth: '1px' },
},
}} />
)}
{agentAction === 'type' && (
<Box sx={{
position: 'absolute', bottom: 8, left: '50%', transform: 'translateX(-50%)',
display: 'flex', gap: '4px', alignItems: 'center', px: 1, py: 0.5, borderRadius: '8px',
bgcolor: `${accentColor}20`, border: `1px solid ${accentColor}40`, zIndex: 15, pointerEvents: 'none',
}}>
{[0, 1, 2].map((i) => (
<Box key={i} sx={{
width: 5, height: 5, borderRadius: '50%', bgcolor: accentColor,
animation: `typing-dot 1s ease-in-out ${i * 0.15}s infinite`,
'@keyframes typing-dot': {
'0%, 60%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'30%': { opacity: 1, transform: 'scale(1.2)' },
},
}} />
))}
</Box>
)}
{showGlow && !agentActive && (
<Box sx={{
position: 'absolute', inset: 0, zIndex: 14, pointerEvents: 'none', borderRadius: 'inherit',
boxShadow: `inset 0 0 40px rgba(${accentRgb},0.35), inset 0 0 80px rgba(${accentRgb},0.15)`,
animation: `accent-glow-${browserId} 2s ease-in-out infinite`,
[`@keyframes accent-glow-${browserId}`]: {
'0%, 100%': { boxShadow: `inset 0 0 40px rgba(${accentRgb},0.35), inset 0 0 80px rgba(${accentRgb},0.15)` },
'50%': { boxShadow: `inset 0 0 50px rgba(${accentRgb},0.45), inset 0 0 100px rgba(${accentRgb},0.22)` },
},
}} />
)}
{showFrostedOverlay && (
<Box sx={{
position: 'absolute', inset: 0, zIndex: 16, backdropFilter: 'blur(2px)', bgcolor: 'rgba(0,0,0,0.15)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5,
animation: 'overlay-fade-in 0.25s ease-out',
'@keyframes overlay-fade-in': { '0%': { opacity: 0 }, '100%': { opacity: 1 } },
}}>
<CircularProgress size={28} thickness={3} sx={{ color: accentColor }} />
<Box sx={{
display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: 0.75, borderRadius: '10px',
bgcolor: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(8px)', border: `1px solid ${accentColor}30`,
}}>
<SmartToyOutlinedIcon sx={{ fontSize: 14, color: accentColor }} />
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: '#fff', letterSpacing: '0.02em' }}>
{getActionLabel(agentAction ?? '')}
</Typography>
</Box>
</Box>
)}
</>
);
export default BrowserActionOverlay;
@@ -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<Props> = ({ session, browserWidth, browserHeight }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const scrollRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(false);
const [confirmStop, setConfirmStop] = useState(false);
const [fadeOut, setFadeOut] = useState(false);
@@ -90,12 +59,6 @@ const BrowserAgentOverlay: React.FC<Props> = ({ 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<Props> = ({ session, browserWidth, browserHe
},
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
@@ -253,92 +215,14 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
)}
</Box>
{/* Body — scrollable action log */}
<Box
ref={scrollRef}
sx={{
flex: 1,
overflowY: 'auto',
px: 1.25,
py: 0.75,
display: 'flex',
flexDirection: 'column',
gap: 0.5,
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(255,255,255,0.12) transparent',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-thumb': {
background: 'rgba(255,255,255,0.12)',
borderRadius: 2,
},
}}
>
{entries.length === 0 && isRunning && (
<Typography sx={{ fontSize: '0.68rem', color: 'rgba(255,255,255,0.35)', fontStyle: 'italic' }}>
Starting...
</Typography>
)}
{entries.map((entry, i) => (
<Box key={i} sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
{entry.type === 'thought' ? (
<>
<Box
sx={{
width: 4,
height: 4,
borderRadius: '50%',
bgcolor: 'rgba(255,255,255,0.25)',
flexShrink: 0,
mt: '5px',
}}
/>
<Typography
sx={{
fontSize: '0.68rem',
color: 'rgba(255,255,255,0.6)',
lineHeight: 1.4,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: expanded ? 6 : 2,
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</>
) : (
<>
<Box
sx={{
width: 4,
height: 4,
borderRadius: '1px',
bgcolor: accentColor,
flexShrink: 0,
mt: '5px',
transform: 'rotate(45deg)',
}}
/>
<Typography
sx={{
fontSize: '0.68rem',
fontFamily: c.font.mono,
color: accentColor,
lineHeight: 1.4,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{entry.text}
</Typography>
</>
)}
</Box>
))}
</Box>
<OverlayActionLog
entries={entries}
expanded={expanded}
isRunning={isRunning}
accentColor={accentColor}
messageCount={session.messages.length}
streamingContent={streamingMsg?.content}
/>
</Box>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
import React from 'react';
import Box from '@mui/material/Box';
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 ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RefreshIcon from '@mui/icons-material/Refresh';
import LockIcon from '@mui/icons-material/Lock';
import SearchIcon from '@mui/icons-material/Search';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface BrowserNavBarProps {
canGoBack: boolean;
canGoForward: boolean;
urlBarValue: string;
isSecure: boolean;
isSearch: boolean;
loading: boolean;
agentActive: boolean;
agentAction: string | null;
accentColor: string;
onUrlChange: (value: string) => void;
onUrlKeyDown: (e: React.KeyboardEvent) => void;
onBack: (e: React.MouseEvent) => void;
onForward: (e: React.MouseEvent) => void;
onRefresh: (e: React.MouseEvent) => void;
}
const BrowserNavBar: React.FC<BrowserNavBarProps> = ({
canGoBack, canGoForward, urlBarValue, isSecure, isSearch, loading,
agentActive, agentAction, accentColor, onUrlChange, onUrlKeyDown,
onBack, onForward, onRefresh,
}) => {
const c = useClaudeTokens();
return (
<>
<Box sx={{
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, py: 0.25,
bgcolor: c.bg.page, borderBottom: `1px solid ${c.border.subtle}`, flexShrink: 0,
}}>
<Tooltip title="Back" placement="top">
<span>
<IconButton size="small" onClick={onBack} onPointerDown={(e) => e.stopPropagation()}
disabled={!canGoBack} sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}>
<ArrowBackIcon sx={{ fontSize: 15 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Forward" placement="top">
<span>
<IconButton size="small" onClick={onForward} onPointerDown={(e) => e.stopPropagation()}
disabled={!canGoForward} sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}>
<ArrowForwardIcon sx={{ fontSize: 15 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Reload" placement="top">
<IconButton size="small" onClick={onRefresh} onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}>
<RefreshIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
<Box sx={{
display: 'flex', alignItems: 'center', flex: 1, gap: 0.5, ml: 0.5, px: 1, py: 0.2,
bgcolor: c.bg.secondary, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
}}>
{isSearch ? (
<SearchIcon sx={{ fontSize: 13, color: c.text.muted, flexShrink: 0 }} />
) : isSecure ? (
<LockIcon sx={{ fontSize: 12, color: c.status.success, flexShrink: 0 }} />
) : null}
<InputBase
value={urlBarValue}
onChange={(e) => onUrlChange(e.target.value)}
onKeyDown={onUrlKeyDown}
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 },
}}
/>
</Box>
</Box>
{(loading || (agentActive && agentAction === 'navigate')) && (
<LinearProgress sx={{
height: 2, flexShrink: 0, bgcolor: 'transparent',
'& .MuiLinearProgress-bar': { bgcolor: agentActive ? accentColor : c.accent.primary },
}} />
)}
</>
);
};
export default BrowserNavBar;
@@ -0,0 +1,194 @@
import React, { useState, useRef, useCallback } 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 CircularProgress from '@mui/material/CircularProgress';
import LanguageIcon from '@mui/icons-material/Language';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import {
reorderBrowserTab, setActiveBrowserTab, addBrowserTab,
removeBrowserTab, removeBrowserCard, type BrowserTab,
} from '@/shared/state/dashboardLayoutSlice';
import type { TabLocalState } from './hooks/useWebviewLifecycle';
interface BrowserTabBarProps {
tabs: BrowserTab[];
activeTabId: string;
browserId: string;
tabLocalStates: Record<string, TabLocalState>;
accentColor: string;
agentActive: boolean;
isDragging: boolean;
onDragPointerDown: (e: React.PointerEvent) => void;
onDragPointerMove: (e: React.PointerEvent) => void;
onDragPointerUp: (e: React.PointerEvent) => void;
}
const BrowserTabBar: React.FC<BrowserTabBarProps> = ({
tabs, activeTabId, browserId, tabLocalStates, accentColor, agentActive,
isDragging, onDragPointerDown, onDragPointerMove, onDragPointerUp,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage);
const tabBarRef = useRef<HTMLDivElement>(null);
const handleSwitchTab = useCallback((tabId: string) => { dispatch(setActiveBrowserTab({ browserId, tabId })); }, [dispatch, browserId]);
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 handleRemove = useCallback((e: React.MouseEvent) => { e.stopPropagation(); dispatch(removeBrowserCard(browserId)); }, [dispatch, browserId]);
const tabDragRef = useRef<{ tabId: string; startX: number; isDragging: boolean } | null>(null);
const swapCooldown = useRef(false);
const [dragTabId, setDragTabId] = useState<string | null>(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 ci = tabs.findIndex((t) => t.id === drag.tabId);
const trySwap = (targetIdx: number) => {
const el = bar.querySelector(`[data-tab-id="${tabs[targetIdx].id}"]`) as HTMLElement | null;
if (!el) return;
const r = el.getBoundingClientRect();
const mid = r.left + r.width / 2;
if ((targetIdx > ci && center > mid) || (targetIdx < ci && center < mid)) {
dispatch(reorderBrowserTab({ browserId, tabId: drag.tabId, toIndex: targetIdx }));
drag.startX = e.clientX; setDragTabOffset(0);
swapCooldown.current = true;
requestAnimationFrame(() => { swapCooldown.current = false; });
}
};
if (ci < tabs.length - 1) trySwap(ci + 1);
if (ci > 0) trySwap(ci - 1);
}, [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]);
return (
<Box ref={tabBarRef} onPointerDown={onDragPointerDown} onPointerMove={onDragPointerMove} onPointerUp={onDragPointerUp}
sx={{
position: 'relative', zIndex: 16, display: 'flex', alignItems: 'stretch',
bgcolor: agentActive ? `${accentColor}0a` : c.bg.secondary,
borderBottom: `1px solid ${agentActive ? `${accentColor}30` : c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab', flexShrink: 0, minHeight: 34, userSelect: 'none',
transition: 'background 0.3s ease', overflow: 'hidden',
}}
>
<Box sx={{ display: 'flex', flex: 1, minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarWidth: 'none', '&::-webkit-scrollbar': { display: 'none' } }}>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const isBeingDragged = tab.id === dragTabId;
const tls = tabLocalStates[tab.id];
return (
<Box key={tab.id} data-tab-id={tab.id} onPointerDown={handleTabPointerDown}
onPointerMove={handleTabPointerMove} onPointerUp={handleTabPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 0.5, px: 1, minWidth: 0, maxWidth: 180, flex: '0 1 180px',
position: 'relative', borderRight: `1px solid ${c.border.subtle}`,
bgcolor: isActive ? c.bg.surface : 'transparent', cursor: isBeingDragged ? 'grabbing' : 'pointer',
transform: isBeingDragged ? `translateX(${dragTabOffset}px)` : 'none',
transition: isBeingDragged ? 'none' : 'background 0.15s ease, transform 0.2s ease',
zIndex: isBeingDragged ? 10 : 1,
'&:hover': { bgcolor: isActive ? c.bg.surface : c.bg.hover },
'&:hover .tab-close': { opacity: 1 },
...(isActive && { '&::after': {
content: '""', position: 'absolute', bottom: 0, left: 0, right: 0, height: '2px', bgcolor: accentColor,
} }),
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, width: 14, height: 14, justifyContent: 'center' }}>
{tls?.loading ? (
<CircularProgress size={10} thickness={5} sx={{ color: accentColor }} />
) : tab.favicon ? (
<Box component="img" src={tab.favicon} sx={{ width: 14, height: 14, borderRadius: '2px' }}
onError={(e: any) => { e.target.style.display = 'none'; }} />
) : (
<LanguageIcon sx={{ fontSize: 13, color: isActive ? accentColor : c.text.ghost }} />
)}
</Box>
<Typography sx={{
flex: 1, fontSize: '0.7rem', fontWeight: isActive ? 600 : 400,
color: isActive ? c.text.primary : c.text.muted,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, lineHeight: 1.2,
}}>
{tab.title || 'New Tab'}
</Typography>
<Box className="tab-close" onClick={(e: React.MouseEvent) => 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 },
}}>
<CloseIcon sx={{ fontSize: 10, color: c.text.muted }} />
</Box>
</Box>
);
})}
<Box onClick={handleAddTab} onPointerDown={(e: React.PointerEvent) => 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` },
}}>
<AddIcon sx={{ fontSize: 15, color: c.text.muted }} />
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, flexShrink: 0 }}>
{agentActive && (
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5, px: 0.75, py: 0.25, borderRadius: '6px',
bgcolor: `${accentColor}18`, border: `1px solid ${accentColor}30`,
animation: 'badge-fade-in 0.25s ease-out',
'@keyframes badge-fade-in': { '0%': { opacity: 0, transform: 'scale(0.85)' }, '100%': { opacity: 1, transform: 'scale(1)' } },
}}>
<Box sx={{
width: 6, height: 6, borderRadius: '50%', bgcolor: accentColor,
animation: 'badge-dot-pulse 1.4s ease-in-out infinite',
'@keyframes badge-dot-pulse': { '0%, 100%': { opacity: 0.5, transform: 'scale(0.8)' }, '50%': { opacity: 1, transform: 'scale(1.3)' } },
}} />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: accentColor, lineHeight: 1 }}>AI</Typography>
</Box>
)}
<Tooltip title="Close browser" placement="top">
<IconButton size="small" onClick={handleRemove} onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }}>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
</Box>
);
};
export default BrowserTabBar;
@@ -0,0 +1,91 @@
import React from 'react';
import Box from '@mui/material/Box';
interface CardGlowOverlayProps {
accentColor: string;
accentHover: string;
glowFading: boolean;
glowFadeMs: number;
}
const CardGlowOverlay: React.FC<CardGlowOverlayProps> = ({
accentColor,
accentHover,
glowFading,
glowFadeMs,
}) => (
<Box
className="agent-card-glow-overlays"
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
borderRadius: 'inherit',
zIndex: 20,
opacity: glowFading ? 0 : 1,
transition: `opacity ${glowFadeMs}ms ease-out`,
}}
>
{/* Rotating conic gradient border */}
<Box
sx={{
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
overflow: 'hidden',
padding: '3px',
mask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
maskComposite: 'exclude',
WebkitMaskComposite: 'xor',
'&::before': {
content: '""',
position: 'absolute',
inset: '-50%',
background: `conic-gradient(from 0deg, transparent 0%, ${accentColor} 25%, transparent 50%, ${accentColor} 75%, transparent 100%)`,
animation: 'agent-card-rotate-glow 3s linear infinite',
},
'@keyframes agent-card-rotate-glow': {
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
{/* Top edge shimmer */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '2px',
background: `linear-gradient(90deg, transparent, ${accentColor}, ${accentHover}, ${accentColor}, transparent)`,
backgroundSize: '200% 100%',
animation: 'agent-card-border-shimmer 2s linear infinite',
'@keyframes agent-card-border-shimmer': {
'0%': { backgroundPosition: '200% 0' },
'100%': { backgroundPosition: '-200% 0' },
},
}}
/>
{/* Inner shadow overlay */}
<Box
sx={{
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
boxShadow: `inset 0 0 40px ${accentColor}30, inset 0 0 80px ${accentColor}12`,
animation: 'agent-card-inner-pulse 2s ease-in-out infinite',
'@keyframes agent-card-inner-pulse': {
'0%, 100%': {
boxShadow: `inset 0 0 40px ${accentColor}30, inset 0 0 80px ${accentColor}12`,
},
'50%': {
boxShadow: `inset 0 0 50px ${accentColor}40, inset 0 0 100px ${accentColor}18`,
},
},
}}
/>
</Box>
);
export default React.memo(CardGlowOverlay);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,246 @@
import React from 'react';
import { AnimatePresence } from 'framer-motion';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import AgentCard from './AgentCard';
import DashboardViewCard from './DashboardViewCard';
import BrowserCard from './BrowserCard';
import CanvasControls from './CanvasControls';
import DashboardToolbar from './DashboardToolbar';
import DashboardHeader from './DashboardHeader';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { EXPANDED_CARD_MIN_H, DEFAULT_CARD_W, GRID_GAP } from '@/shared/state/dashboardLayoutSlice';
import type { TetherInfo } from './hooks/useTetherPaths';
import type { CardType } from './useDashboardSelection';
import type { CanvasActions } from './useCanvasControls';
const TETHER_FADE_MS = 2500;
export interface DashboardCanvasProps {
panX: number; panY: number; zoom: number;
isPanning: boolean; spaceHeld: boolean; cmdHeld: boolean;
viewportRef: React.RefObject<HTMLDivElement>; contentRef: React.RefObject<HTMLDivElement>;
sessions: Record<string, any>; sessionList: any[];
cards: Record<string, any>; viewCards: Record<string, any>; browserCards: Record<string, any>;
outputs: Record<string, any>; expandedSessionIds: string[]; glowingAgentCards: Record<string, any>;
marquee: { x: number; y: number; width: number; height: number } | null;
isSelected: (id: string) => boolean; multiDragDelta: { dx: number; dy: number } | null;
handleCardSelect: (id: string, type: CardType, shiftKey: boolean) => void;
handleCardDragStart: (id: string, type: CardType) => void;
handleCardDragMove: (dx: number, dy: number) => void;
handleCardDragEnd: (dx: number, dy: number, didDrag: boolean) => void;
handleBringToFront: (id: string, type: CardType) => void;
handleBranchFromCard: (sourceId: string, newId: string) => void;
handleFocusRequest: (sessionId: string) => void; handleFocusExit: () => void;
focusedCardId: string | null; highlightedCardId: string | null; autoFocusSessionId: string | null;
tethers: TetherInfo[]; toolbarRef: React.RefObject<HTMLDivElement>; toolbarOpen: boolean;
handleNewAgent: () => void; handleToolbarCancel: () => void;
handleToolbarSend: (...args: any[]) => void; handleAddView: (outputId: string) => void;
handleHistoryResume: (sessionId: string) => void; handleAddBrowser: () => void; handleTidy: () => void;
canvasActions: CanvasActions; dashboardId: string | undefined; dashboardName: string | undefined;
onHighlightCard: (cardId: string) => void; handleMeasuredHeight: (sessionId: string, height: number) => void;
spawnOriginsRef: React.MutableRefObject<Record<string, { x: number; y: number; type?: 'branch' }>>;
revealSpawnedRef: React.MutableRefObject<Set<string>>; measuredHeightsRef: React.RefObject<Record<string, number>>;
handleViewportMouseDown: (e: React.MouseEvent) => void;
handleViewportMouseMove: (e: React.MouseEvent) => void;
handleViewportMouseUp: (e: React.MouseEvent) => void;
}
function getAgentCardExtras(
sessionId: string, cards: Record<string, any>, glowingAgentCards: Record<string, any>,
expandedSessionIds: string[], measuredHeightsRef: React.RefObject<Record<string, number>>,
spawnOriginsRef: React.MutableRefObject<Record<string, { x: number; y: number; type?: 'branch' }>>,
revealSpawnedRef: React.MutableRefObject<Set<string>>,
) {
let origin = spawnOriginsRef.current[sessionId];
if (origin) {
delete spawnOriginsRef.current[sessionId];
} else {
const glow = glowingAgentCards[sessionId];
if (glow && !revealSpawnedRef.current.has(sessionId)) {
revealSpawnedRef.current.add(sessionId);
const srcCard = cards[glow.sourceId];
if (srcCard) {
const srcH = (measuredHeightsRef.current ?? {})[glow.sourceId]
?? (expandedSessionIds.includes(glow.sourceId) ? Math.max(EXPANDED_CARD_MIN_H, srcCard.height) : srcCard.height);
origin = { x: srcCard.x + srcCard.width, y: srcCard.y + srcH / 2, type: 'branch' as const };
}
}
}
let exitTarget: { x: number; y: number } | undefined;
let snapColumn: { x: number; width: number } | undefined;
const glow = glowingAgentCards[sessionId];
if (glow) {
const srcCard = cards[glow.sourceId];
if (srcCard) {
const srcH = (measuredHeightsRef.current ?? {})[glow.sourceId]
?? (expandedSessionIds.includes(glow.sourceId) ? Math.max(EXPANDED_CARD_MIN_H, srcCard.height) : srcCard.height);
exitTarget = { x: srcCard.x + srcCard.width, y: srcCard.y + srcH / 2 };
snapColumn = { x: srcCard.x + srcCard.width + GRID_GAP * 12, width: DEFAULT_CARD_W };
}
}
return { origin, exitTarget, snapColumn };
}
const DashboardCanvas: React.FC<DashboardCanvasProps> = (p) => {
const c = useClaudeTokens();
const dotSize = Math.max(1, 1.5 * p.zoom);
const dotSpacing = 24 * p.zoom;
return (
<Box sx={{ position: 'relative', height: '100%', overflow: 'hidden' }}>
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, pointerEvents: 'none',
p: 3, pb: 0, background: `linear-gradient(to bottom, ${c.bg.page} 60%, transparent)` }}>
<Box sx={{ display: 'flex', alignItems: 'center', pointerEvents: 'auto' }}>
<DashboardHeader dashboardName={p.dashboardName} sessions={p.sessions} cards={p.cards}
viewCards={p.viewCards} browserCards={p.browserCards} outputs={p.outputs}
dashboardId={p.dashboardId} canvasActions={p.canvasActions} onHighlightCard={p.onHighlightCard} />
</Box>
</Box>
<Box ref={p.viewportRef} onMouseDown={p.handleViewportMouseDown} onMouseMove={p.handleViewportMouseMove}
onMouseUp={p.handleViewportMouseUp} onContextMenu={(e) => e.preventDefault()}
sx={{ position: 'absolute', inset: 0, overflow: 'hidden',
cursor: p.isPanning ? 'grabbing' : (p.spaceHeld || p.cmdHeld) ? 'grab' : p.marquee ? 'crosshair' : 'default' }}>
<Box sx={{ position: 'absolute', inset: 0, pointerEvents: 'none',
backgroundImage: `radial-gradient(circle, ${c.border.medium} ${dotSize}px, transparent ${dotSize}px)`,
backgroundSize: `${dotSpacing}px ${dotSpacing}px`,
backgroundPosition: `${p.panX % dotSpacing}px ${p.panY % dotSpacing}px` }} />
{p.sessionList.length === 0 && Object.keys(p.viewCards).length === 0 && Object.keys(p.browserCards).length === 0 ? (
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
<Typography sx={{ color: c.text.tertiary, fontSize: '1.1rem', mb: 1 }}>No agents running</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.9rem' }}>
Click &quot;New Agent&quot; to launch your first Claude Code instance</Typography>
</Box>
) : (
<div ref={p.contentRef} style={{ transform: `translate(${p.panX}px, ${p.panY}px) scale(${p.zoom})`,
transformOrigin: '0 0', willChange: 'transform', position: 'relative' }}>
{p.tethers.length > 0 && (
<svg style={{ position: 'absolute', left: 0, top: 0, width: 1, height: 1,
overflow: 'visible', pointerEvents: 'none', zIndex: 10 }}>
<defs>
<filter id="tether-glow-f" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
<marker id="tether-arrow" viewBox="0 0 10 10" refX="10" refY="5"
markerWidth="10" markerHeight="10" orient="auto">
<path d="M 0 1 L 10 5 L 0 9 z" fill={c.accent.primary} opacity={0.8} />
</marker>
</defs>
<style>{`@keyframes tether-flow { to { stroke-dashoffset: -16; } }
@keyframes tether-pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }`}</style>
{p.tethers.map((t) => (
<g key={t.key} style={{ opacity: t.fading ? 0 : 1, transition: `opacity ${TETHER_FADE_MS}ms ease-out` }}>
<path d={t.path} fill="none" stroke={c.accent.primary} strokeWidth={8}
strokeLinecap="round" strokeLinejoin="round" opacity={0.2} filter="url(#tether-glow-f)" />
<path d={t.path} fill="none" stroke={c.accent.primary} strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" opacity={0.65} markerEnd="url(#tether-arrow)"
style={{ animation: 'tether-pulse 2s ease-in-out infinite' }} />
<path d={t.path} fill="none" stroke={c.accent.primary} strokeWidth={1.5}
strokeLinecap="round" strokeLinejoin="round" strokeDasharray="8 8" opacity={0.9}
style={{ animation: 'tether-flow 0.6s linear infinite' }} />
{t.label && (
<g transform={`translate(${t.labelX},${t.labelY})`}>
<rect x={-4} y={-14} width={t.label.length * 7.5 + 8} height={20} rx={4}
fill={c.bg.surface} stroke={c.accent.primary} strokeWidth={1} opacity={0.95} />
<text x={t.label.length * 7.5 / 2} y={1} textAnchor="middle" fontSize={11}
fontWeight={600} fontFamily="inherit" fill={c.accent.primary}>{t.label}</text>
</g>
)}
</g>
))}
</svg>
)}
<AnimatePresence>
{Object.values(p.cards).map((card: any) => {
const session = p.sessions[card.session_id];
if (!session || p.focusedCardId === session.id) return null;
const extras = getAgentCardExtras(session.id, p.cards, p.glowingAgentCards,
p.expandedSessionIds, p.measuredHeightsRef, p.spawnOriginsRef, p.revealSpawnedRef);
return (
<AgentCard key={session.id} session={session} expanded={p.expandedSessionIds.includes(session.id)}
cardX={card.x} cardY={card.y} cardWidth={card.width} cardHeight={card.height}
cardZOrder={card.zOrder ?? 0} zoom={p.zoom} spawnFrom={extras.origin}
exitTarget={extras.exitTarget} isSelected={p.isSelected(session.id)}
isHighlighted={p.highlightedCardId === session.id} multiDragDelta={p.multiDragDelta}
onCardSelect={p.handleCardSelect} onDragStart={p.handleCardDragStart}
onDragMove={p.handleCardDragMove} onDragEnd={p.handleCardDragEnd}
onBranch={p.handleBranchFromCard} onMeasuredHeight={p.handleMeasuredHeight}
snapColumn={extras.snapColumn} autoFocusInput={p.autoFocusSessionId === session.id}
onBringToFront={p.handleBringToFront} isFocused={false}
onFocusRequest={p.handleFocusRequest} onFocusExit={p.handleFocusExit} />
);
})}
</AnimatePresence>
{Object.values(p.viewCards).map((vc: any) => {
const output = p.outputs[vc.output_id];
if (!output) return null;
return (
<DashboardViewCard key={`view-${vc.output_id}`} output={output}
cardX={vc.x} cardY={vc.y} cardWidth={vc.width} cardHeight={vc.height}
cardZOrder={vc.zOrder ?? 0} zoom={p.zoom} cmdHeld={p.cmdHeld}
isSelected={p.isSelected(vc.output_id)} isHighlighted={p.highlightedCardId === vc.output_id}
multiDragDelta={p.multiDragDelta} onCardSelect={p.handleCardSelect}
onDragStart={p.handleCardDragStart} onDragMove={p.handleCardDragMove}
onDragEnd={p.handleCardDragEnd} onBringToFront={p.handleBringToFront} />
);
})}
{Object.values(p.browserCards).map((bc: any) => (
<BrowserCard key={`browser-${bc.browser_id}`} browserId={bc.browser_id}
tabs={bc.tabs} activeTabId={bc.activeTabId}
cardX={bc.x} cardY={bc.y} cardWidth={bc.width} cardHeight={bc.height}
cardZOrder={bc.zOrder ?? 0} zoom={p.zoom} cmdHeld={p.cmdHeld}
isSelected={p.isSelected(bc.browser_id)} isHighlighted={p.highlightedCardId === bc.browser_id}
multiDragDelta={p.multiDragDelta} onCardSelect={p.handleCardSelect}
onDragStart={p.handleCardDragStart} onDragMove={p.handleCardDragMove}
onDragEnd={p.handleCardDragEnd} onBringToFront={p.handleBringToFront} />
))}
{p.marquee && (
<div style={{ position: 'absolute', left: p.marquee.x, top: p.marquee.y,
width: p.marquee.width, height: p.marquee.height,
border: '1.5px dashed rgba(59, 130, 246, 0.6)', background: 'rgba(59, 130, 246, 0.08)',
borderRadius: 2, pointerEvents: 'none', zIndex: 9999 }} />
)}
</div>
)}
</Box>
<Box sx={{ position: 'absolute', bottom: 16, left: '50%', transform: 'translateX(-50%)', zIndex: 10 }}>
<DashboardToolbar ref={p.toolbarRef} inputOpen={p.toolbarOpen} onNewAgent={p.handleNewAgent}
onCancel={p.handleToolbarCancel} onSend={p.handleToolbarSend} onAddView={p.handleAddView}
onHistoryResume={p.handleHistoryResume} onAddBrowser={p.handleAddBrowser} dashboardId={p.dashboardId} />
</Box>
{!p.focusedCardId && (
<Box sx={{ position: 'absolute', bottom: 16, right: 16, zIndex: 10 }}>
<CanvasControls zoom={p.zoom} actions={p.canvasActions} onTidy={p.handleTidy} />
</Box>
)}
{p.focusedCardId && (() => {
const focusedCard = p.cards[p.focusedCardId];
const focusedSession = focusedCard ? p.sessions[focusedCard.session_id] : null;
if (!focusedSession || !focusedCard) return null;
return (
<>
<Box onClick={p.handleFocusExit} sx={{ position: 'fixed', inset: 0,
bgcolor: 'rgba(0, 0, 0, 0.5)', zIndex: 1200, cursor: 'pointer' }} />
<Box sx={{ position: 'fixed', inset: 48, zIndex: 1250 }}>
<AgentCard session={focusedSession} expanded={true} cardX={0} cardY={0}
cardWidth={0} cardHeight={0} cardZOrder={100000} zoom={1}
isSelected={false} isHighlighted={false}
onCardSelect={() => {}} onMeasuredHeight={() => {}} onBringToFront={() => {}}
isFocused={true} onFocusRequest={p.handleFocusRequest} onFocusExit={p.handleFocusExit}
autoFocusInput={true} />
</Box>
</>
);
})()}
</Box>
);
};
export default DashboardCanvas;
@@ -11,6 +11,7 @@ import type { AgentSession } from '@/shared/state/agentsSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
import type { CanvasActions } from './useCanvasControls';
import { STATUS_DOT, cleanUrl, CategoryGroup, ItemRow } from './DashboardHeaderParts';
interface DashboardHeaderProps {
dashboardName: string | undefined;
@@ -24,15 +25,6 @@ interface DashboardHeaderProps {
onHighlightCard?: (cardId: string) => void;
}
const STATUS_DOT: Record<string, string> = {
running: '#22c55e',
waiting_approval: '#f59e0b',
completed: '#94a3b8',
error: '#ef4444',
stopped: '#94a3b8',
draft: '#6366f1',
};
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
dashboardName,
sessions,
@@ -246,67 +238,4 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
);
};
function cleanUrl(url: string): string {
try {
const u = new URL(url);
return u.hostname + (u.pathname !== '/' ? u.pathname : '');
} catch {
return url;
}
}
const CategoryGroup: React.FC<{
icon: React.ReactNode;
label: string;
count: number;
c: ReturnType<typeof useClaudeTokens>;
children: React.ReactNode;
}> = ({ icon, label, count, c, children }) => (
<Box sx={{ '&:not(:first-of-type)': { borderTop: `1px solid ${c.border.light}`, mt: 0.5, pt: 0.5 } }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.5,
}}
>
<Box sx={{ display: 'flex', color: c.text.tertiary, '& > svg': { fontSize: 15 } }}>{icon}</Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{label}
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost }}>
{count}
</Typography>
</Box>
{children}
</Box>
);
const ItemRow: React.FC<{
onClick: () => void;
c: ReturnType<typeof useClaudeTokens>;
children: React.ReactNode;
}> = ({ onClick, c, children }) => (
<Box
onClick={onClick}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
pl: 3.25,
py: 0.4,
cursor: 'pointer',
borderRadius: 0.5,
mx: 0.5,
'&:hover': { bgcolor: c.bg.secondary },
transition: 'background-color 0.1s',
}}
>
{children}
</Box>
);
export default DashboardHeader;
@@ -0,0 +1,76 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import type { useClaudeTokens } from '@/shared/styles/ThemeContext';
export const STATUS_DOT: Record<string, string> = {
running: '#22c55e',
waiting_approval: '#f59e0b',
completed: '#94a3b8',
error: '#ef4444',
stopped: '#94a3b8',
draft: '#6366f1',
};
export function cleanUrl(url: string): string {
try {
const u = new URL(url);
return u.hostname + (u.pathname !== '/' ? u.pathname : '');
} catch {
return url;
}
}
export const CategoryGroup: React.FC<{
icon: React.ReactNode;
label: string;
count: number;
c: ReturnType<typeof useClaudeTokens>;
children: React.ReactNode;
}> = ({ icon, label, count, c, children }) => (
<Box sx={{ '&:not(:first-of-type)': { borderTop: `1px solid ${c.border.light}`, mt: 0.5, pt: 0.5 } }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.5,
}}
>
<Box sx={{ display: 'flex', color: c.text.tertiary, '& > svg': { fontSize: 15 } }}>{icon}</Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{label}
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost }}>
{count}
</Typography>
</Box>
{children}
</Box>
);
export const ItemRow: React.FC<{
onClick: () => void;
c: ReturnType<typeof useClaudeTokens>;
children: React.ReactNode;
}> = ({ onClick, c, children }) => (
<Box
onClick={onClick}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
pl: 3.25,
py: 0.4,
cursor: 'pointer',
borderRadius: 0.5,
mx: 0.5,
'&:hover': { bgcolor: c.bg.secondary },
transition: 'background-color 0.1s',
}}
>
{children}
</Box>
);
@@ -1,344 +1,30 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import InputBase from '@mui/material/InputBase';
import CircularProgress from '@mui/material/CircularProgress';
import Tooltip, { tooltipClasses } from '@mui/material/Tooltip';
import Icon from '@mui/material/Icon';
import { styled } from '@mui/material/styles';
import AddIcon from '@mui/icons-material/Add';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined';
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
import LanguageIcon from '@mui/icons-material/Language';
import SearchIcon from '@mui/icons-material/Search';
import { motion } from 'framer-motion';
import React from 'react';
import ChatInput from '@/app/pages/AgentChat/ChatInput';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
import { useElementSelection } from '@/app/components/ElementSelectionContext';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import type { Output } from '@/shared/state/outputsSlice';
interface Props {
inputOpen: boolean;
onNewAgent: () => void;
onCancel: () => void;
onSend: (
prompt: string,
mode: string,
model: string,
images?: Array<{ data: string; media_type: string }>,
contextPaths?: ContextPath[],
forcedTools?: string[],
attachedSkills?: Array<{ id: string; name: string; content: string }>,
selectedBrowserIds?: string[],
) => void;
onAddView: (outputId: string) => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
dashboardId?: string;
}
const TOOLBAR_OWNER_ID = '__toolbar__';
const BTN = 40;
const WarmTooltip = styled(
({ className, ...props }: React.ComponentProps<typeof Tooltip> & { className?: string }) => (
<Tooltip {...props} classes={{ popper: className }} />
)
)<{ tokens: ClaudeTokens }>(({ tokens: c }) => ({
[`& .${tooltipClasses.tooltip}`]: {
backgroundColor: c.bg.inverse,
color: c.text.inverse,
fontFamily: c.font.sans,
fontSize: '0.78rem',
fontWeight: 500,
padding: '6px 12px',
borderRadius: c.radius.md,
boxShadow: c.shadow.md,
letterSpacing: '0.01em',
},
[`& .${tooltipClasses.arrow}`]: {
color: c.bg.inverse,
},
}));
const MotionBox = motion.div;
const HISTORY_PAGE_SIZE = 20;
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return '';
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
import type { Props } from './toolbarShared';
import { MotionBox, TOOLBAR_OWNER_ID } from './toolbarShared';
import { useDashboardToolbar } from './useDashboardToolbar';
import HistoryPanel from './HistoryPanel';
import ViewPickerPanel from './ViewPickerPanel';
import ToolbarButtons from './ToolbarButtons';
const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId }, ref) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const elementSelection = useElementSelection();
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const historyInputRef = useRef<HTMLInputElement>(null);
const historyListRef = useRef<HTMLDivElement>(null);
const defaultMode = useAppSelector((s) => s.settings.data.default_mode);
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const [mode, setMode] = useState(defaultMode || 'agent');
const [model, setModel] = useState(defaultModel || 'sonnet');
const settingsApplied = useRef(false);
useEffect(() => {
if (!settingsApplied.current) {
setMode(defaultMode || 'agent');
setModel(defaultModel || 'sonnet');
settingsApplied.current = true;
}
}, [defaultMode, defaultModel]);
const [viewPickerOpen, setViewPickerOpen] = useState(false);
const [viewSearch, setViewSearch] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
const [historyQuery, setHistoryQuery] = useState('');
const shortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut);
const outputs = useAppSelector((s) => s.outputs.items);
const historySearch = useAppSelector((s) => s.agents.historySearch);
const outputList = useMemo(() => Object.values(outputs), [outputs]);
const filteredOutputs = useMemo(() => {
if (!viewSearch.trim()) return outputList;
const q = viewSearch.toLowerCase();
return outputList.filter(
(o) => o.name.toLowerCase().includes(q) || o.description.toLowerCase().includes(q),
);
}, [outputList, viewSearch]);
const shortcutLabel = shortcut
.split('+')
.map((p) => {
if (p === 'Meta') return '⌘';
if (p === 'Ctrl') return 'Ctrl';
if (p === 'Alt') return '⌥';
if (p === 'Shift') return '⇧';
return p.toUpperCase();
})
.join('');
const {
c, containerRef, searchInputRef, historyInputRef, historyListRef,
mode, setMode, model, setModel,
viewPickerOpen, viewSearch, setViewSearch,
historyOpen, historyQuery, setHistoryQuery,
historySearch, outputList, filteredOutputs,
shortcutLabel, isExpanded,
handleSend, handleSelectView,
handleOpenViewPicker, handleOpenHistory,
handleHistorySelect, handleHistoryScroll,
} = useDashboardToolbar({
inputOpen, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId,
});
React.useImperativeHandle(ref, () => containerRef.current!, []);
const handleSend = useCallback(
(
message: string,
images?: Array<{ data: string; media_type: string }>,
contextPaths?: ContextPath[],
forcedTools?: string[],
attachedSkills?: Array<{ id: string; name: string; content: string }>,
selectedBrowserIds?: string[],
) => {
onSend(message, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds);
},
[onSend, mode, model],
);
const handleCloseHistory = useCallback(() => {
setHistoryOpen(false);
setHistoryQuery('');
dispatch(clearHistorySearch());
}, [dispatch]);
const handleDismiss = useCallback(() => {
if (historyOpen) {
handleCloseHistory();
} else if (viewPickerOpen) {
setViewPickerOpen(false);
setViewSearch('');
} else {
onCancel();
}
}, [historyOpen, viewPickerOpen, onCancel, handleCloseHistory]);
const handleSelectView = useCallback((output: Output) => {
onAddView(output.id);
setViewPickerOpen(false);
setViewSearch('');
}, [onAddView]);
const handleOpenViewPicker = useCallback(() => {
if (viewPickerOpen) {
setViewPickerOpen(false);
setViewSearch('');
return;
}
setHistoryOpen(false);
setHistoryQuery('');
dispatch(clearHistorySearch());
setViewPickerOpen(true);
setViewSearch('');
}, [viewPickerOpen, dispatch]);
const handleOpenHistory = useCallback(() => {
if (historyOpen) {
setHistoryOpen(false);
setHistoryQuery('');
dispatch(clearHistorySearch());
return;
}
setViewPickerOpen(false);
setViewSearch('');
setHistoryOpen(true);
setHistoryQuery('');
dispatch(clearHistorySearch());
dispatch(searchHistory({ q: '', limit: HISTORY_PAGE_SIZE, offset: 0, dashboardId }));
}, [historyOpen, dispatch, dashboardId]);
const handleHistorySelect = useCallback((sessionId: string) => {
onHistoryResume(sessionId);
handleCloseHistory();
}, [onHistoryResume, handleCloseHistory]);
const handleHistoryLoadMore = useCallback(() => {
if (historySearch.loading || !historySearch.hasMore) return;
dispatch(searchHistory({
q: historyQuery,
limit: HISTORY_PAGE_SIZE,
offset: historySearch.results.length,
dashboardId,
}));
}, [dispatch, historyQuery, historySearch.loading, historySearch.hasMore, historySearch.results.length, dashboardId]);
const isExpanded = inputOpen || viewPickerOpen || historyOpen;
const autoSelectOnNew = useAppSelector((s) => s.settings.data.auto_select_mode_on_new_agent);
const prevInputOpenRef = useRef(inputOpen);
useEffect(() => {
if (prevInputOpenRef.current && !inputOpen && elementSelection) {
elementSelection.clearOwnerElements(TOOLBAR_OWNER_ID);
if (elementSelection.selectMode && elementSelection.activeOwnerId === TOOLBAR_OWNER_ID) {
elementSelection.setSelectMode(false);
}
}
if (!prevInputOpenRef.current && inputOpen && autoSelectOnNew && elementSelection) {
elementSelection.clearOwnerElements(TOOLBAR_OWNER_ID);
elementSelection.setActiveOwnerId(TOOLBAR_OWNER_ID);
elementSelection.setExcludeSelectId(null);
elementSelection.setSelectMode(true);
}
prevInputOpenRef.current = inputOpen;
}, [inputOpen, elementSelection, autoSelectOnNew]);
useEffect(() => {
if (!isExpanded) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
handleDismiss();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [isExpanded, handleDismiss]);
useEffect(() => {
if (!isExpanded) return;
let downPos: { x: number; y: number; target: Node } | null = null;
const DRAG_THRESHOLD = 5;
const handleDown = (e: MouseEvent) => {
const target = e.target as Node;
if (containerRef.current && !containerRef.current.contains(target)) {
downPos = { x: e.clientX, y: e.clientY, target };
} else {
downPos = null;
}
};
const handleUp = (e: MouseEvent) => {
if (!downPos) return;
const dx = e.clientX - downPos.x;
const dy = e.clientY - downPos.y;
const target = downPos.target;
downPos = null;
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) return;
const el = target instanceof Element ? target : (target as Node).parentElement;
if (el?.closest('[role="dialog"], [role="presentation"], .MuiModal-root, .MuiPopover-root')) {
return;
}
if (elementSelection?.selectMode && el?.closest('[data-select-type]')) {
return;
}
handleDismiss();
};
const t = setTimeout(() => {
document.addEventListener('mousedown', handleDown, true);
document.addEventListener('mouseup', handleUp, true);
}, 50);
return () => {
clearTimeout(t);
document.removeEventListener('mousedown', handleDown, true);
document.removeEventListener('mouseup', handleUp, true);
};
}, [isExpanded, handleDismiss, elementSelection?.selectMode]);
useEffect(() => {
if (viewPickerOpen) {
setTimeout(() => searchInputRef.current?.focus(), 60);
}
}, [viewPickerOpen]);
useEffect(() => {
if (historyOpen) {
setTimeout(() => historyInputRef.current?.focus(), 60);
}
}, [historyOpen]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.metaKey && e.key.toLowerCase() === 'm' && !e.ctrlKey && !e.shiftKey && !e.altKey) {
e.preventDefault();
handleOpenViewPicker();
}
if (e.metaKey && e.key.toLowerCase() === 'o' && !e.ctrlKey && !e.shiftKey && !e.altKey) {
e.preventDefault();
handleOpenHistory();
}
if (e.metaKey && e.key.toLowerCase() === 'n' && !e.ctrlKey && !e.shiftKey && !e.altKey) {
e.preventDefault();
onAddBrowser();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [handleOpenViewPicker, handleOpenHistory, onAddBrowser]);
useEffect(() => {
if (!historyOpen) return;
const timer = setTimeout(() => {
dispatch(searchHistory({ q: historyQuery, limit: HISTORY_PAGE_SIZE, offset: 0, dashboardId }));
}, 300);
return () => clearTimeout(timer);
}, [historyQuery, historyOpen, dispatch, dashboardId]);
const handleHistoryScroll = useCallback(() => {
const el = historyListRef.current;
if (!el) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) {
handleHistoryLoadMore();
}
}, [handleHistoryLoadMore]);
const placeholderItems = [
{ icon: StickyNote2OutlinedIcon, label: 'Add Notes', sub: 'Coming soon' },
];
return (
<MotionBox
ref={containerRef}
@@ -371,380 +57,35 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
/>
</div>
) : historyOpen ? (
<div style={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1 }}>
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
<InputBase
inputRef={historyInputRef}
value={historyQuery}
onChange={(e) => setHistoryQuery(e.target.value)}
placeholder="Search past chats..."
sx={{
flex: 1,
fontSize: '0.85rem',
color: c.text.primary,
fontFamily: c.font.sans,
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
}}
/>
{historySearch.loading && historySearch.results.length === 0 && (
<CircularProgress size={16} sx={{ color: c.text.muted }} />
)}
</Box>
<Box
ref={historyListRef}
onScroll={handleHistoryScroll}
sx={{
maxHeight: 320,
overflow: 'auto',
borderTop: `1px solid ${c.border.subtle}`,
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{historySearch.results.length === 0 && !historySearch.loading ? (
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
<Typography sx={{ fontSize: '0.82rem', color: c.text.muted }}>
{historyQuery ? 'No matching chats' : 'No chat history yet'}
</Typography>
</Box>
) : (
<>
{historySearch.results.map((entry) => (
<Box
key={entry.id}
onClick={() => handleHistorySelect(entry.id)}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
px: 1.5,
py: 0.9,
cursor: 'pointer',
transition: 'background-color 0.1s',
'&:hover': { bgcolor: c.bg.elevated },
}}
>
<Typography
sx={{
fontSize: '0.82rem',
fontWeight: 500,
color: c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{entry.name}
</Typography>
<Typography
sx={{
fontSize: '0.7rem',
color: c.text.ghost,
flexShrink: 0,
whiteSpace: 'nowrap',
}}
>
{formatRelativeTime(entry.closed_at)}
</Typography>
</Box>
))}
{historySearch.loading && historySearch.results.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
<CircularProgress size={16} sx={{ color: c.text.muted }} />
</Box>
)}
</>
)}
</Box>
</div>
<HistoryPanel
historyInputRef={historyInputRef}
historyListRef={historyListRef}
historyQuery={historyQuery}
onQueryChange={setHistoryQuery}
historySearch={historySearch}
onScroll={handleHistoryScroll}
onSelect={handleHistorySelect}
c={c}
/>
) : viewPickerOpen ? (
<div style={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1 }}>
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
<InputBase
inputRef={searchInputRef}
value={viewSearch}
onChange={(e) => setViewSearch(e.target.value)}
placeholder="Search apps..."
sx={{
flex: 1,
fontSize: '0.85rem',
color: c.text.primary,
fontFamily: c.font.sans,
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
}}
/>
</Box>
<Box
sx={{
maxHeight: 400,
overflow: 'auto',
borderTop: `1px solid ${c.border.subtle}`,
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 2,
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{filteredOutputs.length === 0 ? (
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
<Typography sx={{ fontSize: '0.82rem', color: c.text.muted }}>
{outputList.length === 0 ? 'No apps created yet' : 'No matching apps'}
</Typography>
</Box>
) : (
filteredOutputs.map((output) => (
<Box
key={output.id}
onClick={() => handleSelectView(output)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 1.5,
py: 1,
cursor: 'pointer',
transition: 'background-color 0.1s',
'&:hover': { bgcolor: c.bg.elevated },
}}
>
{output.thumbnail ? (
<Box
component="img"
src={output.thumbnail}
alt={output.name}
sx={{
width: 144,
height: 96,
borderRadius: '6px',
objectFit: 'cover',
objectPosition: 'top left',
flexShrink: 0,
border: `1px solid ${c.border.subtle}`,
}}
/>
) : (
<Box
sx={{
width: 144,
height: 96,
borderRadius: '6px',
flexShrink: 0,
border: `1px solid ${c.border.subtle}`,
bgcolor: c.accent.primary + '12',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon sx={{ fontSize: 32, color: c.accent.primary, opacity: 0.7 }}>
{output.icon || 'view_quilt'}
</Icon>
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{
fontSize: '0.82rem',
fontWeight: 500,
color: c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{output.name}
</Typography>
{output.description && (
<Typography
sx={{
fontSize: '0.72rem',
color: c.text.muted,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{output.description}
</Typography>
)}
</Box>
</Box>
))
)}
</Box>
</div>
<ViewPickerPanel
searchInputRef={searchInputRef}
viewSearch={viewSearch}
onSearchChange={setViewSearch}
filteredOutputs={filteredOutputs}
outputList={outputList}
onSelect={handleSelectView}
c={c}
/>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
<WarmTooltip tokens={c} title={`New Agent ${shortcutLabel}`} placement="top" arrow enterDelay={400}>
<Box
role="button"
aria-label="New Agent"
tabIndex={0}
onClick={onNewAgent}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: BTN,
height: BTN,
borderRadius: `${c.radius.lg}px`,
bgcolor: c.accent.primary,
color: '#fff',
cursor: 'pointer',
transition: 'background-color 0.15s',
'&:hover': { bgcolor: c.accent.hover },
'&:active': { bgcolor: c.accent.pressed },
}}
>
<AddIcon sx={{ fontSize: 20 }} />
</Box>
</WarmTooltip>
<WarmTooltip
tokens={c}
placement="top"
arrow
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>Add View M</Box>
</Box>
}
>
<Box
role="button"
aria-label="Add View"
tabIndex={0}
onClick={handleOpenViewPicker}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
color: c.text.tertiary,
cursor: 'pointer',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
}}
>
<GridViewRoundedIcon sx={{ fontSize: 22 }} />
</Box>
</WarmTooltip>
<WarmTooltip
tokens={c}
placement="top"
arrow
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>Browser N</Box>
</Box>
}
>
<Box
role="button"
aria-label="Browser"
tabIndex={0}
onClick={onAddBrowser}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
color: c.text.tertiary,
cursor: 'pointer',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
}}
>
<LanguageIcon sx={{ fontSize: 22 }} />
</Box>
</WarmTooltip>
<WarmTooltip
tokens={c}
placement="top"
arrow
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>History O</Box>
</Box>
}
>
<Box
role="button"
aria-label="History"
tabIndex={0}
onClick={handleOpenHistory}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
color: c.text.tertiary,
cursor: 'pointer',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
}}
>
<HistoryRoundedIcon sx={{ fontSize: 22 }} />
</Box>
</WarmTooltip>
{placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => (
<WarmTooltip
key={label}
tokens={c}
placement="top"
arrow
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>{label}</Box>
<Box sx={{ opacity: 0.6, fontSize: '0.7rem', mt: '1px' }}>{sub}</Box>
</Box>
}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
color: c.text.tertiary,
opacity: 0.45,
cursor: 'default',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 0.65, bgcolor: c.bg.secondary },
}}
>
<PlaceholderIcon sx={{ fontSize: 22 }} />
</Box>
</WarmTooltip>
))}
</div>
<ToolbarButtons
onNewAgent={onNewAgent}
onOpenViewPicker={handleOpenViewPicker}
onAddBrowser={onAddBrowser}
onOpenHistory={handleOpenHistory}
shortcutLabel={shortcutLabel}
c={c}
/>
)}
</MotionBox>
);
@@ -1,64 +1,18 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import React, { useState, 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 CircularProgress from '@mui/material/CircularProgress';
import RefreshIcon from '@mui/icons-material/Refresh';
import BoltIcon from '@mui/icons-material/Bolt';
import CloseIcon from '@mui/icons-material/Close';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import { Output, autoRunOutput, autoRunAgentOutput, executeOutput, OutputExecuteResult, getBackendCode, SERVE_BASE } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewCardSize, removeViewCard } from '@/shared/state/dashboardLayoutSlice';
import { Output, autoRunOutput, autoRunAgentOutput, executeOutput, getBackendCode, SERVE_BASE } from '@/shared/state/outputsSlice';
import { removeViewCard } from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ViewPreview, { ViewPreviewHandle } from '@/app/pages/Views/ViewPreview';
import { getDefault } from '@/app/pages/Views/InputSchemaForm';
import { useOverlayScrollPassthrough } from './useOverlayScrollPassthrough';
import { ViewCardProps, HANDLE_DEFS, CURSOR_MAP } from './viewCardConstants';
import { useViewCardDrag } from './useViewCardDrag';
import { useViewCardResize } from './useViewCardResize';
import ViewCardHeader from './ViewCardHeader';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const EDGE_THICKNESS = 6;
const CORNER_SIZE = 14;
const MIN_W = 320;
const MIN_H = 200;
const CURSOR_MAP: Record<ResizeDir, string> = {
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<string, any> }[] = [
{ 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 } },
];
interface Props {
output: Output;
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', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'agent' | 'view') => 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;
}
const DashboardViewCard: React.FC<Props> = ({
const DashboardViewCard: React.FC<ViewCardProps> = ({
output, cardX, cardY, cardWidth, cardHeight, zoom = 1, cmdHeld = false,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
cardZOrder = 0, onBringToFront,
@@ -74,122 +28,15 @@ const DashboardViewCard: React.FC<Props> = ({
const hasAutoRun = !!(output.auto_run_config?.enabled && output.auto_run_config?.prompt);
// ---- Drag via header ----
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 {
isDragging, localDragPos, justDraggedRef,
handleDragPointerDown, handleDragPointerMove, handleDragPointerUp,
} = useViewCardDrag({ cardX, cardY, zoom, outputId: output.id, onDragStart, onDragMove, onDragEnd });
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
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?.(output.id, 'view');
}, [cardX, cardY, onDragStart, output.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;
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);
}, [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;
if (didDrag.current) {
dispatch(setViewCardPosition({
outputId: output.id,
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);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [zoom, dispatch, output.id, onDragEnd]);
// ---- Resize ----
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 handleResizeUp = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const result = computeResize(e);
if (result) {
dispatch(setViewCardPosition({ outputId: output.id, x: result.x, y: result.y }));
dispatch(setViewCardSize({ outputId: output.id, width: result.w, height: result.h }));
}
resizeRef.current = null;
setLocalResize(null);
setIsResizing(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [computeResize, dispatch, output.id]);
const {
isResizing, localResize,
handleResizeDown, handleResizeMove, handleResizeUp,
} = useViewCardResize({ cardX, cardY, cardWidth, cardHeight, zoom, outputId: output.id });
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -211,7 +58,7 @@ const DashboardViewCard: React.FC<Props> = ({
try {
if (forcedToolNames.length > 0) {
const res = await dispatch(autoRunAgentOutput({
await dispatch(autoRunAgentOutput({
prompt: config.prompt,
input_schema: output.input_schema,
output_id: output.id,
@@ -220,8 +67,6 @@ const DashboardViewCard: React.FC<Props> = ({
context_paths: config.context_paths,
})).unwrap();
// For agent-based auto-run, we execute with default input for now
// since the agent session result flow is complex for dashboard cards
const execRes = await dispatch(executeOutput({
output_id: output.id,
input_data: inputData,
@@ -243,7 +88,6 @@ const DashboardViewCard: React.FC<Props> = ({
}
}
} catch {
// Silently handle errors on dashboard
} finally {
setAutoRunning(false);
}
@@ -294,26 +138,15 @@ const DashboardViewCard: React.FC<Props> = ({
...(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 },
},
}),
}}
>
{/* Selection overlay blocks click interaction while selected, enabling drag from anywhere */}
{isSelected && (
<Box
ref={scrollOverlayRef}
@@ -334,82 +167,19 @@ const DashboardViewCard: React.FC<Props> = ({
/>
)}
{/* Header */}
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
sx={{
position: 'relative',
zIndex: 16,
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.75,
bgcolor: c.bg.secondary,
borderBottom: `1px solid ${c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
flexShrink: 0,
minHeight: 36,
userSelect: 'none',
}}
>
<GridViewRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
<Typography
sx={{
flex: 1,
fontSize: '0.8rem',
fontWeight: 600,
color: c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{output.name}
</Typography>
<ViewCardHeader
name={output.name}
hasAutoRun={hasAutoRun}
autoRunning={autoRunning}
isDragging={isDragging}
onDragPointerDown={handleDragPointerDown}
onDragPointerMove={handleDragPointerMove}
onDragPointerUp={handleDragPointerUp}
onRefresh={handleRefresh}
onAutoRun={handleAutoRun}
onRemove={handleRemove}
/>
<Tooltip title="Reload preview" placement="top">
<IconButton
size="small"
onClick={handleRefresh}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.muted, p: 0.5, '&:hover': { color: c.text.primary } }}
>
<RefreshIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{hasAutoRun && (
<Tooltip title={autoRunning ? 'Running...' : 'Auto Run'} placement="top">
<span>
<IconButton
size="small"
onClick={handleAutoRun}
onPointerDown={(e) => e.stopPropagation()}
disabled={autoRunning}
sx={{ color: '#f59e0b', p: 0.5, '&:hover': { color: '#d97706' } }}
>
{autoRunning ? <CircularProgress size={14} sx={{ color: '#f59e0b' }} /> : <BoltIcon sx={{ fontSize: 16 }} />}
</IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Remove from dashboard" placement="top">
<IconButton
size="small"
onClick={handleRemove}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.status.error } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
{/* Preview body */}
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
{cmdHeld && !isSelected && (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 12 }} />
@@ -423,7 +193,6 @@ const DashboardViewCard: React.FC<Props> = ({
/>
</Box>
{/* Resize handles */}
{HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}

Some files were not shown because too many files have changed in this diff Show More