starting integration of old frontend

This commit is contained in:
Arnav Naval
2026-04-21 23:24:10 -05:00
parent 58c241442b
commit a6b18752cb
353 changed files with 34515 additions and 29706 deletions
+15 -4
View File
@@ -137,7 +137,7 @@ async def get_all_sessions(dashboard_id: str = "") -> dict:
@agents.router.get("/get_session")
async def get_session(session_id: str = Body()) -> dict:
async def get_session(session_id: str) -> dict:
return get_agent(session_id).model_dump(mode="json")
@@ -436,9 +436,9 @@ async def duplicate_session(session_id: str = Body()) -> dict:
@agents.router.get("/get_history")
async def get_history(
q: str = Body(default=""),
limit: int = Body(default=20),
offset: int = Body(default=0),
q: str = "",
limit: int = 20,
offset: int = 0,
) -> dict:
all_agents: List[Agent] = AGENT_STORE.load_all()
all_agents.sort(
@@ -455,12 +455,23 @@ async def get_history(
continue
msgs = agent.messages.messages
closed_at: str = msgs[-1].timestamp.isoformat() if msgs else ""
created_at: str = msgs[0].timestamp.isoformat() if msgs else ""
# Generate name from first user message or use default
name: str = "Chat"
for m in msgs:
if m.role == "user" and isinstance(m.content, str):
name = m.content[:50] + ("..." if len(m.content) > 50 else "")
break
history.append({
"id": agent.session_id,
"name": name,
"status": agent.status,
"model": agent.model,
"mode": agent.mode,
"created_at": created_at,
"closed_at": closed_at,
"cost_usd": 0, # TODO: track cost
"dashboard_id": agent.dashboard_id,
})
total: int = len(history)
-25
View File
@@ -1,25 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/tailwind.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide",
"registries": {
"@assistant-ui": "https://r.assistant-ui.com/{name}.json",
"@tool-ui": "https://www.tool-ui.com/r/{name}.json"
}
}
-26
View File
@@ -1,26 +0,0 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
"react-hooks": reactHooks,
},
rules: {
...reactHooks.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
},
},
{
ignores: ["dist/", "webpack.config.js"],
},
);
-3
View File
@@ -1,3 +0,0 @@
{
"project": ["src/**/*.{ts,tsx}"]
}
+29 -32
View File
@@ -3,20 +3,12 @@
"version": "1.0.0",
"description": "Open Swarm — Agent Orchestrator frontend built with React",
"scripts": {
"build": "vite build",
"build:watch": "vite build --watch",
"dev": "vite",
"clean": "rm -rf dist",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"knip": "knip"
"build": "webpack --mode=production",
"build:watch": "webpack --mode=development --watch",
"dev": "webpack serve --mode=development",
"clean": "rm -rf dist"
},
"dependencies": {
"@assistant-ui/core": "^0.1.9",
"@assistant-ui/react": "^0.12.21",
"@assistant-ui/react-lexical": "^0.0.3",
"@assistant-ui/react-markdown": "^0.12.7",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
@@ -25,42 +17,47 @@
"@codemirror/view": "^6.39.16",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@eslint/js": "^9.39.4",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.9",
"@pierre/diffs": "^1.1.7",
"@reduxjs/toolkit": "^2.8.2",
"ansi-to-react": "^6.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"@types/react-syntax-highlighter": "^15.5.13",
"codemirror": "^6.0.2",
"framer-motion": "^12.35.2",
"html-to-image": "^1.11.13",
"lucide-react": "^1.7.0",
"radix-ui": "^1.4.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.13.1",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@babel/core": "^7.28.0",
"@babel/preset-env": "^7.28.0",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react-swc": "^4.3.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"knip": "^6.1.0",
"postcss": "^8.5.8",
"@types/react-redux": "^7.1.34",
"babel-loader": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^6.8.0",
"css-modules-types-loader": "^0.6.10",
"html-webpack-plugin": "^5.5.0",
"sass": "^1.89.2",
"sass-loader": "^16.0.5",
"style-loader": "^3.3.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.8"
"webpack": "^5.88.0",
"webpack-cli": "^5.1.0",
"webpack-dev-server": "^4.15.0"
},
"babel": {
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
]
}
}
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
@@ -4,12 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Open Swarm</title>
<link rel="icon" href="/favicon.ico?v=2" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="./favicon.ico?v=2" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
</html>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15 -7
View File
@@ -1,14 +1,21 @@
#!/bin/bash
# The comment above is shebang, DO NOT REMOVE
DEV_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
FRONTEND_DIR_ABSPATH="$(dirname "$DEV_ABSPATH")"
PROJECT_ROOT_ABSPATH="$(dirname "$FRONTEND_DIR_ABSPATH")"
# shellcheck source=../run/utils/platform.sh
source "$PROJECT_ROOT_ABSPATH/run/utils/platform.sh"
ensure_lf "$DEV_ABSPATH"
if [[ "$OSTYPE" == "darwin"* ]]; then
# echo "In macOS server sed START"
# echo "SERVER_ABSPATH: $SERVER_ABSPATH"
sed -i '' 's/\r//g' "$DEV_ABSPATH"
# echo "In macOS server sed END"
else
# echo "NOT in macOS server START"
# echo "SERVER_ABSPATH: $SERVER_ABSPATH"
sed -i 's/\r//g' "$DEV_ABSPATH"
# echo "NOT in macOS server START"
fi
chmod +x "$DEV_ABSPATH"
FRONTEND_DIR_ABSPATH="$(dirname "$DEV_ABSPATH")"
echo "Installing dependencies..."
cd "$FRONTEND_DIR_ABSPATH"
npm install
@@ -16,4 +23,5 @@ npm install
echo "Building with development mode..."
npm run dev
cd -
# exit back to the dir that we were in before
cd -
+295 -23
View File
@@ -1,11 +1,14 @@
import React, { useMemo, useEffect } from 'react';
import React, { useMemo, useEffect, useState, useRef } from 'react';
import { Provider } from 'react-redux';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import { store } from '../shared/state/store';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { GET_SETTINGS } from '@/shared/backend-bridge/apps/settings';
import { SUBSCRIPTIONS_STATUS } from '@/shared/backend-bridge/apps/subscriptions';
import { fetchSettings, updateSettings } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { API_BASE } from '@/shared/config';
import {
setAppVersion,
setUpdateAvailable,
@@ -14,33 +17,170 @@ import {
setUpdateDownloaded,
setUpdateError,
} from '@/shared/state/updateSlice';
import AppShell from './components/AppShell/AppShell';
import Dashboard from './pages/Dashboard/Dashboard';
import AppShell from './components/Layout/AppShell';
import DashboardSelection from './pages/DashboardSelection/DashboardSelection';
import Skills from './pages/Skills/Skills';
import Tools from './pages/Tools/Tools';
import Modes from './pages/Modes/Modes';
import Views from './pages/Views/Views';
import Customization from './pages/Customization/Customization';
import OnboardingModal from './components/OnboardingModal/OnboardingModal';
import Analytics from './pages/Analytics/Analytics';
import OnboardingModal from './components/OnboardingModal';
import { trackEvent, getLastAction, getLastPage, getTimeSpent } from '@/shared/analytics';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import { useDeepLink } from '@/shared/hooks/useDeepLink';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
import { buildMuiTheme } from './buildMuiTheme';
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',
},
},
},
},
});
}
const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
useKeyboardShortcuts();
return <>{children}<KeyboardShortcutsHelp /></>;
};
const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
useDeepLink();
return <>{children}</>;
};
const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const { setMode: setThemeMode } = useThemeMode();
const theme = useAppSelector((s) => s.settings.data.theme);
const loaded = useAppSelector((s) => s.settings.loaded);
useEffect(() => {
dispatch(GET_SETTINGS());
dispatch(SUBSCRIPTIONS_STATUS());
dispatch(fetchSettings());
dispatch(fetchModels());
// Reconcile OpenSwarm Pro state with Stripe on every launch so a
// missed webhook (cancel, upgrade, renewal) can't leave the user
// wedged on stale info. Fire-and-forget; if the cloud is unreachable
// we simply keep whatever local state we already had.
fetch(`${API_BASE}/subscription/sync`, { method: 'POST' })
.then((r) => {
if (r.ok) dispatch(fetchSettings());
})
.catch(() => { /* offline — next launch will reconcile */ });
}, [dispatch]);
useEffect(() => {
if (loaded) setThemeMode(theme as 'light' | 'dark');
@@ -48,6 +188,106 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
return <>{children}</>;
};
// Priority order for picking a default model when the user's stored
// default_model is unreachable (no matching provider connected). The user's
// preferred fallback ordering: direct provider keys first, then OpenSwarm
// Pro, then Copilot-powered OpenSwarm free tier.
const DEFAULT_MODEL_PRIORITY: string[] = [
'Anthropic',
'OpenAI',
'Google',
'OpenSwarm Pro',
'OpenSwarm',
];
// Preferred model pick inside each provider group. Ordered by the user's
// stated preference: Sonnet mid-tier for Claude, GPT-5.4 Mini for OpenAI,
// Flash for Gemini, and conservative picks for the shared tiers.
const DEFAULT_MODEL_PICKS: Record<string, string[]> = {
Anthropic: ['sonnet-cc', 'sonnet'],
OpenAI: ['gpt-5.4-mini', 'gpt-5.4'],
Google: ['gemini-2.5-flash', 'gemini-3-flash', 'gemini-2.5-pro'],
'OpenSwarm Pro': ['sonnet', 'opus'],
OpenSwarm: ['gpt-5-mini', 'claude-haiku-4.5', 'gpt-4.1'],
};
function pickFallbackModel(
byProvider: Record<string, Array<{ value: string; label: string }>>,
): { value: string; label: string; provider: string } | null {
for (const prov of DEFAULT_MODEL_PRIORITY) {
const models = byProvider[prov];
if (!models || models.length === 0) continue;
const available = new Map(models.map((m) => [m.value, m]));
const picks = DEFAULT_MODEL_PICKS[prov] || [];
for (const candidate of picks) {
const m = available.get(candidate);
if (m) return { value: m.value, label: m.label, provider: prov };
}
const first = models[0];
return { value: first.value, label: first.label, provider: prov };
}
return null;
}
// Reconciles the stored default_model against the set of models actually
// reachable given the user's current connections. When the stored value is
// unavailable, falls back per DEFAULT_MODEL_PRIORITY and shows a one-time
// warning so the user knows why their default changed.
const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const settings = useAppSelector((s) => s.settings.data);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
const byProvider = useAppSelector((s) => s.models.byProvider);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
const [warning, setWarning] = useState<{ from: string; to: string; provider: string } | null>(null);
const pendingRef = useRef(false);
useEffect(() => {
if (!settingsLoaded || !modelsLoaded) return;
if (pendingRef.current) return;
if (Object.keys(byProvider).length === 0) return;
const flat = Object.values(byProvider).flat();
const currentExists = flat.some((m) => m.value === settings.default_model);
if (currentExists) return;
const fallback = pickFallbackModel(byProvider);
if (!fallback || fallback.value === settings.default_model) return;
const fromLabel = flat.find((m) => m.value === settings.default_model)?.label ?? settings.default_model;
pendingRef.current = true;
dispatch(updateSettings({ ...settings, default_model: fallback.value }))
.finally(() => {
pendingRef.current = false;
});
setWarning({ from: fromLabel, to: fallback.label, provider: fallback.provider });
}, [settingsLoaded, modelsLoaded, byProvider, settings, dispatch]);
return (
<>
{children}
<Snackbar
open={!!warning}
autoHideDuration={8000}
onClose={() => setWarning(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
severity="warning"
variant="filled"
onClose={() => setWarning(null)}
sx={{ fontSize: '0.8rem' }}
>
{warning && (
<>Default model <b>{warning.from}</b> is no longer available switched to <b>{warning.to}</b> ({warning.provider}).</>
)}
</Alert>
</Snackbar>
</>
);
};
const UpdateListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
@@ -91,27 +331,59 @@ const ThemedApp: React.FC = () => {
const { mode } = useThemeMode();
const muiTheme = useMemo(() => buildMuiTheme(c, mode), [c, mode]);
// Track last action before user leaves and uncaught errors
useEffect(() => {
const handleUnload = () => {
trackEvent('app.last_action', {
last_page: getLastPage(),
last_action: getLastAction(),
time_spent_seconds: getTimeSpent(),
}, true); // useBeacon for reliable delivery during unload
};
const handleError = (event: ErrorEvent) => {
trackEvent('app.error', {
error_message: event.message,
error_stack: event.error?.stack?.slice(0, 500),
last_page: getLastPage(),
});
};
window.addEventListener('beforeunload', handleUnload);
window.addEventListener('error', handleError);
return () => {
window.removeEventListener('beforeunload', handleUnload);
window.removeEventListener('error', handleError);
};
}, []);
return (
<MuiThemeProvider theme={muiTheme}>
<CssBaseline />
<HashRouter>
<ShortcutsProvider>
<SettingsLoader>
<DefaultModelGuard>
<UpdateListener>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
<Route path="/dashboard/:id" element={<Dashboard />} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
<Route path="/actions" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/apps" element={<Views />} />
<Route path="/apps/:id" element={<Views />} />
</Route>
</Routes>
<OnboardingModal />
<DeepLinkListener>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
{/* Dashboard route is a no-op stub — the actual <Dashboard /> is rendered
persistently inside AppShell so its webviews survive navigation between
routes. This route exists only so React Router matches the URL. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
<Route path="/actions" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/apps" element={<Views />} />
<Route path="/apps/:id" element={<Views />} />
<Route path="/analytics" element={<Analytics />} />
</Route>
</Routes>
<OnboardingModal />
</DeepLinkListener>
</UpdateListener>
</DefaultModelGuard>
</SettingsLoader>
</ShortcutsProvider>
</HashRouter>
-123
View File
@@ -1,123 +0,0 @@
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,148 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import Settings from './components/Settings/Settings';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { LIST_DASHBOARDS } from '@/shared/backend-bridge/apps/dashboards';
import { LIST_APPS } from '@/shared/backend-bridge/apps/app_builder';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useUpdateNotification } from './hooks/useUpdateNotification';
import { useSidebarResize } from './hooks/useSidebarResize';
import { useUrlInterception } from './hooks/useUrlInterception';
import TitleBar from './components/TitleBar/TitleBar';
import Sidebar from './components/Sidebar';
import UpdateBanner from './components/UpdateBanner';
const AppShell: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const {
updateStatus, availableVersion, downloadPercent,
snackbarDismissed, setSnackbarDismissed,
showUpdateDot, showUpdateBanner, showUpdateSnackbar,
handleDismissBanner, handleDownloadUpdate, handleInstallUpdate,
} = useUpdateNotification();
const { sidebarWidth, handleResizeStart, handleResizeDoubleClick } = useSidebarResize();
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(),
);
useUrlInterception(dashboardList);
useEffect(() => {
dispatch(LIST_DASHBOARDS());
dispatch(LIST_APPS());
}, [dispatch]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.page }}>
<TitleBar
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((p) => !p)}
/>
{showUpdateBanner && (
<UpdateBanner
updateStatus={updateStatus}
availableVersion={availableVersion}
downloadPercent={downloadPercent}
onDownload={handleDownloadUpdate}
onInstall={handleInstallUpdate}
onDismiss={handleDismissBanner}
/>
)}
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
{!sidebarCollapsed && (
<>
<Box sx={{
width: sidebarWidth, flexShrink: 0, bgcolor: c.bg.secondary,
display: 'flex', flexDirection: 'column',
}}>
<Sidebar showUpdateDot={showUpdateDot} />
</Box>
<Box
onMouseDown={handleResizeStart}
onDoubleClick={handleResizeDoubleClick}
sx={{
width: 6, flexShrink: 0, cursor: 'col-resize',
position: 'relative', zIndex: 10,
'&::after': {
content: '""', position: 'absolute', top: 0, bottom: 0,
left: '50%', transform: 'translateX(-50%)', width: 2,
bgcolor: 'transparent', transition: 'background-color 0.2s',
},
'&:hover::after': { bgcolor: c.border.strong },
'&:active::after': { bgcolor: `${c.accent.primary}40` },
}}
/>
</>
)}
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: c.bg.page }}>
<Outlet />
</Box>
</Box>
<Settings />
<Snackbar
open={showUpdateSnackbar}
autoHideDuration={10000}
onClose={() => setSnackbarDismissed(true)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="info"
icon={updateStatus === 'downloaded'
? <RestartAltIcon sx={{ fontSize: 18 }} />
: <SystemUpdateAltIcon sx={{ fontSize: 18 }} />
}
action={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button size="small" onClick={() => setSnackbarDismissed(true)}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto' }}>
Dismiss
</Button>
{updateStatus === 'available' && (
<Button size="small" variant="contained" onClick={handleDownloadUpdate} sx={{
bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none', fontSize: '0.8rem', borderRadius: 1.5, minWidth: 'auto',
}}>
Download
</Button>
)}
{updateStatus === 'downloaded' && (
<Button size="small" variant="contained" onClick={handleInstallUpdate} sx={{
bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none', fontSize: '0.8rem', borderRadius: 1.5, minWidth: 'auto',
}}>
Restart & Update
</Button>
)}
</Box>
}
sx={{
bgcolor: c.bg.surface, color: c.text.primary,
border: `1px solid ${c.border.medium}`, boxShadow: c.shadow.md,
'& .MuiAlert-icon': { color: c.accent.primary },
}}
>
{updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded — restart to update`}
</Alert>
</Snackbar>
</Box>
);
};
export default AppShell;
@@ -1,191 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import SaveIcon from '@mui/icons-material/Save';
import CloseIcon from '@mui/icons-material/Close';
import { CommandsTab } from './components/CommandsTab/CommandsTab';
import { useSettings } from './hooks/useSettings';
import GeneralTab from './components/GeneralTab/GeneralTab';
import ModelsTab from './components/ModelsTab/ModelsTab';
const Settings: React.FC = () => {
const s = useSettings();
const { c, open, activeTab, setActiveTab, hasChanges, handleSave, handleRequestClose,
confirmDiscard, setConfirmDiscard, handleConfirmDiscard, handleSaveAndClose,
saved, setSaved } = s;
return (
<>
<Dialog
open={open}
onClose={handleRequestClose}
maxWidth={false}
PaperProps={{
sx: {
width: 780,
height: '85vh',
bgcolor: c.bg.page,
borderRadius: 2,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.md,
transition: 'none',
},
}}
>
<DialogTitle
sx={{
px: 3,
py: 0,
borderBottom: `1px solid ${c.border.subtle}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5, pb: 0.5 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
Settings
</Typography>
<IconButton onClick={handleRequestClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
sx={{
minHeight: 36,
'& .MuiTab-root': {
minHeight: 36,
textTransform: 'none',
fontSize: '0.85rem',
fontWeight: 500,
color: c.text.muted,
px: 1.5,
'&.Mui-selected': { color: c.accent.primary, fontWeight: 600 },
},
'& .MuiTabs-indicator': { backgroundColor: c.accent.primary, height: 2 },
}}
>
<Tab label="General" value="general" disableRipple />
<Tab label="Models" value="models" disableRipple />
<Tab label="Commands" value="commands" disableRipple />
</Tabs>
</DialogTitle>
<DialogContent sx={{
px: 3,
py: 0,
'&::-webkit-scrollbar': { width: 6 },
'&::-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`,
}}>
{activeTab === 'general' ? (
<GeneralTab s={s} />
) : activeTab === 'models' ? (
<ModelsTab s={s} />
) : (
<Box sx={{ pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<CommandsTab />
</Box>
)}
</DialogContent>
{(activeTab === 'general' || activeTab === 'models') && (
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 1.5, justifyContent: 'flex-end' }}>
<Button
onClick={handleRequestClose}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
>
Cancel
</Button>
<Button
variant="contained"
startIcon={<SaveIcon sx={{ fontSize: 16 }} />}
onClick={handleSave}
disabled={!hasChanges}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
'&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.ghost },
textTransform: 'none',
borderRadius: 1.5,
px: 2.5,
fontSize: '0.85rem',
}}
>
Save
</Button>
</DialogActions>
)}
<Snackbar
open={saved}
autoHideDuration={3000}
onClose={() => setSaved(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={() => setSaved(false)} severity="success" sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.status.success}` }}>
Settings saved
</Alert>
</Snackbar>
</Dialog>
<Dialog
open={confirmDiscard}
onClose={() => setConfirmDiscard(false)}
PaperProps={{
sx: {
bgcolor: c.bg.page,
borderRadius: 2,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.md,
maxWidth: 380,
},
}}
>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', pb: 0.5, px: 3, pt: 2.5 }}>
Unsaved changes
</DialogTitle>
<DialogContent sx={{ px: 3 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem' }}>
You have unsaved changes. Would you like to save them before closing?
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button
onClick={handleConfirmDiscard}
sx={{ color: c.status.error, textTransform: 'none', fontSize: '0.85rem' }}
>
Discard
</Button>
<Button
onClick={() => setConfirmDiscard(false)}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSaveAndClose}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 1.5,
fontSize: '0.85rem',
}}
>
Save & Close
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default Settings;
@@ -1,22 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useCommands } from './hooks/useCommands';
import SlashCommandsSection from './components/SlashCommandsSection';
import AtCommandsSection from './components/AtCommandsSection';
import ShortcutsSection from './components/ShortcutsSection';
export const CommandsTab: React.FC = () => {
const c = useClaudeTokens();
const { slashCommands, atCommands, modesMap, navShortcuts, actionShortcuts } = useCommands();
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<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>
);
};
@@ -1,102 +0,0 @@
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 './shared/CommandsHelpers';
import { AtCommand } from './shared/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,104 +0,0 @@
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 './shared/CommandsHelpers';
import { Shortcut, SHORTCUTS } from './shared/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;
@@ -1,114 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import PsychologyIcon from '@mui/icons-material/Psychology';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import TerminalIcon from '@mui/icons-material/Terminal';
import { SectionHeader } from './shared/CommandsHelpers';
import { SlashCommand } from './shared/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 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 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 === 'mode' ? (modesMap[cmd.id]?.color || c.accent.primary)
: c.status.success,
display: 'flex',
}}>
{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 === 'mode' ? `${modesMap[cmd.id]?.color || c.accent.primary}15`
: `${c.status.success}15`,
color: 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;
@@ -1,63 +0,0 @@
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>
);
@@ -1,32 +0,0 @@
import React from 'react';
export interface SlashCommand {
id: string;
type: '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: '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' },
];
@@ -1,157 +0,0 @@
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 { LIST_BUILTIN_TOOLS, LIST_TOOLS } from '@/shared/backend-bridge/apps/tools';
import { getToolGroupIcon } from '@/app/pages/_shared/getToolGroupIcon';
import { LIST_APPS } from '@/shared/backend-bridge/apps/app_builder';
import { LIST_SKILLS } from '@/shared/backend-bridge/apps/skills';
import { LIST_MODES } from '@/shared/state/modesSlice';
import { SlashCommand, AtCommand, SHORTCUTS } from '../components/shared/commandsTypes';
export function useCommands() {
const dispatch = useAppDispatch();
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.apps.items);
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.apps.loaded);
useEffect(() => {
if (!skillsLoaded) dispatch(LIST_SKILLS());
if (!modesLoaded) dispatch(LIST_MODES());
if (!builtinLoaded) dispatch(LIST_BUILTIN_TOOLS());
if (!toolsLoaded) dispatch(LIST_TOOLS());
if (!outputsLoaded) dispatch(LIST_APPS());
}, [dispatch, skillsLoaded, modesLoaded, builtinLoaded, toolsLoaded, outputsLoaded]);
const slashCommands: SlashCommand[] = useMemo(() => [
...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, '-'),
})),
], [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 };
}
@@ -1,203 +0,0 @@
import React 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 FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Switch from '@mui/material/Switch';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import LanguageIcon from '@mui/icons-material/Language';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import { RESET_SYSTEM_PROMPT } from '@/shared/backend-bridge/apps/settings';
import { DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
import InterfaceSection from './components/InterfaceSection';
import AboutSection from './components/AboutSection';
import type { UseSettingsReturn } from '../../hooks/useSettings';
const GeneralTab: React.FC<{ s: UseSettingsReturn }> = ({ s }) => {
const { form, setForm, c, dispatch, modesList, browseFolder,
fieldSx, sectionSx, rowSx, rowLastSx, inlineRowSx, inlineRowLastSx, labelSx, descSx } = s;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<Typography sx={sectionSx}>Agent Defaults</Typography>
<Box sx={rowSx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={labelSx}>System prompt</Typography>
{form.default_system_prompt !== DEFAULT_SYSTEM_PROMPT && (
<Button
size="small"
startIcon={<RestartAltIcon sx={{ fontSize: 14 }} />}
onClick={async () => {
await dispatch(RESET_SYSTEM_PROMPT());
setForm((prev) => ({ ...prev, default_system_prompt: DEFAULT_SYSTEM_PROMPT }));
}}
sx={{
color: c.accent.primary,
textTransform: 'none',
fontSize: '0.75rem',
py: 0.25,
'&:hover': { bgcolor: `${c.accent.primary}10` },
}}
>
Reset to default
</Button>
)}
</Box>
<Typography sx={{ ...descSx, mb: 1.5 }}>
Prepended to every agent session before mode-specific instructions. Modes can override with their own.
</Typography>
<TextField
value={form.default_system_prompt ?? DEFAULT_SYSTEM_PROMPT}
onChange={(e) => setForm({ ...form, default_system_prompt: e.target.value || null })}
multiline
minRows={3}
maxRows={8}
fullWidth
size="small"
sx={{
'& .MuiOutlinedInput-root': {
fontFamily: c.font.mono,
fontSize: '0.8rem',
lineHeight: 1.6,
color: c.text.secondary,
},
}}
/>
</Box>
<Box sx={rowSx}>
<Typography sx={labelSx}>Working directory</Typography>
<Typography sx={{ ...descSx, mb: 1.5 }}>
Default folder agents start in. Modes can override per-mode.
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
value={form.default_folder ?? ''}
onChange={(e) => setForm({ ...form, default_folder: e.target.value || null })}
size="small"
fullWidth
placeholder="Not set (uses project root)"
sx={{
...fieldSx,
'& .MuiOutlinedInput-root': {
...fieldSx['& .MuiOutlinedInput-root'],
fontFamily: c.font.mono,
},
}}
/>
<Button
variant="outlined"
onClick={browseFolder}
startIcon={<FolderOpenIcon sx={{ fontSize: 16 }} />}
sx={{
color: c.text.tertiary,
borderColor: c.border.medium,
textTransform: 'none',
whiteSpace: 'nowrap',
minWidth: 'auto',
fontSize: '0.8rem',
'&:hover': { color: c.accent.primary, borderColor: c.accent.primary },
}}
>
Browse
</Button>
</Box>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Model</Typography>
<Typography sx={descSx}>Default model for new sessions.</Typography>
</Box>
<FormControl size="small" sx={{ minWidth: 170 }}>
<Select
value={form.default_model}
onChange={(e) => setForm({ ...form, default_model: e.target.value })}
sx={{ fontSize: '0.85rem' }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
<MenuItem value="sonnet">Sonnet 4.6</MenuItem>
<MenuItem value="opus">Opus 4.6</MenuItem>
<MenuItem value="haiku">Haiku 3.5</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Mode</Typography>
<Typography sx={descSx}>Default interaction mode for new sessions.</Typography>
</Box>
<FormControl size="small" sx={{ minWidth: 170 }}>
<Select
value={form.default_mode}
onChange={(e) => setForm({ ...form, default_mode: e.target.value })}
sx={{ fontSize: '0.85rem' }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
{modesList.map((m) => (
<MenuItem key={m.id} value={m.id}>{m.name}</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Max turns</Typography>
<Typography sx={descSx}>Auto-stop after this many turns. Empty = unlimited.</Typography>
</Box>
<TextField
type="number"
value={form.default_max_turns ?? ''}
onChange={(e) => setForm({ ...form, default_max_turns: e.target.value ? parseInt(e.target.value) : null })}
size="small"
placeholder="∞"
inputProps={{ min: 1 }}
sx={{ ...fieldSx, width: 100 }}
/>
</Box>
<InterfaceSection s={s} />
<Typography sx={{ ...sectionSx, mt: 3 }}>Browser</Typography>
<Box sx={rowLastSx}>
<Typography sx={labelSx}>Default homepage</Typography>
<Typography sx={{ ...descSx, mb: 1.5 }}>
URL loaded when opening a new browser card on the dashboard.
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<LanguageIcon sx={{ fontSize: 18, color: c.text.tertiary, flexShrink: 0 }} />
<TextField
value={form.browser_homepage}
onChange={(e) => setForm({ ...form, browser_homepage: e.target.value })}
size="small"
fullWidth
placeholder="https://www.google.com"
sx={{
...fieldSx,
'& .MuiOutlinedInput-root': {
...fieldSx['& .MuiOutlinedInput-root'],
fontFamily: c.font.mono,
},
}}
/>
</Box>
</Box>
<Typography sx={{ ...sectionSx, mt: 3 }}>Advanced</Typography>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Developer mode</Typography>
<Typography sx={descSx}>Show transport details, environment variables, raw configs, and other technical metadata throughout the app.</Typography>
</Box>
<Switch
checked={form.dev_mode}
onChange={(e) => setForm({ ...form, dev_mode: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
<AboutSection s={s} />
</Box>
);
};
export default GeneralTab;
@@ -1,128 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import LinearProgress from '@mui/material/LinearProgress';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import DownloadIcon from '@mui/icons-material/Download';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import type { UseSettingsReturn } from './hooks/useSettings';
const AboutSection: React.FC<{ s: UseSettingsReturn }> = ({ s }) => {
const { c, sectionSx, rowSx, rowLastSx, labelSx, descSx,
updateStatus, appVersion, availableVersion, downloadPercent, updateError,
handleCheckForUpdates, handleDownloadUpdate, handleInstallUpdate } = s;
return (
<>
<Typography sx={{ ...sectionSx, mt: 3 }}>About</Typography>
<Box sx={rowSx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={labelSx}>Version</Typography>
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>
{appVersion ?? '—'}
</Typography>
</Box>
</Box>
</Box>
<Box sx={rowLastSx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: updateStatus === 'downloading' ? 1 : 0 }}>
<Box>
<Typography sx={labelSx}>Software update</Typography>
<Typography sx={descSx}>
{updateStatus === 'checking' && 'Checking for updates…'}
{updateStatus === 'not-available' && 'You\'re on the latest version.'}
{updateStatus === 'available' && `Version ${availableVersion} is available.`}
{updateStatus === 'downloading' && `Downloading update… ${Math.round(downloadPercent)}%`}
{updateStatus === 'downloaded' && `Version ${availableVersion} is ready to install.`}
{updateStatus === 'error' && (updateError || 'Update check failed.')}
{updateStatus === 'idle' && 'Check for new versions of OpenSwarm.'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, ml: 2 }}>
{updateStatus === 'checking' && (
<CircularProgress size={18} sx={{ color: c.text.tertiary }} />
)}
{updateStatus === 'not-available' && (
<CheckCircleOutlineIcon sx={{ fontSize: 18, color: c.status.success }} />
)}
{updateStatus === 'error' && (
<ErrorOutlineIcon sx={{ fontSize: 18, color: c.status.error }} />
)}
{(updateStatus === 'idle' || updateStatus === 'not-available' || updateStatus === 'error') && (
<Button
variant="outlined"
size="small"
onClick={handleCheckForUpdates}
startIcon={<SystemUpdateAltIcon sx={{ fontSize: 15 }} />}
sx={{
color: c.text.secondary,
borderColor: c.border.medium,
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
'&:hover': { color: c.accent.primary, borderColor: c.accent.primary },
}}
>
Check for Updates
</Button>
)}
{updateStatus === 'available' && (
<Button
variant="outlined"
size="small"
onClick={handleDownloadUpdate}
startIcon={<DownloadIcon sx={{ fontSize: 15 }} />}
sx={{
color: c.accent.primary,
borderColor: c.accent.primary,
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
'&:hover': { bgcolor: `${c.accent.primary}10` },
}}
>
Download
</Button>
)}
{updateStatus === 'downloaded' && (
<Button
variant="contained"
size="small"
onClick={handleInstallUpdate}
startIcon={<RestartAltIcon sx={{ fontSize: 15 }} />}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
borderRadius: 1.5,
}}
>
Restart &amp; Update
</Button>
)}
</Box>
</Box>
{updateStatus === 'downloading' && (
<LinearProgress
variant="determinate"
value={downloadPercent}
sx={{
height: 3,
borderRadius: 2,
bgcolor: `${c.accent.primary}20`,
'& .MuiLinearProgress-bar': { bgcolor: c.accent.primary, borderRadius: 2 },
}}
/>
)}
</Box>
</>
);
};
export default AboutSection;
@@ -1,184 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Slider from '@mui/material/Slider';
import Switch from '@mui/material/Switch';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import LightModeIcon from '@mui/icons-material/LightMode';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import type { UseSettingsReturn } from '../../../hooks/useSettings';
const InterfaceSection: React.FC<{ s: UseSettingsReturn }> = ({ s }) => {
const { form, setForm, c, recordingShortcut, setRecordingShortcut,
sectionSx, rowSx, inlineRowSx, inlineRowLastSx, labelSx, descSx } = s;
return (
<>
<Typography sx={{ ...sectionSx, mt: 3 }}>Interface</Typography>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Theme</Typography>
<Typography sx={descSx}>Application color scheme.</Typography>
</Box>
<ToggleButtonGroup
value={form.theme}
exclusive
onChange={(_, v) => { if (v) setForm({ ...form, theme: v }); }}
size="small"
sx={{
'& .MuiToggleButton-root': {
color: c.text.muted,
borderColor: c.border.medium,
textTransform: 'none',
px: 2,
py: 0.5,
gap: 0.5,
fontSize: '0.8rem',
'&.Mui-selected': {
bgcolor: `${c.accent.primary}15`,
color: c.accent.primary,
borderColor: c.accent.primary,
'&:hover': { bgcolor: `${c.accent.primary}20` },
},
},
}}
>
<ToggleButton value="light">
<LightModeIcon sx={{ fontSize: 16 }} /> Light
</ToggleButton>
<ToggleButton value="dark">
<DarkModeIcon sx={{ fontSize: 16 }} /> Dark
</ToggleButton>
</ToggleButtonGroup>
</Box>
<Box sx={rowSx}>
<Typography sx={labelSx}>Zoom sensitivity</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>
Scroll-to-zoom responsiveness. Lower for trackpads, higher for mouse wheels.
</Typography>
<Box sx={{ px: 1 }}>
<Slider
value={form.zoom_sensitivity}
onChange={(_, v) => setForm({ ...form, zoom_sensitivity: v as number })}
min={1}
max={100}
step={1}
valueLabelDisplay="auto"
marks={[
{ value: 1, label: 'Low' },
{ value: 50, label: 'Default' },
{ value: 100, label: 'High' },
]}
sx={{
color: c.accent.primary,
'& .MuiSlider-markLabel': { color: c.text.tertiary, fontSize: '0.7rem' },
'& .MuiSlider-valueLabel': { bgcolor: c.accent.primary },
}}
/>
</Box>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>New agent shortcut</Typography>
<Typography sx={descSx}>Keyboard shortcut to create an agent.</Typography>
</Box>
<Box
tabIndex={0}
onKeyDown={(e) => {
if (!recordingShortcut) return;
if (['Meta', 'Control', 'Shift', 'Alt'].includes(e.key)) return;
e.preventDefault();
const parts: string[] = [];
if (e.metaKey) parts.push('Meta');
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
parts.push(e.key.length === 1 ? e.key.toLowerCase() : e.key);
setForm({ ...form, new_agent_shortcut: parts.join('+') });
setRecordingShortcut(false);
}}
onBlur={() => setRecordingShortcut(false)}
onClick={() => setRecordingShortcut(true)}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.75,
borderRadius: `${c.radius.sm}px`,
border: `1px solid ${recordingShortcut ? c.accent.primary : c.border.medium}`,
cursor: 'pointer',
outline: 'none',
transition: 'border-color 0.15s',
'&:hover': { borderColor: c.accent.primary },
}}
>
<KeyboardIcon sx={{ fontSize: 16, color: recordingShortcut ? c.accent.primary : c.text.tertiary }} />
{recordingShortcut ? (
<Typography sx={{ fontSize: '0.8rem', color: c.accent.primary, fontWeight: 500 }}>
Press shortcut
</Typography>
) : (
<Typography sx={{ fontSize: '0.8rem', color: c.text.primary, fontFamily: c.font.mono, fontWeight: 500 }}>
{form.new_agent_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(' + ')}
</Typography>
)}
</Box>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Auto-enable element selection</Typography>
<Typography sx={descSx}>Automatically enter element selection mode when creating a new agent.</Typography>
</Box>
<Switch
checked={form.auto_select_mode_on_new_agent}
onChange={(e) => setForm({ ...form, auto_select_mode_on_new_agent: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Default agent spawn state in dashboard</Typography>
<Typography sx={descSx}>When enabled, new agents spawn expanded instead of collapsed.</Typography>
</Box>
<Switch
checked={form.expand_new_chats_in_dashboard}
onChange={(e) => setForm({ ...form, expand_new_chats_in_dashboard: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Auto-reveal sub-agents on dashboard</Typography>
<Typography sx={descSx}>Automatically show sub-agent cards (from CreateAgent / InvokeAgent) tethered to their parent on the dashboard.</Typography>
</Box>
<Switch
checked={form.auto_reveal_sub_agents}
onChange={(e) => setForm({ ...form, auto_reveal_sub_agents: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
</>
);
};
export default InterfaceSection;
@@ -1,72 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import SubscriptionCards from './components/SubscriptionCards';
import type { UseSettingsReturn } from '../../hooks/useSettings';
const ModelsTab: React.FC<{ s: UseSettingsReturn }> = ({ s }) => {
const { c, form, setForm, showApiKey, setShowApiKey, fieldSx, labelSx, descSx } = s;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
Use Your Existing Subscriptions
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Already paying for Claude, ChatGPT, or Gemini? Connect your subscription no API key needed, no extra cost.
</Typography>
<SubscriptionCards />
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Connect With API Keys
</Typography>
<Typography sx={{ ...descSx, mb: -1 }}>
Pay per use. Each key is stored locally on your device.
</Typography>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>Anthropic</Typography>
{form.anthropic_api_key ? (
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 0.75, py: 0.15, borderRadius: '3px' }}>CONNECTED</Typography>
) : null}
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>Claude Sonnet, Opus, Haiku.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type={showApiKey ? 'text' : 'password'}
value={form.anthropic_api_key ?? ''}
onChange={(e) => setForm({ ...form, anthropic_api_key: e.target.value || null })}
size="small"
fullWidth
placeholder="sk-ant-..."
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
<Typography
component="a"
href="https://console.anthropic.com/settings/keys"
target="_blank"
rel="noopener"
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
>
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
</Box>
</Box>
);
};
export default ModelsTab;
@@ -1,88 +0,0 @@
import React from 'react';
import { Box, Typography, Button, CircularProgress } from '@mui/material';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4', preview: true },
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C', preview: true },
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
];
type SubscriptionProvider = typeof SUBSCRIPTION_PROVIDERS[0];
interface SubscriptionCardProps {
provider: SubscriptionProvider;
connected: boolean;
onConnect: () => void;
onDisconnect: () => void;
connecting: boolean;
userCode?: string;
disconnecting?: boolean;
}
const SubscriptionCard: React.FC<SubscriptionCardProps> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
const c = useClaudeTokens();
const isPreview = (provider as any).preview;
return (
<Box sx={{
p: 1.5, borderRadius: `${c.radius.md}px`,
border: `1px solid ${connected ? c.status.success + '30' : connecting ? c.accent.primary + '30' : c.border.subtle}`,
bgcolor: connected ? `${c.status.success}04` : connecting ? `${c.accent.primary}04` : 'transparent',
opacity: isPreview ? 0.5 : 1,
transition: 'all 0.3s ease',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
bgcolor: connected ? c.status.success : connecting ? c.accent.primary : c.border.medium,
transition: 'background-color 0.3s ease',
...(connecting ? {
animation: 'pulse-dot 1.5s ease-in-out infinite',
'@keyframes pulse-dot': {
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.4, transform: 'scale(0.8)' },
},
} : {}),
}} />
<Box>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: connecting ? c.accent.primary : c.text.muted, transition: 'color 0.3s ease' }}>
{connecting ? 'Waiting for authorization...' : provider.desc}
</Typography>
</Box>
</Box>
{isPreview ? (
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost, fontStyle: 'italic' }}>
Coming soon
</Typography>
) : connected ? (
disconnecting ? (
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
) : (
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error }, transition: 'color 0.2s ease' }}>
Disconnect
</Typography>
)
) : connecting && userCode ? (
<Box sx={{ textAlign: 'right' }}>
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
</Box>
) : connecting ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8 }}>
<CircularProgress size={14} sx={{ color: c.accent.primary }} />
<Typography sx={{ fontSize: '0.68rem', color: c.accent.primary }}>Connecting...</Typography>
</Box>
) : (
<Button onClick={onConnect} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary }, transition: 'all 0.2s ease' }}>
Connect
</Button>
)}
</Box>
</Box>
);
};
export default SubscriptionCard;
@@ -1,175 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Box, Typography, CircularProgress } from '@mui/material';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import {
SUBSCRIPTIONS_STATUS,
SUBSCRIPTIONS_CONNECT,
SUBSCRIPTIONS_POLL,
SUBSCRIPTIONS_DISCONNECT,
} from '@/shared/backend-bridge/apps/subscriptions';
import SubscriptionCard, { SUBSCRIPTION_PROVIDERS } from './SubscriptionCard';
const SubscriptionCards: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [status, setStatus] = useState<any>(null);
const [connecting, setConnecting] = useState<string | null>(null);
const [disconnecting, setDisconnecting] = useState<string | null>(null);
const [userCode, setUserCode] = useState('');
const [pollTimer, setPollTimer] = useState<any>(null);
const retryRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchStatus = () => {
dispatch(SUBSCRIPTIONS_STATUS()).unwrap()
.then(setStatus)
.catch(() => setStatus({ running: false, providers: [], models: [] }));
};
const fetchStatusWithRetry = () => {
fetchStatus();
setTimeout(fetchStatus, 1000);
setTimeout(fetchStatus, 3000);
};
useEffect(() => {
fetchStatus();
retryRef.current = setInterval(fetchStatus, 3000);
return () => { if (retryRef.current) clearInterval(retryRef.current); };
}, []);
useEffect(() => {
if (status?.running && retryRef.current) {
clearInterval(retryRef.current);
retryRef.current = null;
}
}, [status?.running]);
const isConnected = (providerId: string) => {
if (!status?.providers) return false;
const connections = status.providers?.connections || (Array.isArray(status.providers) ? status.providers : []);
return connections.some((p: any) => p.provider === providerId && p.isActive);
};
const handleConnect = async (providerId: string) => {
if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); }
setConnecting(providerId);
setUserCode('');
await new Promise(r => setTimeout(r, 500));
try {
const data = await dispatch(SUBSCRIPTIONS_CONNECT(providerId)).unwrap();
if (data.flow === 'device_code') {
const code = (data.user_code as string) || '';
setUserCode(code);
if (data.verification_uri) window.open(data.verification_uri as string, '_blank');
const timer = setInterval(async () => {
try {
const pd = await dispatch(SUBSCRIPTIONS_POLL({
provider: providerId,
device_code: data.device_code as string,
code_verifier: data.code_verifier as string | undefined,
extra_data: data.extra_data as Record<string, unknown> | undefined,
})).unwrap();
if ((pd as any).success) {
clearInterval(timer);
setPollTimer(null);
setConnecting(null);
setUserCode('');
fetchStatusWithRetry();
}
} catch {}
}, 5000);
setPollTimer(timer);
setTimeout(() => { clearInterval(timer); setPollTimer(null); setConnecting(null); setUserCode(''); }, 300000);
} else if (data.flow === 'authorization_code') {
const popup = window.open(data.auth_url as string, 'oauth_connect', 'width=600,height=700');
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
clearInterval(statusPoller);
setPollTimer(null);
window.removeEventListener('message', msgHandler);
if (popup && !popup.closed) popup.close();
setConnecting(null);
fetchStatusWithRetry();
};
const msgHandler = (event: MessageEvent) => {
const d = event.data;
if (d?.type === 'oauth_callback' && d?.data?.connected) cleanup();
};
window.addEventListener('message', msgHandler);
const statusPoller = setInterval(async () => {
try {
if (popup?.closed && !resolved) {
await new Promise(r => setTimeout(r, 1000));
cleanup();
return;
}
const sd = await dispatch(SUBSCRIPTIONS_STATUS()).unwrap();
const connections = (sd.providers as any)?.connections || [];
if (connections.some((p: any) => p.provider === providerId && p.isActive)) {
cleanup();
}
} catch {}
}, 2000);
setPollTimer(statusPoller);
setTimeout(cleanup, 120000);
} else {
setConnecting(null);
}
} catch { setConnecting(null); }
};
const handleDisconnect = async (providerId: string) => {
setDisconnecting(providerId);
try {
await dispatch(SUBSCRIPTIONS_DISCONNECT(providerId)).unwrap();
} catch {}
setTimeout(() => { fetchStatusWithRetry(); setDisconnecting(null); }, 500);
};
if (!status) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{SUBSCRIPTION_PROVIDERS.map(p => (
<Box key={p.id} sx={{
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
display: 'flex', alignItems: 'center', gap: 1,
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
'@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } },
}}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.border.medium, flexShrink: 0 }} />
<Box sx={{ flex: 1 }}>
<Box sx={{ width: 100, height: 12, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
<Box sx={{ width: 180, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
</Box>
</Box>
))}
</Box>
);
}
if (!status?.running) {
return (
<Box sx={{ p: 2, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, textAlign: 'center' }}>
<CircularProgress size={18} sx={{ color: c.text.ghost, mb: 1 }} />
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 0.5 }}>
Starting subscription service...
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost }}>
This connects your existing AI subscriptions. If this doesn't load, make sure Node.js is installed.
</Typography>
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{SUBSCRIPTION_PROVIDERS.map(p => (
<SubscriptionCard
key={p.id}
provider={p}
connected={isConnected(p.id)}
onConnect={() => handleConnect(p.id)}
onDisconnect={() => handleDisconnect(p.id)}
connecting={connecting === p.id}
disconnecting={disconnecting === p.id}
userCode={connecting === p.id ? userCode : undefined}
/>
))}
</Box>
);
};
export default SubscriptionCards;
@@ -1,100 +0,0 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { UPDATE_SETTINGS, AppSettings } from '@/shared/backend-bridge/apps/settings';
import { closeSettingsModal } from '@/shared/state/settingsSlice';
import { SUBSCRIPTIONS_STATUS } from '@/shared/backend-bridge/apps/subscriptions';
import { setChecking, setUpdateError } from '@/shared/state/updateSlice';
import { LIST_MODES } from '@/shared/state/modesSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
export function useSettings() {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const open = useAppSelector((s) => s.settings.modalOpen);
const settings = useAppSelector((s) => s.settings.data);
const loaded = useAppSelector((s) => s.settings.loaded);
const modes = useAppSelector((s) => s.modes.items);
const { setMode: setThemeMode } = useThemeMode();
const modesList = useMemo(() => Object.values(modes), [modes]);
const updateStatus = useAppSelector((s) => s.update.status);
const appVersion = useAppSelector((s) => s.update.appVersion);
const availableVersion = useAppSelector((s) => s.update.availableVersion);
const downloadPercent = useAppSelector((s) => s.update.downloadPercent);
const updateError = useAppSelector((s) => s.update.error);
const [activeTab, setActiveTab] = useState<'general' | 'models' | 'usage' | 'commands'>('general');
const [form, setForm] = useState<AppSettings>({ ...settings });
const [showApiKey, setShowApiKey] = useState(false);
const [saved, setSaved] = useState(false);
const [recordingShortcut, setRecordingShortcut] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const [showApiHelp, setShowApiHelp] = useState(false);
useEffect(() => { dispatch(LIST_MODES()); }, [dispatch]);
useEffect(() => { if (open) setActiveTab('general'); }, [open]);
useEffect(() => { if (loaded) setForm({ ...settings }); }, [loaded, settings]);
const hasChanges = JSON.stringify(form) !== JSON.stringify(settings);
const handleSave = async () => {
await dispatch(UPDATE_SETTINGS(form));
if (form.theme !== settings.theme) setThemeMode(form.theme);
dispatch(SUBSCRIPTIONS_STATUS());
setSaved(true);
};
const handleRequestClose = useCallback(() => {
if (hasChanges) setConfirmDiscard(true);
else dispatch(closeSettingsModal());
}, [hasChanges, dispatch]);
const handleConfirmDiscard = useCallback(() => {
setConfirmDiscard(false);
setForm({ ...settings });
dispatch(closeSettingsModal());
}, [settings, dispatch]);
const handleSaveAndClose = useCallback(async () => {
await dispatch(UPDATE_SETTINGS(form));
if (form.theme !== settings.theme) setThemeMode(form.theme);
dispatch(SUBSCRIPTIONS_STATUS());
setSaved(true);
setConfirmDiscard(false);
dispatch(closeSettingsModal());
}, [dispatch, form, settings, setThemeMode]);
const handleCheckForUpdates = async () => {
dispatch(setChecking());
const timeout = setTimeout(() => {
dispatch(setUpdateError('Update check timed out. Please try again.'));
}, 15000);
try { await (window as any).openswarm?.checkForUpdates(); }
catch {} finally { clearTimeout(timeout); }
};
const handleDownloadUpdate = async () => {
try { await (window as any).openswarm?.downloadUpdate(); } catch {}
};
const handleInstallUpdate = () => { (window as any).openswarm?.installUpdate(); };
const browseFolder = async () => {
const result = await (window as any).openswarm?.showOpenDialog({
properties: ['openDirectory'],
defaultPath: form.default_folder || undefined,
});
if (result && !result.canceled && result.filePaths?.length > 0) {
setForm({ ...form, default_folder: result.filePaths[0] });
}
};
const fieldSx = { '& .MuiOutlinedInput-root': { fontSize: '0.85rem' } };
const sectionSx = { fontSize: '0.7rem', fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase' as const, color: c.text.tertiary, mb: 0.5, mt: 0.5 };
const rowSx = { py: 2, borderBottom: `1px solid ${c.border.subtle}` };
const rowLastSx = { py: 2 };
const inlineRowSx = { ...rowSx, display: 'flex', alignItems: 'center', justifyContent: 'space-between' };
const inlineRowLastSx = { ...rowLastSx, display: 'flex', alignItems: 'center', justifyContent: 'space-between' };
const labelSx = { color: c.text.primary, fontWeight: 500, fontSize: '0.875rem', lineHeight: 1.4 };
const descSx = { color: c.text.tertiary, fontSize: '0.75rem', lineHeight: 1.4 };
return {
c, dispatch, open, settings, modesList,
activeTab, setActiveTab, form, setForm,
showApiKey, setShowApiKey, browseFolder,
saved, setSaved, recordingShortcut, setRecordingShortcut,
confirmDiscard, setConfirmDiscard, showApiHelp, setShowApiHelp,
hasChanges, handleSave, handleRequestClose, handleConfirmDiscard,
handleSaveAndClose, handleCheckForUpdates, handleDownloadUpdate, handleInstallUpdate,
updateStatus, appVersion, availableVersion, downloadPercent, updateError,
fieldSx, sectionSx, rowSx, rowLastSx, inlineRowSx, inlineRowLastSx, labelSx, descSx,
};
}
export type UseSettingsReturn = ReturnType<typeof useSettings>;
@@ -1,225 +0,0 @@
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 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 { CREATE_DASHBOARD, UPDATE_DASHBOARD } from '@/shared/backend-bridge/apps/dashboards';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const CUSTOMIZATION_ITEMS = [
{ 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.apps.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(UPDATE_DASHBOARD({ dashboardId: id, name: t }));
setRenamingId(null);
};
const handleCreateDash = async (e: React.MouseEvent) => {
e.stopPropagation();
const r = await dispatch(CREATE_DASHBOARD('Untitled Dashboard'));
if (CREATE_DASHBOARD.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;
@@ -1,159 +0,0 @@
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 './components/IdlePill';
import { CompactPill } from './components/CompactPill/CompactPill';
import { CompactActionablePill } from './components/CompactActionablePill';
import { ExpandedCard } from './components/ExpandedCard/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;
@@ -1,147 +0,0 @@
import React, { useMemo } from 'react';
import type { ReactNode } 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 } from '@/app/pages/AgentChat/toolkit/approvalToolkit/utils';
import { useMcpToolMeta } from '@/app/pages/AgentChat/toolkit/approvalToolkit/utils/useMcpToolMeta';
import { SPRING_BOUNCE } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/islandTypes';
import type { ClaudeTokens } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/islandTypes';
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
import { Terminal, FileText, FilePen, Search, MessageCircleQuestion, Wrench } from 'lucide-react';
function getToolIcon(toolName: string): ReactNode {
const size = 16;
switch (toolName) {
case 'Bash': return <Terminal size={size} />;
case 'Read': return <FileText size={size} />;
case 'Write': case 'Edit': return <FilePen size={size} />;
case 'Grep': case 'Glob': return <Search size={size} />;
case 'AskUserQuestion': return <MessageCircleQuestion size={size} />;
default: return <Wrench size={size} />;
}
}
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>
);
};
@@ -1,20 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { ClaudeTokens } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/islandTypes';
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)' },
},
}}
/>
);
@@ -1,59 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { motion } from 'framer-motion';
import { ActivityIndicator } from './ActivityIndicator';
import { SPRING_BOUNCE } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/islandTypes';
import type { ClaudeTokens } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/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>
);
@@ -1,85 +0,0 @@
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 '@/app/components/AppShell/components/TitleBar/DynamicIsland/islandTypes';
import type { ClaudeTokens, TrackedAgent } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/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>
);
};
@@ -1,89 +0,0 @@
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 '@/app/components/AppShell/components/TitleBar/DynamicIsland/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>
</>
);
};
@@ -1,214 +0,0 @@
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 { ApprovalRouter } from '@/app/pages/AgentChat/toolkit/approvalToolkit/ApprovalRouter';
import { BatchApprovalWrapper } from '@/app/pages/AgentChat/toolkit/approvalToolkit/BatchApprovalWrapper';
import { AgentStatusRow } from './AgentStatusRow';
import { CompletedAgentsList } from './CompletedAgentsList';
import type { ClaudeTokens, SessionApprovalGroup, TrackedAgent } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/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 ? (
<BatchApprovalWrapper
requests={group.approvals}
onApprove={onApprove}
onDeny={onDeny}
/>
) : (
group.approvals.map((req) => (
<ApprovalRouter
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>
);
};
@@ -1,29 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import { STATUS_CONFIG } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/islandTypes';
import type { ClaudeTokens } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/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)' },
},
}),
}}
/>
);
};
@@ -1,43 +0,0 @@
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 '@/app/components/AppShell/components/TitleBar/DynamicIsland/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>
);
@@ -1,116 +0,0 @@
import { useCallback, useEffect } from 'react';
import type { MutableRefObject } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '@/shared/hooks';
import {
dismissAgentNotification,
dismissAllFinishedNotifications,
} from '@/shared/state/agentsSlice';
import { HANDLE_APPROVAL, STOP_AGENT } from '@/shared/backend-bridge/apps/agents';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import type { IslandState, SessionApprovalGroup } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/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, unknown>) => {
dispatch(HANDLE_APPROVAL({ requestId, behavior: 'allow', updatedInput }));
},
[dispatch],
);
const onDeny = useCallback(
(requestId: string, message?: string) => {
dispatch(HANDLE_APPROVAL({ requestId, behavior: 'deny', message }));
},
[dispatch],
);
const onStopAgent = useCallback(
(sessionId: string) => dispatch(STOP_AGENT(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(HANDLE_APPROVAL({ 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(HANDLE_APPROVAL({ 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,
};
}
@@ -1,114 +0,0 @@
import { useMemo, useState } from 'react';
import { useAppSelector } from '@/shared/hooks';
import type { HistorySession } from '@/shared/state/agentsSlice';
import type { IslandState, SessionApprovalGroup, TrackedAgent } from '@/app/components/AppShell/components/TitleBar/DynamicIsland/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,
};
}
@@ -1,30 +0,0 @@
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 };
@@ -1,75 +0,0 @@
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 './DynamicIsland/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;
@@ -1,78 +0,0 @@
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;
@@ -1,53 +0,0 @@
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 };
}
@@ -1,50 +0,0 @@
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,
};
}
@@ -1,82 +0,0 @@
import { useCallback, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAppDispatch } from '@/shared/hooks';
import { CREATE_DASHBOARD } from '@/shared/backend-bridge/apps/dashboards';
import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice';
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
import { findBrowserByWebContentsId } from '@/shared/browsers/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(CREATE_DASHBOARD('Untitled Dashboard')).then((result: any) => {
if (CREATE_DASHBOARD.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]);
}
@@ -0,0 +1,505 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
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 { fetchSkills } from '@/shared/state/skillsSlice';
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 }>> = {
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: '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 c = useClaudeTokens();
const dispatch = useAppDispatch();
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);
const skillsLoaded = useAppSelector((s) => s.skills.loaded);
useEffect(() => {
if (!builtinLoaded) dispatch(fetchBuiltinTools());
if (!toolsLoaded) dispatch(fetchTools());
if (!outputsLoaded) dispatch(fetchOutputs());
if (!skillsLoaded) dispatch(fetchSkills());
}, [dispatch, builtinLoaded, toolsLoaded, outputsLoaded, skillsLoaded]);
const items: CommandPickerItem[] = useMemo(() => {
let all: CommandPickerItem[] = [];
if (trigger === '/') {
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 = [...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, 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 getIconColor = (item: CommandPickerItem): string => {
switch (item.type) {
case 'skill': return c.status.success;
case 'mode': {
const mode = modesMap[item.id];
return mode?.color || c.accent.primary;
}
case 'context': return c.text.tertiary;
default: return c.text.tertiary;
}
};
useEffect(() => {
setSelectedIndex(0);
}, [filter, trigger]);
useEffect(() => {
if (!containerRef.current) return;
const el = containerRef.current.querySelector(`[data-picker-idx="${selectedIndex}"]`);
if (el) el.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
useEffect(() => {
if (!visible) return;
const handler = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((p) => (p < items.length - 1 ? p + 1 : p));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((p) => (p > 0 ? p - 1 : p));
break;
case 'Enter':
case 'Tab':
if (items[selectedIndex]) {
e.preventDefault();
onSelect(items[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
onClose();
break;
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [visible, items, selectedIndex, onSelect, onClose]);
if (!visible || items.length === 0) return null;
return (
<Paper
ref={containerRef}
elevation={0}
sx={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
mb: 0.5,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: '12px',
maxHeight: 320,
overflow: 'auto',
zIndex: 1000,
boxShadow: c.shadow.lg,
animation: 'cmdPickerIn 120ms ease-out',
'@keyframes cmdPickerIn': {
from: { opacity: 0, transform: 'translateY(4px)' },
to: { opacity: 1, transform: 'translateY(0)' },
},
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 2,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
<Box sx={{ py: 0.5 }}>
{flatItems.map(({ item, isGroupStart, category }, idx) => (
<React.Fragment key={`${item.type}-${item.id}`}>
{isGroupStart && (
<Box sx={{ px: 1.5, pt: idx === 0 ? 0.75 : 1.25, pb: 0.375 }}>
<Typography
sx={{
color: c.text.ghost,
fontSize: '0.625rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
{category}
</Typography>
</Box>
)}
<Box
data-picker-idx={idx}
onClick={() => onSelect(item)}
onMouseEnter={() => setSelectedIndex(idx)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.25,
py: 0.5,
mx: 0.5,
borderRadius: '8px',
cursor: 'pointer',
bgcolor: idx === selectedIndex ? `${c.accent.primary}0a` : 'transparent',
'&:hover': { bgcolor: `${c.accent.primary}0a` },
transition: 'background-color 60ms ease',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
flexShrink: 0,
borderRadius: '6px',
bgcolor: `${getIconColor(item)}12`,
color: getIconColor(item),
}}
>
{item.icon}
</Box>
<Typography
component="span"
sx={{
color: c.text.primary,
fontSize: '0.8rem',
fontWeight: 500,
fontFamily: c.font.mono,
whiteSpace: 'nowrap',
lineHeight: 1.3,
}}
>
{trigger}{highlightMatch(item.command, filter, c.accent.primary)}
</Typography>
<Typography
component="span"
sx={{
color: c.text.muted,
fontSize: '0.72rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
ml: 0.5,
lineHeight: 1.3,
}}
>
{item.description}
</Typography>
</Box>
</React.Fragment>
))}
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 1.5,
py: 0.625,
borderTop: `1px solid ${c.border.subtle}`,
}}
>
{[
{ keys: '↑↓', label: 'navigate' },
{ keys: '↵', label: 'select' },
{ keys: 'esc', label: 'dismiss' },
].map(({ keys, label }) => (
<Box key={label} sx={{ display: 'flex', alignItems: 'center', gap: 0.375 }}>
<Typography
sx={{
fontSize: '0.58rem',
fontFamily: c.font.mono,
color: c.text.ghost,
bgcolor: c.bg.secondary,
px: 0.5,
py: 0.125,
borderRadius: '3px',
border: `1px solid ${c.border.subtle}`,
lineHeight: 1.3,
}}
>
{keys}
</Typography>
<Typography sx={{ fontSize: '0.58rem', color: c.text.ghost }}>
{label}
</Typography>
</Box>
))}
</Box>
</Paper>
);
};
export default CommandPicker;
@@ -0,0 +1,335 @@
import React, { useState, useEffect, useCallback } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
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';
const SETTINGS_API = `${API_BASE}/settings`;
export interface ContextPath {
path: string;
type: 'file' | 'directory';
}
interface DirectoryBrowserProps {
open: boolean;
onClose: () => void;
onSelect: (item: ContextPath) => void;
initialPath?: string;
}
const DirectoryBrowser: React.FC<DirectoryBrowserProps> = ({ open, onClose, onSelect, initialPath }) => {
const c = useClaudeTokens();
const [browseData, setBrowseData] = useState<BrowseResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [manualPath, setManualPath] = useState('');
const [selected, setSelected] = useState<{ name: string; type: 'file' | 'directory' } | null>(null);
const browse = useCallback(async (path: string) => {
setLoading(true);
setError(null);
setSelected(null);
try {
const res = await fetch(`${SETTINGS_API}/browse-directories?path=${encodeURIComponent(path)}`);
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Failed to browse');
}
const data: BrowseResult = await res.json();
setBrowseData(data);
setManualPath(data.current);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
setSelected(null);
browse(initialPath || '');
}
}, [open, initialPath, browse]);
const handleNavigate = (dir: string) => {
if (browseData) browse(`${browseData.current}/${dir}`);
};
const handleGoUp = () => {
if (browseData?.parent) browse(browseData.parent);
};
const handleManualGo = () => {
if (manualPath.trim()) browse(manualPath.trim());
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleManualGo();
};
const handleConfirm = () => {
if (!browseData) return;
if (selected) {
const fullPath = `${browseData.current}/${selected.name}`;
onSelect({ path: fullPath, type: selected.type });
} else {
onSelect({ path: browseData.current, type: 'directory' });
}
onClose();
};
const pathSegments = browseData?.current.split('/').filter(Boolean) ?? [];
const hasEntries = (browseData?.directories.length ?? 0) + (browseData?.files.length ?? 0) > 0;
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: c.bg.surface,
backgroundImage: 'none',
borderRadius: 4,
border: `1px solid ${c.border.subtle}`,
height: 520,
},
}}
>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, pb: 1 }}>
Browse Files &amp; Folders
</DialogTitle>
<DialogContent sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
overflow: 'hidden',
'&::-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`,
}}>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
value={manualPath}
onChange={(e) => setManualPath(e.target.value)}
onKeyDown={handleKeyDown}
size="small"
fullWidth
placeholder="Type a path..."
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: c.bg.page,
fontSize: '0.85rem',
fontFamily: c.font.mono,
},
}}
/>
<Button
onClick={handleManualGo}
variant="outlined"
size="small"
sx={{
color: c.accent.primary,
borderColor: c.border.medium,
textTransform: 'none',
minWidth: 'auto',
px: 2,
}}
>
Go
</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>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'space-between' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem', pl: 1 }}>
{selected
? `Selected: ${selected.name}`
: 'Click to select, double-click folders to open'}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button onClick={onClose} sx={{ color: c.text.tertiary, textTransform: 'none' }}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleConfirm}
disabled={!browseData}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 2,
}}
>
{selected ? `Attach ${selected.type === 'file' ? 'File' : 'Folder'}` : 'Attach This Folder'}
</Button>
</Box>
</DialogActions>
</Dialog>
);
};
export default DirectoryBrowser;
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,44 @@
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { type SelectedElement } from './SelectedElement';
import { ElementSelectionContext } from './useElementSelection';
import React, { createContext, useContext, useState, useRef, useCallback, useMemo, MutableRefObject } from 'react';
export interface SelectedElement {
id: string;
selectorPath: string;
tagName: string;
className: string;
outerHTML: string;
computedStyles: Record<string, string>;
screenshot?: string;
boundingRect: { x: number; y: number; width: number; height: number };
semanticType?: 'agent-card' | 'message' | 'tool-call' | 'tool-group' | 'view-card' | 'browser-card' | 'dom-element';
semanticLabel?: string;
semanticData?: Record<string, any>;
}
interface ElementSelectionContextValue {
selectMode: boolean;
toggleSelectMode: () => void;
setSelectMode: (active: boolean) => void;
excludeSelectId: string | null;
setExcludeSelectId: (id: string | null) => void;
activeOwnerId: string | null;
setActiveOwnerId: (id: string | null) => void;
selectedElements: SelectedElement[];
addSelectedElement: (el: SelectedElement) => void;
updateSelectedElement: (id: string, patch: Partial<SelectedElement>) => void;
removeSelectedElement: (id: string) => void;
clearSelectedElements: () => void;
elementsByOwner: Record<string, SelectedElement[]>;
addElementForOwner: (ownerId: string, el: SelectedElement) => void;
removeOwnerElement: (ownerId: string, elementId: string) => void;
clearOwnerElements: (ownerId: string) => void;
iframeRef: MutableRefObject<HTMLIFrameElement | null>;
}
const ElementSelectionContext = createContext<ElementSelectionContextValue | null>(null);
export function useElementSelection() {
return useContext(ElementSelectionContext);
}
export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [selectMode, setSelectMode] = useState(false);
@@ -10,9 +48,7 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const activeOwnerIdRef = useRef(activeOwnerId);
useEffect(() => {
activeOwnerIdRef.current = activeOwnerId;
}, [activeOwnerId]);
activeOwnerIdRef.current = activeOwnerId;
const selectedElements = useMemo(
() => (activeOwnerId ? elementsByOwner[activeOwnerId] ?? [] : []),
@@ -0,0 +1,21 @@
import React from 'react';
/** Cute slime with × eyes and a red error badge — error / warning illustration. */
export const ErrorSlime: React.FC<{ size?: number }> = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" style={{ flexShrink: 0 }}>
<path
d="M4 20 Q4 7 14 7 Q24 7 24 20 Q22 22 19 21.5 Q16 23 14 22 Q12 23 9 21.5 Q6 22 4 20Z"
fill="#E8927A"
/>
<ellipse cx="11" cy="11" rx="3.5" ry="2" fill="#F0A68E" opacity="0.6" />
<line x1="9.5" y1="13" x2="11.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
<line x1="11.5" y1="13" x2="9.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
<line x1="16.5" y1="13" x2="18.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
<line x1="18.5" y1="13" x2="16.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
<path d="M12 18.5 Q14 17.5 16 18.5" stroke="#4a2020" strokeWidth="1" strokeLinecap="round" fill="none" />
<circle cx="22" cy="5" r="4" fill="#ef4444" stroke="rgba(0,0,0,0.15)" strokeWidth="0.5" />
<text x="22" y="6.8" textAnchor="middle" fontSize="5.5" fill="white" fontWeight="bold" fontFamily="sans-serif">!</text>
</svg>
);
export default ErrorSlime;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
import React, { useEffect } from 'react';
import { DashboardActiveProvider } from '@/shared/hooks/useDashboardActive';
interface DashboardHostProps {
visible: boolean;
children: React.ReactNode;
}
/**
* Wraps the Dashboard component in a stable container that toggles visibility
* via CSS instead of unmounting. This is what keeps the embedded webviews
* alive across non-dashboard route navigation.
*
* Why this approach (vs. display: none or unmount):
* - `visibility: hidden` preserves webview state without triggering Chromium
* to mark the page as hidden (so background sub-agents keep working).
* - `display: none` would trigger full layout recalc on toggle and may pause
* pages that check `document.hidden`.
* - Unmount destroys the webview DOM element, tearing down its Chromium tab.
*
* Also provides DashboardActiveContext to all children so they can gate
* expensive work (canvas rendering, screenshot capture, etc.) on visibility.
*/
const DashboardHost: React.FC<DashboardHostProps> = ({ visible, children }) => {
// When transitioning from visible -> hidden, blur any focused element so
// a focused webview doesn't keep stealing keyboard input behind the scenes.
useEffect(() => {
if (!visible) {
const el = document.activeElement;
if (el instanceof HTMLElement) {
el.blur();
}
}
}, [visible]);
return (
<div
style={{
position: 'absolute',
inset: 0,
// Negative z-index when hidden so any visible Outlet content sits above
zIndex: visible ? 10 : -1,
visibility: visible ? 'visible' : 'hidden',
// Belt-and-suspenders: even if z-index ordering glitches, no clicks land
pointerEvents: visible ? 'auto' : 'none',
}}
>
<DashboardActiveProvider value={visible}>
{children}
</DashboardActiveProvider>
</div>
);
};
export default DashboardHost;
@@ -0,0 +1,896 @@
import React, { useState, useEffect, useRef } from 'react';
import { Box, Typography, Modal, Button, CircularProgress, TextField, InputAdornment } from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
import { trackEvent } from '@/shared/analytics';
import PlanPicker from '@/app/components/PlanPicker';
// Email validation: format check + typo correction for common domains.
// Real ownership verification is intentionally pushed downstream (mailing list /
// CRM system handles the confirm-subscription flow).
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const COMMON_DOMAIN_TYPOS: Record<string, string> = {
'gmial.com': 'gmail.com',
'gmai.com': 'gmail.com',
'gnail.com': 'gmail.com',
'gmaill.com': 'gmail.com',
'gmail.co': 'gmail.com',
'gmail.cm': 'gmail.com',
'yahooo.com': 'yahoo.com',
'yaho.com': 'yahoo.com',
'yahoo.co': 'yahoo.com',
'hotmial.com': 'hotmail.com',
'hotmai.com': 'hotmail.com',
'hotmail.co': 'hotmail.com',
'outlok.com': 'outlook.com',
'outloook.com': 'outlook.com',
'iclould.com': 'icloud.com',
'iclud.com': 'icloud.com',
'protonmial.com': 'protonmail.com',
};
function getEmailSuggestion(email: string): string | null {
const at = email.lastIndexOf('@');
if (at < 0) return null;
const domain = email.slice(at + 1).toLowerCase();
const correction = COMMON_DOMAIN_TYPOS[domain];
if (!correction) return null;
return email.slice(0, at + 1) + correction;
}
function isValidEmail(email: string): boolean {
return EMAIL_REGEX.test(email.trim());
}
const SUBSCRIPTION_PROVIDERS = [
{ id: 'openswarm-pro', name: 'OpenSwarm Pro', desc: 'One subscription — no setup, no Claude account needed', color: '#6366F1', preview: false, recommended: true },
{ id: 'claude', name: 'Claude', desc: 'Use your own Claude Pro/Max subscription', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro & Flash', color: '#4285F4', preview: false },
{ id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, GPT-5.4 Mini, GPT-5.3 Codex', color: '#74AA9C', preview: false },
];
const EDUCATION_STEPS: { title: string; body: string[] }[] = [
{
title: 'Launch an Agent',
body: [
'The core functionality of OpenSwarm revolves around Agents. Click the "+" button in your toolbar to launch a new Agent. Agents can take actions for you, work with files on your computer, and much more.',
'Within an Agent\u2019s input, you can choose what AI model powers it, the mode it runs in, and attach context via "@" and "/" commands.',
],
},
{
title: 'Connect your actions',
body: [
'Actions are how your Agents interact with the outside world \u2014 Gmail, Google Calendar, Notion, Slack, and more. Head to the Connections page to link your accounts and unlock what your Agents can do.',
'Once connected, your Agents can read emails, create events, update databases, and take real actions across your tools \u2014 all from a single conversation.',
],
},
{
title: 'Browsers',
body: [
'OpenSwarm has built-in browsers so you never have to jump between apps. Stay in one place, stay in the zone \u2014 just one seamless workspace for you and your Agents.',
"And when you'd rather not do it yourself, let an Agent open & control browsers, navigate sites, fill out forms, and complete tasks end-to-end while you watch it happen in real time.",
],
},
{
title: 'Select and send',
body: [
"Select any existing browser or agent in your dashboard to let a new agent control it. It's the fastest way to get things done \u2014 just select something on your dashboard and let your Agent handle the rest.",
'No copy-pasting, no context-switching. Just select, send, and let your Agent take it from there.',
],
},
{
title: 'Make it yours',
body: [
'Customize how OpenSwarm works with Skills, Modes, and Apps. Skills teach your Agents reusable workflows. Modes let you switch between different behavior profiles. Apps are standalone applications that run directly in OpenSwarm that Agents build for you.',
'Explore the Customization & Apps sections in the sidebar to get started \u2014 or just ask an Agent to help you create your first Skill.',
],
},
];
const USE_CASES = [
'Software Development',
'Research & Analysis',
'Content & Writing',
'Data & Analytics',
'Automation & Workflows',
'Design & Creative',
'Sales & Outreach',
'Customer Support',
'Marketing',
'Education & Learning',
'Personal Assistant',
'Other',
];
const REFERRAL_SOURCES = [
'Twitter / X',
'LinkedIn',
'YouTube',
'TikTok',
'Reddit',
'Hacker News',
'GitHub',
'Friend / Word of mouth',
'Search engine',
'Blog / Article',
'Other',
];
const OnboardingModal: React.FC = () => {
const c = useClaudeTokens();
const settings = useAppSelector((s) => s.settings);
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'profile' | 'walkthrough' | 'connect' | 'pricing'>('profile');
const [walkthroughIdx, setWalkthroughIdx] = useState(0);
const [userName, setUserName] = useState('');
const [userEmail, setUserEmail] = useState('');
const [emailBlurred, setEmailBlurred] = useState(false);
const [useCases, setUseCases] = useState<string[]>([]);
const [useCaseOther, setUseCaseOther] = useState<string>('');
const [referralSource, setReferralSource] = useState<string>('');
const [referralSourceOther, setReferralSourceOther] = useState<string>('');
const [connecting, setConnecting] = useState<string | null>(null);
const [nineRouterReady, setNineRouterReady] = useState<boolean | null>(null);
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 = () => {
const alreadySeen = localStorage.getItem('openswarm_onboarding_seen');
fetch(`${API_BASE}/subscriptions/status`)
.then((r) => r.json())
.then((data) => {
if (data.running) {
// Skip onboarding only if already seen AND has active subscription
const connections = data.providers?.connections || [];
if (alreadySeen === 'true' && connections.some((p: any) => p.isActive)) {
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); // Still show onboarding even if 9Router isn't available
});
};
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);
trackEvent('onboarding.started', { step: 'profile' });
}, [nineRouterReady]);
// Cleanup timers on unmount
useEffect(() => {
return () => {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
if (msgHandlerRef.current) window.removeEventListener('message', msgHandlerRef.current);
};
}, []);
// Auto-dismiss ONLY on the inactive → active transition (i.e. Stripe
// checkout deep-link activation while the modal is open). We used to fire
// on any settings tick where Pro was active, which meant a returning user
// who already had Pro (but cleared onboarding_seen) would see the modal
// flash then immediately skip to the dashboard.
const initialProActiveRef = useRef<boolean | null>(null);
useEffect(() => {
if (!open) { initialProActiveRef.current = null; return; }
const mode = (settings.data as any).connection_mode;
const bearer = (settings.data as any).openswarm_bearer_token;
const isActive = mode === 'openswarm-pro' && !!bearer;
if (initialProActiveRef.current === null) {
initialProActiveRef.current = isActive;
return;
}
if (!initialProActiveRef.current && isActive) {
trackEvent('onboarding.openswarm_pro_activated');
dismiss();
}
// dismiss is stable enough — don't include in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, settings.data]);
const dismiss = async () => {
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);
// Create a demo dashboard with a pre-populated example agent
try {
const createRes = await fetch(`${API_BASE}/dashboards/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Getting Started' }),
});
if (createRes.ok) {
const dashboard = await createRes.json();
if (dashboard?.id) {
const seedRes = await fetch(`${API_BASE}/dashboards/${dashboard.id}/seed-demo`, { method: 'POST' });
if (seedRes.ok) {
trackEvent('onboarding.completed', { dashboard_id: dashboard.id });
localStorage.setItem('openswarm_walkthrough_pending', 'true');
setOpen(false);
// Force full page load to ensure dashboard mounts fresh with walkthrough
window.location.href = `${window.location.pathname}${window.location.search}#/dashboard/${dashboard.id}`;
window.location.reload();
return;
}
}
}
} catch (e) {
console.warn('Demo dashboard creation failed:', e);
}
setOpen(false);
};
// Actually persist profile + advance to connect step.
const submitProfile = async () => {
try {
const r = await fetch(`${API_BASE}/settings`);
const currentSettings = await r.json();
// If "Other" is selected, replace it with the user's custom text
const resolvedUseCases = useCases.map((u) =>
u === 'Other' && useCaseOther.trim() ? `Other: ${useCaseOther.trim()}` : u
);
const resolvedReferralSource =
referralSource === 'Other' && referralSourceOther.trim()
? `Other: ${referralSourceOther.trim()}`
: referralSource;
await fetch(`${API_BASE}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...currentSettings,
user_name: userName.trim() || null,
user_email: userEmail.trim() || null,
user_use_case: resolvedUseCases.length > 0 ? resolvedUseCases.join(', ') : null,
user_referral_source: resolvedReferralSource || null,
}),
});
} catch {}
trackEvent('onboarding.profile_submitted', {
has_name: !!userName.trim(),
has_email: !!userEmail.trim(),
use_cases: useCases,
use_cases_count: useCases.length,
use_case_other: useCases.includes('Other') ? useCaseOther.trim() : '',
referral_source: referralSource,
referral_source_other: referralSource === 'Other' ? referralSourceOther.trim() : '',
});
setStep('walkthrough');
setWalkthroughIdx(0);
trackEvent('onboarding.education_started');
};
const advanceWalkthrough = () => {
const next = walkthroughIdx + 1;
const currentTitle = EDUCATION_STEPS[walkthroughIdx]?.title;
if (next >= EDUCATION_STEPS.length) {
trackEvent('onboarding.education_completed');
setStep('connect');
trackEvent('onboarding.connect_started', { nine_router_ready: nineRouterReady });
return;
}
trackEvent('onboarding.education_step_advanced', { from: walkthroughIdx, title: currentTitle });
setWalkthroughIdx(next);
};
const backWalkthrough = () => {
setWalkthroughIdx((i) => Math.max(0, i - 1));
};
// Whether all required profile fields are filled in.
const isProfileComplete = (() => {
const trimmedName = userName.trim();
const trimmedEmail = userEmail.trim();
if (!trimmedName) return false;
if (!trimmedEmail || !isValidEmail(trimmedEmail)) return false;
if (useCases.length === 0) return false;
if (useCases.includes('Other') && !useCaseOther.trim()) return false;
if (!referralSource) return false;
if (referralSource === 'Other' && !referralSourceOther.trim()) return false;
return true;
})();
// Continue: gate on full profile completion, then submit.
const handleProfileContinue = async () => {
const trimmed = userEmail.trim();
// Invalid format with non-empty value — refuse and force error state.
if (trimmed && !isValidEmail(trimmed)) {
setEmailBlurred(true);
trackEvent('onboarding.email_invalid_blocked', { value_length: trimmed.length });
return;
}
if (!isProfileComplete) return;
submitProfile();
};
const handleApplySuggestion = (suggested: string) => {
setUserEmail(suggested);
trackEvent('onboarding.email_suggestion_applied');
};
// 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);
trackEvent('onboarding.provider_selected', { provider: providerId });
// OpenSwarm Pro: switch to the dedicated pricing step so the user can
// pick a tier + billing interval before heading to Stripe. The
// post-payment openswarm://auth deep link will dismiss this modal
// automatically via useDeepLink → fetchSettings.
if (providerId === 'openswarm-pro') {
setConnecting(null);
setStep('pricing');
return;
}
// 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;
trackEvent('onboarding.provider_connected', { provider: providerId });
dismiss();
}
} 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;
}
trackEvent('onboarding.provider_connected', { provider: providerId });
dismiss();
}
} 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 {}
trackEvent('onboarding.provider_connected', { provider: providerId });
dismiss();
}
};
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 handleApiKey = () => { trackEvent('onboarding.api_key_chosen'); dismiss(); };
const handleSkip = () => {
trackEvent(step === 'profile' ? 'onboarding.profile_skipped' : 'onboarding.connect_skipped');
dismiss();
};
if (!open) return null;
return (
<Modal open={open} onClose={step === 'connect' ? handleSkip : undefined} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{
width: step === 'walkthrough' ? 600 : step === 'pricing' ? 780 : 480, maxWidth: '90vw',
bgcolor: c.bg.surface, borderRadius: `${c.radius.xl}px`,
border: `1px solid ${c.border.subtle}`,
p: step === 'walkthrough' ? 0 : 3.5, outline: 'none',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
}}>
{step !== 'walkthrough' && (
<Typography sx={{ fontSize: '1.5rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Welcome to OpenSwarm
</Typography>
)}
{step === 'profile' ? (
<>
<Typography sx={{ fontSize: '0.88rem', color: c.text.muted, mb: 2.5, textAlign: 'center' }}>
Tell us a bit about yourself
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 2.5 }}>
<TextField
placeholder="Your name"
value={userName}
onChange={(e) => setUserName(e.target.value)}
size="small"
fullWidth
sx={{
'& .MuiOutlinedInput-root': {
fontSize: '0.92rem',
color: c.text.primary,
borderRadius: `${c.radius.md}px`,
'& fieldset': { borderColor: c.border.subtle },
'&:hover fieldset': { borderColor: c.border.medium },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
'& .MuiOutlinedInput-input::placeholder': { color: c.text.ghost, opacity: 1 },
}}
/>
{(() => {
const trimmed = userEmail.trim();
const valid = trimmed.length > 0 && isValidEmail(trimmed);
const showError = emailBlurred && trimmed.length > 0 && !valid;
const suggestion = valid ? getEmailSuggestion(trimmed) : null;
return (
<Box>
<TextField
placeholder="Email address"
type="email"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
onBlur={() => setEmailBlurred(true)}
error={showError}
size="small"
fullWidth
InputProps={{
endAdornment: valid ? (
<InputAdornment position="end">
<CheckCircleIcon sx={{ fontSize: 16, color: c.status.success }} />
</InputAdornment>
) : undefined,
}}
sx={{
'& .MuiOutlinedInput-root': {
fontSize: '0.92rem',
color: c.text.primary,
borderRadius: `${c.radius.md}px`,
'& fieldset': { borderColor: showError ? c.status.error : c.border.subtle },
'&:hover fieldset': { borderColor: showError ? c.status.error : c.border.medium },
'&.Mui-focused fieldset': { borderColor: showError ? c.status.error : c.accent.primary },
},
'& .MuiOutlinedInput-input::placeholder': { color: c.text.ghost, opacity: 1 },
}}
/>
{showError && (
<Typography sx={{ fontSize: '0.78rem', color: c.status.error, mt: 0.4, ml: 0.5 }}>
That doesn't look like a valid email address
</Typography>
)}
{suggestion && (
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mt: 0.4, ml: 0.5 }}>
Did you mean{' '}
<Box
component="span"
onClick={() => handleApplySuggestion(suggestion)}
sx={{
color: c.accent.primary,
fontWeight: 600,
cursor: 'pointer',
'&:hover': { textDecoration: 'underline' },
}}
>
{suggestion}
</Box>
?
</Typography>
)}
</Box>
);
})()}
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mt: 0.5 }}>
What will you use OpenSwarm for?
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{USE_CASES.map((uc) => (
<Box
key={uc}
onClick={() => setUseCases(prev => prev.includes(uc) ? prev.filter(u => u !== uc) : [...prev, uc])}
sx={{
px: 1.5, py: 0.6,
borderRadius: `${c.radius.md}px`,
border: `1px solid ${useCases.includes(uc) ? c.accent.primary : c.border.subtle}`,
bgcolor: useCases.includes(uc) ? `${c.accent.primary}15` : 'transparent',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': { borderColor: c.border.medium },
}}
>
<Typography sx={{ fontSize: '0.82rem', color: useCases.includes(uc) ? c.accent.primary : c.text.secondary }}>
{uc}
</Typography>
</Box>
))}
</Box>
{useCases.includes('Other') && (
<TextField
placeholder="Tell us what else..."
value={useCaseOther}
onChange={(e) => setUseCaseOther(e.target.value)}
size="small"
fullWidth
sx={{
mt: 0.5,
'& .MuiOutlinedInput-root': {
fontSize: '0.92rem',
color: c.text.primary,
borderRadius: `${c.radius.md}px`,
'& fieldset': { borderColor: c.border.subtle },
'&:hover fieldset': { borderColor: c.border.medium },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
'& .MuiOutlinedInput-input::placeholder': { color: c.text.ghost, opacity: 1 },
}}
/>
)}
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mt: 0.5 }}>
How did you hear about OpenSwarm?
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{REFERRAL_SOURCES.map((src) => (
<Box
key={src}
onClick={() => setReferralSource(prev => prev === src ? '' : src)}
sx={{
px: 1.5, py: 0.6,
borderRadius: `${c.radius.md}px`,
border: `1px solid ${referralSource === src ? c.accent.primary : c.border.subtle}`,
bgcolor: referralSource === src ? `${c.accent.primary}15` : 'transparent',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': { borderColor: c.border.medium },
}}
>
<Typography sx={{ fontSize: '0.82rem', color: referralSource === src ? c.accent.primary : c.text.secondary }}>
{src}
</Typography>
</Box>
))}
</Box>
{referralSource === 'Other' && (
<TextField
placeholder="Where did you hear about us?"
value={referralSourceOther}
onChange={(e) => setReferralSourceOther(e.target.value)}
size="small"
fullWidth
sx={{
mt: 0.5,
'& .MuiOutlinedInput-root': {
fontSize: '0.92rem',
color: c.text.primary,
borderRadius: `${c.radius.md}px`,
'& fieldset': { borderColor: c.border.subtle },
'&:hover fieldset': { borderColor: c.border.medium },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
'& .MuiOutlinedInput-input::placeholder': { color: c.text.ghost, opacity: 1 },
}}
/>
)}
</Box>
<Button
onClick={handleProfileContinue}
fullWidth
disabled={!isProfileComplete}
sx={{
textTransform: 'none', fontSize: '0.92rem', fontWeight: 600,
bgcolor: c.accent.primary, color: '#fff',
borderRadius: `${c.radius.md}px`, py: 1,
'&:hover': { bgcolor: c.accent.hover },
'&.Mui-disabled': { bgcolor: c.accent.primary, color: '#fff', opacity: 0.4 },
mb: 1,
}}
>
Continue
</Button>
</>
) : step === 'walkthrough' ? (
<>
{/* Hero video area — autoplay/loop/muted demo for the current
step. `key` forces remount on step change so the next video
restarts from frame 0 instead of resuming. The pastel
gradient stays as a fallback bg behind the video while it
buffers / if loading fails. */}
<Box
sx={{
position: 'relative',
width: '100%',
height: 400,
background: `
radial-gradient(circle at 18% 78%, #F5A574 0%, rgba(245,165,116,0) 48%),
radial-gradient(circle at 58% 55%, #E9A5D0 0%, rgba(233,165,208,0) 52%),
radial-gradient(circle at 82% 22%, #B9C9F4 0%, rgba(185,201,244,0) 58%),
linear-gradient(135deg, #C4D0F2 0%, #EDB3CC 50%, #F5B088 100%)
`,
overflow: 'hidden',
}}
>
<Box
key={walkthroughIdx}
component="video"
src={`./onboarding-videos/Step${walkthroughIdx + 1}.mp4`}
autoPlay
muted
loop
playsInline
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
</Box>
<Box sx={{ px: 3, pt: 2, pb: 2.5 }}>
<Box sx={{ display: 'flex', gap: 0.5, justifyContent: 'center', mb: 1.75 }}>
{EDUCATION_STEPS.map((_, i) => (
<Box
key={i}
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: i === walkthroughIdx ? c.accent.primary : i < walkthroughIdx ? c.accent.primary + '60' : c.border.medium,
transition: 'background-color 0.25s',
}}
/>
))}
</Box>
<Box
sx={{
display: 'inline-block',
fontSize: '0.72rem',
fontWeight: 700,
color: c.accent.primary,
bgcolor: c.accent.primary + '1a',
border: `1px solid ${c.accent.primary}33`,
letterSpacing: '0.12em',
textTransform: 'uppercase',
px: 1.1,
py: 0.35,
borderRadius: `${c.radius.sm}px`,
mb: 1,
fontFamily: c.font.sans,
}}
>
Step {walkthroughIdx + 1}
</Box>
<Typography sx={{ fontSize: '1.25rem', fontWeight: 700, color: c.text.primary, mb: 1 }}>
{EDUCATION_STEPS[walkthroughIdx].title}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2.5, minHeight: 200 }}>
{EDUCATION_STEPS[walkthroughIdx].body.map((p, i) => (
<Typography key={i} sx={{ fontSize: '1.02rem', color: c.text.secondary, lineHeight: 1.6 }}>
{p}
</Typography>
))}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
onClick={backWalkthrough}
disabled={walkthroughIdx === 0}
sx={{
textTransform: 'none', fontSize: '0.92rem', fontWeight: 600,
color: c.text.tertiary, borderRadius: `${c.radius.md}px`, px: 1.5, py: 0.75,
visibility: walkthroughIdx === 0 ? 'hidden' : 'visible',
'&:hover': { bgcolor: `${c.accent.primary}08` },
}}
>
← Back
</Button>
<Box sx={{ flex: 1 }} />
<Button
onClick={advanceWalkthrough}
sx={{
textTransform: 'none', fontSize: '0.92rem', fontWeight: 600,
bgcolor: c.accent.primary, color: '#fff',
borderRadius: `${c.radius.md}px`, px: 2.25, py: 0.75,
'&:hover': { bgcolor: c.accent.hover },
}}
>
{walkthroughIdx === EDUCATION_STEPS.length - 1 ? 'Continue' : 'Next'}
</Button>
</Box>
</Box>
</>
) : step === 'pricing' ? (
<>
<Typography sx={{ fontSize: '0.88rem', color: c.text.muted, mb: 2.5, textAlign: 'center' }}>
Pick your OpenSwarm Pro plan
</Typography>
<PlanPicker source="onboarding" defaultPlan="pro_plus" />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
<Button
onClick={() => { setStep('connect'); trackEvent('onboarding.pricing_back'); }}
startIcon={<ArrowBackIcon sx={{ fontSize: 14 }} />}
sx={{
textTransform: 'none', fontSize: '0.85rem', fontWeight: 500,
color: c.text.tertiary, '&:hover': { bgcolor: `${c.accent.primary}08` },
}}
>
Back
</Button>
<Button
onClick={handleSkip}
sx={{ textTransform: 'none', fontSize: '0.82rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
>
Skip for now
</Button>
</Box>
</>
) : (
<>
<Typography sx={{ fontSize: '0.88rem', color: c.text.muted, mb: 3, textAlign: 'center' }}>
Connect an AI model to get started
</Typography>
{/* Subscription options */}
<Typography sx={{ fontSize: '0.75rem', 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.92rem', fontWeight: 600, color: c.text.primary }}>{p.name}</Typography>
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted }}>{p.desc}</Typography>
</Box>
<Typography sx={{ fontSize: '0.78rem', 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.75rem', 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.88rem', color: c.text.primary }}>
I have an API key
</Typography>
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted }}>
Go to Settings &rarr; Models to enter your key
</Typography>
</Box>
{/* Skip */}
<Button
onClick={handleSkip}
fullWidth
sx={{ textTransform: 'none', fontSize: '0.82rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
>
Skip for now
</Button>
</>
)}
</Box>
</Modal>
);
};
export default OnboardingModal;
@@ -1,45 +0,0 @@
import React from 'react';
import { Box, Modal } from '@mui/material';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useOnboarding } from './components/useOnboarding';
import ProviderStep from './components/ProviderStep';
import ToolsStep from './components/ToolsStep';
const OnboardingModal: React.FC = () => {
const c = useClaudeTokens();
const {
open, step, connecting, nineRouterReady, connectedTools,
dismiss, handleConnect, handleToolConnect, handleApiKey, handleSkip,
} = useOnboarding();
if (!open) return null;
return (
<Modal open={open} onClose={handleSkip} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{
width: 480, maxWidth: '90vw', bgcolor: c.bg.surface, borderRadius: `${c.radius.xl}px`,
border: `1px solid ${c.border.subtle}`, p: 3.5, outline: 'none',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
}}>
{step === 'tools' ? (
<ToolsStep
connecting={connecting}
connectedTools={connectedTools}
onToolConnect={handleToolConnect}
onDismiss={dismiss}
/>
) : (
<ProviderStep
connecting={connecting}
nineRouterReady={nineRouterReady}
onConnect={handleConnect}
onApiKey={handleApiKey}
onSkip={handleSkip}
/>
)}
</Box>
</Modal>
);
};
export default OnboardingModal;
@@ -1,86 +0,0 @@
import React from 'react';
import { Box, Typography, Button } from '@mui/material';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { SUBSCRIPTION_PROVIDERS } from './onboardingConstants';
interface ProviderStepProps {
connecting: string | null;
nineRouterReady: boolean | null;
onConnect: (providerId: string) => void;
onApiKey: () => void;
onSkip: () => void;
}
const ProviderStep: React.FC<ProviderStepProps> = ({
connecting, nineRouterReady, onConnect, onApiKey, onSkip,
}) => {
const c = useClaudeTokens();
return (
<>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Welcome to OpenSwarm
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 3, textAlign: 'center' }}>
Connect an AI model to get started
</Typography>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Use your existing subscription
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
{SUBSCRIPTION_PROVIDERS.map((p) => (
<Box
key={p.id}
onClick={() => !p.preview && !connecting && nineRouterReady && onConnect(p.id)}
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
cursor: p.preview || !nineRouterReady ? 'default' : connecting ? 'wait' : 'pointer',
opacity: p.preview ? 0.5 : !nineRouterReady ? 0.6 : 1,
transition: 'border-color 0.15s, background 0.15s',
...(!p.preview && nineRouterReady && { '&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` } }),
}}
>
<Box>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{p.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{p.desc}</Typography>
</Box>
<Typography sx={{ fontSize: '0.68rem', color: p.preview ? c.text.ghost : connecting === p.id ? c.accent.primary : !nineRouterReady ? c.text.ghost : c.text.tertiary, fontStyle: p.preview ? 'italic' : 'normal' }}>
{p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : !nineRouterReady && nineRouterReady !== false ? 'Starting...' : 'Connect \u2192'}
</Typography>
</Box>
))}
</Box>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Or use an API key
</Typography>
<Box
onClick={onApiKey}
sx={{
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
cursor: 'pointer', mb: 2.5,
'&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` },
}}
>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
I have an API key
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>
Go to Settings &rarr; Models to enter your key
</Typography>
</Box>
<Button
onClick={onSkip}
fullWidth
sx={{ textTransform: 'none', fontSize: '0.72rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
>
Skip for now
</Button>
</>
);
};
export default ProviderStep;
@@ -1,82 +0,0 @@
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;
@@ -1,19 +0,0 @@
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 },
];
@@ -1,137 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { SUBSCRIPTIONS_STATUS } from '@/shared/backend-bridge/apps/subscriptions';
import { CREATE_TOOL, OAUTH_START, GET_TOOL, DISCOVER_TOOL } from '@/shared/backend-bridge/apps/tools';
import type { ToolDefinition } from '@/shared/state/toolsSlice';
import { ToolIntegration } from './onboardingConstants';
import { useSubscriptionConnect } from './useSubscriptionConnect';
export function useOnboarding() {
const dispatch = useAppDispatch();
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 = () => {
dispatch(SUBSCRIPTIONS_STATUS()).unwrap()
.then((data) => {
if (data.running) {
const connections = (data.providers as any)?.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 createResult = await dispatch(CREATE_TOOL({
name: integration.name,
description: integration.desc,
mcp_config: integration.mcp_config,
auth_type: 'oauth2',
auth_status: 'configured',
oauth_provider: integration.oauthProvider,
}));
if (!CREATE_TOOL.fulfilled.match(createResult)) { setConnecting(null); return; }
const tool = createResult.payload.tool as unknown as ToolDefinition;
const oauthResult = await dispatch(OAUTH_START(tool.id));
if (!OAUTH_START.fulfilled.match(oauthResult)) { setConnecting(null); return; }
const { auth_url } = oauthResult.payload;
const popup = window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100');
const afterConnect = async () => {
const statusResult = await dispatch(GET_TOOL(tool.id));
if (
GET_TOOL.fulfilled.match(statusResult) &&
(statusResult.payload as unknown as ToolDefinition).auth_status === 'connected'
) {
setConnectedTools((prev) => new Set(prev).add(integration.name));
dispatch(DISCOVER_TOOL(tool.id));
}
setConnecting(null);
};
const onMsg = (event: MessageEvent) => {
if (event.data?.type === 'oauth_complete' && event.data?.tool_id === tool.id) {
window.removeEventListener('message', onMsg);
afterConnect();
}
};
window.addEventListener('message', onMsg);
const poller = setInterval(() => {
if (popup?.closed) {
clearInterval(poller);
window.removeEventListener('message', onMsg);
afterConnect();
}
}, 1000);
setTimeout(() => { clearInterval(poller); setConnecting(null); }, 60000);
} catch {
setConnecting(null);
}
}, [dispatch]);
const handleApiKey = useCallback(() => advanceToTools(), [advanceToTools]);
const handleSkip = useCallback(() => dismiss(), [dismiss]);
return {
open, step, connecting, nineRouterReady, connectedTools,
dismiss, handleConnect, handleToolConnect, handleApiKey, handleSkip,
};
}
@@ -1,103 +0,0 @@
import { useCallback, MutableRefObject } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import {
SUBSCRIPTIONS_CONNECT,
SUBSCRIPTIONS_POLL,
SUBSCRIPTIONS_STATUS,
} from '@/shared/backend-bridge/apps/subscriptions';
interface UseSubscriptionConnectParams {
pollTimerRef: MutableRefObject<any>;
msgHandlerRef: MutableRefObject<any>;
setConnecting: (v: string | null) => void;
advanceToTools: () => void;
}
export function useSubscriptionConnect({
pollTimerRef, msgHandlerRef, setConnecting, advanceToTools,
}: UseSubscriptionConnectParams) {
const dispatch = useAppDispatch();
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 data = await dispatch(SUBSCRIPTIONS_CONNECT(providerId)).unwrap();
if (data.flow === 'device_code') {
if (data.verification_uri) window.open(data.verification_uri as string, '_blank');
const timer = setInterval(async () => {
try {
const pd = await dispatch(SUBSCRIPTIONS_POLL({
provider: providerId,
device_code: data.device_code as string,
code_verifier: data.code_verifier as string | undefined,
extra_data: data.extra_data as Record<string, unknown> | undefined,
})).unwrap();
if ((pd as any).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 as string, 'oauth_connect', 'width=600,height=700');
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
clearInterval(statusPoller);
pollTimerRef.current = null;
if (msgHandlerRef.current) {
window.removeEventListener('message', msgHandlerRef.current);
msgHandlerRef.current = null;
}
if (popup && !popup.closed) popup.close();
};
const msgHandler = (event: MessageEvent) => {
const d = event.data;
if (d?.type === 'oauth_callback' && d?.data?.connected) {
cleanup();
advanceToTools();
}
};
window.addEventListener('message', msgHandler);
msgHandlerRef.current = msgHandler;
const statusPoller = setInterval(async () => {
try {
if (popup?.closed && !resolved) {
await new Promise(r => setTimeout(r, 1000));
cleanup();
advanceToTools();
return;
}
const sd = await dispatch(SUBSCRIPTIONS_STATUS()).unwrap();
const connections = (sd.providers as any)?.connections || [];
if (connections.some((p: any) => p.provider === providerId && p.isActive)) {
cleanup();
advanceToTools();
}
} catch {}
}, 2000);
pollTimerRef.current = statusPoller;
setTimeout(() => { cleanup(); setConnecting(null); }, 120000);
} else {
setConnecting(null);
}
} catch {
setConnecting(null);
}
}, [dispatch, pollTimerRef, msgHandlerRef, setConnecting, advanceToTools]);
return handleConnect;
}
@@ -0,0 +1,398 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { trackEvent } from '@/shared/analytics';
export interface WalkthroughStep {
target: string; // data-onboarding="<value>" selector
title: string;
description: string;
placement: 'top' | 'bottom' | 'left' | 'right';
actionHint?: string; // e.g. "Click the + button"
waitForTarget?: boolean; // pause until target appears in DOM
}
const STEPS: WalkthroughStep[] = [
{
target: 'agent-card',
title: 'This is an AI conversation',
description: 'Each card is a chat with AI. You can ask questions, get help writing, research topics, or have it browse the web for you.',
placement: 'right',
actionHint: 'Click it to open',
},
{
target: 'new-agent-button',
title: 'Start a new conversation',
description: 'Click here to create a new AI assistant. You can have multiple conversations running at the same time, side by side.',
placement: 'top',
actionHint: 'Try clicking the + button below',
},
{
target: 'browser-button',
title: 'Browse the web',
description: 'Open a web browser right inside your workspace. Your AI assistants can see and interact with any website.',
placement: 'top',
},
{
target: 'canvas-controls',
title: 'Navigate your workspace',
description: 'Scroll to zoom in and out. Drag the background to pan around. Click any card to focus on it.',
placement: 'top',
},
{
target: 'sidebar-skills',
title: 'Skills',
description: 'Browse and install ready-made workflows \u2014 no coding needed. Skills teach your AI new abilities.',
placement: 'right',
},
{
target: 'sidebar-actions',
title: 'Connect Your Tools',
description: 'Link Google Docs, Notion, Reddit, and more. Your AI assistants can read, write, and interact with your favorite apps.',
placement: 'right',
},
{
target: 'sidebar-modes',
title: 'Assistant Types',
description: 'Customize how your AI behaves. Create specialized assistants for writing, research, coding, or any task.',
placement: 'right',
},
{
target: 'sidebar-apps',
title: 'Build Mini Apps',
description: 'Create simple apps powered by AI \u2014 dashboards, forms, data tools. Just describe what you want.',
placement: 'right',
},
];
interface Props {
onComplete: () => void;
}
interface SpotlightRect {
top: number;
left: number;
width: number;
height: number;
}
const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
const c = useClaudeTokens();
const [currentStep, setCurrentStep] = useState(0);
const [spotlightRect, setSpotlightRect] = useState<SpotlightRect | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const [visible, setVisible] = useState(false);
const tooltipRef = useRef<HTMLDivElement>(null);
const animFrameRef = useRef<number | null>(null);
const step = STEPS[currentStep];
const totalSteps = STEPS.length;
const isLastStep = currentStep === totalSteps - 1;
// Track walkthrough start on mount
useEffect(() => {
trackEvent('walkthrough.started');
}, []);
// Track each step viewed
useEffect(() => {
if (step) {
trackEvent('walkthrough.step_viewed', { step: currentStep, step_name: step.target || 'done' });
}
}, [currentStep, step]);
// Find target element and compute spotlight + tooltip position
const updatePosition = useCallback(() => {
if (!step) return;
const el = (
document.querySelector(`[data-onboarding="${step.target}"]`) ||
document.querySelector(`[data-select-type="${step.target}"]`)
) as HTMLElement | null;
if (!el) {
if (step.waitForTarget) {
// Retry next frame
animFrameRef.current = requestAnimationFrame(updatePosition);
return;
}
// Skip this step if target not found
if (currentStep < totalSteps - 1) {
setCurrentStep((s) => s + 1);
}
return;
}
const rect = el.getBoundingClientRect();
const pad = 8;
const sr: SpotlightRect = {
top: rect.top - pad,
left: rect.left - pad,
width: rect.width + pad * 2,
height: rect.height + pad * 2,
};
setSpotlightRect(sr);
// Position tooltip relative to spotlight
const tooltipW = 320;
const tooltipH = 180;
const gap = 16;
let tp = { top: 0, left: 0 };
// If target is in the lower half of the screen, anchor the tooltip at a
// fixed center-upper position so it doesn't shift between toolbar steps.
const isBottomTarget = sr.top > window.innerHeight * 0.5;
if (isBottomTarget) {
tp = {
top: Math.round(window.innerHeight * 0.35),
left: Math.round(window.innerWidth / 2 - tooltipW / 2),
};
} else {
switch (step.placement) {
case 'right':
tp = { top: sr.top + sr.height / 2 - tooltipH / 2, left: sr.left + sr.width + gap };
break;
case 'left':
tp = { top: sr.top + sr.height / 2 - tooltipH / 2, left: sr.left - tooltipW - gap };
break;
case 'top':
tp = { top: sr.top - tooltipH - gap, left: sr.left + sr.width / 2 - tooltipW / 2 };
break;
case 'bottom':
tp = { top: sr.top + sr.height + gap, left: sr.left + sr.width / 2 - tooltipW / 2 };
break;
}
}
// Final clamp
tp.left = Math.max(8, Math.min(tp.left, window.innerWidth - tooltipW - 8));
tp.top = Math.max(8, Math.min(tp.top, window.innerHeight - tooltipH - 8));
setTooltipPos(tp);
setVisible(true);
}, [step, currentStep, totalSteps]);
useEffect(() => {
// Don't toggle visibility between steps — that fades the dark overlay out
// and back in, briefly showing the bright dashboard underneath (the "white
// flash"). Just update positions and let the existing CSS transitions
// smoothly animate the spotlight and tooltip to their new locations.
const timer = setTimeout(updatePosition, 0);
return () => {
clearTimeout(timer);
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, [currentStep, updatePosition]);
// Recompute on resize
useEffect(() => {
const onResize = () => updatePosition();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [updatePosition]);
const handleNext = useCallback(() => {
if (isLastStep) {
trackEvent('walkthrough.completed', { steps_viewed: currentStep + 1 });
onComplete();
} else {
setCurrentStep((s) => s + 1);
}
}, [isLastStep, onComplete, currentStep]);
const handleBack = useCallback(() => {
setCurrentStep((s) => Math.max(0, s - 1));
}, []);
// Allow clicking the spotlight target to advance for action steps
useEffect(() => {
if (!step?.actionHint) return;
const el = (
document.querySelector(`[data-onboarding="${step.target}"]`) ||
document.querySelector(`[data-select-type="${step.target}"]`)
) as HTMLElement | null;
if (!el) return;
const handler = () => {
trackEvent('walkthrough.step_action', { step: currentStep, step_name: step.target });
setTimeout(() => handleNext(), 300);
};
el.addEventListener('click', handler, { once: true });
return () => el.removeEventListener('click', handler);
}, [step, handleNext]);
if (!step) return null;
// SVG mask for spotlight cutout
const clipPath = spotlightRect
? `polygon(
0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%,
${spotlightRect.left}px ${spotlightRect.top}px,
${spotlightRect.left}px ${spotlightRect.top + spotlightRect.height}px,
${spotlightRect.left + spotlightRect.width}px ${spotlightRect.top + spotlightRect.height}px,
${spotlightRect.left + spotlightRect.width}px ${spotlightRect.top}px,
${spotlightRect.left}px ${spotlightRect.top}px
)`
: undefined;
return (
<Box
sx={{
position: 'fixed',
inset: 0,
zIndex: 9999,
transition: 'opacity 0.3s ease',
opacity: visible ? 1 : 0,
pointerEvents: 'none',
}}
>
{/* Dark overlay with spotlight cutout — clicks pass through the cutout */}
<Box
sx={{
position: 'absolute',
inset: 0,
bgcolor: 'rgba(0, 0, 0, 0.65)',
clipPath: clipPath || 'none',
transition: 'clip-path 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
}}
onClick={handleNext}
/>
{/* Spotlight ring glow */}
{spotlightRect && (
<Box
sx={{
position: 'absolute',
top: spotlightRect.top - 2,
left: spotlightRect.left - 2,
width: spotlightRect.width + 4,
height: spotlightRect.height + 4,
borderRadius: '12px',
border: `2px solid ${c.accent.primary}`,
boxShadow: `0 0 20px ${c.accent.primary}40, inset 0 0 20px ${c.accent.primary}10`,
pointerEvents: 'none',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
)}
{/* Tooltip card */}
<Box
ref={tooltipRef}
sx={{
position: 'absolute',
top: tooltipPos.top,
left: tooltipPos.left,
width: 320,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.xl}px`,
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
p: 2.5,
overflow: 'hidden',
transition: 'top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s',
opacity: visible ? 1 : 0,
pointerEvents: 'auto',
zIndex: 10000,
}}
>
{/* Step counter dots */}
<Box sx={{ display: 'flex', gap: 0.5, mb: 1.5, justifyContent: 'center' }}>
{STEPS.map((_, i) => (
<Box
key={i}
sx={{
width: i === currentStep ? 16 : 5,
height: 5,
borderRadius: 3,
bgcolor: i === currentStep ? c.accent.primary : i < currentStep ? c.accent.primary + '60' : c.border.medium,
transition: 'all 0.3s',
}}
/>
))}
</Box>
<Typography
sx={{
fontSize: '1rem',
fontWeight: 700,
color: c.text.primary,
mb: 0.75,
fontFamily: c.font.sans,
}}
>
{step.title}
</Typography>
<Typography
sx={{
fontSize: '0.82rem',
color: c.text.secondary,
lineHeight: 1.5,
mb: step.actionHint ? 1 : 2,
fontFamily: c.font.sans,
}}
>
{step.description}
</Typography>
{step.actionHint && (
<Typography
sx={{
fontSize: '0.72rem',
color: c.accent.primary,
fontWeight: 600,
mb: 2,
fontFamily: c.font.sans,
}}
>
{step.actionHint}
</Typography>
)}
{/* Buttons */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button
onClick={handleBack}
disabled={currentStep === 0}
sx={{
textTransform: 'none',
fontSize: '0.82rem',
fontWeight: 600,
color: c.text.tertiary,
borderRadius: `${c.radius.md}px`,
px: 2,
py: 0.75,
fontFamily: c.font.sans,
visibility: currentStep === 0 || step.target === 'new-agent-button' ? 'hidden' : 'visible',
'&:hover': { bgcolor: 'rgba(255,255,255,0.05)' },
}}
>
Back
</Button>
<Button
onClick={handleNext}
sx={{
textTransform: 'none',
fontSize: '0.82rem',
fontWeight: 600,
bgcolor: c.accent.primary,
color: '#fff',
borderRadius: `${c.radius.md}px`,
px: 2.5,
py: 0.75,
fontFamily: c.font.sans,
'&:hover': { bgcolor: c.accent.hover || c.accent.primary },
}}
>
{isLastStep ? 'Get Started' : 'Next'}
</Button>
</Box>
</Box>
</Box>
);
};
export default OnboardingWalkthrough;
+331
View File
@@ -0,0 +1,331 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import CheckIcon from '@mui/icons-material/Check';
import CircularProgress from '@mui/material/CircularProgress';
import { trackEvent } from '@/shared/analytics';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import {
subscribeToPlan,
OpenSwarmPlan,
BillingInterval,
CheckoutSource,
} from '@/shared/subscription/checkout';
// Pricing table. Keep in sync with the Stripe price IDs configured on
// api.openswarm.com. Annual is shown as the monthly-equivalent rate with a
// "billed annually" subtitle, mirroring Anthropic's pricing page copy.
interface PlanDef {
id: OpenSwarmPlan;
name: string;
tagline: string;
monthly: number;
annual: number; // billed monthly equivalent when paid annually
featuresHeader: string;
features: string[];
recommended?: boolean;
}
const PLANS: PlanDef[] = [
{
id: 'pro',
name: 'Pro',
tagline: 'Research, code, and organize',
monthly: 20,
annual: 17,
featuresHeader: 'Everything in Hobby and:',
features: [
'All Claude models (Sonnet, Opus, Haiku)',
'Claude Code + Cowork',
'Higher usage limits',
'Browser agents that complete tasks end-to-end',
],
},
{
id: 'pro_plus',
name: 'Pro+',
tagline: 'Higher limits, priority access',
monthly: 100,
annual: 85,
featuresHeader: 'Everything in Pro, plus:',
features: [
'Up to 5\u00d7 more usage than Pro*',
'Higher output limits per response',
'Priority access at busy times',
],
recommended: true,
},
{
id: 'ultra',
name: 'Ultra',
tagline: 'Maximum headroom',
monthly: 200,
annual: 170,
featuresHeader: 'Everything in Pro+, plus:',
features: [
'Up to 20\u00d7 more usage than Pro*',
'Highest output limits',
'First-in-line priority access',
],
},
];
interface PlanPickerProps {
source: CheckoutSource;
defaultPlan?: OpenSwarmPlan;
defaultInterval?: BillingInterval;
compact?: boolean;
// The user's current or most-recent tier, if any. Drives the CTA text on
// each card: same-tier → "Resubscribe", higher-tier → "Upgrade",
// lower-tier → "Downgrade". When undefined the user is a new customer and
// every card says "Subscribe".
currentPlan?: OpenSwarmPlan;
onSubscribed?: (plan: OpenSwarmPlan) => void;
}
// Tier ordering for upgrade/downgrade comparison.
const TIER_RANK: Record<OpenSwarmPlan, number> = {
pro: 1,
pro_plus: 2,
ultra: 3,
};
function ctaLabel(cardId: OpenSwarmPlan, cardName: string, currentPlan?: OpenSwarmPlan): string {
if (!currentPlan) return `Subscribe to ${cardName}`;
if (currentPlan === cardId) return `Resubscribe to ${cardName}`;
return TIER_RANK[cardId] > TIER_RANK[currentPlan]
? `Upgrade to ${cardName}`
: `Downgrade to ${cardName}`;
}
const PlanPicker: React.FC<PlanPickerProps> = ({
source,
defaultPlan,
defaultInterval = 'annual',
compact = false,
currentPlan,
onSubscribed,
}) => {
const c = useClaudeTokens();
const [interval, setInterval] = useState<BillingInterval>(defaultInterval);
const [pending, setPending] = useState<OpenSwarmPlan | null>(null);
React.useEffect(() => {
trackEvent('subscription.plan_picker_opened', { source, default_plan: defaultPlan ?? 'pro_plus' });
}, [source, defaultPlan]);
const handleSubscribe = async (plan: OpenSwarmPlan) => {
setPending(plan);
try {
await subscribeToPlan(plan, interval, source, { wasSubscribed: !!currentPlan });
onSubscribed?.(plan);
} finally {
setPending(null);
}
};
const handleIntervalChange = (_: React.MouseEvent<HTMLElement>, next: BillingInterval | null) => {
if (!next) return;
setInterval(next);
trackEvent('subscription.billing_interval_toggled', { source, interval: next });
};
// Typography scale — scaled down in compact mode (MessageBubble modal) but
// still keeping the same visual hierarchy (plan name ≈ price size).
const sz = compact
? { name: '1.35rem', price: '2rem', tagline: '0.78rem', features: '0.78rem', cta: '0.82rem', micro: '0.7rem', sub: '0.68rem', hdr: '0.72rem', suffix: '0.78rem' }
: { name: '1.75rem', price: '2.4rem', tagline: '0.85rem', features: '0.85rem', cta: '0.88rem', micro: '0.72rem', sub: '0.72rem', hdr: '0.78rem', suffix: '0.85rem' };
return (
<Box sx={{ width: '100%' }}>
{/* Billing interval toggle — annual selected by default */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: compact ? 2 : 2.5 }}>
<ToggleButtonGroup
value={interval}
exclusive
onChange={handleIntervalChange}
size="small"
sx={{
'& .MuiToggleButton-root': {
textTransform: 'none',
fontSize: '0.78rem',
fontWeight: 500,
px: 2,
py: 0.5,
color: c.text.tertiary,
borderColor: c.border.subtle,
'&.Mui-selected': {
bgcolor: `${c.accent.primary}15`,
color: c.accent.primary,
borderColor: `${c.accent.primary}60`,
'&:hover': { bgcolor: `${c.accent.primary}20` },
},
},
}}
>
<ToggleButton value="monthly">Monthly</ToggleButton>
<ToggleButton value="annual">Annual · save 15%</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Plan cards — grid in regular mode, stacked column in compact */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: compact ? '1fr' : 'repeat(3, 1fr)',
gap: compact ? 1.5 : 2,
alignItems: 'stretch',
}}
>
{PLANS.map((plan) => {
const price = interval === 'annual' ? plan.annual : plan.monthly;
const isRecommended = !!plan.recommended;
const isPending = pending === plan.id;
const isDefault = defaultPlan === plan.id;
return (
<Box
key={plan.id}
sx={{
position: 'relative',
p: compact ? 2 : 2.5,
borderRadius: `${c.radius.lg}px`,
border: `1px solid ${isRecommended ? c.accent.primary : c.border.subtle}`,
bgcolor: isRecommended ? `${c.accent.primary}08` : c.bg.surface,
display: 'flex',
flexDirection: 'column',
transition: 'border-color 0.15s, background 0.15s',
}}
>
{/* Name + "your plan" indicator */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.8, mb: 0.4 }}>
<Typography sx={{ fontSize: sz.name, fontWeight: 700, color: c.text.primary, lineHeight: 1.1 }}>
{plan.name}
</Typography>
{isDefault && (
<Typography sx={{ fontSize: sz.micro, color: c.text.muted, fontWeight: 500 }}>
· your plan
</Typography>
)}
</Box>
<Typography sx={{ fontSize: sz.tagline, color: c.text.muted, mb: 1.4, lineHeight: 1.35 }}>
{plan.tagline}
</Typography>
{/* Price row — big number + /mo */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 0.2 }}>
<Typography sx={{ fontSize: sz.price, fontWeight: 700, color: c.text.primary, lineHeight: 1 }}>
${price}
</Typography>
<Typography sx={{ fontSize: sz.suffix, color: c.text.muted, fontWeight: 500 }}>
/mo
</Typography>
</Box>
<Typography sx={{ fontSize: sz.sub, color: c.text.ghost, mb: 1.8, lineHeight: 1.35 }}>
{interval === 'annual' ? 'billed annually' : 'billed monthly'}
</Typography>
{/* CTA moved ABOVE features — Anthropic pattern. Filled accent
for the recommended tier, outlined for the others; no
separate RECOMMENDED badge needed. */}
<Button
onClick={() => handleSubscribe(plan.id)}
disabled={pending !== null}
variant={isRecommended ? 'contained' : 'outlined'}
fullWidth
sx={{
textTransform: 'none',
fontSize: sz.cta,
fontWeight: 600,
py: compact ? 0.85 : 1.05,
borderRadius: `${c.radius.md}px`,
...(isRecommended
? { bgcolor: c.accent.primary, color: '#fff', boxShadow: 'none', '&:hover': { bgcolor: c.accent.hover, boxShadow: 'none' } }
: { borderColor: c.border.medium, color: c.text.primary, '&:hover': { borderColor: c.accent.primary, bgcolor: `${c.accent.primary}06` } }),
}}
>
{isPending ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.7 }}>
<CircularProgress size={14} sx={{ color: 'inherit' }} />
<span>Opening</span>
</Box>
) : (
ctaLabel(plan.id, plan.name, currentPlan)
)}
</Button>
{/* Microcopy row under every CTA — matches Anthropic's
reassurance-under-the-big-button pattern. */}
<Typography
sx={{
fontSize: sz.micro,
color: c.text.muted,
textAlign: 'center',
mt: 0.7,
minHeight: '1em',
}}
>
{isRecommended
? 'Most popular · cancel anytime'
: plan.id === 'ultra'
? 'No commitment · cancel anytime'
: 'Cancel anytime'}
</Typography>
{/* Divider + cumulative features — "Everything in Pro, plus:" */}
<Box
sx={{
borderTop: `1px solid ${c.border.subtle}`,
mt: 1.8,
pt: 1.6,
display: 'flex',
flexDirection: 'column',
gap: 0.8,
flex: 1,
}}
>
<Typography sx={{ fontSize: sz.hdr, fontWeight: 600, color: c.text.secondary, mb: 0.2 }}>
{plan.featuresHeader}
</Typography>
{plan.features.map((f) => (
<Box key={f} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.8 }}>
<CheckIcon
sx={{
fontSize: compact ? 14 : 16,
color: isRecommended ? c.accent.primary : c.text.tertiary,
mt: '2px',
flexShrink: 0,
}}
/>
<Typography sx={{ fontSize: sz.features, color: c.text.secondary, lineHeight: 1.45 }}>
{f}
</Typography>
</Box>
))}
</Box>
</Box>
);
})}
</Box>
<Typography
sx={{
fontSize: compact ? '0.65rem' : '0.7rem',
color: c.text.ghost,
textAlign: 'center',
mt: 2,
lineHeight: 1.5,
}}
>
*Usage limits apply. Prices shown don't include applicable tax.
{' '}Prices and plans are subject to change at OpenSwarm's discretion.
</Typography>
</Box>
);
};
export default PlanPicker;
@@ -1,5 +1,8 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { CommandPickerItem } from './CommandPicker/components/commandPickerTypes';
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,
@@ -9,28 +12,45 @@ import {
detectEditorTrigger,
TriggerState,
EMPTY_TRIGGER,
} from './richEditorUtils';
} from '@/app/components/richEditorUtils';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { RichPromptEditorProps, LINE_HEIGHT, FONT_SIZE } from './richPromptEditorTypes';
export function 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,
}: 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 skills = useAppSelector((state) => state.skills.items);
useEffect(() => {
@@ -43,8 +63,10 @@ export function useRichPromptEditor({
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;
@@ -207,6 +229,7 @@ export function useRichPromptEditor({
}
if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) {
e.preventDefault();
return;
}
};
@@ -216,11 +239,120 @@ export function useRichPromptEditor({
if (plain) document.execCommand('insertText', false, plain);
}, []);
return {
c, editorRef, wrapperRef, focused, setFocused, hasContent,
picker, setPicker, pickerRect,
minHeight, maxHeight, isLabelFloating,
handleInput, handleEditorClick, handlePickerSelect,
handleKeyDown, handlePaste, updateHasContent, emitChange,
};
}
return (
<Box ref={wrapperRef} sx={{ position: 'relative' }}>
{picker.visible && pickerRect && createPortal(
<div
style={{
position: 'fixed',
top: pickerRect.top,
left: pickerRect.left,
width: pickerRect.width,
height: 0,
zIndex: 1400,
pointerEvents: 'none',
}}
>
<div style={{ position: 'relative', width: '100%', pointerEvents: 'auto' }}>
<CommandPicker
trigger={picker.trigger}
filter={picker.filter}
onSelect={handlePickerSelect}
onClose={() => setPicker((p) => ({ ...p, visible: false }))}
visible={picker.visible}
/>
</div>
</div>,
document.body,
)}
<Box
onClick={() => editorRef.current?.focus()}
sx={{
position: 'relative',
border: `1px solid ${focused ? c.accent.primary : c.border.medium}`,
borderRadius: '4px',
bgcolor: c.bg.page,
transition: 'border-color 0.15s',
'&:hover': {
borderColor: focused ? c.accent.primary : c.text.primary,
},
cursor: 'text',
}}
>
{label && (
<Typography
component="label"
sx={{
position: 'absolute',
left: 12,
top: isLabelFloating ? -1 : '50%',
transform: isLabelFloating ? 'translateY(-50%) scale(0.75)' : 'translateY(-50%)',
transformOrigin: 'top left',
color: focused ? c.accent.primary : c.text.tertiary,
fontSize: '1rem',
lineHeight: 1,
pointerEvents: 'none',
transition: 'all 0.15s ease',
bgcolor: isLabelFloating ? c.bg.page : 'transparent',
px: isLabelFloating ? 0.5 : 0,
zIndex: 1,
}}
>
{label}
</Typography>
)}
<Box sx={{ px: 1.75, pt: label ? 2 : 1.25, pb: 1.25, position: 'relative' }}>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={handleInput}
onClick={handleEditorClick}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
width: '100%',
minHeight: `${minHeight}rem`,
maxHeight: `${maxHeight}rem`,
overflowY: 'auto',
background: 'transparent',
border: 'none',
outline: 'none',
color: c.text.primary,
fontSize: `${FONT_SIZE}rem`,
lineHeight: `${LINE_HEIGHT}`,
fontFamily: 'inherit',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
/>
{!hasContent && (
<div
style={{
position: 'absolute',
top: label ? 16 : 10,
left: 14,
right: 14,
color: c.text.tertiary,
fontSize: `${FONT_SIZE}rem`,
lineHeight: `${LINE_HEIGHT}`,
fontFamily: 'inherit',
pointerEvents: 'none',
userSelect: 'none',
}}
>
{placeholder}
</div>
)}
</Box>
</Box>
</Box>
);
};
export default RichPromptEditor;
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import ReactDOM from 'react-dom';
import { type OverlayState, type DragRect, type DragPreviewElement } from './_shared/types';
import { useElementSelection } from '@/app/pages/_shared/element_selection/useElementSelection';
import { OverlayState, DragRect, DragPreviewElement } from './useDomElementSelector';
import { useElementSelection } from './ElementSelectionContext';
const HIGHLIGHT_COLOR = '#3b82f6';
const HIGHLIGHT_BG = 'rgba(59, 130, 246, 0.08)';
@@ -1,5 +1,5 @@
export const SKILL_PILL_ATTR = 'data-skill-id';
const SKILL_COLOR = '#7B61BD';
export const SKILL_COLOR = '#7B61BD';
export interface AttachedSkill {
id: string;
@@ -1,14 +1,112 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useElementSelection } from '@/app/pages/_shared/element_selection/useElementSelection';
import {
type SelectMeta,
type DomSelectorState,
EMPTY_OVERLAY, EMPTY_DRAG, DRAG_THRESHOLD,
SELECT_ATTR, SELECT_ID_ATTR, SELECT_META_ATTR,
findSelectableAncestor, buildSemanticLabel, buildSelectedElement,
computeDragPreview, processDragSelection,
} from './domSelectorHelpers';
import { type OverlayState, type DragRect, type DragPreviewElement } from '@/app/pages/Dashboard/_shared/types';
import { SelectedElement, useElementSelection } from './ElementSelectionContext';
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 function useDomElementSelector(): DomSelectorState {
const ctx = useElementSelection();
@@ -41,6 +139,7 @@ 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;
@@ -72,14 +171,45 @@ export function useDomElementSelector(): DomSelectorState {
dragPreviewRafRef.current = requestAnimationFrame(() => {
const b = dragBoundsRef.current;
if (!b) return;
setDragPreview(computeDragPreview(b, excludeIdRef.current, selectedIdsRef.current));
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);
});
}
return;
}
const target = e.target as Element;
if (!target || target.tagName === 'IFRAME') {
if (!target) {
setOverlay(EMPTY_OVERLAY);
hoveredRef.current = null;
return;
}
if (target.tagName === 'IFRAME') {
setOverlay(EMPTY_OVERLAY);
hoveredRef.current = null;
return;
@@ -98,8 +228,8 @@ export function useDomElementSelector(): DomSelectorState {
rafRef.current = requestAnimationFrame(() => {
const rect = selectable.getBoundingClientRect();
const type = selectable.getAttribute(SELECT_ATTR) || '';
let meta: SelectMeta = {};
try { meta = JSON.parse(selectable.getAttribute(SELECT_META_ATTR) || '{}'); } catch { /* malformed meta */ }
let meta: Record<string, any> = {};
try { meta = JSON.parse(selectable.getAttribute(SELECT_META_ATTR) || '{}'); } catch {}
const label = buildSemanticLabel(type, meta);
setOverlay({
visible: true,
@@ -135,13 +265,34 @@ export function useDomElementSelector(): DomSelectorState {
right: Math.max(dragOriginRef.current.x, e.clientX),
bottom: Math.max(dragOriginRef.current.y, e.clientY),
};
processDragSelection(
dr,
excludeIdRef.current,
selectedIdsRef.current,
ctx.addSelectedElement,
ctx.removeSelectedElement,
);
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));
}
}
});
}
const wasDragging = isDraggingRef.current;
@@ -177,7 +328,17 @@ export function useDomElementSelector(): DomSelectorState {
}, [ctx]);
useEffect(() => {
if (!ctx?.selectMode) return;
if (!ctx?.selectMode) {
setOverlay(EMPTY_OVERLAY);
setDragRect(EMPTY_DRAG);
setDragPreview([]);
hoveredRef.current = null;
dragOriginRef.current = null;
dragBoundsRef.current = null;
isDraggingRef.current = false;
preDragFocusRef.current = null;
return;
}
const prevUserSelect = document.body.style.userSelect;
document.body.style.userSelect = 'none';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface Props {
currentIndex: number;
totalBranches: number;
onPrevious: () => void;
onNext: () => void;
}
const BranchNavigator: React.FC<Props> = ({ currentIndex, totalBranches, onPrevious, onNext }) => {
const c = useClaudeTokens();
if (totalBranches <= 1) return null;
return (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
mt: -0.25,
mb: 0.5,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.25,
}}
>
<IconButton
size="small"
onClick={onPrevious}
disabled={currentIndex === 0}
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
>
<ChevronLeftIcon sx={{ fontSize: 16 }} />
</IconButton>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.7rem', minWidth: 28, textAlign: 'center', userSelect: 'none' }}>
{currentIndex + 1} / {totalBranches}
</Typography>
<IconButton
size="small"
onClick={onNext}
disabled={currentIndex === totalBranches - 1}
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
>
<ChevronRightIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
);
};
export default BranchNavigator;
@@ -0,0 +1,507 @@
import React, { useEffect, useRef, useMemo, useCallback } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import PanToolOutlinedIcon from '@mui/icons-material/PanToolOutlined';
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, handleApproval } from '@/shared/state/agentsSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import type { RootState } from '@/shared/state/store';
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,
(_: RootState, __: string, browserId?: string) => browserId],
(sessions, parentSessionId, browserId) =>
Object.values(sessions).filter(
(s): s is AgentSession =>
s.mode === 'browser-agent' &&
s.parent_session_id === parentSessionId &&
(!browserId || s.browser_id === browserId),
),
);
const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { mode } = useThemeMode();
const fc = mode === 'dark' ? darkFeedColors : lightFeedColors;
const scrollRef = useRef<HTMLDivElement>(null);
const fetchedForSession = useRef<string | null>(null);
const browserSessions = useAppSelector((state) =>
selectBrowserSessions(state, parentSessionId, browserId),
);
useEffect(() => {
if (browserSessions.length === 0 && fetchedForSession.current !== parentSessionId) {
fetchedForSession.current = parentSessionId;
dispatch(fetchBrowserAgentChildren(parentSessionId))
.unwrap()
.catch(() => { fetchedForSession.current = null; });
}
}, [browserSessions.length, parentSessionId, dispatch]);
const sessionsWithEntries = useMemo(() => {
return browserSessions.map((session) => {
const entries: FeedEntry[] = [];
for (const msg of session.messages) {
const entry = formatMessage(msg);
if (entry) entries.push(entry);
}
if (session.streamingMessage?.role === 'assistant' && session.streamingMessage.content) {
entries.push({ type: 'thought', text: session.streamingMessage.content });
}
return { session, entries };
});
}, [browserSessions]);
const totalMessages = browserSessions.reduce(
(n, s) => n + s.messages.length + (s.streamingMessage ? 1 : 0),
0,
);
// Sticky-to-bottom: auto-scroll to the latest content unless the user
// has manually scrolled up. Re-enable auto-scroll when the user scrolls
// back to the bottom (within a small threshold).
const isStuckToBottom = useRef(true);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
// "At bottom" = within 30px of the bottom edge
isStuckToBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
}, []);
useEffect(() => {
if (isStuckToBottom.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [totalMessages]);
if (browserSessions.length === 0) return null;
const showLabels = sessionsWithEntries.length > 1;
const accentColor = c.accent.primary;
return (
<Box
ref={scrollRef}
onScroll={handleScroll}
onWheel={(e) => {
// Capture wheel events so the feed scrolls on hover without
// needing to click/focus first. Without this, the parent chat
// scroll container eats the wheel events.
const el = scrollRef.current;
if (!el) return;
const atTop = el.scrollTop <= 0 && e.deltaY < 0;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 1 && e.deltaY > 0;
// Only stop propagation when the feed has room to scroll in this
// direction. At boundaries, let the parent scroll naturally.
if (!atTop && !atBottom) e.stopPropagation();
}}
sx={{
maxHeight: 300,
overflowY: 'auto',
px: 1.5,
py: 1,
display: 'flex',
flexDirection: 'column',
gap: 0.25,
scrollbarWidth: 'thin',
scrollbarColor: `${fc.scrollThumb} transparent`,
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-thumb': {
background: fc.scrollThumb,
borderRadius: 2,
},
}}
>
{sessionsWithEntries.map(({ session, entries }, si) => (
<Box key={session.id} sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{showLabels && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: si > 0 ? 1 : 0, mb: 0.25 }}>
<LanguageIcon sx={{ fontSize: 12, color: accentColor, opacity: 0.7 }} />
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 600,
color: accentColor,
opacity: 0.8,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{session.browser_id || `Browser ${si + 1}`}
</Typography>
<SessionStatusChip status={session.status} />
</Box>
)}
{!showLabels && entries.length === 0 && session.status === 'running' && (
<Typography
sx={{
fontSize: '0.7rem',
color: c.text.tertiary,
fontStyle: 'italic',
fontFamily: c.font.mono,
}}
>
Starting browser agent...
</Typography>
)}
{entries.map((entry, i) => (
<EntryRow key={i} entry={entry} accentColor={accentColor} fc={fc} />
))}
{/* Inline RequestHumanIntervention — matches the DynamicIsland
and BrowserAgentOverlay style (amber, hand icon, compact pill).
Same request_id → whichever surface the user responds from
first resolves the approval; the others auto-dismiss. */}
{session.pending_approvals?.filter(
(a) => a.tool_name === 'RequestHumanIntervention',
).map((intervention) => {
const problem = (intervention.tool_input as any)?.problem || 'Browser agent needs help';
return (
<Box
key={intervention.id}
sx={{
mt: 0.75,
mb: 0.5,
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1,
py: 0.5,
borderRadius: '8px',
bgcolor: 'rgba(245,158,11,0.10)',
border: '1px solid rgba(245,158,11,0.25)',
}}
>
<PanToolOutlinedIcon sx={{ fontSize: 12, color: '#f59e0b', flexShrink: 0, mt: '2px' }} />
<Typography
sx={{
fontSize: '0.72rem',
fontWeight: 600,
color: '#f59e0b',
flex: 1,
minWidth: 0,
lineHeight: 1.4,
}}
>
{problem}
</Typography>
<Tooltip title="Done — continue" arrow>
<IconButton
size="small"
onClick={() => dispatch(handleApproval({ requestId: intervention.id, behavior: 'allow' }))}
sx={{
p: 0,
width: 18,
height: 18,
color: '#fff',
bgcolor: '#f59e0b',
'&:hover': { bgcolor: '#d97706' },
}}
>
<CheckIcon sx={{ fontSize: 11 }} />
</IconButton>
</Tooltip>
<Tooltip title="Skip" arrow>
<IconButton
size="small"
onClick={() => dispatch(handleApproval({ requestId: intervention.id, behavior: 'deny', message: 'User declined to help' }))}
sx={{
p: 0,
width: 18,
height: 18,
color: '#f59e0b',
border: '1px solid rgba(245,158,11,0.4)',
'&:hover': { bgcolor: 'rgba(245,158,11,0.1)' },
}}
>
<CloseIcon sx={{ fontSize: 11 }} />
</IconButton>
</Tooltip>
</Box>
);
})}
{!showLabels && session.status === 'running' && entries.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: accentColor,
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)' },
},
}}
/>
</Box>
)}
</Box>
))}
</Box>
);
};
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);
@@ -1,83 +0,0 @@
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 { 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;
onClose?: () => void;
}
const ChatHeader: React.FC<ChatHeaderProps> = ({ session, isDraft, 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>
{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
@@ -1,161 +0,0 @@
import React, { useCallback, useEffect, useRef, useState, type FC, type MutableRefObject } from 'react';
import { ComposerPrimitive, useAui } from '@assistant-ui/react';
import { LexicalComposerInput } from '@assistant-ui/react-lexical';
import type { Unstable_MentionItem } from '@assistant-ui/core';
import { useAppSelector } from '@/shared/hooks';
import type { ComposerExtras } from '../runtime/useOpenSwarmRuntime';
import type { ContextPath } from '@/shared/state/agentsTypes';
import { useOpenSwarmMentionAdapter, type MentionItemMetadata } from './components/OpenSwarmMentionAdapter';
import { useComposerAttachments } from './components/useComposerAttachments';
import { MentionSelectOverride, MentionPopover, ComposerAttachmentChips } from './components/ComposerParts';
import ModelModeSelector from './components/ModelModeSelector/ModelModeSelector';
interface ChatInputProps {
composerExtrasRef: MutableRefObject<ComposerExtras>;
mode: string;
onModeChange: (mode: string) => void;
model: string;
onModelChange: (model: string) => void;
isRunning?: boolean;
onStop?: () => void;
sessionId?: string;
queueLength?: number;
contextEstimate?: { used: number; limit: number };
autoFocus?: boolean;
initialContextPaths?: ContextPath[];
embedded?: boolean;
}
const ChatInput: FC<ChatInputProps> = ({
composerExtrasRef, mode, onModeChange, model, onModelChange,
isRunning, onStop, sessionId, queueLength, contextEstimate, autoFocus,
initialContextPaths, embedded,
}) => {
const aui = useAui();
const mentionAdapter = useOpenSwarmMentionAdapter();
const att = useComposerAttachments();
const formRef = useRef<HTMLFormElement>(null);
const [hasContent, setHasContent] = useState(false);
const initialContextApplied = useRef(false);
const skills = useAppSelector((s) => s.skills.items);
useEffect(() => {
if (initialContextApplied.current || !initialContextPaths?.length) return;
att.setContextPaths(initialContextPaths);
initialContextApplied.current = true;
}, [initialContextPaths, att]);
const syncExtras = useCallback(() => {
const allForcedTools = att.forcedTools.flatMap((ft) => ft.tools);
const skillList = Object.values(att.attachedSkills);
composerExtrasRef.current = {
images: att.images.length > 0 ? att.images.map(({ data, media_type }) => ({ data, media_type })) : undefined,
contextPaths: att.contextPaths.length > 0 ? att.contextPaths : undefined,
forcedTools: allForcedTools.length > 0 ? allForcedTools : undefined,
attachedSkills: skillList.length > 0 ? skillList : undefined,
};
}, [composerExtrasRef, att.images, att.contextPaths, att.forcedTools, att.attachedSkills]);
const handleFormSubmit = useCallback(() => {
syncExtras();
setTimeout(() => att.clearAll(), 0);
}, [syncExtras, att]);
const handleSendClick = useCallback(() => {
syncExtras();
aui.composer().send();
att.clearAll();
}, [syncExtras, aui, att]);
useEffect(() => {
return aui.subscribe(() => {
const text = aui.composer().getState().text;
setHasContent(text.trim().length > 0 || att.images.length > 0 || att.contextPaths.length > 0);
});
}, [aui, att.images.length, att.contextPaths.length]);
const handleMentionSelect = useCallback(
(item: Unstable_MentionItem): boolean => {
const meta = item.metadata as unknown as MentionItemMetadata | undefined;
if (!meta) return false;
switch (meta.itemType) {
case 'skill': {
const skill = skills[item.id];
if (!skill) return true;
att.setAttachedSkills((prev) => ({
...prev,
[skill.id]: { id: skill.id, name: skill.name, content: skill.content },
}));
return true;
}
case 'mode':
onModeChange(item.id);
return true;
case 'file':
att.browseAndAttachFiles();
return true;
case 'tool-group':
case 'output':
if (meta.toolNames && meta.toolNames.length > 0) {
att.setForcedTools((prev) => [
...prev,
{ label: item.label, tools: meta.toolNames!, iconKey: meta.iconKey },
]);
}
return true;
default:
return false;
}
},
[skills, onModeChange, aui, att],
);
const hasAttachments =
att.images.length > 0 || att.contextPaths.length > 0 ||
att.forcedTools.length > 0 || Object.keys(att.attachedSkills).length > 0;
return (
<div className={embedded ? 'flex w-full flex-col' : 'mx-auto flex w-full max-w-(--thread-max-width) flex-col'}>
<ComposerPrimitive.Unstable_MentionRoot trigger="@" adapter={mentionAdapter}>
<ComposerPrimitive.Root ref={formRef} onSubmit={handleFormSubmit} className="aui-composer-root relative flex w-full flex-col">
<MentionSelectOverride onSelect={handleMentionSelect} />
<div
className={embedded
? 'flex w-full flex-col gap-1 bg-transparent p-1'
: 'flex w-full flex-col gap-1 rounded-2xl border bg-background p-2 transition-shadow focus-within:border-ring/75 focus-within:ring-2 focus-within:ring-ring/20'
}
onDragOver={att.handleDragOver} onDragLeave={att.handleDragLeave} onDrop={att.handleDrop}
data-dragging={att.isDragOver || undefined}
>
{hasAttachments && (
<ComposerAttachmentChips
images={att.images} contextPaths={att.contextPaths}
forcedTools={att.forcedTools} attachedSkills={att.attachedSkills}
onRemoveImage={att.removeImage} onRemoveContextPath={att.removeContextPath}
onRemoveForcedTool={att.removeForcedTool} onRemoveSkill={att.removeSkill}
/>
)}
<LexicalComposerInput
placeholder="Message — @ for context and commands"
className="aui-composer-input max-h-40 min-h-10 w-full resize-none bg-transparent px-2 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/80"
autoFocus={autoFocus}
/>
<MentionPopover />
<div className="flex items-center justify-between px-1">
<ModelModeSelector
mode={mode} onModeChange={onModeChange} model={model} onModelChange={onModelChange}
contextEstimate={contextEstimate} ownerId={sessionId || 'composer'} sessionId={sessionId}
hasContent={hasContent} isRunning={isRunning} onSend={handleSendClick} onStop={onStop}
browseAndAttachFiles={att.browseAndAttachFiles}
queueLength={queueLength}
/>
</div>
</div>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_MentionRoot>
</div>
);
};
export default ChatInput;
@@ -1,108 +0,0 @@
import { useEffect, type FC } from 'react';
import {
ComposerPrimitive,
unstable_useMentionContextOptional,
} from '@assistant-ui/react';
import type { Unstable_MentionItem } from '@assistant-ui/core';
import { XIcon } from 'lucide-react';
/**
* Registers a selectItemOverride callback on the nearest MentionRoot context.
* Must be rendered inside a ComposerPrimitive.Unstable_MentionRoot.
* Returns true from the callback to prevent the default mention directive insertion.
*/
export const MentionSelectOverride: FC<{
onSelect: (item: Unstable_MentionItem) => boolean;
}> = ({ onSelect }) => {
const ctx = unstable_useMentionContextOptional();
useEffect(() => {
if (!ctx) return;
return ctx.registerSelectItemOverride(onSelect);
}, [ctx, onSelect]);
return null;
};
export const MentionPopover: FC = () => (
<ComposerPrimitive.Unstable_MentionPopover className="z-50 max-h-64 min-w-56 overflow-y-auto rounded-lg border bg-popover p-1 shadow-lg">
<ComposerPrimitive.Unstable_MentionBack className="mb-1 flex w-full items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent">
Back
</ComposerPrimitive.Unstable_MentionBack>
<ComposerPrimitive.Unstable_MentionCategories>
{(categories) =>
categories.map((cat) => (
<ComposerPrimitive.Unstable_MentionCategoryItem
key={cat.id}
categoryId={cat.id}
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent data-[highlighted]:bg-accent"
>
{cat.label}
</ComposerPrimitive.Unstable_MentionCategoryItem>
))
}
</ComposerPrimitive.Unstable_MentionCategories>
<ComposerPrimitive.Unstable_MentionItems>
{(items) =>
items.map((item) => (
<ComposerPrimitive.Unstable_MentionItem
key={item.id}
item={item}
className="flex w-full cursor-pointer flex-col gap-0.5 rounded px-2 py-1.5 hover:bg-accent data-[highlighted]:bg-accent"
>
<span className="text-sm font-medium">{item.label}</span>
{item.description && (
<span className="text-xs text-muted-foreground">{item.description}</span>
)}
</ComposerPrimitive.Unstable_MentionItem>
))
}
</ComposerPrimitive.Unstable_MentionItems>
</ComposerPrimitive.Unstable_MentionPopover>
);
const Chip: FC<{ label: string; onRemove: () => void }> = ({ label, onRemove }) => (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
{label}
<button onClick={onRemove} className="ml-0.5 text-muted-foreground hover:text-foreground">
<XIcon className="h-3 w-3" />
</button>
</span>
);
export const ComposerAttachmentChips: FC<{
images: { preview: string }[];
contextPaths: { path: string; type: string }[];
forcedTools: { label: string }[];
attachedSkills: Record<string, { name: string }>;
onRemoveImage: (idx: number) => void;
onRemoveContextPath: (idx: number) => void;
onRemoveForcedTool: (idx: number) => void;
onRemoveSkill: (id: string) => void;
}> = ({
images, contextPaths, forcedTools, attachedSkills,
onRemoveImage, onRemoveContextPath, onRemoveForcedTool, onRemoveSkill,
}) => (
<div className="flex flex-wrap gap-1 px-1">
{images.map((img, i) => (
<div key={`img-${i}`} className="group relative h-10 w-10 overflow-hidden rounded border">
<img src={img.preview} alt="" className="h-full w-full object-cover" />
<button
onClick={() => onRemoveImage(i)}
className="absolute -top-1 -right-1 hidden rounded-full bg-destructive p-0.5 text-destructive-foreground group-hover:block"
>
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
))}
{contextPaths.map((cp, i) => (
<Chip key={`cp-${i}`} label={cp.path.split('/').pop() || cp.path} onRemove={() => onRemoveContextPath(i)} />
))}
{forcedTools.map((ft, i) => (
<Chip key={`ft-${i}`} label={`@${ft.label}`} onRemove={() => onRemoveForcedTool(i)} />
))}
{Object.entries(attachedSkills).map(([id, s]) => (
<Chip key={`sk-${id}`} label={s.name} onRemove={() => onRemoveSkill(id)} />
))}
</div>
);
@@ -1,41 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
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;
@@ -1,231 +0,0 @@
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/pages/_shared/element_selection/useElementSelection';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { LIST_MODES } 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;
browseAndAttachFiles: () => void;
queueLength?: number;
}
const ModelModeSelector: React.FC<Props> = ({
mode, onModeChange, model, onModelChange, onProviderChange,
contextEstimate, ownerId, sessionId,
autoRunMode, hasContent, isRunning, disabled, onSend, onStop,
browseAndAttachFiles,
}) => {
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(LIST_MODES()); }, [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>
)}
<Tooltip title="Attach file">
<IconButton size="small" onClick={browseAndAttachFiles}
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;
@@ -1,201 +0,0 @@
import { useMemo, useEffect } from 'react';
import type {
Unstable_MentionAdapter,
Unstable_MentionCategory,
Unstable_MentionItem,
} from '@assistant-ui/core';
import { useAppSelector, useAppDispatch } from '@/shared/hooks';
import { LIST_BUILTIN_TOOLS, LIST_TOOLS } from '@/shared/backend-bridge/apps/tools';
import { LIST_APPS } from '@/shared/backend-bridge/apps/app_builder';
export interface MentionItemMetadata {
itemType: 'skill' | 'mode' | 'file' | 'tool-group' | 'output';
toolNames?: string[];
iconKey?: string;
command?: string;
}
function buildItem(
id: string,
type: string,
label: string,
description: string,
meta: MentionItemMetadata,
): Unstable_MentionItem {
return { id, type, label, description, metadata: meta as any };
}
function matchesQuery(item: Unstable_MentionItem, lower: string): boolean {
return (
item.label.toLowerCase().includes(lower) ||
(item.description?.toLowerCase().includes(lower) ?? false) ||
((item.metadata as any)?.command?.toLowerCase().includes(lower) ?? false)
);
}
/**
* Hook that builds an Unstable_MentionAdapter from Redux state.
* Provides categories and items for skills, modes, context tools,
* MCP tool groups, and output apps.
*/
export function useOpenSwarmMentionAdapter(): Unstable_MentionAdapter {
const dispatch = useAppDispatch();
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.apps.items);
const toolsLoaded = useAppSelector((s) => s.tools.loaded);
const builtinLoaded = useAppSelector((s) => s.tools.builtinLoaded);
const outputsLoaded = useAppSelector((s) => s.apps.loaded);
useEffect(() => {
if (!builtinLoaded) dispatch(LIST_BUILTIN_TOOLS());
if (!toolsLoaded) dispatch(LIST_TOOLS());
if (!outputsLoaded) dispatch(LIST_APPS());
}, [dispatch, builtinLoaded, toolsLoaded, outputsLoaded]);
const { categories, itemsByCategory, allItems } = useMemo(() => {
const cats: Unstable_MentionCategory[] = [];
const byCategory: Record<string, Unstable_MentionItem[]> = {};
const all: Unstable_MentionItem[] = [];
const addCategory = (id: string, label: string) => {
if (!byCategory[id]) {
cats.push({ id, label });
byCategory[id] = [];
}
};
const addItem = (catId: string, item: Unstable_MentionItem) => {
byCategory[catId]?.push(item);
all.push(item);
};
// --- Skills ---
const skillValues = Object.values(skills);
if (skillValues.length > 0) {
addCategory('skills', 'Skills');
for (const s of skillValues) {
addItem(
'skills',
buildItem(s.id, 'skill', s.name, s.description || 'Skill', {
itemType: 'skill',
command: s.command || s.id,
}),
);
}
}
// --- Modes ---
const modeValues = Object.values(modesMap);
if (modeValues.length > 0) {
addCategory('modes', 'Modes');
for (const m of modeValues) {
addItem(
'modes',
buildItem(m.id, 'mode', m.name, m.description || 'Switch to this mode', {
itemType: 'mode',
command: m.name.toLowerCase().replace(/\s+/g, '-'),
}),
);
}
}
// --- Context: File ---
addCategory('context', 'Context');
addItem(
'context',
buildItem('file', 'context', 'File', 'Attach a file or folder as context', {
itemType: 'file',
command: 'file',
}),
);
// --- Actions: Web ---
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[];
addItem(
'context',
buildItem('web', 'context', 'Web', 'Search the web and fetch URLs', {
itemType: 'tool-group',
command: 'web',
toolNames: webTools,
iconKey: 'Web',
}),
);
}
// --- MCP Tool Groups ---
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 enabled: { name: string; tools: string[] }[] = [];
for (const [sn, st] of Object.entries(services)) {
const names = [...(st.read || []), ...(st.write || [])].filter((n) => perms[n] !== 'deny');
if (names.length > 0) enabled.push({ name: sn, tools: names });
}
if (enabled.length === 0) continue;
const catId = `mcp-${tool.id}`;
addCategory(catId, tool.name);
const emitted = new Set<string>();
for (const [gn, gsn] of Object.entries(serviceGroups)) {
const gc = gn.toLowerCase().replace(/\s+/g, '-');
const gs = enabled.filter((s) => gsn.includes(s.name));
if (gs.length === 0) continue;
gs.forEach((s) => emitted.add(s.name));
if (gs.length >= 2) {
addItem(catId, buildItem(`mcp-${tool.id}-group-${gn}`, 'context', gn, `Use all ${gn} actions`, { itemType: 'tool-group', command: gc, toolNames: gs.flatMap((s) => s.tools), iconKey: gn }));
}
for (const svc of gs) {
const cmd = gs.length >= 2 ? `${gc}/${svc.name.toLowerCase().replace(/\s+/g, '-')}` : svc.name.toLowerCase().replace(/\s+/g, '-');
addItem(catId, buildItem(`mcp-${tool.id}-${svc.name}`, 'context', svc.name, `Use ${svc.name} actions from ${tool.name}`, { itemType: 'tool-group', command: cmd, toolNames: svc.tools, iconKey: gn }));
}
}
for (const svc of enabled) {
if (emitted.has(svc.name)) continue;
addItem(catId, buildItem(`mcp-${tool.id}-${svc.name}`, 'context', svc.name, `Use ${svc.name} actions from ${tool.name}`, { itemType: 'tool-group', command: svc.name.toLowerCase().replace(/\s+/g, '-'), toolNames: svc.tools }));
}
}
// --- Apps / Outputs ---
const outputValues = Object.values(outputItems).filter((o) => o.permission !== 'deny');
if (outputValues.length > 0) {
addCategory('apps', 'Apps');
for (const out of outputValues) {
addItem(
'apps',
buildItem(`view-${out.id}`, 'context', out.name, out.description || `Render ${out.name} view`, {
itemType: 'output',
command: out.name.toLowerCase().replace(/\s+/g, '-'),
toolNames: ['RenderOutput'],
iconKey: 'View',
}),
);
}
}
return { categories: cats, itemsByCategory: byCategory, allItems: all };
}, [skills, modesMap, builtinTools, customTools, outputItems]);
return useMemo<Unstable_MentionAdapter>(
() => ({
categories: () => categories,
categoryItems: (categoryId: string) => itemsByCategory[categoryId] ?? [],
search: (query: string) => {
if (!query) return allItems;
const lower = query.toLowerCase();
return allItems.filter((item) => matchesQuery(item, lower));
},
}),
[categories, itemsByCategory, allItems],
);
}
@@ -1,138 +0,0 @@
import { useState, useCallback } from 'react';
import type { ContextPath } from '@/shared/state/agentsTypes';
interface AttachedImage {
data: string;
media_type: string;
preview: string;
}
interface ForcedToolGroup {
label: string;
tools: string[];
iconKey?: string;
}
interface AttachedSkill {
id: string;
name: string;
content: string;
}
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
const isImagePath = (p: string) => IMAGE_EXTS.has(p.slice(p.lastIndexOf('.')).toLowerCase());
export function useComposerAttachments() {
const [images, setImages] = useState<AttachedImage[]>([]);
const [contextPaths, setContextPaths] = useState<ContextPath[]>([]);
const [forcedTools, setForcedTools] = useState<ForcedToolGroup[]>([]);
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
const [isDragOver, setIsDragOver] = useState(false);
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 attachFilePaths = useCallback((paths: string[]) => {
if (paths.length === 0) return;
const newPaths: ContextPath[] = paths
.filter((p) => !isImagePath(p))
.map((p) => ({ path: p, type: 'file' as const }));
if (newPaths.length > 0) setContextPaths((prev) => [...prev, ...newPaths]);
}, []);
/** Attach non-image files using their native Electron File.path. */
const attachFiles = useCallback((files: File[]) => {
if (files.length === 0) return;
const paths = files
.map((f) => (f as File & { path?: string }).path)
.filter((p): p is string => Boolean(p));
attachFilePaths(paths);
}, [attachFilePaths]);
/** Open native OS file picker and attach selected files as context paths. */
const browseAndAttachFiles = useCallback(async () => {
const result = await window.openswarm.showOpenDialog({
properties: ['openFile', 'multiSelections'],
});
if (result.canceled || !result.filePaths?.length) return;
attachFilePaths(result.filePaths);
}, [attachFilePaths]);
const removeImage = useCallback(
(idx: number) => setImages((prev) => prev.filter((_, i) => i !== idx)),
[],
);
const removeContextPath = useCallback(
(idx: number) => setContextPaths((prev) => prev.filter((_, i) => i !== idx)),
[],
);
const removeForcedTool = useCallback(
(idx: number) => setForcedTools((prev) => prev.filter((_, i) => i !== idx)),
[],
);
const removeSkill = useCallback(
(id: string) =>
setAttachedSkills((prev) => {
const { [id]: _, ...rest } = prev;
return rest;
}),
[],
);
const clearAll = useCallback(() => {
setImages([]);
setContextPaths([]);
setForcedTools([]);
setAttachedSkills({});
}, []);
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) attachFiles(otherFiles);
},
[addImageFiles, attachFiles],
);
return {
images, contextPaths, forcedTools, attachedSkills, isDragOver,
setImages, setContextPaths, setForcedTools, setAttachedSkills,
addImageFiles, attachFiles, browseAndAttachFiles, removeImage, removeContextPath,
removeForcedTool, removeSkill, clearAll,
handleDragOver, handleDragLeave, handleDrop,
};
}
@@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import RefreshIcon from '@mui/icons-material/Refresh';
import DifferenceIcon from '@mui/icons-material/Difference';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
const AGENTS_API = `${API_BASE}/agents`;
interface Props {
sessionId: string;
}
const DiffViewer: React.FC<Props> = ({ sessionId }) => {
const c = useClaudeTokens();
const [diff, setDiff] = useState<string>('');
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const fetchDiff = async () => {
setLoading(true);
try {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/diff`);
const data = await res.json();
setDiff(data.diff || '');
} catch {
setDiff('Failed to fetch diff');
}
setLoading(false);
};
useEffect(() => {
if (open) fetchDiff();
}, [open, sessionId]);
if (!open) {
return (
<Tooltip title="View changes">
<IconButton onClick={() => setOpen(true)} sx={{ color: c.text.tertiary }}>
<DifferenceIcon />
</IconButton>
</Tooltip>
);
}
return (
<Box
sx={{
width: 400,
flexShrink: 0,
boxShadow: '-1px 0 4px rgba(0,0,0,0.04)',
bgcolor: c.bg.surface,
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1,
borderBottom: `0.5px solid ${c.border.medium}`,
bgcolor: c.bg.secondary,
}}
>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.85rem' }}>
Worktree Changes
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Refresh">
<IconButton size="small" onClick={fetchDiff} sx={{ color: c.text.tertiary }}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={() => setOpen(false)} sx={{ color: c.text.tertiary }}>
<Typography sx={{ fontSize: '0.85rem' }}>×</Typography>
</IconButton>
</Box>
</Box>
<Box
sx={{
flex: 1,
overflow: 'auto',
p: 1.5,
'&::-webkit-scrollbar': { width: 5, height: 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 ? (
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>Loading...</Typography>
) : diff ? (
<pre
style={{
margin: 0,
fontSize: '0.72rem',
fontFamily: c.font.mono,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{diff.split('\n').map((line, i) => {
let color = c.text.muted;
if (line.startsWith('+') && !line.startsWith('+++')) color = c.status.success;
else if (line.startsWith('-') && !line.startsWith('---')) color = c.status.error;
else if (line.startsWith('@@')) color = c.accent.primary;
else if (line.startsWith('diff ') || line.startsWith('index ')) color = c.text.tertiary;
return (
<span key={i} style={{ color }}>
{line}
{'\n'}
</span>
);
})}
</pre>
) : (
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>
No changes detected in the worktree.
</Typography>
)}
</Box>
</Box>
);
};
export default DiffViewer;
@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import EditIcon from '@mui/icons-material/Edit';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import ReplayIcon from '@mui/icons-material/Replay';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface BranchNavProps {
currentIndex: number;
totalBranches: number;
onPrevious: () => void;
onNext: () => void;
}
interface Props {
role: 'user' | 'assistant';
onCopy: () => void;
onEdit?: () => void;
onRegenerate?: () => void;
onBranch?: () => void;
branchNav?: BranchNavProps;
}
const btnSx = (c: ReturnType<typeof useClaudeTokens>) => ({
color: c.text.tertiary,
p: 0.4,
'&:hover': { color: c.text.secondary, bgcolor: 'transparent' },
'&.Mui-disabled': { color: c.border.medium },
});
const MessageActionBar: React.FC<Props> = ({
role,
onCopy,
onEdit,
onRegenerate,
onBranch,
branchNav,
}) => {
const c = useClaudeTokens();
const [copied, setCopied] = useState(false);
const handleCopy = () => {
onCopy();
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const isUser = role === 'user';
return (
<Box
className="msg-actions"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: isUser ? 'flex-end' : 'flex-start',
gap: 0,
opacity: 0,
transition: 'opacity 0.15s',
mt: -0.25,
mb: 0.25,
minHeight: 28,
}}
>
{isUser ? (
<>
<Tooltip title="Coming soon" arrow>
<span>
<IconButton size="small" disabled sx={btnSx(c)}>
<BookmarkBorderIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title={copied ? 'Copied!' : 'Copy'} arrow>
<IconButton size="small" onClick={handleCopy} sx={btnSx(c)}>
{copied ? <CheckIcon sx={{ fontSize: 16 }} /> : <ContentCopyIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
{onEdit && (
<Tooltip title="Edit" arrow>
<IconButton size="small" onClick={onEdit} sx={btnSx(c)}>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
{branchNav && branchNav.totalBranches > 1 && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', ml: 0.25 }}>
<IconButton
size="small"
onClick={branchNav.onPrevious}
disabled={branchNav.currentIndex === 0}
sx={btnSx(c)}
>
<ChevronLeftIcon sx={{ fontSize: 16 }} />
</IconButton>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.7rem',
minWidth: 28,
textAlign: 'center',
userSelect: 'none',
}}
>
{branchNav.currentIndex + 1} / {branchNav.totalBranches}
</Typography>
<IconButton
size="small"
onClick={branchNav.onNext}
disabled={branchNav.currentIndex === branchNav.totalBranches - 1}
sx={btnSx(c)}
>
<ChevronRightIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
)}
</>
) : (
<>
<Tooltip title={copied ? 'Copied!' : 'Copy'} arrow>
<IconButton size="small" onClick={handleCopy} sx={btnSx(c)}>
{copied ? <CheckIcon sx={{ fontSize: 16 }} /> : <ContentCopyIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
{onRegenerate && (
<Tooltip title="Regenerate" arrow>
<IconButton size="small" onClick={onRegenerate} sx={btnSx(c)}>
<ReplayIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
{onBranch && (
<Tooltip title="Branch chat" arrow>
<IconButton size="small" onClick={onBranch} sx={btnSx(c)}>
<CallSplitIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
</>
)}
</Box>
);
};
export default MessageActionBar;
File diff suppressed because it is too large Load Diff
@@ -1,182 +0,0 @@
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;
@@ -1,101 +0,0 @@
import { type FC, type ReactNode } from 'react';
import {
ThreadPrimitive,
MessagePrimitive,
ComposerPrimitive,
AuiIf,
} from '@assistant-ui/react';
import { ArrowDownIcon } from 'lucide-react';
import { Button } from '@/app/pages/AgentChat/_shared/Button';
import { TooltipProvider } from './components/tooltip';
import { TooltipIconButton } from './components/TooltipIconButton';
import { UserMessage } from './components/UserMessage/UserMessage';
import { AssistantMessage } from './components/AssistantMessage/AssistantMessage';
import { SessionIdContext, BranchChatContext } from './utils';
interface OpenSwarmThreadProps {
sessionId?: string;
onBranchChat?: (newSessionId: string) => void;
children?: ReactNode;
}
const OpenSwarmThread: FC<OpenSwarmThreadProps> = ({
sessionId,
onBranchChat,
children,
}) => {
return (
<TooltipProvider>
<SessionIdContext.Provider value={sessionId}>
<BranchChatContext.Provider value={onBranchChat}>
<ThreadPrimitive.Root
className="aui-root aui-thread-root flex h-full flex-col bg-background"
style={{
['--thread-max-width' as string]: '44rem',
}}
>
<ThreadPrimitive.Viewport className="aui-thread-viewport relative flex flex-1 flex-col overflow-y-auto scroll-smooth px-4 pt-4">
<AuiIf condition={(s) => s.thread.isEmpty}>
<ThreadWelcome />
</AuiIf>
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage,
EditComposer,
}}
/>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col items-center overflow-visible pb-4">
<ThreadScrollToBottom />
{children}
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
</BranchChatContext.Provider>
</SessionIdContext.Provider>
</TooltipProvider>
);
};
const ThreadWelcome: FC = () => (
<div className="mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center justify-center">
<p className="text-muted-foreground text-lg">How can I help you today?</p>
</div>
);
const ThreadScrollToBottom: FC = () => (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible dark:border-border dark:bg-background dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
const EditComposer: FC = () => (
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
<ComposerPrimitive.Input
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
autoFocus
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
<ComposerPrimitive.Cancel asChild>
<Button variant="ghost" size="sm">
Cancel
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size="sm">Update</Button>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
</MessagePrimitive.Root>
);
export default OpenSwarmThread;
@@ -1,40 +0,0 @@
import { type FC } from 'react';
import { MessagePrimitive, ErrorPrimitive } from '@assistant-ui/react';
import { MarkdownText } from './MarkdownText/MarkdownText';
import { ToolFallback } from './ToolFallback/ToolFallback';
import { AssistantActionBar } from '../MessageActions';
import { BranchPicker } from '../BranchPicker';
export const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root relative mx-auto w-full max-w-(--thread-max-width) py-3"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === 'text') return <MarkdownText />;
if (part.type === 'tool-call')
return part.toolUI ?? <ToolFallback {...part} />;
return null;
}}
</MessagePrimitive.Parts>
<MessageError />
</div>
<div className="aui-assistant-message-footer mt-1 ml-2 flex min-h-6 items-center">
<BranchPicker />
<AssistantActionBar />
</div>
</MessagePrimitive.Root>
);
};
const MessageError: FC = () => (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
@@ -1,52 +0,0 @@
"use client";
import "@assistant-ui/react-markdown/styles/dot.css";
import {
type CodeHeaderProps,
} from "@assistant-ui/react-markdown";
import { type FC, useState } from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { TooltipIconButton } from "@/app/pages/AgentChat/OpenSwarmThread/components/TooltipIconButton";
const useCopyToClipboard = ({
copiedDuration = 3000,
}: {
copiedDuration?: number;
} = {}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const copyToClipboard = (value: string) => {
if (!value) return;
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), copiedDuration);
});
};
return { isCopied, copyToClipboard };
};
export const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard();
const onCopy = () => {
if (!code || isCopied) return;
copyToClipboard(code);
};
return (
<div className="aui-code-header-root mt-2.5 flex items-center justify-between rounded-t-lg border border-border/50 border-b-0 bg-muted/50 px-3 py-1.5 text-xs">
<span className="aui-code-header-language font-medium text-muted-foreground lowercase">
{language}
</span>
<TooltipIconButton tooltip="Copy" onClick={onCopy}>
{!isCopied && <CopyIcon />}
{isCopied && <CheckIcon />}
</TooltipIconButton>
</div>
);
};
@@ -1,187 +0,0 @@
"use client";
import "@assistant-ui/react-markdown/styles/dot.css";
import {
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import { cn } from "@/lib/utils";
import { CodeHeader } from "./CodeHeader";
export const DEFAULT_COMPONENTS = memoizeMarkdownComponents({
h1: ({ className, ...props }) => (
<h1
className={cn(
"aui-md-h1 mb-2 scroll-m-20 font-semibold text-base first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h2: ({ className, ...props }) => (
<h2
className={cn(
"aui-md-h2 mt-3 mb-1.5 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h3: ({ className, ...props }) => (
<h3
className={cn(
"aui-md-h3 mt-2.5 mb-1 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h4: ({ className, ...props }) => (
<h4
className={cn(
"aui-md-h4 mt-2 mb-1 scroll-m-20 font-medium text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h5: ({ className, ...props }) => (
<h5
className={cn(
"aui-md-h5 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h6: ({ className, ...props }) => (
<h6
className={cn(
"aui-md-h6 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
p: ({ className, ...props }) => (
<p
className={cn(
"aui-md-p my-2.5 leading-normal first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
a: ({ className, ...props }) => (
<a
className={cn(
"aui-md-a text-primary underline underline-offset-2 hover:text-primary/80",
className,
)}
{...props}
/>
),
blockquote: ({ className, ...props }) => (
<blockquote
className={cn(
"aui-md-blockquote my-2.5 border-muted-foreground/30 border-l-2 pl-3 text-muted-foreground italic",
className,
)}
{...props}
/>
),
ul: ({ className, ...props }) => (
<ul
className={cn(
"aui-md-ul my-2 ml-4 list-disc marker:text-muted-foreground [&>li]:mt-1",
className,
)}
{...props}
/>
),
ol: ({ className, ...props }) => (
<ol
className={cn(
"aui-md-ol my-2 ml-4 list-decimal marker:text-muted-foreground [&>li]:mt-1",
className,
)}
{...props}
/>
),
hr: ({ className, ...props }) => (
<hr
className={cn("aui-md-hr my-2 border-muted-foreground/20", className)}
{...props}
/>
),
table: ({ className, ...props }) => (
<table
className={cn(
"aui-md-table my-2 w-full border-separate border-spacing-0 overflow-y-auto",
className,
)}
{...props}
/>
),
th: ({ className, ...props }) => (
<th
className={cn(
"aui-md-th bg-muted px-2 py-1 text-left font-medium first:rounded-tl-lg last:rounded-tr-lg [[align=center]]:text-center [[align=right]]:text-right",
className,
)}
{...props}
/>
),
td: ({ className, ...props }) => (
<td
className={cn(
"aui-md-td border-muted-foreground/20 border-b border-l px-2 py-1 text-left last:border-r [[align=center]]:text-center [[align=right]]:text-right",
className,
)}
{...props}
/>
),
tr: ({ className, ...props }) => (
<tr
className={cn(
"aui-md-tr m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg",
className,
)}
{...props}
/>
),
li: ({ className, ...props }) => (
<li className={cn("aui-md-li leading-normal", className)} {...props} />
),
sup: ({ className, ...props }) => (
<sup
className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)}
{...props}
/>
),
pre: ({ className, ...props }) => (
<pre
className={cn(
"aui-md-pre overflow-x-auto rounded-t-none rounded-b-lg border border-border/50 border-t-0 bg-muted/30 p-3 text-xs leading-relaxed",
className,
)}
{...props}
/>
),
code: function Code({ className, ...props }) {
const isCodeBlock = useIsMarkdownCodeBlock();
return (
<code
className={cn(
!isCodeBlock &&
"aui-md-inline-code rounded-md border border-border/50 bg-muted/50 px-1.5 py-0.5 font-mono text-[0.85em]",
className,
)}
{...props}
/>
);
},
CodeHeader,
});
@@ -1,15 +0,0 @@
"use client";
import "@assistant-ui/react-markdown/styles/dot.css";
import remarkGfm from "remark-gfm";
import { DEFAULT_COMPONENTS } from "./DEFAULT_COMPONENTS/DEFAULT_COMPONENTS";
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
export const MarkdownText = () => (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm]}
className="aui-md"
components={DEFAULT_COMPONENTS}
/>
);
@@ -1,64 +0,0 @@
"use client";
import { memo } from "react";
import {
type ToolCallMessagePartComponent,
} from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { ToolFallbackRoot } from "./components/ToolFallbackRoot";
import { ToolFallbackTrigger } from "./components/ToolFallbackTrigger";
import { ToolFallbackContent } from "./components/ToolFallbackContent";
import { ToolFallbackError } from "./components/ToolFallbackError";
import { ToolFallbackArgs } from "./components/ToolFallbackArgs";
import { ToolFallbackResult } from "./components/ToolFallbackResult";
const ToolFallbackImpl: ToolCallMessagePartComponent = ({
toolName,
argsText,
result,
status,
}) => {
const isCancelled =
status?.type === "incomplete" && status.reason === "cancelled";
return (
<ToolFallbackRoot
className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")}
>
<ToolFallbackTrigger toolName={toolName} status={status} />
<ToolFallbackContent>
<ToolFallbackError status={status} />
<ToolFallbackArgs
argsText={argsText}
className={cn(isCancelled && "opacity-60")}
/>
{!isCancelled && <ToolFallbackResult result={result} />}
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
const ToolFallback = memo(
ToolFallbackImpl,
) as unknown as ToolCallMessagePartComponent & {
Root: typeof ToolFallbackRoot;
Trigger: typeof ToolFallbackTrigger;
Content: typeof ToolFallbackContent;
Args: typeof ToolFallbackArgs;
Result: typeof ToolFallbackResult;
Error: typeof ToolFallbackError;
};
ToolFallback.displayName = "ToolFallback";
ToolFallback.Root = ToolFallbackRoot;
ToolFallback.Trigger = ToolFallbackTrigger;
ToolFallback.Content = ToolFallbackContent;
ToolFallback.Args = ToolFallbackArgs;
ToolFallback.Result = ToolFallbackResult;
ToolFallback.Error = ToolFallbackError;
export {
ToolFallback,
};
@@ -1,25 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
export function ToolFallbackArgs({
argsText,
className,
...props
}: React.ComponentProps<"div"> & {
argsText?: string;
}) {
if (!argsText) return null;
return (
<div
data-slot="tool-fallback-args"
className={cn("aui-tool-fallback-args px-4", className)}
{...props}
>
<pre className="aui-tool-fallback-args-value whitespace-pre-wrap">
{argsText}
</pre>
</div>
);
}
@@ -1,31 +0,0 @@
"use client";
import { CollapsibleContent } from "@/app/pages/AgentChat/_shared/collapsible";
import { cn } from "@/lib/utils";
export function ToolFallbackContent({
className,
children,
...props
}: React.ComponentProps<typeof CollapsibleContent>) {
return (
<CollapsibleContent
data-slot="tool-fallback-content"
className={cn(
"aui-tool-fallback-content relative overflow-hidden text-sm outline-none",
"group/collapsible-content ease-out",
"data-[state=closed]:animate-collapsible-up",
"data-[state=open]:animate-collapsible-down",
"data-[state=closed]:fill-mode-forwards",
"data-[state=closed]:pointer-events-none",
"data-[state=open]:duration-(--animation-duration)",
"data-[state=closed]:duration-(--animation-duration)",
className,
)}
{...props}
>
<div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div>
</CollapsibleContent>
);
}
@@ -1,43 +0,0 @@
"use client";
import { type ToolCallMessagePartStatus } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
export function ToolFallbackError({
status,
className,
...props
}: React.ComponentProps<"div"> & {
status?: ToolCallMessagePartStatus;
}) {
if (status?.type !== "incomplete") return null;
const error = status.error;
const errorText = error
? typeof error === "string"
? error
: JSON.stringify(error)
: null;
if (!errorText) return null;
const isCancelled = status.reason === "cancelled";
const headerText = isCancelled ? "Cancelled reason:" : "Error:";
return (
<div
data-slot="tool-fallback-error"
className={cn("aui-tool-fallback-error px-4", className)}
{...props}
>
<p className="aui-tool-fallback-error-header font-semibold text-muted-foreground">
{headerText}
</p>
<p className="aui-tool-fallback-error-reason text-muted-foreground">
{errorText}
</p>
</div>
);
}
@@ -1,29 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
export function ToolFallbackResult({
result,
className,
...props
}: React.ComponentProps<"div"> & {
result?: unknown;
}) {
if (result === undefined) return null;
return (
<div
data-slot="tool-fallback-result"
className={cn(
"aui-tool-fallback-result border-t border-dashed px-4 pt-2",
className,
)}
{...props}
>
<p className="aui-tool-fallback-result-header font-semibold">Result:</p>
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
);
}
@@ -1,69 +0,0 @@
"use client";
import { useCallback, useRef, useState } from "react";
import {
useScrollLock,
} from "@assistant-ui/react";
import { Collapsible } from "@/app/pages/AgentChat/_shared/collapsible";
import { cn } from "@/lib/utils";
const ANIMATION_DURATION = 200;
type ToolFallbackRootProps = Omit<
React.ComponentProps<typeof Collapsible>,
"open" | "onOpenChange"
> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean;
};
export function ToolFallbackRoot({
className,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
defaultOpen = false,
children,
...props
}: ToolFallbackRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
lockScroll();
}
if (!isControlled) {
setUncontrolledOpen(open);
}
controlledOnOpenChange?.(open);
},
[lockScroll, isControlled, controlledOnOpenChange],
);
return (
<Collapsible
ref={collapsibleRef}
data-slot="tool-fallback-root"
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
"aui-tool-fallback-root group/tool-fallback-root w-full rounded-lg border py-3",
className,
)}
style={
{
"--animation-duration": `${ANIMATION_DURATION}ms`,
} as React.CSSProperties
}
{...props}
>
{children}
</Collapsible>
);
}
@@ -1,88 +0,0 @@
"use client";
import {
AlertCircleIcon,
CheckIcon,
ChevronDownIcon,
LoaderIcon,
XCircleIcon,
} from "lucide-react";
import { type ToolCallMessagePartStatus } from "@assistant-ui/react";
import { CollapsibleTrigger } from "@/app/pages/AgentChat/_shared/collapsible";
import { cn } from "@/lib/utils";
type ToolStatus = ToolCallMessagePartStatus["type"];
const statusIconMap: Record<ToolStatus, React.ElementType> = {
running: LoaderIcon,
complete: CheckIcon,
incomplete: XCircleIcon,
"requires-action": AlertCircleIcon,
};
export function ToolFallbackTrigger({
toolName,
status,
className,
...props
}: React.ComponentProps<typeof CollapsibleTrigger> & {
toolName: string;
status?: ToolCallMessagePartStatus;
}) {
const statusType = status?.type ?? "complete";
const isRunning = statusType === "running";
const isCancelled =
status?.type === "incomplete" && status.reason === "cancelled";
const Icon = statusIconMap[statusType];
const label = isCancelled ? "Cancelled tool" : "Used tool";
return (
<CollapsibleTrigger
data-slot="tool-fallback-trigger"
className={cn(
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors",
className,
)}
{...props}
>
<Icon
data-slot="tool-fallback-trigger-icon"
className={cn(
"aui-tool-fallback-trigger-icon size-4 shrink-0",
isCancelled && "text-muted-foreground",
isRunning && "animate-spin",
)}
/>
<span
data-slot="tool-fallback-trigger-label"
className={cn(
"aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-left leading-none",
isCancelled && "text-muted-foreground line-through",
)}
>
<span>
{label}: <b>{toolName}</b>
</span>
{isRunning && (
<span
aria-hidden
data-slot="tool-fallback-trigger-shimmer"
className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
>
{label}: <b>{toolName}</b>
</span>
)}
</span>
<ChevronDownIcon
data-slot="tool-fallback-trigger-chevron"
className={cn(
"aui-tool-fallback-trigger-chevron size-4 shrink-0",
"transition-transform duration-(--animation-duration) ease-out",
"group-data-[state=closed]/trigger:-rotate-90",
"group-data-[state=open]/trigger:rotate-0",
)}
/>
</CollapsibleTrigger>
);
}
@@ -1,33 +0,0 @@
import { type FC } from 'react';
import { BranchPickerPrimitive } from '@assistant-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { TooltipIconButton } from './TooltipIconButton';
import { cn } from '@/lib/utils';
export const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
className,
...rest
}) => (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn(
'aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs',
className,
)}
{...rest}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="aui-branch-picker-state font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
@@ -1,92 +0,0 @@
import { type FC, useCallback } from 'react';
import {
ActionBarPrimitive,
AuiIf,
useAui,
} from '@assistant-ui/react';
import {
CheckIcon,
CopyIcon,
GitBranchIcon,
PencilIcon,
RefreshCwIcon,
} from 'lucide-react';
import { TooltipIconButton } from './TooltipIconButton';
import { useAppDispatch } from '@/shared/hooks';
import {
setActiveSession,
} from '@/shared/state/agentsSlice';
import { DUPLICATE_SESSION } from '@/shared/backend-bridge/apps/agents';
import { useSessionId, useBranchChatCallback } from '../utils';
export const UserActionBar: FC = () => (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-user-action-bar-root flex flex-col items-end"
>
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
export const AssistantActionBar: FC = () => (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-assistant-action-bar-root -ml-1 flex gap-1 text-muted-foreground"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<AuiIf condition={(s) => s.message.isCopied}>
<CheckIcon />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<CopyIcon />
</AuiIf>
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Regenerate">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<BranchChatButton />
</ActionBarPrimitive.Root>
);
const BranchChatButton: FC = () => {
const dispatch = useAppDispatch();
const sessionId = useSessionId();
const onBranchChat = useBranchChatCallback();
const aui = useAui();
const handleBranchChat = useCallback(async () => {
if (!sessionId) return;
let messageId: string | undefined;
try {
messageId = aui.message().getState().id;
} catch {
return;
}
if (!messageId) return;
const action = await dispatch(
DUPLICATE_SESSION(sessionId),
);
if (DUPLICATE_SESSION.fulfilled.match(action)) {
if (onBranchChat) onBranchChat(action.payload.session.session_id);
else dispatch(setActiveSession(action.payload.session.session_id));
}
}, [sessionId, dispatch, aui, onBranchChat]);
return (
<TooltipIconButton tooltip="Branch chat" onClick={handleBranchChat}>
<GitBranchIcon />
</TooltipIconButton>
);
};

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