mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[Haik]: Agentic refactor 4. Frontend Cleanup
This commit is contained in:
+2
-123
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 → 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 → 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 "New Agent" 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
Reference in New Issue
Block a user