diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 92889ace..6b90ec12 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -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) diff --git a/frontend/.lintignore-max-folder-items b/frontend/.lintignore-max-folder-items deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/components.json b/frontend/components.json deleted file mode 100644 index e123b5f2..00000000 --- a/frontend/components.json +++ /dev/null @@ -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" - } -} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs deleted file mode 100644 index 6f90f3c3..00000000 --- a/frontend/eslint.config.mjs +++ /dev/null @@ -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"], - }, -); diff --git a/frontend/knip.json b/frontend/knip.json deleted file mode 100644 index d931f5f9..00000000 --- a/frontend/knip.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "project": ["src/**/*.{ts,tsx}"] -} diff --git a/frontend/package.json b/frontend/package.json index 0227dd2c..6fdc10a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" + ] } } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index e5640725..00000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; diff --git a/frontend/index.html b/frontend/public/index.html similarity index 60% rename from frontend/index.html rename to frontend/public/index.html index c067515a..5294c8ad 100644 --- a/frontend/index.html +++ b/frontend/public/index.html @@ -4,12 +4,11 @@ Open Swarm - - + +
- - + \ No newline at end of file diff --git a/frontend/public/onboarding-videos/Step1.mp4 b/frontend/public/onboarding-videos/Step1.mp4 new file mode 100644 index 00000000..56914855 Binary files /dev/null and b/frontend/public/onboarding-videos/Step1.mp4 differ diff --git a/frontend/public/onboarding-videos/Step2.mp4 b/frontend/public/onboarding-videos/Step2.mp4 new file mode 100644 index 00000000..9ac5cdaa Binary files /dev/null and b/frontend/public/onboarding-videos/Step2.mp4 differ diff --git a/frontend/public/onboarding-videos/Step3.mp4 b/frontend/public/onboarding-videos/Step3.mp4 new file mode 100644 index 00000000..10af62cd Binary files /dev/null and b/frontend/public/onboarding-videos/Step3.mp4 differ diff --git a/frontend/public/onboarding-videos/Step4.mp4 b/frontend/public/onboarding-videos/Step4.mp4 new file mode 100644 index 00000000..e7010077 Binary files /dev/null and b/frontend/public/onboarding-videos/Step4.mp4 differ diff --git a/frontend/public/onboarding-videos/Step5.mp4 b/frontend/public/onboarding-videos/Step5.mp4 new file mode 100644 index 00000000..bf63d51c Binary files /dev/null and b/frontend/public/onboarding-videos/Step5.mp4 differ diff --git a/frontend/run.sh b/frontend/run.sh index 4bb6bb85..0fa200ab 100755 --- a/frontend/run.sh +++ b/frontend/run.sh @@ -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 - \ No newline at end of file diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index bf38ef91..e855da8d 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -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}; }; +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 = { + 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>, +): { 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} + setWarning(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + > + setWarning(null)} + sx={{ fontSize: '0.8rem' }} + > + {warning && ( + <>Default model {warning.from} is no longer available — switched to {warning.to} ({warning.provider}). + )} + + + + ); +}; + 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 ( + - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + }> + } /> + {/* Dashboard route is a no-op stub — the actual is rendered + persistently inside AppShell so its webviews survive navigation between + routes. This route exists only so React Router matches the URL. */} + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + diff --git a/frontend/src/app/buildMuiTheme.ts b/frontend/src/app/buildMuiTheme.ts deleted file mode 100644 index ccf7f559..00000000 --- a/frontend/src/app/buildMuiTheme.ts +++ /dev/null @@ -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', - }, - }, - }, - }, - }); -} diff --git a/frontend/src/app/components/AppShell/AppShell.tsx b/frontend/src/app/components/AppShell/AppShell.tsx deleted file mode 100644 index 035e3934..00000000 --- a/frontend/src/app/components/AppShell/AppShell.tsx +++ /dev/null @@ -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 ( - - setSidebarCollapsed((p) => !p)} - /> - - {showUpdateBanner && ( - - )} - - - {!sidebarCollapsed && ( - <> - - - - - - )} - - - - - - - - setSnackbarDismissed(true)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - - : - } - action={ - - - {updateStatus === 'available' && ( - - )} - {updateStatus === 'downloaded' && ( - - )} - - } - 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`} - - - - ); -}; - -export default AppShell; diff --git a/frontend/src/app/components/AppShell/components/Settings/Settings.tsx b/frontend/src/app/components/AppShell/components/Settings/Settings.tsx deleted file mode 100644 index 8739e5ea..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/Settings.tsx +++ /dev/null @@ -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 ( - <> - - - - - Settings - - - - - - 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 }, - }} - > - - - - - - - {activeTab === 'general' ? ( - - ) : activeTab === 'models' ? ( - - ) : ( - - - - )} - - {(activeTab === 'general' || activeTab === 'models') && ( - - - - - )} - setSaved(false)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - setSaved(false)} severity="success" sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.status.success}` }}> - Settings saved - - - - setConfirmDiscard(false)} - PaperProps={{ - sx: { - bgcolor: c.bg.page, - borderRadius: 2, - border: `1px solid ${c.border.subtle}`, - boxShadow: c.shadow.md, - maxWidth: 380, - }, - }} - > - - Unsaved changes - - - - You have unsaved changes. Would you like to save them before closing? - - - - - - - - - - ); -}; - -export default Settings; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/CommandsTab.tsx b/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/CommandsTab.tsx deleted file mode 100644 index 17daaa2f..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/CommandsTab.tsx +++ /dev/null @@ -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 ( - - - - - - - - ); -}; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/AtCommandsSection.tsx b/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/AtCommandsSection.tsx deleted file mode 100644 index 5e3b813b..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/AtCommandsSection.tsx +++ /dev/null @@ -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 = ({ atCommands, c }) => ( - - } - title="@ Context Commands" - subtitle="Type @ in chat to attach context and activate actions" - count={atCommands.length} - c={c} - /> - - {atCommands.length === 0 ? ( - - - - No @ commands yet. Install MCP actions to see them here. - - - ) : ( - - {atCommands.map((cmd) => ( - - - {cmd.icon} - - - {cmd.prefix} - - - - {cmd.description} - - - ))} - - )} - -); - -export default AtCommandsSection; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/ShortcutsSection.tsx b/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/ShortcutsSection.tsx deleted file mode 100644 index a924a565..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/ShortcutsSection.tsx +++ /dev/null @@ -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 = ({ navShortcuts, actionShortcuts, c }) => ( - - } - title="Keyboard Shortcuts" - subtitle="Press ? anywhere to see the quick-reference dialog" - count={SHORTCUTS.length} - c={c} - /> - - - - - Navigation - - - {navShortcuts.map((s) => ( - - - {s.description} - - - - ))} - - - - - - Actions - - - {actionShortcuts.map((s) => ( - - - {s.description} - - - - ))} - - - - -); - -export default ShortcutsSection; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/SlashCommandsSection.tsx b/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/SlashCommandsSection.tsx deleted file mode 100644 index aa054768..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/SlashCommandsSection.tsx +++ /dev/null @@ -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; - c: any; -} - -const SlashCommandsSection: React.FC = ({ slashCommands, modesMap, c }) => ( - - } - title="Slash Commands" - subtitle="Type / in chat to invoke skills and modes" - count={slashCommands.length} - c={c} - /> - - {slashCommands.length === 0 ? ( - - - - No slash commands yet. Create skills or modes to see them here. - - - ) : ( - - {slashCommands.map((cmd) => ( - - - {cmd.type === 'mode' ? ( - - ) : ( - - )} - - - /{cmd.command} - - - - {cmd.description} - - - ))} - - )} - -); - -export default SlashCommandsSection; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/shared/CommandsHelpers.tsx b/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/shared/CommandsHelpers.tsx deleted file mode 100644 index c7a42e01..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/shared/CommandsHelpers.tsx +++ /dev/null @@ -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 }) => ( - - - {keys} - - -); - -export const SectionHeader: React.FC<{ - icon: React.ReactNode; - title: string; - subtitle: string; - count?: number; - c: any; -}> = ({ icon, title, subtitle, count, c }) => ( - - {icon} - - - - {title} - - {count !== undefined && ( - - )} - - {subtitle} - - -); diff --git a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/shared/commandsTypes.ts b/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/shared/commandsTypes.ts deleted file mode 100644 index a136cfd4..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/components/shared/commandsTypes.ts +++ /dev/null @@ -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' }, -]; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/hooks/useCommands.tsx b/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/hooks/useCommands.tsx deleted file mode 100644 index 40a86808..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/CommandsTab/hooks/useCommands.tsx +++ /dev/null @@ -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: , source: 'builtin' }, - ]; - - const hasWebSearch = builtinTools.some((t) => t.name === 'WebSearch' && t.deferred); - const hasWebFetch = builtinTools.some((t) => t.name === 'WebFetch' && t.deferred); - if (hasWebSearch || hasWebFetch) { - items.push({ - prefix: '@web', - label: 'Web', - description: 'Search the web and fetch URLs', - icon: , - source: 'builtin', - }); - } - - for (const tool of Object.values(customTools)) { - if (!tool.mcp_config || Object.keys(tool.mcp_config).length === 0) continue; - const services = tool.tool_permissions?._services as Record | undefined; - if (!services) continue; - const perms = tool.tool_permissions as Record; - const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record; - - const enabledServices: { name: string }[] = []; - for (const [serviceName, serviceTools] of Object.entries(services)) { - const allToolNames = [...(serviceTools.read || []), ...(serviceTools.write || [])]; - const enabled = allToolNames.filter((name) => perms[name] !== 'deny'); - if (enabled.length > 0) enabledServices.push({ name: serviceName }); - } - - if (enabledServices.length === 0) continue; - - const groupEntries = Object.entries(serviceGroups); - const emittedServices = new Set(); - - for (const [groupName, groupServiceNames] of groupEntries) { - const groupCmd = groupName.toLowerCase().replace(/\s+/g, '-'); - const groupServices = enabledServices.filter((s) => groupServiceNames.includes(s.name)); - if (groupServices.length === 0) continue; - groupServices.forEach((s) => emittedServices.add(s.name)); - - const groupIcon = getToolGroupIcon(groupName, 18); - if (groupServices.length >= 2) { - items.push({ - prefix: `@${groupCmd}`, - label: groupName, - description: `Use all ${groupName} actions`, - icon: groupIcon, - source: tool.name, - }); - for (const svc of groupServices) { - items.push({ - prefix: `@${groupCmd}/${svc.name.toLowerCase().replace(/\s+/g, '-')}`, - label: svc.name, - description: `Use ${svc.name} actions from ${tool.name}`, - icon: groupIcon, - source: tool.name, - isChild: true, - }); - } - } else { - const svc = groupServices[0]; - items.push({ - prefix: `@${svc.name.toLowerCase().replace(/\s+/g, '-')}`, - label: svc.name, - description: `Use ${svc.name} actions from ${tool.name}`, - icon: groupIcon, - source: tool.name, - }); - } - } - - for (const svc of enabledServices) { - if (emittedServices.has(svc.name)) continue; - items.push({ - prefix: `@${svc.name.toLowerCase().replace(/\s+/g, '-')}`, - label: svc.name, - description: `Use ${svc.name} actions from ${tool.name}`, - icon: , - source: tool.name, - }); - } - } - - for (const out of Object.values(outputItems)) { - if (out.permission === 'deny') continue; - const cmd = out.name.toLowerCase().replace(/\s+/g, '-'); - items.push({ - prefix: `@${cmd}`, - label: out.name, - description: out.description || `Render ${out.name} view`, - icon: , - source: 'view', - }); - } - - return items; - }, [builtinTools, customTools, outputItems]); - - const navShortcuts = SHORTCUTS.filter((s) => s.category === 'navigation'); - const actionShortcuts = SHORTCUTS.filter((s) => s.category === 'action'); - - return { slashCommands, atCommands, modesMap, navShortcuts, actionShortcuts }; -} diff --git a/frontend/src/app/components/AppShell/components/Settings/components/GeneralTab/GeneralTab.tsx b/frontend/src/app/components/AppShell/components/Settings/components/GeneralTab/GeneralTab.tsx deleted file mode 100644 index 1106ebbc..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/GeneralTab/GeneralTab.tsx +++ /dev/null @@ -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 ( - - Agent Defaults - - - System prompt - {form.default_system_prompt !== DEFAULT_SYSTEM_PROMPT && ( - - )} - - - Prepended to every agent session before mode-specific instructions. Modes can override with their own. - - 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, - }, - }} - /> - - - Working directory - - Default folder agents start in. Modes can override per-mode. - - - 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, - }, - }} - /> - - - - - - Model - Default model for new sessions. - - - - - - - - Mode - Default interaction mode for new sessions. - - - - - - - - Max turns - Auto-stop after this many turns. Empty = unlimited. - - setForm({ ...form, default_max_turns: e.target.value ? parseInt(e.target.value) : null })} - size="small" - placeholder="∞" - inputProps={{ min: 1 }} - sx={{ ...fieldSx, width: 100 }} - /> - - - Browser - - Default homepage - - URL loaded when opening a new browser card on the dashboard. - - - - 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, - }, - }} - /> - - - Advanced - - - Developer mode - Show transport details, environment variables, raw configs, and other technical metadata throughout the app. - - 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 }, - }} - /> - - - - ); -}; - -export default GeneralTab; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/GeneralTab/components/AboutSection.tsx b/frontend/src/app/components/AppShell/components/Settings/components/GeneralTab/components/AboutSection.tsx deleted file mode 100644 index 02665b04..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/GeneralTab/components/AboutSection.tsx +++ /dev/null @@ -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 ( - <> - About - - - - Version - - {appVersion ?? '—'} - - - - - - - - Software update - - {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.'} - - - - {updateStatus === 'checking' && ( - - )} - {updateStatus === 'not-available' && ( - - )} - {updateStatus === 'error' && ( - - )} - {(updateStatus === 'idle' || updateStatus === 'not-available' || updateStatus === 'error') && ( - - )} - {updateStatus === 'available' && ( - - )} - {updateStatus === 'downloaded' && ( - - )} - - - {updateStatus === 'downloading' && ( - - )} - - - ); -}; - -export default AboutSection; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/GeneralTab/components/InterfaceSection.tsx b/frontend/src/app/components/AppShell/components/Settings/components/GeneralTab/components/InterfaceSection.tsx deleted file mode 100644 index 96fc672c..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/GeneralTab/components/InterfaceSection.tsx +++ /dev/null @@ -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 ( - <> - Interface - - - Theme - Application color scheme. - - { 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` }, - }, - }, - }} - > - - Light - - - Dark - - - - - Zoom sensitivity - - Scroll-to-zoom responsiveness. Lower for trackpads, higher for mouse wheels. - - - 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 }, - }} - /> - - - - - New agent shortcut - Keyboard shortcut to create an agent. - - { - 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 }, - }} - > - - {recordingShortcut ? ( - - Press shortcut… - - ) : ( - - {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(' + ')} - - )} - - - - - Auto-enable element selection - Automatically enter element selection mode when creating a new agent. - - 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 }, - }} - /> - - - - Default agent spawn state in dashboard - When enabled, new agents spawn expanded instead of collapsed. - - 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 }, - }} - /> - - - - Auto-reveal sub-agents on dashboard - Automatically show sub-agent cards (from CreateAgent / InvokeAgent) tethered to their parent on the dashboard. - - 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 }, - }} - /> - - - ); -}; - -export default InterfaceSection; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/ModelsTab/ModelsTab.tsx b/frontend/src/app/components/AppShell/components/Settings/components/ModelsTab/ModelsTab.tsx deleted file mode 100644 index 7ca72fc1..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/ModelsTab/ModelsTab.tsx +++ /dev/null @@ -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 ( - - - Use Your Existing Subscriptions - - - Already paying for Claude, ChatGPT, or Gemini? Connect your subscription — no API key needed, no extra cost. - - - - Or Connect With API Keys - - - Pay per use. Each key is stored locally on your device. - - - - Anthropic - {form.anthropic_api_key ? ( - CONNECTED - ) : null} - - Claude Sonnet, Opus, Haiku. - - 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: ( - - setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}> - {showApiKey ? : } - - - ), - }} - /> - - Get key - - - - - ); -}; - -export default ModelsTab; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/ModelsTab/components/SubscriptionCard.tsx b/frontend/src/app/components/AppShell/components/Settings/components/ModelsTab/components/SubscriptionCard.tsx deleted file mode 100644 index e4f11e10..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/ModelsTab/components/SubscriptionCard.tsx +++ /dev/null @@ -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 = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => { - const c = useClaudeTokens(); - const isPreview = (provider as any).preview; - return ( - - - - - - {provider.name} - - {connecting ? 'Waiting for authorization...' : provider.desc} - - - - {isPreview ? ( - - Coming soon - - ) : connected ? ( - disconnecting ? ( - - ) : ( - - Disconnect - - ) - ) : connecting && userCode ? ( - - Enter code: - {userCode} - - ) : connecting ? ( - - - Connecting... - - ) : ( - - )} - - - ); -}; - -export default SubscriptionCard; diff --git a/frontend/src/app/components/AppShell/components/Settings/components/ModelsTab/components/SubscriptionCards.tsx b/frontend/src/app/components/AppShell/components/Settings/components/ModelsTab/components/SubscriptionCards.tsx deleted file mode 100644 index eea76087..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/components/ModelsTab/components/SubscriptionCards.tsx +++ /dev/null @@ -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(null); - const [connecting, setConnecting] = useState(null); - const [disconnecting, setDisconnecting] = useState(null); - const [userCode, setUserCode] = useState(''); - const [pollTimer, setPollTimer] = useState(null); - const retryRef = useRef | 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 | 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 ( - - {SUBSCRIPTION_PROVIDERS.map(p => ( - - - - - - - - ))} - - ); - } - if (!status?.running) { - return ( - - - - Starting subscription service... - - - This connects your existing AI subscriptions. If this doesn't load, make sure Node.js is installed. - - - ); - } - return ( - - {SUBSCRIPTION_PROVIDERS.map(p => ( - handleConnect(p.id)} - onDisconnect={() => handleDisconnect(p.id)} - connecting={connecting === p.id} - disconnecting={disconnecting === p.id} - userCode={connecting === p.id ? userCode : undefined} - /> - ))} - - ); -}; - -export default SubscriptionCards; diff --git a/frontend/src/app/components/AppShell/components/Settings/hooks/useSettings.ts b/frontend/src/app/components/AppShell/components/Settings/hooks/useSettings.ts deleted file mode 100644 index 3aafd6dd..00000000 --- a/frontend/src/app/components/AppShell/components/Settings/hooks/useSettings.ts +++ /dev/null @@ -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({ ...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; diff --git a/frontend/src/app/components/AppShell/components/Sidebar.tsx b/frontend/src/app/components/AppShell/components/Sidebar.tsx deleted file mode 100644 index 7b716e94..00000000 --- a/frontend/src/app/components/AppShell/components/Sidebar.tsx +++ /dev/null @@ -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: }, - { label: 'Actions', path: '/actions', icon: }, - { label: 'Modes', path: '/modes', icon: }, -]; -const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path)); - -interface SidebarProps { showUpdateDot: boolean } - -const Sidebar: React.FC = ({ showUpdateDot }) => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - const navigate = useNavigate(); - const location = useLocation(); - const [dashExpanded, setDashExpanded] = useState(true); - const [appsExpanded, setAppsExpanded] = useState(true); - const [customExpanded, setCustomExpanded] = useState(true); - const [renamingId, setRenamingId] = useState(null); - const [renameValue, setRenameValue] = useState(''); - - const dashboardItems = useAppSelector((s) => s.dashboards.items); - const dashboardList = Object.values(dashboardItems).sort( - (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), - ); - const appsList = Object.values(useAppSelector((s) => s.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 ( - <> - - - - - - - - - - - - - {dashboardList.length > 0 && } - - 0} timeout={200}> - - {dashboardList.map((entry) => { - const isActive = activeDashId === entry.id; - const isRen = renamingId === entry.id; - return ( - handleDashItemClick(entry.id)} - sx={{ ...subItemSx(isActive), py: isRen ? 0.25 : 0.5, cursor: isRen ? 'default' : 'pointer' }}> - {isRen ? ( - setRenameValue(e.target.value)} - onBlur={() => handleRenameSubmit(entry.id)} - onKeyDown={(e) => { if (e.key === 'Enter') handleRenameSubmit(entry.id); if (e.key === 'Escape') setRenamingId(null); }} - onClick={(e) => e.stopPropagation()} - onFocus={(e) => e.target.select()} - sx={{ flex: 1, minWidth: 0, fontSize: '0.78rem', fontWeight: isActive ? 500 : 400, - color: isActive ? c.text.secondary : c.text.ghost, py: 0, px: 0.5, - borderRadius: 0.75, border: `1px solid ${c.accent.primary}80`, bgcolor: c.bg.page, - '& input': { padding: '1px 0' } }} - /> - ) : ( - { e.stopPropagation(); handleStartRename(entry.id, entry.name); }} - sx={subTextSx(isActive)}> - {entry.name} - - )} - - ); - })} - - - - - - { - if (isCustomRoute) setCustomExpanded((p) => !p); - else { navigate('/customization'); setCustomExpanded(true); } - }} sx={sectionSx(isCustomRoute)}> - - - - - - - - - {CUSTOMIZATION_ITEMS.map((item) => ( - - {({ isActive }) => ( - - {item.label} - - )} - - ))} - - - - - - - - - - - - - - - - {appsList.length > 0 && } - - 0} timeout={200}> - - {appsList.map((app) => { - const isActive = activeAppId === app.id; - return ( - navigate(`/apps/${app.id}`)} sx={subItemSx(isActive)}> - {app.name} - - ); - })} - - - - - - dispatch(openSettingsModal())} sx={{ - borderRadius: 1.5, py: 0.6, px: 1.25, - '&:hover': { bgcolor: `${c.text.tertiary}0A` }, transition: 'background-color 0.15s', - }}> - - - {showUpdateDot && ( - - )} - - - - - - ); -}; - -export default Sidebar; diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/DynamicIsland.tsx b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/DynamicIsland.tsx deleted file mode 100644 index 543dae3c..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/DynamicIsland.tsx +++ /dev/null @@ -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(null); - - const { - groups, - totalApprovals, - activeAgents, - finishedAgents, - hasApprovals, - hasAgents, - nonQuestionApprovalCount, - oldestNonQuestionApproval, - islandState, - userExpanded, - setUserExpanded, - } = useDynamicIslandData(); - - const { - onApprove, - onDeny, - onStopAgent, - onDismissAgent, - onNavigateToDashboard, - onClearAllFinished, - handleIslandClick, - } = useDynamicIslandActions(groups, islandState, hasAgents, hasApprovals, setUserExpanded, islandRef); - - const islandWidth = islandState === 'idle' - ? 200 - : islandState === 'compact' - ? 210 - : islandState === 'compact-actionable' - ? 310 - : 400; - - const islandBorderRadius = islandState === 'expanded' ? 14 : 50; - - const shadow = islandState === 'idle' - ? 'none' - : islandState === 'compact' - ? c.shadow.sm - : c.shadow.md; - - const compactText = useMemo(() => { - const parts: string[] = []; - if (activeAgents.length > 0) parts.push(`${activeAgents.length} running`); - if (finishedAgents.length > 0) parts.push(`${finishedAgents.length} done`); - return parts.join(' · ') || 'Agents'; - }, [activeAgents.length, finishedAgents.length]); - - const glowKeyframes = useMemo(() => ` - @keyframes approvalGlow { - 0%, 100% { box-shadow: 0 0 6px 1px ${c.status.warning}30; } - 50% { box-shadow: 0 0 12px 3px ${c.status.warning}60; } - } - `, [c.status.warning]); - - return ( - <> - {islandState === 'compact-actionable' && } - - - - {islandState === 'idle' && ( - - )} - {islandState === 'compact' && ( - - )} - {islandState === 'compact-actionable' && oldestNonQuestionApproval && ( - setUserExpanded(true)} - /> - )} - {islandState === 'expanded' && ( - setUserExpanded(false)} - /> - )} - - - - - ); -}; - -export default DynamicIsland; diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/CompactActionablePill.tsx b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/CompactActionablePill.tsx deleted file mode 100644 index dfb333c8..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/CompactActionablePill.tsx +++ /dev/null @@ -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 ; - case 'Read': return ; - case 'Write': case 'Edit': return ; - case 'Grep': case 'Glob': return ; - case 'AskUserQuestion': return ; - default: return ; - } -} - -export const CompactActionablePill: React.FC<{ - c: ClaudeTokens; - request: ApprovalRequest; - remainingCount: number; - onApprove: (requestId: string) => void; - onDeny: (requestId: string) => void; - onExpand: () => void; -}> = ({ c, request, remainingCount, onApprove, onDeny, onExpand }) => { - const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]); - const meta = useMcpToolMeta(parsed); - - const icon = parsed.isMcp - ? (meta.integration?.icon || null) - : getToolIcon(request.tool_name); - - return ( - - - - {icon} - - - {parsed.displayName} - - {remainingCount > 1 && ( - - +{remainingCount - 1} - - )} - - { e.stopPropagation(); onApprove(request.id); }} - sx={{ - p: 0, - width: 18, - height: 18, - color: '#fff', - bgcolor: c.status.success, - '&:hover': { bgcolor: c.status.success, filter: 'brightness(0.85)' }, - }} - > - - - - - { e.stopPropagation(); onDeny(request.id); }} - sx={{ - p: 0, - width: 18, - height: 18, - color: c.status.error, - border: `1px solid ${c.status.error}`, - '&:hover': { bgcolor: `${c.status.error}0a` }, - }} - > - - - - - { e.stopPropagation(); onExpand(); }} - sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }} - > - - - - - - ); -}; diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/CompactPill/ActivityIndicator.tsx b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/CompactPill/ActivityIndicator.tsx deleted file mode 100644 index c0b10352..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/CompactPill/ActivityIndicator.tsx +++ /dev/null @@ -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 }) => ( - -); diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/CompactPill/CompactPill.tsx b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/CompactPill/CompactPill.tsx deleted file mode 100644 index 4716d272..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/CompactPill/CompactPill.tsx +++ /dev/null @@ -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 }) => ( - - - - - {text} - - {hasApprovals && ( - - )} - - -); diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/AgentStatusRow.tsx b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/AgentStatusRow.tsx deleted file mode 100644 index 206209fd..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/AgentStatusRow.tsx +++ /dev/null @@ -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 ( - agent.dashboardId && onNavigate(agent.dashboardId, agent.id)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1, - px: 2, - py: 0.75, - cursor: agent.dashboardId ? 'pointer' : 'default', - '&:hover': { bgcolor: c.border.subtle }, - transition: 'background-color 0.15s', - minHeight: 34, - }} - > - - - {agent.name} - - - {cfg.label} - - {isActive ? ( - - { e.stopPropagation(); onStop(agent.id); }} - sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.border.subtle } }} - > - - - - ) : ( - - { e.stopPropagation(); onDismiss(agent.id); }} - sx={{ p: 0.25, color: c.text.ghost, '&:hover': { bgcolor: c.border.subtle } }} - > - - - - )} - - ); -}; diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/CompletedAgentsList.tsx b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/CompletedAgentsList.tsx deleted file mode 100644 index 86b8b8da..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/CompletedAgentsList.tsx +++ /dev/null @@ -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 && ( - - )} - setCompletedExpanded((v) => !v)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1, - px: 2, - py: 0.5, - cursor: 'pointer', - userSelect: 'none', - '&:hover': { bgcolor: c.border.subtle }, - transition: 'background-color 0.15s', - }} - > - - Completed ({finishedAgents.length}) - - { e.stopPropagation(); onClearAllFinished(); }} - sx={{ - fontSize: '0.58rem', - fontWeight: 600, - color: c.text.ghost, - cursor: 'pointer', - '&:hover': { color: c.text.secondary }, - transition: 'color 0.15s', - }} - > - Clear all - - - {completedExpanded - ? - : } - - - - {finishedAgents.map((agent) => ( - - ))} - - - ); -}; diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/ExpandedCard.tsx b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/ExpandedCard.tsx deleted file mode 100644 index bd7dcbe0..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/ExpandedCard.tsx +++ /dev/null @@ -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) => void; - onDeny: (requestId: string, message?: string) => void; - onStopAgent: (id: string) => void; - onDismissAgent: (id: string) => void; - onNavigateToDashboard: (dashboardId: string, agentId: string) => void; - onClearAllFinished: () => void; - onCollapse: () => void; -}> = ({ - c, groups, totalApprovals, - activeAgents, finishedAgents, hasApprovals, hasAgents, - onApprove, onDeny, onStopAgent, onDismissAgent, onNavigateToDashboard, onClearAllFinished, onCollapse, -}) => { - const headerTitle = hasApprovals && !hasAgents - ? 'Approval Required' - : hasAgents && !hasApprovals - ? 'Agents' - : 'Notifications'; - - const badgeCount = totalApprovals + activeAgents.length; - - return ( - - {/* Header */} - - - {headerTitle} - - {badgeCount > 0 && ( - - {badgeCount} - - )} - {!hasApprovals && ( - { e.stopPropagation(); onCollapse(); }} - sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }} - > - - - )} - - - {/* Content */} - - {hasApprovals && ( - - {hasAgents && ( - - Approvals - - )} - {groups.map((group) => ( - - {groups.length > 1 && ( - - {group.sessionName} - - )} - {group.approvals.length > 1 ? ( - - ) : ( - group.approvals.map((req) => ( - - )) - )} - - ))} - - )} - - {hasApprovals && hasAgents && ( - - )} - - {hasAgents && ( - - {hasApprovals && ( - - Agents - - )} - {activeAgents.map((agent) => ( - - ))} - 0} - onStopAgent={onStopAgent} - onDismissAgent={onDismissAgent} - onNavigateToDashboard={onNavigateToDashboard} - onClearAllFinished={onClearAllFinished} - /> - - )} - - - ); -}; diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/StatusDot.tsx b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/StatusDot.tsx deleted file mode 100644 index 77a7d2d8..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/ExpandedCard/StatusDot.tsx +++ /dev/null @@ -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 ( - - ); -}; \ No newline at end of file diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/IdlePill.tsx b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/IdlePill.tsx deleted file mode 100644 index 5b5e6917..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/components/IdlePill.tsx +++ /dev/null @@ -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 }) => ( - - - - - - Search... - - - - -); diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/hooks/useDynamicIslandActions.ts b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/hooks/useDynamicIslandActions.ts deleted file mode 100644 index 6e380bd8..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/hooks/useDynamicIslandActions.ts +++ /dev/null @@ -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, -) { - const dispatch = useAppDispatch(); - const navigate = useNavigate(); - - useEffect(() => { - if (!hasAgents && !hasApprovals) { - setUserExpanded(false); - } - }, [hasAgents, hasApprovals, setUserExpanded]); - - useEffect(() => { - if (islandState !== 'expanded') return; - const handler = (e: MouseEvent) => { - if (islandRef.current && !islandRef.current.contains(e.target as Node)) { - setUserExpanded(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [islandState, islandRef, setUserExpanded]); - - const onApprove = useCallback( - (requestId: string, updatedInput?: Record) => { - dispatch(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, - }; -} diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/hooks/useDynamicIslandData.ts b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/hooks/useDynamicIslandData.ts deleted file mode 100644 index 98a3eaf4..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/hooks/useDynamicIslandData.ts +++ /dev/null @@ -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, - }; -} diff --git a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/islandTypes.ts b/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/islandTypes.ts deleted file mode 100644 index c0b46b67..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/DynamicIsland/islandTypes.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ApprovalRequest, AgentSession } from '@/shared/state/agentsSlice'; -import type { useClaudeTokens } from '@/shared/styles/ThemeContext'; - -export type ClaudeTokens = ReturnType; - -export type IslandState = 'idle' | 'compact' | 'compact-actionable' | 'expanded'; - -export interface SessionApprovalGroup { - sessionId: string; - sessionName: string; - approvals: ApprovalRequest[]; -} - -export type TrackedAgent = { - id: string; - name: string; - status: AgentSession['status'] | string; - dashboardId?: string; -}; - -export const STATUS_CONFIG: Record = { - running: { label: 'Running', tokenKey: 'success' }, - waiting_approval: { label: 'Waiting', tokenKey: 'warning' }, - completed: { label: 'Done', tokenKey: 'success' }, - error: { label: 'Error', tokenKey: 'error' }, - stopped: { label: 'Stopped', tokenKey: 'info' }, -}; - -export const SPRING_LAYOUT = { type: 'spring' as const, stiffness: 400, damping: 30 }; -export const SPRING_BOUNCE = { type: 'spring' as const, stiffness: 500, damping: 25 }; diff --git a/frontend/src/app/components/AppShell/components/TitleBar/TitleBar.tsx b/frontend/src/app/components/AppShell/components/TitleBar/TitleBar.tsx deleted file mode 100644 index 36369525..00000000 --- a/frontend/src/app/components/AppShell/components/TitleBar/TitleBar.tsx +++ /dev/null @@ -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 = ({ sidebarCollapsed, onToggleSidebar }) => { - const c = useClaudeTokens(); - const navigate = useNavigate(); - - const navBtnSx = { - WebkitAppRegion: 'no-drag', - color: c.text.tertiary, - p: 0.5, - borderRadius: 1, - '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, - }; - - return ( - - - - - - - - navigate(-1)} sx={navBtnSx}> - - - - - navigate(1)} sx={navBtnSx}> - - - - - - - - - - - - OpenSwarm - - - - ); -}; - -export default TitleBar; diff --git a/frontend/src/app/components/AppShell/components/UpdateBanner.tsx b/frontend/src/app/components/AppShell/components/UpdateBanner.tsx deleted file mode 100644 index fb6f1301..00000000 --- a/frontend/src/app/components/AppShell/components/UpdateBanner.tsx +++ /dev/null @@ -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 = ({ - updateStatus, availableVersion, downloadPercent, - onDownload, onInstall, onDismiss, -}) => { - const c = useClaudeTokens(); - - const actionBtnSx = { - bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed }, - textTransform: 'none' as const, fontSize: '0.75rem', fontWeight: 600, - borderRadius: 1.5, minWidth: 'auto', py: 0.25, px: 1.5, - lineHeight: 1.5, flexShrink: 0, - }; - - return ( - - - - {updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`} - {updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}…`} - {updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`} - - {updateStatus === 'downloading' && ( - - )} - {updateStatus === 'downloading' && ( - - {Math.round(downloadPercent)}% - - )} - {updateStatus === 'available' && ( - - )} - {updateStatus === 'downloaded' && ( - - )} - - - - - ); -}; - -export default UpdateBanner; diff --git a/frontend/src/app/components/AppShell/hooks/useSidebarResize.ts b/frontend/src/app/components/AppShell/hooks/useSidebarResize.ts deleted file mode 100644 index c4bcdc25..00000000 --- a/frontend/src/app/components/AppShell/hooks/useSidebarResize.ts +++ /dev/null @@ -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 }; -} diff --git a/frontend/src/app/components/AppShell/hooks/useUpdateNotification.ts b/frontend/src/app/components/AppShell/hooks/useUpdateNotification.ts deleted file mode 100644 index f4e55e81..00000000 --- a/frontend/src/app/components/AppShell/hooks/useUpdateNotification.ts +++ /dev/null @@ -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(() => { - try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; } - }); - const [snackbarDismissed, setSnackbarDismissed] = useState(false); - - const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion; - const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading'; - const showUpdateDot = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion; - const showUpdateBanner = isUpdateActionable && !bannerDismissedForVersion; - const showUpdateSnackbar = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion && !snackbarDismissed; - - const handleDismissBanner = useCallback(() => { - if (availableVersion) { - try { localStorage.setItem(UPDATE_DISMISS_KEY, availableVersion); } catch {} - setDismissedVersion(availableVersion); - } - }, [availableVersion]); - - const handleDownloadUpdate = useCallback(async () => { - try { await (window as any).openswarm?.downloadUpdate(); } catch {} - }, []); - - const handleInstallUpdate = useCallback(() => { - (window as any).openswarm?.installUpdate(); - }, []); - - return { - updateStatus, - availableVersion, - downloadPercent, - snackbarDismissed, - setSnackbarDismissed, - showUpdateDot, - showUpdateBanner, - showUpdateSnackbar, - handleDismissBanner, - handleDownloadUpdate, - handleInstallUpdate, - }; -} diff --git a/frontend/src/app/components/AppShell/hooks/useUrlInterception.ts b/frontend/src/app/components/AppShell/hooks/useUrlInterception.ts deleted file mode 100644 index 0c03b15d..00000000 --- a/frontend/src/app/components/AppShell/hooks/useUrlInterception.ts +++ /dev/null @@ -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]); -} diff --git a/frontend/src/app/components/CommandPicker.tsx b/frontend/src/app/components/CommandPicker.tsx new file mode 100644 index 00000000..d381db2b --- /dev/null +++ b/frontend/src/app/components/CommandPicker.tsx @@ -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 }) => ( + + + + + + +); + +const RedditIcon: React.FC<{ sx?: object }> = ({ sx }) => ( + + + +); + +const TOOL_GROUP_ICONS: Record> = { + Google: GoogleIcon, + Reddit: RedditIcon, + Web: LanguageIcon, + View: ViewQuiltOutlinedIcon, +}; + +export function getToolGroupIcon(groupName: string, size: number = 15): React.ReactNode { + const Icon = TOOL_GROUP_ICONS[groupName]; + if (Icon) return ; + return ; +} + +export interface CommandPickerItem { + id: string; + type: 'skill' | 'mode' | 'context'; + category: string; + name: string; + description: string; + command: string; + icon: React.ReactNode; + toolNames?: string[]; + iconKey?: string; +} + +interface Props { + trigger: '/' | '@'; + filter: string; + onSelect: (item: CommandPickerItem) => void; + onClose: () => void; + visible: boolean; +} + +const MODE_ICON_MAP: Record> = { + smart_toy: SmartToyOutlinedIcon, + question_answer: QuestionAnswerOutlinedIcon, + map: MapOutlinedIcon, + category: CategoryOutlinedIcon, + tune: TuneOutlinedIcon, +}; + +function highlightMatch(text: string, query: string, color: string): React.ReactNode { + if (!query) return text; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return text; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + query.length)} + {text.slice(idx + query.length)} + + ); +} + +const CommandPicker: React.FC = ({ trigger, filter, onSelect, onClose, visible }) => { + const 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(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: , + })); + + const modeItems: CommandPickerItem[] = Object.values(modesMap).map((m) => { + const IconComp = MODE_ICON_MAP[m.icon] || SmartToyOutlinedIcon; + return { + id: m.id, + type: 'mode' as const, + category: 'Modes', + name: m.name, + description: m.description || 'Switch to this mode', + command: m.name.toLowerCase().replace(/\s+/g, '-'), + icon: , + }; + }); + + all = [...skillItems, ...modeItems]; + } else { + const atItems: CommandPickerItem[] = [ + { + id: 'file', + type: 'context' as const, + category: 'Context', + name: 'File', + description: 'Attach a file or folder as context', + command: 'file', + icon: , + }, + ]; + + const hasWebSearch = builtinTools.some((t) => t.name === 'WebSearch' && t.deferred); + const hasWebFetch = builtinTools.some((t) => t.name === 'WebFetch' && t.deferred); + if (hasWebSearch || hasWebFetch) { + const webTools = [hasWebSearch && 'WebSearch', hasWebFetch && 'WebFetch'].filter(Boolean) as string[]; + atItems.push({ + id: 'web', + type: 'context' as const, + category: 'Actions', + name: 'Web', + description: 'Search the web and fetch URLs', + command: 'web', + icon: , + toolNames: webTools, + iconKey: 'Web', + }); + } + + for (const tool of Object.values(customTools)) { + if (!tool.mcp_config || Object.keys(tool.mcp_config).length === 0) continue; + const services = tool.tool_permissions?._services as Record | undefined; + if (!services) continue; + const perms = tool.tool_permissions as Record; + const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record; + + const enabledServices: { name: string; tools: string[] }[] = []; + for (const [serviceName, serviceTools] of Object.entries(services)) { + const allToolNames = [...(serviceTools.read || []), ...(serviceTools.write || [])]; + const enabled = allToolNames.filter((name) => perms[name] !== 'deny'); + if (enabled.length > 0) enabledServices.push({ name: serviceName, tools: enabled }); + } + + if (enabledServices.length === 0) continue; + + const groupEntries = Object.entries(serviceGroups); + const emittedServices = new Set(); + + for (const [groupName, groupServiceNames] of groupEntries) { + const groupCmd = groupName.toLowerCase().replace(/\s+/g, '-'); + const groupServices = enabledServices.filter((s) => groupServiceNames.includes(s.name)); + if (groupServices.length === 0) continue; + groupServices.forEach((s) => emittedServices.add(s.name)); + + const groupIcon = getToolGroupIcon(groupName); + if (groupServices.length >= 2) { + const allTools = groupServices.flatMap((s) => s.tools); + atItems.push({ + id: `mcp-${tool.id}-group-${groupName}`, + type: 'context' as const, + category: tool.name, + name: groupName, + description: `Use all ${groupName} actions`, + command: groupCmd, + icon: groupIcon, + toolNames: allTools, + iconKey: groupName, + }); + for (const svc of groupServices) { + atItems.push({ + id: `mcp-${tool.id}-${svc.name}`, + type: 'context' as const, + category: tool.name, + name: svc.name, + description: `Use ${svc.name} actions from ${tool.name}`, + command: `${groupCmd}/${svc.name.toLowerCase().replace(/\s+/g, '-')}`, + icon: groupIcon, + toolNames: svc.tools, + iconKey: groupName, + }); + } + } else { + const svc = groupServices[0]; + atItems.push({ + id: `mcp-${tool.id}-${svc.name}`, + type: 'context' as const, + category: tool.name, + name: svc.name, + description: `Use ${svc.name} actions from ${tool.name}`, + command: svc.name.toLowerCase().replace(/\s+/g, '-'), + icon: groupIcon, + toolNames: svc.tools, + iconKey: groupName, + }); + } + } + + for (const svc of enabledServices) { + if (emittedServices.has(svc.name)) continue; + atItems.push({ + id: `mcp-${tool.id}-${svc.name}`, + type: 'context' as const, + category: tool.name, + name: svc.name, + description: `Use ${svc.name} actions from ${tool.name}`, + command: svc.name.toLowerCase().replace(/\s+/g, '-'), + icon: , + toolNames: svc.tools, + }); + } + } + + for (const out of Object.values(outputItems)) { + if (out.permission === 'deny') continue; + const cmd = out.name.toLowerCase().replace(/\s+/g, '-'); + atItems.push({ + id: `view-${out.id}`, + type: 'context' as const, + category: 'Apps', + name: out.name, + description: out.description || `Render ${out.name} view`, + command: cmd, + icon: , + toolNames: ['RenderOutput'], + iconKey: 'View', + }); + } + + all = atItems; + } + + if (!filter) return all; + const lower = filter.toLowerCase(); + return all.filter( + (item) => + item.name.toLowerCase().includes(lower) || + item.command.toLowerCase().includes(lower) || + item.description.toLowerCase().includes(lower), + ); + }, [trigger, 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 ( + + + {flatItems.map(({ item, isGroupStart, category }, idx) => ( + + {isGroupStart && ( + + + {category} + + + )} + 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', + }} + > + + {item.icon} + + + {trigger}{highlightMatch(item.command, filter, c.accent.primary)} + + + {item.description} + + + + ))} + + + + {[ + { keys: '↑↓', label: 'navigate' }, + { keys: '↵', label: 'select' }, + { keys: 'esc', label: 'dismiss' }, + ].map(({ keys, label }) => ( + + + {keys} + + + {label} + + + ))} + + + ); +}; + +export default CommandPicker; diff --git a/frontend/src/app/components/DirectoryBrowser.tsx b/frontend/src/app/components/DirectoryBrowser.tsx new file mode 100644 index 00000000..00294dec --- /dev/null +++ b/frontend/src/app/components/DirectoryBrowser.tsx @@ -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 = ({ open, onClose, onSelect, initialPath }) => { + const c = useClaudeTokens(); + const [browseData, setBrowseData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + + Browse Files & Folders + + + + 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, + }, + }} + /> + + + + {browseData && ( + + + + + + browse('/')} + sx={{ color: c.text.tertiary, fontSize: '0.78rem' }} + > + / + + {pathSegments.map((seg, i) => { + const fullPath = '/' + pathSegments.slice(0, i + 1).join('/'); + const isLast = i === pathSegments.length - 1; + return isLast ? ( + + {seg} + + ) : ( + browse(fullPath)} + sx={{ color: c.text.tertiary, fontSize: '0.78rem' }} + > + {seg} + + ); + })} + + + )} + + {error && ( + + {error} + + )} + + + {loading ? ( + + + + ) : !hasEntries ? ( + + + Empty directory + + + ) : ( + + {browseData?.directories.map((dir) => ( + handleNavigate(dir)} + onClick={() => + setSelected((prev) => + prev?.name === dir && prev.type === 'directory' ? null : { name: dir, type: 'directory' }, + ) + } + sx={{ + py: 0.75, + '&.Mui-selected': { bgcolor: `${c.accent.primary}0c` }, + '&:hover': { bgcolor: `${c.accent.primary}08` }, + }} + > + + + + + + ))} + {browseData?.files.map((file) => ( + + setSelected((prev) => + prev?.name === file && prev.type === 'file' ? null : { name: file, type: 'file' }, + ) + } + sx={{ + py: 0.75, + '&.Mui-selected': { bgcolor: `${c.accent.primary}0c` }, + '&:hover': { bgcolor: `${c.accent.primary}08` }, + }} + > + + + + + + ))} + + )} + + + + + {selected + ? `Selected: ${selected.name}` + : 'Click to select, double-click folders to open'} + + + + + + + + ); +}; + +export default DirectoryBrowser; diff --git a/frontend/src/app/components/DynamicIsland.tsx b/frontend/src/app/components/DynamicIsland.tsx new file mode 100644 index 00000000..44f00ea7 --- /dev/null +++ b/frontend/src/app/components/DynamicIsland.tsx @@ -0,0 +1,1029 @@ +import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Collapse from '@mui/material/Collapse'; +import SearchIcon from '@mui/icons-material/Search'; +import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined'; +import CloseIcon from '@mui/icons-material/Close'; +import CheckIcon from '@mui/icons-material/Check'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import PanToolOutlinedIcon from '@mui/icons-material/PanToolOutlined'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useNavigate } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { + handleApproval, + stopAgent, + dismissAgentNotification, + dismissAllFinishedNotifications, + ApprovalRequest, + AgentSession, + HistorySession, +} from '@/shared/state/agentsSlice'; +import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; +import ApprovalBar, { BatchApprovalBar, parseMcpToolName, useMcpToolMeta, getToolIcon } from '@/app/pages/AgentChat/ApprovalBar'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type IslandState = 'idle' | 'compact' | 'compact-actionable' | 'expanded'; + +interface SessionApprovalGroup { + sessionId: string; + sessionName: string; + approvals: ApprovalRequest[]; +} + +type TrackedAgent = { + id: string; + name: string; + status: AgentSession['status'] | string; + dashboardId?: string; +}; + +const STATUS_CONFIG: Record = { + running: { label: 'Running', tokenKey: 'success' }, + waiting_approval: { label: 'Waiting', tokenKey: 'warning' }, + completed: { label: 'Done', tokenKey: 'success' }, + error: { label: 'Error', tokenKey: 'error' }, + stopped: { label: 'Stopped', tokenKey: 'info' }, +}; + +// --------------------------------------------------------------------------- +// Spring configs +// --------------------------------------------------------------------------- + +const SPRING_LAYOUT = { type: 'spring' as const, stiffness: 400, damping: 30 }; +const SPRING_BOUNCE = { type: 'spring' as const, stiffness: 500, damping: 25 }; + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +const StatusDot: React.FC<{ status: string; c: ReturnType }> = ({ status, c }) => { + const cfg = STATUS_CONFIG[status]; + const color = cfg?.tokenKey ? (c.status as any)[cfg.tokenKey] : c.text.ghost; + const isActive = status === 'running'; + return ( + + ); +}; + +const AgentStatusRow: React.FC<{ + agent: TrackedAgent; + c: ReturnType; + onStop: (id: string) => void; + onDismiss: (id: string) => void; + onNavigate: (dashboardId: string, agentId: string) => void; +}> = ({ agent, c, onStop, onDismiss, onNavigate }) => { + const isActive = agent.status === 'running' || agent.status === 'waiting_approval'; + const cfg = STATUS_CONFIG[agent.status] ?? { label: agent.status }; + + return ( + agent.dashboardId && onNavigate(agent.dashboardId, agent.id)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + px: 2, + py: 0.75, + cursor: agent.dashboardId ? 'pointer' : 'default', + '&:hover': { bgcolor: c.border.subtle }, + transition: 'background-color 0.15s', + minHeight: 34, + }} + > + + + {agent.name} + + + {cfg.label} + + {isActive ? ( + + { e.stopPropagation(); onStop(agent.id); }} + sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.border.subtle } }} + > + + + + ) : ( + + { e.stopPropagation(); onDismiss(agent.id); }} + sx={{ p: 0.25, color: c.text.ghost, '&:hover': { bgcolor: c.border.subtle } }} + > + + + + )} + + ); +}; + +// --------------------------------------------------------------------------- +// Compact activity indicator — subtle breathing dot +// --------------------------------------------------------------------------- + +const ActivityIndicator: React.FC<{ c: ReturnType }> = ({ c }) => ( + +); + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +const DynamicIsland: React.FC = () => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const islandRef = useRef(null); + + const sessions = useAppSelector((state) => state.agents.sessions); + const history = useAppSelector((state) => state.agents.history); + const trackedIds = useAppSelector((state) => state.agents.trackedNotificationIds); + + const [userExpanded, setUserExpanded] = useState(false); + + // ---- Derived data ---- + + const groups: SessionApprovalGroup[] = useMemo(() => { + const result: SessionApprovalGroup[] = []; + for (const [sessionId, session] of Object.entries(sessions)) { + if (session.pending_approvals?.length > 0) { + result.push({ + sessionId, + sessionName: session.name || 'Agent', + approvals: session.pending_approvals, + }); + } + } + return result; + }, [sessions]); + + const totalApprovals = useMemo( + () => groups.reduce((sum, g) => sum + g.approvals.length, 0), + [groups], + ); + + const trackedAgents: TrackedAgent[] = useMemo(() => { + const agents = trackedIds + .map((id): TrackedAgent | null => { + const session = sessions[id]; + if (session && session.status !== 'draft') { + return { id, name: session.name, status: session.status, dashboardId: session.dashboard_id }; + } + const hist: HistorySession | undefined = history[id]; + if (hist) { + return { id, name: hist.name, status: hist.status, dashboardId: hist.dashboard_id }; + } + return null; + }) + .filter((a): a is TrackedAgent => a !== null); + + const trackedIdSet = new Set(trackedIds); + for (const g of groups) { + if (!trackedIdSet.has(g.sessionId)) { + const session = sessions[g.sessionId]; + if (session && session.status !== 'draft') { + agents.push({ id: g.sessionId, name: session.name, status: session.status, dashboardId: session.dashboard_id }); + } + } + } + + return agents; + }, [trackedIds, sessions, history, groups]); + + const activeAgents = useMemo( + () => trackedAgents.filter((a) => a.status === 'running' || a.status === 'waiting_approval'), + [trackedAgents], + ); + const finishedAgents = useMemo( + () => trackedAgents.filter((a) => a.status !== 'running' && a.status !== 'waiting_approval'), + [trackedAgents], + ); + + const hasApprovals = totalApprovals > 0; + const hasAgents = trackedAgents.length > 0; + + const hasOnlyQuestionApprovals = useMemo(() => { + if (!hasApprovals) return false; + const allApprovals = groups.flatMap((g) => g.approvals); + return allApprovals.every((a) => a.tool_name === 'AskUserQuestion'); + }, [hasApprovals, groups]); + + const nonQuestionApprovalCount = useMemo( + () => groups.reduce((sum, g) => sum + g.approvals.filter((a) => a.tool_name !== 'AskUserQuestion').length, 0), + [groups], + ); + + const oldestNonQuestionApproval = useMemo(() => { + const all = groups + .flatMap((g) => g.approvals) + .filter((a) => a.tool_name !== 'AskUserQuestion'); + if (all.length === 0) return null; + return all.reduce((oldest, a) => + a.created_at < oldest.created_at ? a : oldest, + ); + }, [groups]); + + // ---- Island state machine ---- + + const islandState: IslandState = useMemo(() => { + if (userExpanded && (hasAgents || hasApprovals)) return 'expanded'; + if (hasApprovals && hasOnlyQuestionApprovals) return 'expanded'; + if (hasApprovals) return 'compact-actionable'; + if (hasAgents) return 'compact'; + return 'idle'; + }, [hasApprovals, hasOnlyQuestionApprovals, userExpanded, hasAgents]); + + useEffect(() => { + if (!hasAgents && !hasApprovals) { + setUserExpanded(false); + } + }, [hasAgents, hasApprovals]); + + // ---- Click outside to collapse ---- + + useEffect(() => { + if (islandState !== 'expanded') return; + const handler = (e: MouseEvent) => { + if (islandRef.current && !islandRef.current.contains(e.target as Node)) { + setUserExpanded(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [islandState]); + + // ---- Callbacks ---- + + const onApprove = useCallback( + (requestId: string, updatedInput?: Record) => { + dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput })); + }, + [dispatch], + ); + + const onDeny = useCallback( + (requestId: string, message?: string) => { + dispatch(handleApproval({ requestId, behavior: 'deny', message })); + }, + [dispatch], + ); + + const onStopAgent = useCallback( + (sessionId: string) => dispatch(stopAgent({ sessionId })), + [dispatch], + ); + + const onDismissAgent = useCallback( + (sessionId: string) => dispatch(dismissAgentNotification(sessionId)), + [dispatch], + ); + + const onNavigateToDashboard = useCallback( + (dashboardId: string, agentId: string) => { + dispatch(setPendingFocusAgentId(agentId)); + navigate(`/dashboard/${dashboardId}`); + }, + [navigate, dispatch], + ); + + const onApproveAllNonQuestion = useCallback(() => { + for (const g of groups) { + for (const req of g.approvals) { + if (req.tool_name !== 'AskUserQuestion') { + dispatch(handleApproval({ requestId: req.id, behavior: 'allow' })); + } + } + } + }, [dispatch, groups]); + + const onDenyAllNonQuestion = useCallback(() => { + for (const g of groups) { + for (const req of g.approvals) { + if (req.tool_name !== 'AskUserQuestion') { + dispatch(handleApproval({ requestId: req.id, behavior: 'deny' })); + } + } + } + }, [dispatch, groups]); + + const onClearAllFinished = useCallback(() => { + dispatch(dismissAllFinishedNotifications()); + }, [dispatch]); + + const handleIslandClick = useCallback(() => { + if (islandState === 'compact' || islandState === 'compact-actionable') { + setUserExpanded(true); + } else if (islandState === 'expanded') { + setUserExpanded(false); + } + }, [islandState]); + + // ---- Styling — uses the same neutral palette as the rest of the UI ---- + + const islandWidth = islandState === 'idle' + ? 200 + : islandState === 'compact' + ? 210 + : islandState === 'compact-actionable' + ? 310 + : 400; + + const islandBorderRadius = islandState === 'expanded' ? 14 : 50; + + const shadow = islandState === 'idle' + ? 'none' + : islandState === 'compact' + ? c.shadow.sm + : c.shadow.md; + + // ---- Compact summary text ---- + + const compactText = useMemo(() => { + const parts: string[] = []; + if (activeAgents.length > 0) { + parts.push(`${activeAgents.length} running`); + } + if (finishedAgents.length > 0) { + parts.push(`${finishedAgents.length} done`); + } + return parts.join(' · ') || 'Agents'; + }, [activeAgents.length, finishedAgents.length]); + + const glowKeyframes = useMemo(() => ` + @keyframes approvalGlow { + 0%, 100% { box-shadow: 0 0 6px 1px ${c.status.warning}30; } + 50% { box-shadow: 0 0 12px 3px ${c.status.warning}60; } + } + `, [c.status.warning]); + + // ---- Render ---- + + return ( + <> + {islandState === 'compact-actionable' && } + + + + {islandState === 'idle' && ( + + )} + {islandState === 'compact' && ( + + )} + {islandState === 'compact-actionable' && oldestNonQuestionApproval && ( + setUserExpanded(true)} + /> + )} + {islandState === 'expanded' && ( + setUserExpanded(false)} + /> + )} + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Idle pill — disabled search bar +// --------------------------------------------------------------------------- + +const IdlePill: React.FC<{ c: ReturnType }> = ({ c }) => ( + + + + + + Search... + + + + +); + +// --------------------------------------------------------------------------- +// Compact pill +// --------------------------------------------------------------------------- + +const CompactPill: React.FC<{ + c: ReturnType; + text: string; + activeCount: number; + hasApprovals: boolean; +}> = ({ c, text, activeCount, hasApprovals }) => ( + + + + + {text} + + {hasApprovals && ( + + )} + + +); + +// --------------------------------------------------------------------------- +// Compact-actionable pill — single approval with icon + name + approve/deny +// --------------------------------------------------------------------------- + +const CompactActionablePill: React.FC<{ + c: ReturnType; + request: ApprovalRequest; + remainingCount: number; + onApprove: (requestId: string) => void; + onApproveAll: () => void; + onDeny: (requestId: string) => void; + onExpand: () => void; +}> = ({ c, request, remainingCount, onApprove, onApproveAll, onDeny, onExpand }) => { + const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]); + const meta = useMcpToolMeta(parsed); + + const isIntervention = request.tool_name === 'RequestHumanIntervention'; + const interventionProblem = isIntervention + ? ((request.tool_input as any)?.problem || 'Browser agent needs help') + : ''; + + const icon = isIntervention + ? + : parsed.isMcp + ? (meta.integration?.icon || null) + : getToolIcon(request.tool_name); + + return ( + + + + {icon} + + + {isIntervention ? interventionProblem : parsed.displayName} + + {remainingCount > 1 && ( + + +{remainingCount - 1} + + )} + + { e.stopPropagation(); onApprove(request.id); }} + sx={{ + p: 0, + width: 18, + height: 18, + color: '#fff', + bgcolor: isIntervention ? '#f59e0b' : c.status.success, + '&:hover': { bgcolor: isIntervention ? '#d97706' : c.status.success, filter: isIntervention ? undefined : 'brightness(0.85)' }, + }} + > + + + + {remainingCount > 1 && !isIntervention && ( + + { e.stopPropagation(); onApproveAll(); }} + sx={{ + all: 'unset', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: 18, + px: 0.5, + borderRadius: '4px', + fontSize: '0.58rem', + fontWeight: 700, + color: '#fff', + bgcolor: c.status.success, + '&:hover': { filter: 'brightness(0.85)' }, + whiteSpace: 'nowrap', + }} + > + ✓ All + + + )} + + { e.stopPropagation(); onDeny(request.id); }} + sx={{ + p: 0, + width: 18, + height: 18, + color: c.status.error, + border: `1px solid ${c.status.error}`, + '&:hover': { bgcolor: `${c.status.error}0a` }, + }} + > + + + + + { e.stopPropagation(); onExpand(); }} + sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }} + > + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Expanded card +// --------------------------------------------------------------------------- + +const ExpandedCard: React.FC<{ + c: ReturnType; + groups: SessionApprovalGroup[]; + totalApprovals: number; + activeAgents: TrackedAgent[]; + finishedAgents: TrackedAgent[]; + hasApprovals: boolean; + hasAgents: boolean; + onApprove: (requestId: string, updatedInput?: Record) => void; + onDeny: (requestId: string, message?: string) => void; + onStopAgent: (id: string) => void; + onDismissAgent: (id: string) => void; + onNavigateToDashboard: (dashboardId: string, agentId: string) => void; + onClearAllFinished: () => void; + onCollapse: () => void; +}> = ({ + c, groups, totalApprovals, + activeAgents, finishedAgents, hasApprovals, hasAgents, + onApprove, onDeny, onStopAgent, onDismissAgent, onNavigateToDashboard, onClearAllFinished, onCollapse, +}) => { + const [completedExpanded, setCompletedExpanded] = useState(false); + const headerTitle = hasApprovals && !hasAgents + ? 'Approval Required' + : hasAgents && !hasApprovals + ? 'Agents' + : 'Notifications'; + + const badgeCount = totalApprovals + activeAgents.length; + + return ( + + {/* Header */} + + + {headerTitle} + + {badgeCount > 0 && ( + + {badgeCount} + + )} + {!hasApprovals && ( + { e.stopPropagation(); onCollapse(); }} + sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }} + > + + + )} + + + {/* Content */} + + {hasApprovals && ( + + {hasAgents && ( + + Approvals + + )} + {groups.map((group) => ( + + {groups.length > 1 && ( + + {group.sessionName} + + )} + {group.approvals.length > 1 ? ( + + ) : ( + group.approvals.map((req) => ( + + )) + )} + + ))} + + )} + + {hasApprovals && hasAgents && ( + + )} + + {hasAgents && ( + + {hasApprovals && ( + + Agents + + )} + {activeAgents.map((agent) => ( + + ))} + {finishedAgents.length > 0 && ( + <> + {activeAgents.length > 0 && ( + + )} + setCompletedExpanded((v) => !v)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + px: 2, + py: 0.5, + cursor: 'pointer', + userSelect: 'none', + '&:hover': { bgcolor: c.border.subtle }, + transition: 'background-color 0.15s', + }} + > + + Completed ({finishedAgents.length}) + + { e.stopPropagation(); onClearAllFinished(); }} + sx={{ + fontSize: '0.58rem', + fontWeight: 600, + color: c.text.ghost, + cursor: 'pointer', + '&:hover': { color: c.text.secondary }, + transition: 'color 0.15s', + }} + > + Clear all + + + {completedExpanded + ? + : } + + + + {finishedAgents.map((agent) => ( + + ))} + + + )} + + )} + + + ); +}; + +export default DynamicIsland; diff --git a/frontend/src/app/pages/_shared/element_selection/ElementSelectionProvider.tsx b/frontend/src/app/components/ElementSelectionContext.tsx similarity index 68% rename from frontend/src/app/pages/_shared/element_selection/ElementSelectionProvider.tsx rename to frontend/src/app/components/ElementSelectionContext.tsx index 97e74153..a5a0ba78 100644 --- a/frontend/src/app/pages/_shared/element_selection/ElementSelectionProvider.tsx +++ b/frontend/src/app/components/ElementSelectionContext.tsx @@ -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; + 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; +} + +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) => void; + removeSelectedElement: (id: string) => void; + clearSelectedElements: () => void; + elementsByOwner: Record; + addElementForOwner: (ownerId: string, el: SelectedElement) => void; + removeOwnerElement: (ownerId: string, elementId: string) => void; + clearOwnerElements: (ownerId: string) => void; + iframeRef: MutableRefObject; +} + +const ElementSelectionContext = createContext(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(null); const activeOwnerIdRef = useRef(activeOwnerId); - useEffect(() => { - activeOwnerIdRef.current = activeOwnerId; - }, [activeOwnerId]); + activeOwnerIdRef.current = activeOwnerId; const selectedElements = useMemo( () => (activeOwnerId ? elementsByOwner[activeOwnerId] ?? [] : []), diff --git a/frontend/src/app/components/ErrorSlime.tsx b/frontend/src/app/components/ErrorSlime.tsx new file mode 100644 index 00000000..1205b4cc --- /dev/null +++ b/frontend/src/app/components/ErrorSlime.tsx @@ -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 }) => ( + + + + + + + + + + ! + +); + +export default ErrorSlime; diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx new file mode 100644 index 00000000..2f700a6b --- /dev/null +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -0,0 +1,1120 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { NavLink, Outlet, 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 Button from '@mui/material/Button'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import InputBase from '@mui/material/InputBase'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import PsychologyIcon from '@mui/icons-material/Psychology'; +import BuildIcon from '@mui/icons-material/Build'; +import TuneIcon from '@mui/icons-material/Tune'; +import ViewQuiltIcon from '@mui/icons-material/ViewQuilt'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import AddIcon from '@mui/icons-material/Add'; +import SettingsIcon from '@mui/icons-material/Settings'; +import ExtensionIcon from '@mui/icons-material/Extension'; +import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined'; +import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined'; +import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; +import CloseIcon from '@mui/icons-material/Close'; +import LinearProgress from '@mui/material/LinearProgress'; +import CircularProgress from '@mui/material/CircularProgress'; +import Settings from '@/app/pages/Settings/Settings'; +import DynamicIsland from '@/app/components/DynamicIsland'; +import Dashboard from '@/app/pages/Dashboard/Dashboard'; +import DashboardHost from '@/app/components/Layout/DashboardHost'; +import { useLastDashboardId } from '@/shared/hooks/useLastDashboardId'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice'; +import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice'; +import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice'; +import { fetchOutputs } from '@/shared/state/outputsSlice'; +import { setInstalling } from '@/shared/state/updateSlice'; +import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { ErrorSlime } from '@/app/components/ErrorSlime'; + +const SIDEBAR_MIN = 160; +const SIDEBAR_MAX = 400; +const SIDEBAR_DEFAULT = 220; +const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width'; +const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed'; + +const CUSTOMIZATION_ITEMS = [ + { label: 'Skills', path: '/skills', icon: , onboarding: 'sidebar-skills' }, + { label: 'Actions', path: '/actions', icon: , onboarding: 'sidebar-actions' }, + { label: 'Modes', path: '/modes', icon: , onboarding: 'sidebar-modes' }, +]; + +const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path)); + +const AppShell: React.FC = () => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + const [dashboardsExpanded, setDashboardsExpanded] = useState(true); + const [appsExpanded, setAppsExpanded] = useState(true); + const [customizationExpanded, setCustomizationExpanded] = useState(true); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [renamingDashboardId, setRenamingDashboardId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [sidebarWidth, setSidebarWidth] = useState(() => { + try { + const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY); + if (stored) { + const w = Number(stored); + if (w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) return w; + } + } catch {} + return SIDEBAR_DEFAULT; + }); + const isResizing = useRef(false); + + const updateStatus = useAppSelector((state) => state.update.status); + const availableVersion = useAppSelector((state) => state.update.availableVersion); + const downloadPercent = useAppSelector((state) => state.update.downloadPercent); + const installing = useAppSelector((state) => state.update.installing); + + const [dismissedVersion, setDismissedVersion] = useState(() => { + try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; } + }); + const [snackbarDismissed, setSnackbarDismissed] = useState(false); + + // ---- Warning banner: no internet / no model connected ---- + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const goOnline = () => setIsOnline(true); + const goOffline = () => setIsOnline(false); + window.addEventListener('online', goOnline); + window.addEventListener('offline', goOffline); + return () => { + window.removeEventListener('online', goOnline); + window.removeEventListener('offline', goOffline); + }; + }, []); + + // Derive "any model connected" from the /agents/models response (already + // fetched into Redux at app start via Main.tsx and re-fetched by + // Settings.tsx after every subscription connect/disconnect). That endpoint + // intersects BUILTIN_MODELS with both the user's API keys AND 9Router's + // live connection state, so a non-empty byProvider means there's at least + // one usable model — regardless of whether it came from a typed API key + // or an OAuth subscription flow. This replaces the previous approach of + // polling /agents/subscriptions/status in an effect keyed to anthropicKey, + // which didn't refresh when a non-Anthropic subscription was connected. + const modelsByProvider = useAppSelector((s) => s.models.byProvider); + const modelsLoaded = useAppSelector((s) => s.models.loaded); + const hasModelConnected = Object.keys(modelsByProvider).length > 0; + // Don't flash the banner while the initial /agents/models fetch is in flight + const showWarningBanner = !isOnline || (modelsLoaded && !hasModelConnected); + + 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(() => { + if (installing) return; + dispatch(setInstalling()); + (window as any).openswarm?.installUpdate(); + }, [installing, dispatch]); + + const dashboardItems = useAppSelector((state) => state.dashboards.items); + const dashboardList = Object.values(dashboardItems).sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + + const outputItems = useAppSelector((state) => state.outputs.items); + const appsList = Object.values(outputItems).sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + + useEffect(() => { + dispatch(fetchDashboards()); + dispatch(fetchOutputs()); + }, [dispatch]); + + const openUrlInBrowser = useCallback((url: string, webContentsId?: number) => { + const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/); + if (dashMatch) { + if (webContentsId != null) { + const browserId = findBrowserByWebContentsId(webContentsId); + if (browserId) { + dispatch(addBrowserTab({ browserId, url, makeActive: true })); + return; + } + } + dispatch(addBrowserCard({ url })); + } else { + dispatch(setPendingBrowserUrl(url)); + const lastId = (window as any).__openswarm_last_dashboard_id as string | undefined; + const firstDashboard = dashboardList[0]; + const targetId = lastId || firstDashboard?.id; + if (targetId) { + navigate(`/dashboard/${targetId}`); + } else { + dispatch(createDashboard('Untitled Dashboard')).then((result: any) => { + if (createDashboard.fulfilled.match(result)) { + navigate(`/dashboard/${result.payload.id}`); + } + }); + } + } + }, [location.pathname, dashboardList, dispatch, navigate]); + + useEffect(() => { + let lastUrl = ''; + let lastTime = 0; + + const handleClick = (e: MouseEvent) => { + const anchor = (e.target as HTMLElement)?.closest?.('a'); + if (!anchor) return; + const href = anchor.getAttribute('href'); + if (!href) return; + if (!/^https?:\/\//i.test(href)) return; + if (href.startsWith('http://localhost:')) return; + + e.preventDefault(); + e.stopPropagation(); + + const now = Date.now(); + if (href === lastUrl && now - lastTime < 1000) return; + lastUrl = href; + lastTime = now; + + openUrlInBrowser(href); + }; + + document.addEventListener('click', handleClick, true); + return () => document.removeEventListener('click', handleClick, true); + }, [openUrlInBrowser]); + + useEffect(() => { + const w = window as any; + if (!w.openswarm?.onWebviewNewWindow) return; + let lastUrl = ''; + let lastTime = 0; + return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number) => { + const now = Date.now(); + if (url === lastUrl && now - lastTime < 1000) return; + lastUrl = url; + lastTime = now; + openUrlInBrowser(url, webContentsId); + }); + }, [openUrlInBrowser]); + + useEffect(() => { + try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {} + }, [sidebarWidth]); + + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isResizing.current = true; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const onMouseMove = (ev: MouseEvent) => { + if (!isResizing.current) return; + setSidebarWidth(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, ev.clientX))); + }; + + const onMouseUp = () => { + isResizing.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, []); + + const handleResizeDoubleClick = useCallback(() => { + setSidebarWidth(SIDEBAR_DEFAULT); + }, []); + + const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/'); + const isDashboardViewActive = location.pathname.startsWith('/dashboard/'); + const isAppsRoute = location.pathname === '/apps' || location.pathname.startsWith('/apps/'); + const isCustomizationRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname); + const activeDashboardId = location.pathname.startsWith('/dashboard/') + ? location.pathname.split('/dashboard/')[1] + : null; + + // Sticky last-visited dashboard id — survives navigation away from /dashboard/:id + // so the Dashboard component can stay mounted with stable props. + const [lastDashboardId, setLastDashboardId] = useLastDashboardId(); + const activeAppId = location.pathname.startsWith('/apps/') + ? location.pathname.split('/apps/')[1] + : null; + + const handleDashboardsClick = () => { + if (isDashboardRoute && location.pathname === '/') { + setDashboardsExpanded((prev) => !prev); + } else { + navigate('/'); + setDashboardsExpanded(true); + } + }; + + const handleDashboardItemClick = (dashboardId: string) => { + if (renamingDashboardId === dashboardId) return; + navigate(`/dashboard/${dashboardId}`); + }; + + const handleStartDashboardRename = (id: string, currentName: string) => { + setRenamingDashboardId(id); + setRenameValue(currentName); + }; + + const handleDashboardRenameSubmit = (id: string) => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== dashboardItems[id]?.name) { + dispatch(renameDashboard({ id, name: trimmed })); + } + setRenamingDashboardId(null); + }; + + const handleCreateDashboard = async (e: React.MouseEvent) => { + e.stopPropagation(); + const result = await dispatch(createDashboard('Untitled Dashboard')); + if (createDashboard.fulfilled.match(result)) { + navigate(`/dashboard/${result.payload.id}`); + } + }; + + const handleAppsClick = () => { + if (isAppsRoute && location.pathname === '/apps') { + setAppsExpanded((prev) => !prev); + } else { + navigate('/apps'); + setAppsExpanded(true); + } + }; + + const handleCreateApp = (e: React.MouseEvent) => { + e.stopPropagation(); + navigate('/apps/new'); + }; + + return ( + + {/* Draggable title bar */} + + + setSidebarCollapsed((prev) => !prev)} + sx={{ + WebkitAppRegion: 'no-drag', + color: c.text.tertiary, + p: 0.5, + borderRadius: 1, + '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, + }} + > + + + + + navigate(-1)} + sx={{ + WebkitAppRegion: 'no-drag', + color: c.text.tertiary, + p: 0.5, + borderRadius: 1, + '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, + }} + > + + + + + navigate(1)} + sx={{ + WebkitAppRegion: 'no-drag', + color: c.text.tertiary, + p: 0.5, + borderRadius: 1, + '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, + }} + > + + + + + + + + + + + + OpenSwarm + + + + + {/* Warning banner: no internet or no model connected */} + + + + + {!isOnline + ? 'No internet connection — agents cannot reach AI models or external services' + : ( + <> + No AI model connected —{' '} + dispatch(openSettingsModal('models'))} + sx={{ + textDecoration: 'underline', + cursor: 'pointer', + fontWeight: 600, + '&:hover': { opacity: 0.8 }, + transition: 'opacity 0.15s', + }} + > + Configure models + + {' '}to get started + + )} + + + + + {showUpdateBanner && ( + + + + {updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`} + {updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}…`} + {updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`} + + {updateStatus === 'downloading' && ( + + )} + {updateStatus === 'downloading' && ( + + {Math.round(downloadPercent)}% + + )} + {updateStatus === 'available' && ( + + )} + {updateStatus === 'downloaded' && ( + + )} + + + + + )} + + + {!sidebarCollapsed && ( + <> + + + {/* Dashboards section */} + + + + + + + + + + + + {dashboardList.length > 0 && ( + + )} + + + 0} timeout={200}> + + {dashboardList.map((entry) => { + const isActive = activeDashboardId === entry.id; + const isRenaming = renamingDashboardId === entry.id; + return ( + handleDashboardItemClick(entry.id)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.75, + pl: 1.25, + pr: 1, + py: isRenaming ? 0.25 : 0.5, + ml: '-0.5px', + cursor: isRenaming ? 'default' : 'pointer', + borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent', + bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent', + '&:hover': { bgcolor: `${c.text.tertiary}0A` }, + transition: 'background-color 0.12s, border-color 0.12s', + }} + > + {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={() => handleDashboardRenameSubmit(entry.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleDashboardRenameSubmit(entry.id); + if (e.key === 'Escape') setRenamingDashboardId(null); + }} + onClick={(e) => e.stopPropagation()} + onFocus={(e) => e.target.select()} + sx={{ + flex: 1, + minWidth: 0, + fontSize: '0.78rem', + fontWeight: isActive ? 500 : 400, + color: isActive ? c.text.secondary : c.text.ghost, + py: 0, + px: 0.5, + borderRadius: 0.75, + border: `1px solid ${c.accent.primary}80`, + bgcolor: `${c.bg.page}`, + '& input': { + padding: '1px 0', + }, + }} + /> + ) : ( + { + e.stopPropagation(); + handleStartDashboardRename(entry.id, entry.name); + }} + sx={{ + color: isActive ? c.text.secondary : c.text.ghost, + fontSize: '0.78rem', + fontWeight: isActive ? 500 : 400, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + flex: 1, + minWidth: 0, + }} + > + {entry.name} + + )} + + ); + })} + + + + + {/* Divider */} + + + {/* Customization section */} + + { + if (isCustomizationRoute) { + setCustomizationExpanded((prev) => !prev); + } else { + navigate('/customization'); + setCustomizationExpanded(true); + } + }} + sx={{ + borderRadius: 1.5, + py: 0.6, + px: 1.25, + bgcolor: isCustomizationRoute ? `${c.accent.primary}12` : 'transparent', + '&:hover': { bgcolor: isCustomizationRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` }, + transition: 'background-color 0.15s', + }} + > + + + + + + + + + + {CUSTOMIZATION_ITEMS.map((item) => ( + + {({ isActive }) => ( + + + {item.label} + + + )} + + ))} + + + + + {/* Divider */} + + + {/* Apps section */} + + + + + + + + + + + + {appsList.length > 0 && ( + + )} + + + 0} timeout={200}> + + {appsList.map((app) => { + const isActive = activeAppId === app.id; + return ( + navigate(`/apps/${app.id}`)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.75, + pl: 1.25, + pr: 1, + py: 0.5, + ml: '-0.5px', + cursor: 'pointer', + borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent', + bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent', + '&:hover': { bgcolor: `${c.text.tertiary}0A` }, + transition: 'background-color 0.12s, border-color 0.12s', + }} + > + + {app.name} + + + ); + })} + + + + + + + {/* Settings */} + + dispatch(openSettingsModal())} + sx={{ + borderRadius: 1.5, + py: 0.6, + px: 1.25, + '&:hover': { bgcolor: `${c.text.tertiary}0A` }, + transition: 'background-color 0.15s', + }} + > + + + {showUpdateDot && ( + + )} + + + + + + + + )} + + + {/* Non-dashboard routes render here. Hidden when the dashboard view is active + so the persistent Dashboard layered above can take over the visible area. */} + + + + + {/* Persistent Dashboard layer — always mounted once a dashboard has been visited. + Hidden via CSS when on other routes so webviews and dashboard state survive + route navigation. The Dashboard component reads its dashboardId from the + sticky lastDashboardId hook so its dashboardId useEffect doesn't re-fire on + incidental URL changes. */} + {lastDashboardId && ( + + + + )} + + + + + + setSnackbarDismissed(true)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + + : + } + action={ + + + {updateStatus === 'available' && ( + + )} + {updateStatus === 'downloaded' && ( + + )} + + } + 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`} + + + + ); +}; + +export default AppShell; diff --git a/frontend/src/app/components/Layout/DashboardHost.tsx b/frontend/src/app/components/Layout/DashboardHost.tsx new file mode 100644 index 00000000..5ce94e5b --- /dev/null +++ b/frontend/src/app/components/Layout/DashboardHost.tsx @@ -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 = ({ 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 ( + + ); +}; + +export default DashboardHost; diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx new file mode 100644 index 00000000..0928371f --- /dev/null +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -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 = { + '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([]); + const [useCaseOther, setUseCaseOther] = useState(''); + const [referralSource, setReferralSource] = useState(''); + const [referralSourceOther, setReferralSourceOther] = useState(''); + const [connecting, setConnecting] = useState(null); + const [nineRouterReady, setNineRouterReady] = useState(null); + const pollTimerRef = useRef(null); + const msgHandlerRef = useRef(null); + + // Poll for 9Router readiness (it may still be starting when onboarding shows) + useEffect(() => { + let attempts = 0; + const maxAttempts = 15; // 30 seconds + const check = () => { + 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(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 ( + + + {step !== 'walkthrough' && ( + + Welcome to OpenSwarm + + )} + + {step === 'profile' ? ( + <> + + Tell us a bit about yourself + + + + 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 ( + + setUserEmail(e.target.value)} + onBlur={() => setEmailBlurred(true)} + error={showError} + size="small" + fullWidth + InputProps={{ + endAdornment: valid ? ( + + + + ) : 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 && ( + + That doesn't look like a valid email address + + )} + {suggestion && ( + + Did you mean{' '} + handleApplySuggestion(suggestion)} + sx={{ + color: c.accent.primary, + fontWeight: 600, + cursor: 'pointer', + '&:hover': { textDecoration: 'underline' }, + }} + > + {suggestion} + + ? + + )} + + ); + })()} + + + What will you use OpenSwarm for? + + + {USE_CASES.map((uc) => ( + 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 }, + }} + > + + {uc} + + + ))} + + {useCases.includes('Other') && ( + 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 }, + }} + /> + )} + + + How did you hear about OpenSwarm? + + + {REFERRAL_SOURCES.map((src) => ( + 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 }, + }} + > + + {src} + + + ))} + + {referralSource === 'Other' && ( + 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 }, + }} + /> + )} + + + + + ) : 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. */} + + + + + + + {EDUCATION_STEPS.map((_, i) => ( + + ))} + + + + Step {walkthroughIdx + 1} + + + + {EDUCATION_STEPS[walkthroughIdx].title} + + + + {EDUCATION_STEPS[walkthroughIdx].body.map((p, i) => ( + + {p} + + ))} + + + + + + + + + + ) : step === 'pricing' ? ( + <> + + Pick your OpenSwarm Pro plan + + + + + + + + + + ) : ( + <> + + Connect an AI model to get started + + + {/* Subscription options */} + + Use your existing subscription + + + {SUBSCRIPTION_PROVIDERS.map((p) => ( + !p.preview && !connecting && nineRouterReady && handleConnect(p.id)} + sx={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, + cursor: p.preview || !nineRouterReady ? 'default' : connecting ? 'wait' : 'pointer', + opacity: p.preview ? 0.5 : !nineRouterReady ? 0.6 : 1, + transition: 'border-color 0.15s, background 0.15s', + ...(!p.preview && nineRouterReady && { '&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` } }), + }} + > + + {p.name} + {p.desc} + + + {p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : !nineRouterReady && nineRouterReady !== false ? 'Starting...' : 'Connect \u2192'} + + + ))} + + + {/* API key option */} + + Or use an API key + + + + I have an API key + + + Go to Settings → Models to enter your key + + + + {/* Skip */} + + + )} + + + ); +}; + +export default OnboardingModal; diff --git a/frontend/src/app/components/OnboardingModal/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal/OnboardingModal.tsx deleted file mode 100644 index 285ae54e..00000000 --- a/frontend/src/app/components/OnboardingModal/OnboardingModal.tsx +++ /dev/null @@ -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 ( - - - {step === 'tools' ? ( - - ) : ( - - )} - - - ); -}; - -export default OnboardingModal; diff --git a/frontend/src/app/components/OnboardingModal/components/ProviderStep.tsx b/frontend/src/app/components/OnboardingModal/components/ProviderStep.tsx deleted file mode 100644 index 066fb7c4..00000000 --- a/frontend/src/app/components/OnboardingModal/components/ProviderStep.tsx +++ /dev/null @@ -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 = ({ - connecting, nineRouterReady, onConnect, onApiKey, onSkip, -}) => { - const c = useClaudeTokens(); - - return ( - <> - - Welcome to OpenSwarm - - - Connect an AI model to get started - - - - Use your existing subscription - - - {SUBSCRIPTION_PROVIDERS.map((p) => ( - !p.preview && !connecting && nineRouterReady && onConnect(p.id)} - sx={{ - display: 'flex', alignItems: 'center', justifyContent: 'space-between', - p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, - cursor: p.preview || !nineRouterReady ? 'default' : connecting ? 'wait' : 'pointer', - opacity: p.preview ? 0.5 : !nineRouterReady ? 0.6 : 1, - transition: 'border-color 0.15s, background 0.15s', - ...(!p.preview && nineRouterReady && { '&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` } }), - }} - > - - {p.name} - {p.desc} - - - {p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : !nineRouterReady && nineRouterReady !== false ? 'Starting...' : 'Connect \u2192'} - - - ))} - - - - Or use an API key - - - - I have an API key - - - Go to Settings → Models to enter your key - - - - - - ); -}; - -export default ProviderStep; diff --git a/frontend/src/app/components/OnboardingModal/components/ToolsStep.tsx b/frontend/src/app/components/OnboardingModal/components/ToolsStep.tsx deleted file mode 100644 index 3cbde4f2..00000000 --- a/frontend/src/app/components/OnboardingModal/components/ToolsStep.tsx +++ /dev/null @@ -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; - onToolConnect: (integration: ToolIntegration) => void; - onDismiss: () => void; -} - -const ToolsStep: React.FC = ({ - connecting, connectedTools, onToolConnect, onDismiss, -}) => { - const c = useClaudeTokens(); - - return ( - <> - - Connect Your Accounts - - - 10+ tools already active with no setup needed - - - Connect services below for even more capabilities - - - - {ONBOARDING_TOOL_INTEGRATIONS.map((ig) => { - const isConnected = connectedTools.has(ig.name); - const isConnecting = connecting === ig.name; - return ( - !isConnected && !isConnecting && !connecting && onToolConnect(ig)} - sx={{ - display: 'flex', alignItems: 'center', justifyContent: 'space-between', - p: 1.5, borderRadius: `${c.radius.md}px`, - border: `1px solid ${isConnected ? `${ig.color}40` : c.border.subtle}`, - cursor: isConnected ? 'default' : connecting ? 'wait' : 'pointer', - bgcolor: isConnected ? `${ig.color}08` : 'transparent', - transition: 'border-color 0.15s, background 0.15s', - ...(!isConnected && !connecting && { '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}05` } }), - }} - > - - {ig.name} - {ig.desc} - - {isConnected ? ( - - ) : ( - - {isConnecting ? 'Connecting...' : 'Connect \u2192'} - - )} - - ); - })} - - - - - ); -}; - -export default ToolsStep; diff --git a/frontend/src/app/components/OnboardingModal/components/onboardingConstants.ts b/frontend/src/app/components/OnboardingModal/components/onboardingConstants.ts deleted file mode 100644 index 6cf9cbab..00000000 --- a/frontend/src/app/components/OnboardingModal/components/onboardingConstants.ts +++ /dev/null @@ -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 }, -]; \ No newline at end of file diff --git a/frontend/src/app/components/OnboardingModal/components/useOnboarding.ts b/frontend/src/app/components/OnboardingModal/components/useOnboarding.ts deleted file mode 100644 index 7ed2f749..00000000 --- a/frontend/src/app/components/OnboardingModal/components/useOnboarding.ts +++ /dev/null @@ -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(null); - const [nineRouterReady, setNineRouterReady] = useState(null); - const [connectedTools, setConnectedTools] = useState>(new Set()); - const pollTimerRef = useRef(null); - const msgHandlerRef = useRef(null); - - const advanceToTools = useCallback(() => { - if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } - if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } - setConnecting(null); - setStep('tools'); - }, []); - - const handleConnect = useSubscriptionConnect({ - pollTimerRef, msgHandlerRef, setConnecting, advanceToTools, - }); - - useEffect(() => { - let attempts = 0; - const maxAttempts = 15; - const check = () => { - 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, - }; -} diff --git a/frontend/src/app/components/OnboardingModal/components/useSubscriptionConnect.ts b/frontend/src/app/components/OnboardingModal/components/useSubscriptionConnect.ts deleted file mode 100644 index 9156eef0..00000000 --- a/frontend/src/app/components/OnboardingModal/components/useSubscriptionConnect.ts +++ /dev/null @@ -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; - msgHandlerRef: MutableRefObject; - 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 | 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; -} diff --git a/frontend/src/app/components/OnboardingWalkthrough.tsx b/frontend/src/app/components/OnboardingWalkthrough.tsx new file mode 100644 index 00000000..e5056c5a --- /dev/null +++ b/frontend/src/app/components/OnboardingWalkthrough.tsx @@ -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="" 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 = ({ onComplete }) => { + const c = useClaudeTokens(); + const [currentStep, setCurrentStep] = useState(0); + const [spotlightRect, setSpotlightRect] = useState(null); + const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); + const [visible, setVisible] = useState(false); + const tooltipRef = useRef(null); + const animFrameRef = useRef(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 ( + + {/* Dark overlay with spotlight cutout — clicks pass through the cutout */} + + + {/* Spotlight ring glow */} + {spotlightRect && ( + + )} + + {/* Tooltip card */} + + {/* Step counter dots */} + + {STEPS.map((_, i) => ( + + ))} + + + + {step.title} + + + + {step.description} + + + {step.actionHint && ( + + {step.actionHint} + + )} + + {/* Buttons */} + + + + + + + ); +}; + +export default OnboardingWalkthrough; diff --git a/frontend/src/app/components/PlanPicker.tsx b/frontend/src/app/components/PlanPicker.tsx new file mode 100644 index 00000000..c1826b6d --- /dev/null +++ b/frontend/src/app/components/PlanPicker.tsx @@ -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 = { + 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 = ({ + source, + defaultPlan, + defaultInterval = 'annual', + compact = false, + currentPlan, + onSubscribed, +}) => { + const c = useClaudeTokens(); + const [interval, setInterval] = useState(defaultInterval); + const [pending, setPending] = useState(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, 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 ( + + {/* Billing interval toggle — annual selected by default */} + + + Monthly + Annual · save 15% + + + + {/* Plan cards — grid in regular mode, stacked column in compact */} + + {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 ( + + {/* Name + "your plan" indicator */} + + + {plan.name} + + {isDefault && ( + + · your plan + + )} + + + + {plan.tagline} + + + {/* Price row — big number + /mo */} + + + ${price} + + + /mo + + + + {interval === 'annual' ? 'billed annually' : 'billed monthly'} + + + {/* CTA moved ABOVE features — Anthropic pattern. Filled accent + for the recommended tier, outlined for the others; no + separate RECOMMENDED badge needed. */} + + + {/* Microcopy row under every CTA — matches Anthropic's + reassurance-under-the-big-button pattern. */} + + {isRecommended + ? 'Most popular · cancel anytime' + : plan.id === 'ultra' + ? 'No commitment · cancel anytime' + : 'Cancel anytime'} + + + {/* Divider + cumulative features — "Everything in Pro, plus:" */} + + + {plan.featuresHeader} + + {plan.features.map((f) => ( + + + + {f} + + + ))} + + + ); + })} + + + + *Usage limits apply. Prices shown don't include applicable tax. + {' '}Prices and plans are subject to change at OpenSwarm's discretion. + + + ); +}; + +export default PlanPicker; diff --git a/frontend/src/app/pages/Modes/ModeFormDialog/components/RichPromptEditor/useRichPromptEditor.ts b/frontend/src/app/components/RichPromptEditor.tsx similarity index 62% rename from frontend/src/app/pages/Modes/ModeFormDialog/components/RichPromptEditor/useRichPromptEditor.ts rename to frontend/src/app/components/RichPromptEditor.tsx index b9648657..63374cea 100644 --- a/frontend/src/app/pages/Modes/ModeFormDialog/components/RichPromptEditor/useRichPromptEditor.ts +++ b/frontend/src/app/components/RichPromptEditor.tsx @@ -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 = ({ value, onChange, + label = '', + placeholder = '', minRows = 3, maxRows = 8, -}: RichPromptEditorProps) { +}) => { const c = useClaudeTokens(); const editorRef = useRef(null); const wrapperRef = useRef(null); const [focused, setFocused] = useState(false); const [hasContent, setHasContent] = useState(false); + const [attachedSkills, setAttachedSkills] = useState>({}); const attachedSkillsRef = useRef(attachedSkills); attachedSkillsRef.current = attachedSkills; + const removeSkillPillRef = useRef<(id: string) => void>(() => {}); + const [picker, setPicker] = useState(EMPTY_TRIGGER); const [pickerRect, setPickerRect] = useState(null); + const 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(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 ( + + {picker.visible && pickerRect && createPortal( +
+
+ setPicker((p) => ({ ...p, visible: false }))} + visible={picker.visible} + /> +
+
, + document.body, + )} + + 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 && ( + + {label} + + )} + + +
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 && ( +
+ {placeholder} +
+ )} + + + + + ); +}; + +export default RichPromptEditor; diff --git a/frontend/src/app/pages/Dashboard/SelectionOverlay.tsx b/frontend/src/app/components/SelectionOverlay.tsx similarity index 97% rename from frontend/src/app/pages/Dashboard/SelectionOverlay.tsx rename to frontend/src/app/components/SelectionOverlay.tsx index edca3e58..51e534e4 100644 --- a/frontend/src/app/pages/Dashboard/SelectionOverlay.tsx +++ b/frontend/src/app/components/SelectionOverlay.tsx @@ -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)'; diff --git a/frontend/src/app/pages/Modes/ModeFormDialog/components/RichPromptEditor/richEditorUtils.ts b/frontend/src/app/components/richEditorUtils.ts similarity index 99% rename from frontend/src/app/pages/Modes/ModeFormDialog/components/RichPromptEditor/richEditorUtils.ts rename to frontend/src/app/components/richEditorUtils.ts index 35fca309..cae8450c 100644 --- a/frontend/src/app/pages/Modes/ModeFormDialog/components/RichPromptEditor/richEditorUtils.ts +++ b/frontend/src/app/components/richEditorUtils.ts @@ -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; diff --git a/frontend/src/app/pages/Dashboard/hooks/useDomElementSelector/useDomElementSelector.ts b/frontend/src/app/components/useDomElementSelector.ts similarity index 51% rename from frontend/src/app/pages/Dashboard/hooks/useDomElementSelector/useDomElementSelector.ts rename to frontend/src/app/components/useDomElementSelector.ts index 9f50611c..8115769b 100644 --- a/frontend/src/app/pages/Dashboard/hooks/useDomElementSelector/useDomElementSelector.ts +++ b/frontend/src/app/components/useDomElementSelector.ts @@ -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 = { + 'agent-card': 'Agent', + 'message': 'Message', + 'tool-call': 'Tool Call', + 'tool-group': 'Tool Group', + 'view-card': 'View', + 'browser-card': 'Browser', +}; + +function findSelectableAncestor(target: Element, excludeId?: string | null): Element | null { + let current: Element | null = target; + while (current) { + if (current.hasAttribute(SELECT_ATTR)) { + if (excludeId && current.getAttribute(SELECT_ID_ATTR) === excludeId) return null; + return current; + } + current = current.parentElement; + } + return null; +} + +function buildSemanticLabel(type: string, meta: Record): string { + const prefix = SEMANTIC_LABELS[type] || type; + if (meta.name) return `${prefix}: ${meta.name}`; + if (meta.role && meta.content) { + const truncated = String(meta.content).slice(0, 40); + return `${prefix} (${meta.role}): ${truncated}${String(meta.content).length > 40 ? '…' : ''}`; + } + if (meta.label) return `${prefix}: ${meta.label}`; + if (meta.tool) return `${prefix}: ${meta.tool}`; + return prefix; +} + +function rectsIntersect( + a: { top: number; left: number; bottom: number; right: number }, + b: { top: number; left: number; bottom: number; right: number }, +): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +function buildSelectedElement(el: Element): SelectedElement { + const type = el.getAttribute(SELECT_ATTR) || ''; + const selectId = el.getAttribute(SELECT_ID_ATTR) || ''; + let meta: Record = {}; + try { meta = JSON.parse(el.getAttribute(SELECT_META_ATTR) || '{}'); } catch {} + const rect = el.getBoundingClientRect(); + const semanticLabel = buildSemanticLabel(type, meta); + + return { + id: `sel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + selectorPath: `[${SELECT_ATTR}="${type}"][${SELECT_ID_ATTR}="${selectId}"]`, + tagName: el.tagName, + className: '', + outerHTML: '', + computedStyles: {}, + boundingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + semanticType: type as SelectedElement['semanticType'], + semanticLabel, + semanticData: { ...meta, selectId }, + }; +} + +export interface DragPreviewElement { + selectId: string; + top: number; + left: number; + width: number; + height: number; + label: string; + action: 'add' | 'remove'; +} + +const DRAG_THRESHOLD = 5; + +export interface DomSelectorState { + overlay: OverlayState; + dragRect: DragRect; + dragPreview: DragPreviewElement[]; +} export 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(); + const excId = excludeIdRef.current; + allSelectables.forEach((el) => { + const selectId = el.getAttribute(SELECT_ID_ATTR) || ''; + if (excId && selectId === excId) return; + const rect = el.getBoundingClientRect(); + if (rectsIntersect(b, { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom })) { + if (seen.has(selectId)) return; + seen.add(selectId); + const type = el.getAttribute(SELECT_ATTR) || ''; + let meta: Record = {}; + try { meta = JSON.parse(el.getAttribute(SELECT_META_ATTR) || '{}'); } catch {} + preview.push({ + selectId, + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + label: buildSemanticLabel(type, meta), + action: selectedIdsRef.current.has(selectId) ? 'remove' : 'add', + }); + } + }); + setDragPreview(preview); }); } 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 = {}; + 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(); + + 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'; diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 05fae16b..057ca72b 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1,19 +1,121 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import { AssistantRuntimeProvider, useAui, Tools } from '@assistant-ui/react'; -import { ApprovalRouter } from './toolkit/approvalToolkit/ApprovalRouter'; -import { BatchApprovalWrapper } from './toolkit/approvalToolkit/BatchApprovalWrapper'; -import ChatHeader from './ChatHeader'; -import MessageQueue from './MessageQueue'; -import OpenSwarmThread from './OpenSwarmThread/OpenSwarmThread'; -import ChatInput from './ChatInput/ChatInput'; -import { useAgentChat } from './hooks/useAgentChat'; -import { useOpenSwarmRuntime, type ComposerExtras, type DispatchableMessage } from './runtime/useOpenSwarmRuntime'; -import { toolkit } from './toolkit/toolkit'; -import type { ContextPath } from '@/shared/state/agentsTypes'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import TextField from '@mui/material/TextField'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import CloseIcon from '@mui/icons-material/Close'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import CheckIcon from '@mui/icons-material/Check'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { + sendMessage as sendMessageThunk, + launchAndSendFirstMessage, + generateTitle, + generateGroupMeta, + stopAgent, + handleApproval, + editMessage, + switchBranch, + duplicateSession, + setActiveSession, + updateSessionModel, + updateSessionMode, + updateSessionThinkingLevel, + updateThinkingLevel, + fetchSession, + AgentMessage, +} from '@/shared/state/agentsSlice'; +import { fetchModes } from '@/shared/state/modesSlice'; +import { createSessionWs } from '@/shared/ws/WebSocketManager'; +import MessageBubble from './MessageBubble'; +import MessageActionBar from './MessageActionBar'; +import ToolCallBubble, { ToolPair } from './ToolCallBubble'; +import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './ToolGroupBubble'; +import ApprovalBar, { BatchApprovalBar } from './ApprovalBar'; +import ChatInput, { ChatInputHandle } from './ChatInput'; +import { ContextPath } from '@/app/components/DirectoryBrowser'; +import DiffViewer from './DiffViewer'; +import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +const CONTEXT_WINDOWS: Record = { + sonnet: 200_000, + opus: 200_000, + haiku: 200_000, +}; + +function stringifyContent(content: any): string { + if (content == null) return ''; + if (typeof content === 'string') return content; + return JSON.stringify(content); +} + +const thinkingShimmerKeyframes = ` +@keyframes thinking-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +`; + +const ThinkingBubble: React.FC = () => { + const c = useClaudeTokens(); + const shimmerBase = c.text.tertiary; + const shimmerHighlight = c.text.primary; + return ( + + + + + Thinking… + + + + ); +}; + +interface QueuedMessage { + prompt: string; + images?: Array<{ data: string; media_type: string }>; + contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>; + forcedTools?: string[]; + attachedSkills?: Array<{ id: string; name: string; content: string }>; + selectedBrowserIds?: string[]; +} + interface AgentChatProps { sessionId?: string; onClose?: () => void; @@ -25,110 +127,1097 @@ interface AgentChatProps { onBranch?: (newSessionId: string) => void; } -const AgentChat: React.FC = ({ sessionId, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch }) => { +const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch }) => { const c = useClaudeTokens(); - const { - id, session, isDraft, mode, model, - messageQueueRef, showResumeBubble, - queueLength, setQueueLength, agentBusy, - handleSend, handleModeChange, handleModelChange, - handleApprove, handleDeny, handleStop, handleResume, - } = useAgentChat({ sessionId }); + const STATUS_STYLES: Record = { + running: { color: c.status.success, bg: c.status.successBg }, + waiting_approval: { color: c.status.warning, bg: c.status.warningBg }, + completed: { color: c.text.tertiary, bg: c.bg.secondary }, + error: { color: c.status.error, bg: c.status.errorBg }, + stopped: { color: c.text.tertiary, bg: c.bg.secondary }, + }; + const { id: routeId } = useParams<{ id: string }>(); + const id = sessionIdProp || routeId; + const dispatch = useAppDispatch(); + const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined)); + const modesMap = useAppSelector((state) => state.modes.items); + const modelsByProvider = useAppSelector((state) => state.models.byProvider); + const scrollContainerRef = useRef(null); + const chatInputRef = useRef(null); + const isAtBottomRef = useRef(true); + const [showScrollButton, setShowScrollButton] = useState(false); + const [showResumeBubble, setShowResumeBubble] = useState(false); + const [awaitingResponse, setAwaitingResponse] = useState(false); + const [mode, setMode] = useState('agent'); + const [model, setModel] = useState('sonnet'); - const composerExtrasRef = useRef({}); - const dispatchForRuntime = useCallback((msg: DispatchableMessage) => { - handleSend(msg.prompt, msg.images, msg.contextPaths, msg.forcedTools, msg.attachedSkills, msg.selectedBrowserIds); - }, [handleSend]); + const wsRef = useRef | null>(null); + const initialContextApplied = useRef(false); + const messageQueueRef = useRef([]); + const [queueLength, setQueueLength] = useState(0); + const [queueExpanded, setQueueExpanded] = useState(false); + const [editingQueueIdx, setEditingQueueIdx] = useState(null); + const [editingQueueText, setEditingQueueText] = useState(''); + const [dragIdx, setDragIdx] = useState(null); + const [dropTargetIdx, setDropTargetIdx] = useState(null); - const runtime = useOpenSwarmRuntime(id, { - composerExtrasRef, - dispatchMessage: dispatchForRuntime, - }); - const aui = useAui({ tools: Tools({ toolkit }) }); + const isDraft = session?.status === 'draft'; - const contextEstimate = { used: 0, limit: 200_000 }; + useEffect(() => { + if (!id || isDraft) return; + const ws = createSessionWs(id); + ws.connect(); + wsRef.current = ws; + dispatch(fetchSession(id)); + return () => { + ws.disconnect(); + wsRef.current = null; + }; + }, [id, isDraft, dispatch]); + + useEffect(() => { + if (initialContextApplied.current || !initialContextPaths?.length) return; + const timer = setTimeout(() => { + chatInputRef.current?.setContent('', initialContextPaths); + initialContextApplied.current = true; + }, 50); + return () => clearTimeout(timer); + }, [initialContextPaths]); + + useEffect(() => { + if (session) setMode(session.mode); + }, [session?.mode]); + + useEffect(() => { + if (session) setModel(session.model); + }, [session?.model]); + + useEffect(() => { + if (Object.keys(modesMap).length === 0) dispatch(fetchModes()); + }, [dispatch, modesMap]); + + const dispatchMessage = useCallback((msg: QueuedMessage) => { + if (!id) return; + setShowResumeBubble(false); + setAwaitingResponse(true); + if (isDraft) { + const config: Record = { model, mode }; + if (session?.system_prompt) config.system_prompt = session.system_prompt; + if (session?.target_directory) config.target_directory = session.target_directory; + dispatch( + launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }) + ).then((action) => { + if (launchAndSendFirstMessage.fulfilled.match(action)) { + const realId = action.payload.session.id; + dispatch(generateTitle({ sessionId: realId, prompt: msg.prompt })); + if (msg.selectedBrowserIds?.length) { + dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: realId, label: 'Use Browser' })); + } + } + }); + } else { + if (msg.selectedBrowserIds?.length) { + dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' })); + } + dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds })) + .then((action) => { + if (sendMessageThunk.rejected.match(action)) { + setAwaitingResponse(false); + } + }); + } + }, [id, isDraft, mode, model, session?.system_prompt, session?.target_directory, dispatch]); + + const agentBusy = awaitingResponse || (!isDraft && (session?.status === 'running' || session?.status === 'waiting_approval')); + + const prevStatusRef = useRef(session?.status); + useEffect(() => { + const prev = prevStatusRef.current; + const curr = session?.status; + prevStatusRef.current = curr; + let didDispatchQueued = false; + + const wasActive = prev === 'running' || prev === 'waiting_approval'; + const isTerminal = curr === 'completed' || curr === 'stopped' || curr === 'error'; + + if (wasActive && isTerminal) { + if (id) { + dispatch(fadeGlowingBrowserCards(id)); + setTimeout(() => dispatch(clearGlowingBrowserCards(id)), 2800); + } + + const nextQueued = messageQueueRef.current.shift(); + if (nextQueued) { + setQueueLength(messageQueueRef.current.length); + dispatchMessage(nextQueued); + didDispatchQueued = true; + } else { + if (curr === 'stopped') { + setShowResumeBubble(true); + } + } + + const currentMode = modesMap[mode]; + if (currentMode?.default_next_mode && modesMap[currentMode.default_next_mode]) { + setMode(currentMode.default_next_mode); + if (id && !isDraft) { + dispatch(updateSessionMode({ sessionId: id, mode: currentMode.default_next_mode as any })); + } + } + } + if (curr === 'running') { + setShowResumeBubble(false); + } + if (curr !== 'draft' && !didDispatchQueued) { + setAwaitingResponse(false); + } + }, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]); + + // Idle reconcile: if the session has been 'running' for 5s with no + // WebSocket activity (no new messages, no streaming updates), do a + // single GET to fetch the real status from the backend. Catches the + // case where the completion WebSocket event was dropped (network blip, + // sleep/wake, SDK subprocess dying). Resets on every activity signal + // so it never fires during normal streaming. + const reconcileTimer = useRef | null>(null); + const messageCount = session?.messages?.length ?? 0; + const hasStreaming = !!session?.streamingMessage; + + useEffect(() => { + if (reconcileTimer.current) { + clearTimeout(reconcileTimer.current); + reconcileTimer.current = null; + } + + if (!id || session?.status !== 'running') return; + + reconcileTimer.current = setTimeout(() => { + reconcileTimer.current = null; + dispatch(fetchSession(id)); + }, 5000); + + return () => { + if (reconcileTimer.current) { + clearTimeout(reconcileTimer.current); + reconcileTimer.current = null; + } + }; + }, [id, session?.status, messageCount, hasStreaming, dispatch]); + + const SCROLL_THRESHOLD = 50; + + const handleScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; + isAtBottomRef.current = atBottom; + setShowScrollButton(!atBottom); + }, []); + + // Prevent scroll from leaking into the dashboard canvas when at boundaries + useEffect(() => { + const el = scrollContainerRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + const atTop = el.scrollTop <= 0; + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; + const scrollingDown = e.deltaY > 0; + const scrollingUp = e.deltaY < 0; + if ((scrollingUp && atTop) || (scrollingDown && atBottom)) { + e.preventDefault(); + } + e.stopPropagation(); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, []); + + const scrollToBottom = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + isAtBottomRef.current = true; + setShowScrollButton(false); + }, []); + + const scrollRafRef = useRef(null); + useEffect(() => { + if (!isAtBottomRef.current) return; + if (scrollRafRef.current != null) return; + scrollRafRef.current = requestAnimationFrame(() => { + scrollRafRef.current = null; + if (!isAtBottomRef.current) return; + const el = scrollContainerRef.current; + if (el) el.scrollTop = el.scrollHeight; + }); + }, [session?.messages.length, session?.streamingMessage?.content]); + + useEffect(() => () => { + if (scrollRafRef.current != null) { + cancelAnimationFrame(scrollRafRef.current); + scrollRafRef.current = null; + } + }, []); + + const handleSend = (prompt: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>, forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => { + if (!id) return; + // Sending a message is a clear intent signal: the user wants to see + // the response. Force-scroll to bottom regardless of isAtBottomRef. + scrollToBottom(); + const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }; + if (agentBusy) { + messageQueueRef.current.push(msg); + setQueueLength(messageQueueRef.current.length); + return; + } + dispatchMessage(msg); + }; + + const handleModeChange = useCallback((newMode: string) => { + setMode(newMode); + if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: newMode })); + }, [id, isDraft, dispatch]); + + const handleModelChange = useCallback((newModel: string) => { + setModel(newModel); + if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel })); + }, [id, isDraft, dispatch]); + + const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => { + if (!id) return; + dispatch(updateSessionThinkingLevel({ sessionId: id, level })); + if (!isDraft) dispatch(updateThinkingLevel({ sessionId: id, level })); + }, [id, isDraft, dispatch]); + + const handleApprove = (requestId: string, updatedInput?: Record) => { + dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput })); + }; + + const handleDeny = (requestId: string, message?: string) => { + dispatch(handleApproval({ requestId, behavior: 'deny', message })); + }; + + const handleStop = () => { + if (!id) return; + dispatch(stopAgent({ sessionId: id })); + }; + + const handleResume = useCallback(() => { + if (!id) return; + setShowResumeBubble(false); + dispatch(sendMessageThunk({ + sessionId: id, + prompt: "Continue where you left off. Start you're response EXACTLY with 'Sorry, let me pick up where I left off", + mode, + model, + hidden: true, + })); + }, [id, mode, model, dispatch]); + + const [editingMessageId, setEditingMessageId] = useState(null); + + const handleSaveEdit = useCallback( + (messageId: string, newContent: string) => { + if (!id) return; + dispatch(editMessage({ sessionId: id, messageId, content: newContent })); + setEditingMessageId(null); + }, + [id, dispatch] + ); + + const handleCancelEdit = useCallback(() => { + setEditingMessageId(null); + }, []); + + const activeBranchMessages = useMemo(() => { + if (!session) return []; + const branchId = session.active_branch_id || 'main'; + const branch = session.branches?.[branchId]; + + if (!branch || !branch.fork_point_message_id) { + return session.messages.filter((m) => m.branch_id === 'main' || m.branch_id === branchId); + } + + const segments: Array<{ branchId: string; upToMessageId?: string }> = []; + let cur = branch; + let curId = branchId; + while (cur && cur.fork_point_message_id) { + segments.unshift({ branchId: curId, upToMessageId: cur.fork_point_message_id }); + curId = cur.parent_branch_id || 'main'; + cur = session.branches?.[curId]; + } + segments.unshift({ branchId: curId }); + + const result: typeof session.messages = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const nextForkMsgId = seg.upToMessageId; + if (nextForkMsgId) { + const forkIdx = session.messages.findIndex((m) => m.id === nextForkMsgId); + const pre = session.messages + .slice(0, forkIdx) + .filter((m) => m.branch_id === seg.branchId); + result.push(...pre); + } else if (i < segments.length - 1) { + const nextFork = segments[i + 1].upToMessageId; + const forkIdx = nextFork + ? session.messages.findIndex((m) => m.id === nextFork) + : session.messages.length; + result.push( + ...session.messages.slice(0, forkIdx).filter((m) => m.branch_id === seg.branchId) + ); + } else { + result.push(...session.messages.filter((m) => m.branch_id === seg.branchId)); + } + } + const leafMsgs = session.messages.filter((m) => m.branch_id === branchId); + if (!result.some((m) => m.branch_id === branchId)) { + result.push(...leafMsgs); + } + return result; + }, [session?.messages, session?.active_branch_id, session?.branches]); + + const handleRegenerate = useCallback( + (assistantMsg: AgentMessage) => { + if (!id) return; + const idx = activeBranchMessages.findIndex((m) => m.id === assistantMsg.id); + for (let i = idx - 1; i >= 0; i--) { + if (activeBranchMessages[i].role === 'user') { + const userMsg = activeBranchMessages[i]; + const content = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content); + dispatch(editMessage({ sessionId: id, messageId: userMsg.id, content })); + break; + } + } + }, + [id, activeBranchMessages, dispatch] + ); + + const handleBranchChat = useCallback(async (upToMessageId: string) => { + if (!id) return; + const dashId = session?.dashboard_id; + const action = await dispatch(duplicateSession({ sessionId: id, dashboardId: dashId, upToMessageId })); + if (duplicateSession.fulfilled.match(action)) { + if (onBranch) { + onBranch(action.payload.id); + } else { + dispatch(setActiveSession(action.payload.id)); + } + } + }, [id, dispatch, onBranch, session?.dashboard_id]); + + const contextEstimate = useMemo(() => { + // Look up the actual context window from the models store (backend + // registry is the source of truth). Fall back to the legacy hardcoded + // map for any model that isn't in the store yet. + let limit = 0; + for (const ms of Object.values(modelsByProvider)) { + const hit = ms.find((m) => m.value === model); + if (hit?.context_window) { limit = hit.context_window; break; } + } + if (!limit) limit = CONTEXT_WINDOWS[model] || 200_000; + let totalChars = 0; + if (session?.system_prompt) totalChars += session.system_prompt.length; + for (const msg of activeBranchMessages) { + totalChars += stringifyContent(msg.content).length; + } + if (session?.streamingMessage) { + totalChars += (session.streamingMessage.content || '').length; + } + const used = Math.round(totalChars / 4); + return { used, limit }; + }, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model, modelsByProvider]); + + const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval'; + + const renderItems: RenderItem[] = useMemo(() => { + const isOutputCall = (m: AgentMessage) => + m.role === 'tool_call' && typeof m.content === 'object' && m.content.tool === 'RenderOutput'; + const isOutputResult = (m: AgentMessage) => { + if (m.role !== 'tool_result') return false; + try { + const parsed = typeof m.content === 'string' ? JSON.parse(m.content) : m.content; + return !!(parsed?.output_id && parsed?.frontend_code); + } catch { return false; } + }; + + const items: RenderItem[] = []; + let i = 0; + while (i < activeBranchMessages.length) { + const msg = activeBranchMessages[i]; + if (msg.role === 'tool_call' || msg.role === 'tool_result') { + const group: typeof activeBranchMessages = []; + while ( + i < activeBranchMessages.length && + (activeBranchMessages[i].role === 'tool_call' || + activeBranchMessages[i].role === 'tool_result') + ) { + group.push(activeBranchMessages[i]); + i++; + } + + const regular: typeof activeBranchMessages = []; + const outputItems: typeof activeBranchMessages = []; + for (const m of group) { + if (isOutputCall(m) || isOutputResult(m)) { outputItems.push(m); continue; } + regular.push(m); + } + + const calls = regular.filter((m) => m.role === 'tool_call'); + const results = regular.filter((m) => m.role === 'tool_result'); + const pairs: ToolPair[] = calls.map((call, idx) => ({ + type: 'tool_pair' as const, + id: `pair-${call.id}`, + call, + result: results[idx] || null, + })); + + const mcpServers = new Set( + calls.map((m) => { + const tool = typeof m.content === 'object' ? m.content.tool || '' : ''; + const match = tool.match(/^mcp__([^_]+(?:-[^_]+)*)__/); + return match ? match[1] : ''; + }).filter(Boolean) + ); + const allSameMcp = mcpServers.size === 1 && pairs.length > 0; + + if (allSameMcp) { + const mcpServer = [...mcpServers][0]; + const toolNames = new Set( + calls.map((m) => (typeof m.content === 'object' ? m.content.tool : '')) + ); + const label = + toolNames.size === 1 ? calls[0].content?.tool || 'Tool calls' : `${calls.length} tool calls`; + items.push({ + type: 'tool_group', + id: `group-${group[0].id}`, + pairs, + label, + callCount: calls.length, + mcpServer, + } satisfies ToolGroup); + } else if (pairs.length <= 2) { + items.push(...pairs); + } else if (pairs.length > 0) { + const toolNames = new Set( + calls.map((m) => (typeof m.content === 'object' ? m.content.tool : '')) + ); + const label = + toolNames.size === 1 ? calls[0].content?.tool || 'Tool calls' : `${calls.length} tool calls`; + items.push({ + type: 'tool_group', + id: `group-${group[0].id}`, + pairs, + label, + callCount: calls.length, + } satisfies ToolGroup); + } + + items.push(...outputItems); + } else { + if (!msg.hidden) { + items.push(msg); + } + i++; + } + } + return items; + }, [activeBranchMessages]); + + const lastAssistantIdsInTurn = useMemo(() => { + const ids = new Set(); + let lastAssistantId: string | null = null; + for (const item of renderItems) { + if (!isToolGroup(item) && !isToolPair(item)) { + const msg = item as AgentMessage; + if (msg.role === 'assistant') { + lastAssistantId = msg.id; + } else if (msg.role === 'user') { + if (lastAssistantId) ids.add(lastAssistantId); + lastAssistantId = null; + } + } + } + if (lastAssistantId) ids.add(lastAssistantId); + return ids; + }, [renderItems]); + + const groupMetaRequestedRef = useRef>(new Set()); + const groupMetaRefinedRef = useRef>(new Set()); + + useEffect(() => { + if (!id || isDraft) return; + const toolGroups = renderItems.filter(isToolGroup) as ToolGroup[]; + const meta = session?.tool_group_meta ?? {}; + + for (const group of toolGroups) { + const allDone = group.pairs.every((p) => p.result !== null); + + if (!groupMetaRequestedRef.current.has(group.id) && !meta[group.id]) { + groupMetaRequestedRef.current.add(group.id); + const toolCalls = group.pairs.map((p) => { + const c = p.call.content; + const tool = typeof c === 'object' ? c.tool || '' : ''; + const input = typeof c === 'object' ? c.input : ''; + const summary = typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120); + return { tool, input_summary: summary }; + }); + dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls })); + } + + if (allDone && meta[group.id] && !meta[group.id].is_refined && !groupMetaRefinedRef.current.has(group.id)) { + groupMetaRefinedRef.current.add(group.id); + const toolCalls = group.pairs.map((p) => { + const c = p.call.content; + const tool = typeof c === 'object' ? c.tool || '' : ''; + const input = typeof c === 'object' ? c.input : ''; + const summary = typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120); + return { tool, input_summary: summary }; + }); + const resultsSummary = group.pairs + .filter((p) => p.result) + .map((p) => { + const rc = p.result!.content; + const text = typeof rc === 'string' ? rc : typeof rc === 'object' && rc?.text ? rc.text : JSON.stringify(rc); + return text.slice(0, 150); + }); + dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls, resultsSummary, isRefinement: true })); + } + } + }, [renderItems, id, isDraft, session?.tool_group_meta, dispatch]); + + const getSiblingBranches = useCallback( + (messageId: string): string[] => { + if (!session?.branches) return []; + + const directForks = Object.values(session.branches) + .filter((b) => b.fork_point_message_id === messageId) + .map((b) => b.id); + if (directForks.length > 0) { + const originalMsg = session.messages.find((m) => m.id === messageId); + const parentBranchId = originalMsg?.branch_id || 'main'; + return [parentBranchId, ...directForks]; + } + + const msg = session.messages.find((m) => m.id === messageId); + if (!msg || msg.role !== 'user') return []; + const msgBranch = session.branches[msg.branch_id]; + if (!msgBranch?.fork_point_message_id) return []; + const branchUserMsgs = session.messages.filter( + (m) => m.branch_id === msg.branch_id && m.role === 'user' + ); + if (branchUserMsgs.length === 0 || branchUserMsgs[0].id !== messageId) return []; + + const forkPointId = msgBranch.fork_point_message_id; + const siblingBranches = Object.values(session.branches) + .filter((b) => b.fork_point_message_id === forkPointId) + .map((b) => b.id); + const parentBranchId = msgBranch.parent_branch_id || 'main'; + return [parentBranchId, ...siblingBranches]; + }, + [session?.branches, session?.messages] + ); if (!session) { return ( - Session not found + + Session not found + ); } + + const isActive = session.status === 'running' || session.status === 'waiting_approval' || session.status === 'draft'; + const statusStyle = STATUS_STYLES[session.status] || { color: c.text.tertiary, bg: c.bg.secondary }; + return ( - - {!embedded && } - - + {!embedded && ( + + + + {session.name} + {!isDraft && statusStyle && ( + + )} + + {!isDraft && ( + + + {session.model} + + + {session.branch_name} + + {session.cost_usd > 0 && ( + + ${session.cost_usd.toFixed(4)} + + )} + + )} + + {!isDraft && id && } + {onClose && ( + + + + )} + + )} + + + + {renderItems.filter((item) => !session.streamingMessage || item.id !== session.streamingMessage.id).map((item) => { + if (isToolGroup(item)) { + const groupMeta = session.tool_group_meta?.[item.id]; + return ; + } + if (isToolPair(item)) { + const isPending = item.result === null && sessionRunning; + return ; + } + const msg = item; + const isEditing = editingMessageId === msg.id; + const siblings = getSiblingBranches(msg.id); + const hasBranches = siblings.length > 0; + const currentBranchIdx = hasBranches + ? siblings.indexOf(session.active_branch_id || 'main') + : 0; + const rawText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); + + return ( + + + {!isEditing && (msg.role === 'user' || (msg.role === 'assistant' && lastAssistantIdsInTurn.has(msg.id))) && ( + navigator.clipboard.writeText(rawText)} + onEdit={msg.role === 'user' ? () => setEditingMessageId(msg.id) : undefined} + onRegenerate={msg.role === 'assistant' ? () => handleRegenerate(msg) : undefined} + onBranch={msg.role === 'assistant' ? () => handleBranchChat(msg.id) : undefined} + branchNav={ + hasBranches + ? { + currentIndex: Math.max(0, currentBranchIdx), + totalBranches: siblings.length, + onPrevious: () => { + const prevBranch = siblings[Math.max(0, currentBranchIdx - 1)]; + if (prevBranch && id) dispatch(switchBranch({ sessionId: id, branchId: prevBranch })); + }, + onNext: () => { + const nextBranch = siblings[Math.min(siblings.length - 1, currentBranchIdx + 1)]; + if (nextBranch && id) dispatch(switchBranch({ sessionId: id, branchId: nextBranch })); + }, + } + : undefined + } + /> + )} + + ); + })} + {session.streamingMessage && ( + session.streamingMessage.role === 'tool_call' ? ( + + ) : ( + + ) + )} + {(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && ( + + )} + {showResumeBubble && session.status === 'stopped' && ( + + + + + Resume Agent Response + + + + )} + + {showScrollButton && ( + + + + + + )} {session.pending_approvals.length > 1 ? ( - + ) : ( session.pending_approvals.map((req) => ( - + )) )} - {showResumeBubble && session.status === 'stopped' && ( - - Resume - - )} - {isGlowing ? ( { e.stopPropagation(); onDismissGlow?.(); }} sx={{ - mx: 1.5, mb: 1.5, py: 1.25, display: 'flex', alignItems: 'center', justifyContent: 'center', - borderRadius: 2.5, cursor: 'pointer', fontWeight: 600, fontSize: '0.85rem', - color: c.accent.primary, border: `1.5px solid ${c.accent.primary}`, + mx: 1.5, + mb: 1.5, + py: 1.25, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 2.5, + cursor: 'pointer', + fontWeight: 600, + fontSize: '0.85rem', + color: c.accent.primary, + border: `1.5px solid ${c.accent.primary}`, background: `${c.accent.primary}08`, boxShadow: `0 0 12px ${c.accent.primary}25, inset 0 0 12px ${c.accent.primary}08`, animation: 'continue-chat-glow 2s ease-in-out infinite', transition: 'background 0.15s, box-shadow 0.15s', '@keyframes continue-chat-glow': { - '0%, 100%': { boxShadow: `0 0 12px ${c.accent.primary}25, inset 0 0 12px ${c.accent.primary}08` }, - '50%': { boxShadow: `0 0 20px ${c.accent.primary}40, inset 0 0 20px ${c.accent.primary}15` }, + '0%, 100%': { + boxShadow: `0 0 12px ${c.accent.primary}25, inset 0 0 12px ${c.accent.primary}08`, + }, + '50%': { + boxShadow: `0 0 20px ${c.accent.primary}40, inset 0 0 20px ${c.accent.primary}15`, + }, + }, + '&:hover': { + background: `${c.accent.primary}14`, + boxShadow: `0 0 24px ${c.accent.primary}50, inset 0 0 20px ${c.accent.primary}18`, }, - '&:hover': { background: `${c.accent.primary}14`, boxShadow: `0 0 24px ${c.accent.primary}50, inset 0 0 20px ${c.accent.primary}18` }, }} > Continue chat ) : ( - - - + { if (queueExpanded) { setQueueExpanded(false); setEditingQueueIdx(null); } }}> + + {queueLength > 0 && ( + + { setQueueExpanded((v) => !v); setEditingQueueIdx(null); }} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + px: 1.25, + py: 0.25, + borderRadius: '8px 8px 0 0', + bgcolor: c.bg.surface, + border: `1px solid ${c.border.subtle}`, + borderBottom: 'none', + cursor: 'pointer', + userSelect: 'none', + '&:hover': { bgcolor: c.bg.secondary }, + transition: 'background 0.12s', + }} + > + {queueExpanded + ? + : + } + + {queueLength} queued + + + { e.stopPropagation(); messageQueueRef.current = []; setQueueLength(0); setQueueExpanded(false); setEditingQueueIdx(null); }} + sx={{ p: 0.15, color: c.text.tertiary, '&:hover': { color: c.status.error } }} + > + + + + + + {queueExpanded && ( + + {messageQueueRef.current.map((msg, idx) => ( + { + setDragIdx(idx); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (dragIdx !== null && dragIdx !== idx) setDropTargetIdx(idx); + }} + onDragLeave={() => { if (dropTargetIdx === idx) setDropTargetIdx(null); }} + onDrop={(e) => { + e.preventDefault(); + if (dragIdx !== null && dragIdx !== idx) { + const q = messageQueueRef.current; + const [item] = q.splice(dragIdx, 1); + q.splice(idx, 0, item); + setQueueLength(q.length); + } + setDragIdx(null); + setDropTargetIdx(null); + }} + onDragEnd={() => { setDragIdx(null); setDropTargetIdx(null); }} + sx={{ + display: 'flex', + alignItems: 'flex-start', + gap: 0.75, + px: 1.5, + py: 1, + borderBottom: idx < queueLength - 1 ? `1px solid ${c.border.subtle}` : 'none', + '&:hover': { bgcolor: c.bg.secondary }, + transition: 'background 0.1s, opacity 0.15s', + ...(dragIdx === idx ? { opacity: 0.35 } : {}), + ...(dropTargetIdx === idx && dragIdx !== null && dragIdx !== idx + ? { borderTop: `2px solid ${c.accent.primary}` } + : {}), + }} + > + + + + {editingQueueIdx === idx ? ( + + setEditingQueueText(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + const trimmed = editingQueueText.trim(); + if (trimmed) { + messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed }; + setQueueLength(messageQueueRef.current.length); + } + setEditingQueueIdx(null); + } + if (e.key === 'Escape') setEditingQueueIdx(null); + }} + sx={{ + '& .MuiOutlinedInput-root': { + fontSize: '0.78rem', + color: c.text.primary, + '& fieldset': { borderColor: c.border.medium }, + '&.Mui-focused fieldset': { borderColor: c.accent.primary }, + }, + }} + /> + { + const trimmed = editingQueueText.trim(); + if (trimmed) { + messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed }; + setQueueLength(messageQueueRef.current.length); + } + setEditingQueueIdx(null); + }} + sx={{ p: 0.25, color: c.accent.primary, mt: 0.25 }} + > + + + + ) : ( + + {msg.prompt} + + )} + {editingQueueIdx !== idx && ( + + + { setEditingQueueIdx(idx); setEditingQueueText(msg.prompt); }} + sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.text.primary } }} + > + + + + + { + messageQueueRef.current.splice(idx, 1); + setQueueLength(messageQueueRef.current.length); + if (messageQueueRef.current.length === 0) setQueueExpanded(false); + }} + sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.status.error } }} + > + + + + + )} + + ))} + + )} + + )} + + + )} - ); }; diff --git a/frontend/src/app/pages/AgentChat/ApprovalBar.tsx b/frontend/src/app/pages/AgentChat/ApprovalBar.tsx new file mode 100644 index 00000000..08e80f20 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ApprovalBar.tsx @@ -0,0 +1,1159 @@ +import React, { useCallback, useState, useMemo } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Chip from '@mui/material/Chip'; +import Collapse from '@mui/material/Collapse'; +import IconButton from '@mui/material/IconButton'; +import SendIcon from '@mui/icons-material/Send'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import DescriptionIcon from '@mui/icons-material/Description'; +import EditIcon from '@mui/icons-material/Edit'; +import SearchIcon from '@mui/icons-material/Search'; +import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer'; +import BuildIcon from '@mui/icons-material/Build'; +import ExtensionIcon from '@mui/icons-material/Extension'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { ApprovalRequest } from '@/shared/state/agentsSlice'; +import { useAppSelector } from '@/shared/hooks'; +import { ToolDefinition } from '@/shared/state/toolsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +// --------------------------------------------------------------------------- +// Integration metadata (icons, colors) for known MCP servers +// --------------------------------------------------------------------------- + +interface IntegrationMeta { + label: string; + color: string; + icon: React.ReactNode; +} + +const GoogleIcon = ( + + + + + + +); + +const RedditIcon = ( + + + + +); + +const INTEGRATION_META: Record = { + 'Google Workspace': { label: 'Google Workspace', color: '#4285F4', icon: GoogleIcon }, + 'Reddit': { label: 'Reddit', color: '#FF4500', icon: RedditIcon }, +}; + +// --------------------------------------------------------------------------- +// MCP tool name parser +// --------------------------------------------------------------------------- + +export interface ParsedTool { + isMcp: boolean; + serverSlug: string; + actionName: string; + displayName: string; +} + +export function parseMcpToolName(rawName: string): ParsedTool { + const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/); + if (!m) { + return { isMcp: false, serverSlug: '', actionName: rawName, displayName: rawName }; + } + const serverSlug = m[1]; + const actionName = m[2]; + const displayName = actionName + .replace(/_/g, ' ') + .replace(/\b\w/g, (ch) => ch.toUpperCase()); + return { isMcp: true, serverSlug, actionName, displayName }; +} + +function sanitizeServerName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); +} + +// --------------------------------------------------------------------------- +// Look up MCP tool metadata from the Redux tools store +// --------------------------------------------------------------------------- + +interface McpToolMeta { + integration: IntegrationMeta | null; + description: string; + serverLabel: string; +} + +export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta { + const toolItems = useAppSelector((s) => s.tools.items); + + return useMemo(() => { + if (!parsed.isMcp) { + return { integration: null, description: '', serverLabel: '' }; + } + + const toolDef: ToolDefinition | undefined = Object.values(toolItems).find( + (t) => t.mcp_config && Object.keys(t.mcp_config).length > 0 && sanitizeServerName(t.name) === parsed.serverSlug + ); + + if (!toolDef) { + return { integration: null, description: '', serverLabel: parsed.serverSlug }; + } + + const description = toolDef.tool_permissions?._tool_descriptions?.[parsed.actionName] || ''; + const integration = INTEGRATION_META[toolDef.name] || null; + const serverLabel = toolDef.name; + + return { integration, description, serverLabel }; + }, [parsed, toolItems]); +} + +// --------------------------------------------------------------------------- +// Smart input summary for MCP tools +// --------------------------------------------------------------------------- + +function getMcpInputSummary(actionName: string, toolInput: Record): string { + const lower = actionName.toLowerCase(); + + if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) { + const query = toolInput.query || toolInput.search_query || toolInput.q || ''; + const to = toolInput.to || toolInput.recipient || ''; + const subject = toolInput.subject || ''; + if (query) return `Search: "${query}"`; + if (to && subject) return `To ${to} — ${subject}`; + if (to) return `To ${to}`; + if (subject) return `Subject: ${subject}`; + } + + if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) { + const summary = toolInput.summary || toolInput.title || toolInput.event_name || ''; + const start = toolInput.start || toolInput.start_time || toolInput.date || ''; + if (summary && start) return `${summary} — ${start}`; + if (summary) return summary; + if (start) return `Date: ${start}`; + } + + if (lower.includes('drive') || lower.includes('doc') || lower.includes('sheet') || lower.includes('slide')) { + const name = toolInput.name || toolInput.title || toolInput.filename || toolInput.file_name || ''; + const query = toolInput.query || toolInput.q || ''; + if (name) return name; + if (query) return `Search: "${query}"`; + } + + if (lower.includes('tweet') || lower.includes('post') || lower.includes('send') || lower.includes('reply')) { + const text = toolInput.text || toolInput.content || toolInput.body || toolInput.message || ''; + if (text) return text.length > 80 ? text.slice(0, 77) + '...' : text; + } + + if (lower.includes('search') || lower.includes('find') || lower.includes('query') || lower.includes('list')) { + const query = toolInput.query || toolInput.q || toolInput.search_query || toolInput.keyword || toolInput.term || ''; + if (query) return `"${query}"`; + } + + const stringVals: string[] = []; + for (const [key, val] of Object.entries(toolInput)) { + if (key.startsWith('_')) continue; + if (typeof val === 'string' && val.trim()) { + stringVals.push(val.trim()); + } + if (stringVals.length >= 2) break; + } + if (stringVals.length > 0) { + const joined = stringVals.join(' -- '); + return joined.length > 100 ? joined.slice(0, 97) + '...' : joined; + } + + return ''; +} + +// --------------------------------------------------------------------------- +// Shared components +// --------------------------------------------------------------------------- + +interface Props { + request: ApprovalRequest; + onApprove: (requestId: string, updatedInput?: Record) => void; + onDeny: (requestId: string, message?: string) => void; +} + +export function getToolIcon(toolName: string) { + switch (toolName) { + case 'Bash': return ; + case 'Read': return ; + case 'Write': case 'Edit': return ; + case 'Grep': case 'Glob': return ; + case 'AskUserQuestion': return ; + default: return ; + } +} + +interface ToolPreviewProps { + request: ApprovalRequest; + tokens: ReturnType; +} + +const CodeBlock: React.FC<{ tokens: ReturnType; children: React.ReactNode }> = ({ tokens: c, children }) => ( + + {children} + +); + +const ToolPreview: React.FC = ({ request, tokens: c }) => { + const { tool_name, tool_input } = request; + + switch (tool_name) { + case 'Bash': { + return ( + + {tool_input.description && ( + + {tool_input.description} + + )} + {tool_input.command || '(empty command)'} + + ); + } + + case 'Read': + return ( + + + + {tool_input.file_path || tool_input.path || JSON.stringify(tool_input)} + + + ); + + case 'Write': + case 'Edit': { + const path = tool_input.file_path || tool_input.path || ''; + const content = tool_input.content || tool_input.new_content || tool_input.old_string; + return ( + + + + + {path} + + + {content && {typeof content === 'string' ? content : JSON.stringify(content, null, 2)}} + + ); + } + + case 'Grep': + case 'Glob': { + const pattern = tool_input.pattern || tool_input.glob_pattern || tool_input.query || ''; + const path = tool_input.path || tool_input.directory || ''; + return ( + + + + {path && ( + + in {path} + + )} + + + ); + } + + case 'AskUserQuestion': + return null; + + default: { + const preview = tool_input.command || tool_input.file_path || tool_input.path || tool_input.query || null; + if (preview) { + return {preview}; + } + return {JSON.stringify(tool_input, null, 2)}; + } + } +}; + +// --------------------------------------------------------------------------- +// QuestionForm (AskUserQuestion — unchanged) +// --------------------------------------------------------------------------- + +function getOptionKey(opt: any): string { + return opt.id || opt.value || opt.label || opt.text || String(opt); +} + +function getOptionLabel(opt: any): string { + return opt.label || opt.value || opt.text || String(opt); +} + +type Answers = Record; + +export interface QuestionFormProps { + request: ApprovalRequest; + onApprove: (requestId: string, updatedInput?: Record) => void; + onDeny: (requestId: string, message?: string) => void; + compact?: boolean; +} + +const OTHER_KEY = '__other__'; + +export const QuestionForm: React.FC = ({ request, onApprove, onDeny, compact }) => { + const c = useClaudeTokens(); + const questions: any[] = request.tool_input.questions || []; + const [answers, setAnswers] = useState(() => { + const init: Answers = {}; + questions.forEach((q: any, i: number) => { + init[i] = q.multiSelect ? [] : ''; + }); + return init; + }); + const [otherActive, setOtherActive] = useState>({}); + const [otherText, setOtherText] = useState>({}); + + const toggleOption = useCallback((qIdx: number, key: string, multi: boolean) => { + setAnswers((prev) => { + const copy = { ...prev }; + if (multi) { + const arr = Array.isArray(copy[qIdx]) ? [...(copy[qIdx] as string[])] : []; + const idx = arr.indexOf(key); + if (idx >= 0) arr.splice(idx, 1); + else arr.push(key); + copy[qIdx] = arr; + } else { + copy[qIdx] = copy[qIdx] === key ? '' : key; + } + return copy; + }); + if (key !== OTHER_KEY) { + if (!multi) { + setOtherActive((prev) => ({ ...prev, [qIdx]: false })); + setOtherText((prev) => ({ ...prev, [qIdx]: '' })); + } + } + }, []); + + const toggleOther = useCallback((qIdx: number, multi: boolean) => { + setOtherActive((prev) => { + const wasActive = !!prev[qIdx]; + if (wasActive) { + setOtherText((p) => ({ ...p, [qIdx]: '' })); + } + if (!multi && !wasActive) { + setAnswers((p) => ({ ...p, [qIdx]: '' })); + } + return { ...prev, [qIdx]: !wasActive }; + }); + }, []); + + const setTextAnswer = useCallback((qIdx: number, text: string) => { + setAnswers((prev) => ({ ...prev, [qIdx]: text })); + }, []); + + const handleSubmit = () => { + const answersDict: Record = {}; + questions.forEach((q: any, i: number) => { + const questionText = q.question || q.prompt || q.text || ''; + const hasOptions = Array.isArray(q.options) && q.options.length > 0; + let answer = answers[i]; + if (hasOptions && otherActive[i] && otherText[i]) { + if (q.multiSelect) { + const arr = Array.isArray(answer) ? [...answer] : []; + arr.push(otherText[i]); + answer = arr; + } else { + answer = otherText[i]; + } + } + if (Array.isArray(answer)) { + answersDict[questionText] = answer.join(', '); + } else { + answersDict[questionText] = answer || ''; + } + }); + onApprove(request.id, { ...request.tool_input, questions, answers: answersDict }); + }; + + const isSelected = (qIdx: number, key: string): boolean => { + const val = answers[qIdx]; + if (Array.isArray(val)) return val.includes(key); + return val === key; + }; + + return ( + + + + + + + Agent has a question + + + + + {questions.map((q: any, i: number) => { + const hasOptions = Array.isArray(q.options) && q.options.length > 0; + const multi = !!q.multiSelect; + const isOtherActive = !!otherActive[i]; + return ( + + {q.header && ( + + {q.header} + + )} + + {q.question || q.prompt || q.text || '(question)'} + + {hasOptions ? ( + + + {q.options.map((opt: any) => { + const key = getOptionKey(opt); + const selected = isSelected(i, key); + return ( + toggleOption(i, key, multi)} + sx={{ + fontSize: '0.78rem', + fontWeight: selected ? 600 : 400, + cursor: 'pointer', + color: selected ? c.accent.primary : c.text.secondary, + bgcolor: selected ? `${c.accent.primary}18` : 'transparent', + borderColor: selected ? c.accent.primary : c.border.medium, + borderWidth: 1, + borderStyle: 'solid', + transition: 'all 0.15s ease', + '&:hover': { + bgcolor: selected ? `${c.accent.primary}24` : `${c.text.secondary}0a`, + borderColor: selected ? c.accent.primary : c.text.secondary, + }, + }} + /> + ); + })} + toggleOther(i, multi)} + sx={{ + fontSize: '0.78rem', + fontWeight: isOtherActive ? 600 : 400, + fontStyle: 'italic', + cursor: 'pointer', + color: isOtherActive ? c.accent.primary : c.text.muted, + bgcolor: isOtherActive ? `${c.accent.primary}18` : 'transparent', + borderColor: isOtherActive ? c.accent.primary : c.border.subtle, + borderWidth: 1, + borderStyle: 'dashed', + transition: 'all 0.15s ease', + '&:hover': { + bgcolor: isOtherActive ? `${c.accent.primary}24` : `${c.text.secondary}0a`, + borderColor: isOtherActive ? c.accent.primary : c.border.medium, + }, + }} + /> + + {isOtherActive && ( + setOtherText((prev) => ({ ...prev, [i]: e.target.value }))} + fullWidth + size="small" + autoFocus + sx={{ + mt: 0.25, + '& .MuiOutlinedInput-root': { + color: c.text.primary, + fontSize: '0.82rem', + '& fieldset': { borderColor: c.border.medium }, + '&:hover fieldset': { borderColor: c.border.strong }, + '&.Mui-focused fieldset': { borderColor: c.accent.primary }, + }, + }} + /> + )} + + ) : ( + setTextAnswer(i, e.target.value)} + fullWidth + size="small" + multiline + maxRows={4} + sx={{ + '& .MuiOutlinedInput-root': { + color: c.text.primary, + fontSize: '0.82rem', + '& fieldset': { borderColor: c.border.medium }, + '&:hover fieldset': { borderColor: c.border.strong }, + '&.Mui-focused fieldset': { borderColor: c.accent.primary }, + }, + }} + /> + )} + + ); + })} + + + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// GenericApprovalBar — redesigned for MCP tools +// --------------------------------------------------------------------------- + +const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => { + const c = useClaudeTokens(); + const [denyMessage, setDenyMessage] = useState(''); + const [showDenyInput, setShowDenyInput] = useState(false); + const [detailsExpanded, setDetailsExpanded] = useState(false); + + const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]); + const meta = useMcpToolMeta(parsed); + + const accentColor = meta.integration?.color || c.status.warning; + const summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : ''; + + if (!parsed.isMcp) { + return ( + + + + {getToolIcon(request.tool_name)} + + + Permission Required + + + + + + + + + {showDenyInput && ( + setDenyMessage(e.target.value)} + fullWidth + size="small" + sx={{ + mb: 1.5, + '& .MuiOutlinedInput-root': { + color: c.text.primary, + fontSize: '0.8rem', + '& fieldset': { borderColor: c.border.strong }, + '&.Mui-focused fieldset': { borderColor: c.status.error }, + }, + }} + /> + )} + + + + {showDenyInput ? ( + + ) : ( + + )} + + + ); + } + + return ( + + {/* Header row */} + + + {meta.integration?.icon || } + + + + + + {parsed.displayName} + + + + {meta.description && ( + + {meta.description} + + )} + + + + {/* Input summary / details */} + + {summary && ( + setDetailsExpanded((v) => !v)} + > + + {summary} + + + {detailsExpanded ? : } + + + )} + + + + {JSON.stringify(request.tool_input, null, 2)} + + + + + + {/* Deny reason input */} + {showDenyInput && ( + + setDenyMessage(e.target.value)} + fullWidth + size="small" + autoFocus + sx={{ + '& .MuiOutlinedInput-root': { + color: c.text.primary, + fontSize: '0.8rem', + '& fieldset': { borderColor: c.border.strong }, + '&.Mui-focused fieldset': { borderColor: c.status.error }, + }, + }} + /> + + )} + + {/* Action buttons */} + + + {showDenyInput ? ( + + ) : ( + + )} + + + ); +}; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +const ApprovalBar: React.FC = (props) => { + if (props.request.tool_name === 'AskUserQuestion') { + return ; + } + return ; +}; + +// --------------------------------------------------------------------------- +// BatchApprovalBar — grouped mass approve/deny when many approvals pending +// --------------------------------------------------------------------------- + +interface ToolGroup { + toolName: string; + parsed: ParsedTool; + requests: ApprovalRequest[]; +} + +interface BatchApprovalBarProps { + requests: ApprovalRequest[]; + onApprove: (requestId: string, updatedInput?: Record) => void; + onDeny: (requestId: string, message?: string) => void; +} + +export const BatchApprovalBar: React.FC = ({ requests, onApprove, onDeny }) => { + const c = useClaudeTokens(); + const [expandedGroup, setExpandedGroup] = useState(null); + + const questions = requests.filter((r) => r.tool_name === 'AskUserQuestion'); + const nonQuestions = requests.filter((r) => r.tool_name !== 'AskUserQuestion'); + + const groups = useMemo(() => { + const map = new Map(); + for (const req of nonQuestions) { + const existing = map.get(req.tool_name); + if (existing) { + existing.requests.push(req); + } else { + map.set(req.tool_name, { + toolName: req.tool_name, + parsed: parseMcpToolName(req.tool_name), + requests: [req], + }); + } + } + return Array.from(map.values()); + }, [nonQuestions]); + + const handleApproveAll = () => { + for (const req of nonQuestions) onApprove(req.id); + }; + + const handleDenyAll = () => { + for (const req of nonQuestions) onDeny(req.id); + }; + + const handleApproveGroup = (group: ToolGroup) => { + for (const req of group.requests) onApprove(req.id); + }; + + const handleDenyGroup = (group: ToolGroup) => { + for (const req of group.requests) onDeny(req.id); + }; + + return ( + + {questions.map((req) => ( + + ))} + + {nonQuestions.length > 1 && ( + + {/* Global actions bar */} + + + {nonQuestions.length} pending approvals + + + + + + {/* Per-group rows */} + {groups.map((group) => ( + setExpandedGroup((prev) => prev === group.toolName ? null : group.toolName)} + onApprove={onApprove} + onDeny={onDeny} + onApproveGroup={() => handleApproveGroup(group)} + onDenyGroup={() => handleDenyGroup(group)} + /> + ))} + + )} + + {nonQuestions.length === 1 && ( + + )} + + ); +}; + +// --------------------------------------------------------------------------- +// GroupRow — a single tool-name group within the batch bar +// --------------------------------------------------------------------------- + +interface GroupRowProps { + group: ToolGroup; + expanded: boolean; + onToggle: () => void; + onApprove: (requestId: string, updatedInput?: Record) => void; + onDeny: (requestId: string, message?: string) => void; + onApproveGroup: () => void; + onDenyGroup: () => void; +} + +const GroupRow: React.FC = ({ group, expanded, onToggle, onApprove, onDeny, onApproveGroup, onDenyGroup }) => { + const c = useClaudeTokens(); + const meta = useMcpToolMeta(group.parsed); + const accentColor = meta.integration?.color || c.status.warning; + + return ( + + + + {group.parsed.isMcp + ? (meta.integration?.icon || ) + : getToolIcon(group.toolName)} + + + + {group.parsed.isMcp ? group.parsed.displayName : group.toolName} + + + + + {group.requests.length > 1 && ( + <> + + + + )} + + + {expanded ? : } + + + + + + {group.requests.map((req) => ( + + ))} + + + + ); +}; + +export default ApprovalBar; diff --git a/frontend/src/app/pages/AgentChat/BranchNavigator.tsx b/frontend/src/app/pages/AgentChat/BranchNavigator.tsx new file mode 100644 index 00000000..e506f8c5 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/BranchNavigator.tsx @@ -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 = ({ currentIndex, totalBranches, onPrevious, onNext }) => { + const c = useClaudeTokens(); + if (totalBranches <= 1) return null; + + return ( + + + + + + + {currentIndex + 1} / {totalBranches} + + + + + + + ); +}; + +export default BranchNavigator; diff --git a/frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx b/frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx new file mode 100644 index 00000000..a529cfa7 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx @@ -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 = ({ parentSessionId, browserId }) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const { mode } = useThemeMode(); + const fc = mode === 'dark' ? darkFeedColors : lightFeedColors; + const scrollRef = useRef(null); + const fetchedForSession = useRef(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 ( + { + // 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) => ( + + {showLabels && ( + 0 ? 1 : 0, mb: 0.25 }}> + + + {session.browser_id || `Browser ${si + 1}`} + + + + )} + + {!showLabels && entries.length === 0 && session.status === 'running' && ( + + Starting browser agent... + + )} + + {entries.map((entry, i) => ( + + ))} + + {/* 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 ( + + + + {problem} + + + dispatch(handleApproval({ requestId: intervention.id, behavior: 'allow' }))} + sx={{ + p: 0, + width: 18, + height: 18, + color: '#fff', + bgcolor: '#f59e0b', + '&:hover': { bgcolor: '#d97706' }, + }} + > + + + + + 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)' }, + }} + > + + + + + ); + })} + + {!showLabels && session.status === 'running' && entries.length > 0 && ( + + + + )} + + ))} + + ); +}; + +const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors }> = ({ entry, accentColor, fc }) => { + const c = useClaudeTokens(); + + if (entry.type === 'thought') { + return ( + + + + {entry.text} + + + ); + } + + if (entry.type === 'action') { + const ActionIcon = getActionIcon(entry.actionTool); + return ( + + + + {entry.text} + + + ); + } + + if (entry.type === 'result') { + return ( + + + ↳ {entry.text} + + + ); + } + + if (entry.type === 'system') { + return ( + + + + {entry.text} + + + ); + } + + return null; +}; + +const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => { + const c = useClaudeTokens(); + if (status === 'running') { + return ( + + ); + } + if (status === 'completed') { + return ; + } + if (status === 'error') { + return ; + } + return null; +}; + +export default React.memo(BrowserAgentInlineFeed); diff --git a/frontend/src/app/pages/AgentChat/ChatHeader.tsx b/frontend/src/app/pages/AgentChat/ChatHeader.tsx deleted file mode 100644 index 10594499..00000000 --- a/frontend/src/app/pages/AgentChat/ChatHeader.tsx +++ /dev/null @@ -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 = ({ session, isDraft, onClose }) => { - const c = useClaudeTokens(); - const STATUS_STYLES: Record = { - running: { color: c.status.success, bg: c.status.successBg }, - waiting_approval: { color: c.status.warning, bg: c.status.warningBg }, - completed: { color: c.text.tertiary, bg: c.bg.secondary }, - error: { color: c.status.error, bg: c.status.errorBg }, - stopped: { color: c.text.tertiary, bg: c.bg.secondary }, - }; - const statusStyle = STATUS_STYLES[session.status] || { color: c.text.tertiary, bg: c.bg.secondary }; - - return ( - - - - {session.name} - {!isDraft && ( - - )} - - {!isDraft && ( - - {session.model} - {session.branch_name} - {session.cost_usd > 0 && ( - - ${session.cost_usd.toFixed(4)} - - )} - - )} - - {onClose && ( - - - - )} - - ); -}; - -export default ChatHeader; diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx new file mode 100644 index 00000000..ef20474e --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -0,0 +1,1424 @@ +import React, { useState, useRef, useCallback, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react'; +import Box from '@mui/material/Box'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Chip from '@mui/material/Chip'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import StopIcon from '@mui/icons-material/Stop'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined'; +import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; +import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined'; +import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; +import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined'; +import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined'; +import CloseIcon from '@mui/icons-material/Close'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; +import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; +import Modal from '@mui/material/Modal'; +import CircularProgress from '@mui/material/CircularProgress'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import AdsClickIcon from '@mui/icons-material/AdsClick'; +import CommandPicker, { CommandPickerItem, getToolGroupIcon } from '@/app/components/CommandPicker'; +import { useElementSelection, SelectedElement } from '@/app/components/ElementSelectionContext'; +import { getClipboardCards, clearClipboard } from '@/shared/dashboardClipboard'; +import { getWebview } from '@/shared/browserRegistry'; +import { API_BASE } from '@/shared/config'; +import { ContextPath } from '@/app/components/DirectoryBrowser'; +import { + SKILL_PILL_ATTR, + AttachedSkill, + createSkillPillElement, + serializeEditorContent, + detectEditorTrigger, + TriggerState, + EMPTY_TRIGGER, +} from '@/app/components/richEditorUtils'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { fetchModes } from '@/shared/state/modesSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +export interface AttachedImage { + data: string; + media_type: string; + preview: string; +} + +export interface ForcedToolGroup { + label: string; + tools: string[]; + icon?: React.ReactNode; + iconKey?: string; +} + +export type { AttachedSkill } from '@/app/components/richEditorUtils'; + +interface Props { + onSend: (message: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: ContextPath[], forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => void; + disabled?: boolean; + mode: string; + onModeChange: (mode: string) => void; + model: string; + onModelChange: (model: string) => void; + provider?: string; + onProviderChange?: (provider: string) => void; + isRunning?: boolean; + onStop?: () => void; + autoRunMode?: boolean; + contextEstimate?: { used: number; limit: number }; + embedded?: boolean; + autoFocus?: boolean; + sessionId?: string; + queueLength?: number; + thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto'; + onThinkingLevelChange?: (level: 'off' | 'low' | 'medium' | 'high' | 'auto') => void; +} + +export interface ChatInputHandle { + getConfig: () => { prompt: string; contextPaths: ContextPath[]; forcedTools: ForcedToolGroup[] }; + setContent: (prompt: string, contextPaths?: ContextPath[], forcedTools?: ForcedToolGroup[]) => void; +} + +// Module-level draft store — survives component unmount/remount. Keyed by +// sessionId (or a fallback owner id). Stores the raw innerHTML of the +// contentEditable div so skill pills, formatting, etc. are preserved. +const _draftStore = new Map(); + +const ICON_MAP: Record = { + smart_toy: , + question_answer: , + map: , + category: , + tune: , +}; + +const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy }; + +const FALLBACK_MODELS = [ + { value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000, reasoning: true }, + { value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000, reasoning: true }, + { value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000, reasoning: true }, +]; + +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 ( + + + + + + + + + ); +}; + +// Brand colors for provider headers in the model picker — these match +// the SubscriptionCard colors in Settings and help users distinguish +// groups at a glance. +const PROVIDER_COLORS: Record = { + anthropic: '#E8927A', + openai: '#74AA9C', + google: '#4285F4', + gemini: '#4285F4', + xai: '#8B949E', + meta: '#0866FF', + deepseek: '#4D6BFE', + mistral: '#FF7000', + qwen: '#A974FF', + cohere: '#FF7759', +}; + +const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange }, ref) => { + const c = useClaudeTokens(); + const editorRef = useRef(null); + const containerRef = useRef(null); + const generalFileInputRef = useRef(null); + const dispatch = useAppDispatch(); + const elementSelection = useElementSelection(); + + const fallbackOwnerIdRef = useRef(`input-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`); + const ownerId = sessionId || fallbackOwnerIdRef.current; + + useEffect(() => { + if (autoFocus) editorRef.current?.focus(); + }, [autoFocus]); + + // Restore draft from the module-level store on mount. + useEffect(() => { + const saved = _draftStore.get(ownerId); + const editor = editorRef.current; + if (saved && editor && !editor.textContent?.trim()) { + editor.innerHTML = saved; + // Move cursor to end + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } + // Only on mount — ownerId is stable for the component's lifetime + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [hasContent, setHasContent] = useState(() => !!_draftStore.get(ownerId)); + const [attachedSkills, setAttachedSkills] = useState>({}); + const attachedSkillsRef = useRef(attachedSkills); + attachedSkillsRef.current = attachedSkills; + + const [picker, setPicker] = useState(EMPTY_TRIGGER); + const skills = useAppSelector((state) => state.skills.items); + const modesMap = useAppSelector((state) => state.modes.items); + const modesArr = useMemo(() => Object.values(modesMap), [modesMap]); + const modelsByProvider = useAppSelector((state) => state.models.byProvider); + const modelsLoaded = useAppSelector((state) => state.models.loaded); + const connectionMode = useAppSelector((state) => state.settings.data.connection_mode); + const toolItems = useAppSelector((state) => state.tools.items); + + + // Build flat model list with provider grouping. Group names come from the + // backend's /agents/models response verbatim — "OpenSwarm Pro" for + // proxy-routed Claude, "Anthropic" for direct/subscription-routed Claude, + // plus the non-Anthropic providers. Only the pre-load fallback still needs + // to pick a label since no models have been fetched yet. + const allModelOptions = useMemo(() => { + if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) { + const key = connectionMode === 'openswarm-pro' ? 'OpenSwarm Pro' : 'Anthropic'; + return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: key })), grouped: { [key]: FALLBACK_MODELS } }; + } + const flat: Array<{ value: string; label: string; context_window: number; provider: string; reasoning: boolean }> = []; + const grouped: Record> = {}; + for (const [prov, models] of Object.entries(modelsByProvider)) { + grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, reasoning: !!m.reasoning })); + for (const m of models) { + flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov, reasoning: !!m.reasoning }); + } + } + return { flat, grouped }; + }, [modelsByProvider, modelsLoaded, connectionMode]); + + useEffect(() => { + if (modesArr.length === 0) dispatch(fetchModes()); + }, [dispatch, modesArr.length]); + + // Collapsible provider groups in the model picker. The group containing + // the currently selected model is always expanded; others start collapsed + // when there are 3+ groups to keep the dropdown manageable. + const [collapsedGroups, setCollapsedGroups] = useState>({}); + // Toggle based on the *effective* collapsed state (which can come from + // the default), not the raw stored value. Otherwise the first click on + // a group that was defaulted-collapsed is a no-op (undefined → true). + const toggleGroup = (prov: string, currentlyCollapsed: boolean) => + setCollapsedGroups(prev => ({ ...prev, [prov]: !currentlyCollapsed })); + + const [images, setImages] = useState([]); + const [lightboxSrc, setLightboxSrc] = useState(null); + const [isDragOver, setIsDragOver] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [contextPaths, setContextPaths] = useState([]); + const [forcedTools, setForcedTools] = useState([]); + const [copiedPathIdx, setCopiedPathIdx] = useState(null); + + useImperativeHandle(ref, () => ({ + getConfig: () => { + const editor = editorRef.current; + const prompt = editor ? serializeEditorContent(editor, attachedSkillsRef.current).trim() : ''; + return { prompt, contextPaths, forcedTools }; + }, + setContent: (prompt: string, newContextPaths?: ContextPath[], newForcedTools?: ForcedToolGroup[]) => { + const editor = editorRef.current; + if (editor) { + editor.textContent = prompt; + setHasContent(!!prompt); + } + if (newContextPaths) setContextPaths(newContextPaths); + if (newForcedTools) setForcedTools(newForcedTools); + }, + }), [contextPaths, forcedTools]); + + const [modeAnchor, setModeAnchor] = useState(null); + const [modelAnchor, setModelAnchor] = useState(null); + const [thinkingAnchor, setThinkingAnchor] = useState(null); + + const currentMode = modesMap[mode]; + const FALLBACK_MODE = { ...FALLBACK_MODE_BASE, color: c.accent.primary }; + const modeConf = currentMode + ? { label: currentMode.name, icon: ICON_MAP[currentMode.icon] || ICON_MAP.smart_toy, color: currentMode.color } + : FALLBACK_MODE; + + const updateHasContent = useCallback(() => { + const editor = editorRef.current; + if (!editor) return; + const text = (editor.textContent || '').replace(/\u200B/g, ''); + const hasPills = editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null; + setHasContent(text.trim().length > 0 || hasPills); + }, []); + + const syncAttachedSkills = useCallback(() => { + const editor = editorRef.current; + if (!editor) return; + const pillIds = new Set( + Array.from(editor.querySelectorAll(`[${SKILL_PILL_ATTR}]`)) + .map((el) => el.getAttribute(SKILL_PILL_ATTR)) + .filter(Boolean) as string[], + ); + setAttachedSkills((prev) => { + const prevKeys = Object.keys(prev); + if (prevKeys.length === pillIds.size && prevKeys.every((k) => pillIds.has(k))) return prev; + const next: Record = {}; + for (const [id, skill] of Object.entries(prev)) { + if (pillIds.has(id)) next[id] = skill; + } + return next; + }); + }, []); + + const removeSkillPill = useCallback((skillId: string) => { + const editor = editorRef.current; + if (!editor) return; + const pill = editor.querySelector(`[${SKILL_PILL_ATTR}="${skillId}"]`); + if (pill) pill.remove(); + setAttachedSkills((prev) => { + const { [skillId]: _, ...rest } = prev; + return rest; + }); + const text = (editor.textContent || '').replace(/\u200B/g, ''); + const hasPills = editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null; + setHasContent(text.trim().length > 0 || hasPills); + editor.focus(); + }, []); + + const addImageFiles = useCallback((files: FileList | File[]) => { + Array.from(files).forEach((file) => { + if (!file.type.startsWith('image/')) return; + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(',')[1]; + setImages((prev) => [ + ...prev, + { data: base64, media_type: file.type, preview: result }, + ]); + }; + reader.readAsDataURL(file); + }); + }, []); + + const uploadAndAttachFiles = useCallback(async (files: File[]) => { + if (files.length === 0) return; + setIsUploading(true); + try { + const formData = new FormData(); + files.forEach((f) => formData.append('files', f)); + const resp = await fetch(`${API_BASE}/settings/upload-files`, { + method: 'POST', + body: formData, + }); + if (!resp.ok) throw new Error('Upload failed'); + const data = await resp.json(); + const newPaths: ContextPath[] = (data.files || []).map((f: { path: string }) => ({ + path: f.path, + type: 'file' as const, + })); + setContextPaths((prev) => [...prev, ...newPaths]); + } catch (err) { + console.error('File upload failed:', err); + } finally { + setIsUploading(false); + } + }, []); + + const handleSend = useCallback(async () => { + const editor = editorRef.current; + if (!editor || disabled) return; + const serialized = serializeEditorContent(editor, attachedSkillsRef.current); + let trimmed = serialized.trim(); + if (!trimmed) return; + + const selectedEls = elementSelection?.elementsByOwner?.[ownerId] ?? []; + let allImages = images.length > 0 + ? images.map(({ data, media_type }) => ({ data, media_type })) + : []; + + if (selectedEls.length > 0) { + const lines: string[] = ['\n\n---\nSelected UI Elements:\n']; + for (let i = 0; i < selectedEls.length; i++) { + const el = selectedEls[i]; + + if (el.semanticType === 'browser-card' && el.semanticData?.selectId) { + const wv = getWebview(el.semanticData.selectId as string); + const url = wv ? (el.semanticData.url || wv.getURL()) : (el.semanticData.url || ''); + const title = wv ? (el.semanticData.name || wv.getTitle()) : (el.semanticLabel || ''); + lines.push(`${i + 1}. [Browser Card] ${title}`); + lines.push(` browser_id: ${el.semanticData.selectId}`); + if (url) lines.push(` URL: ${url}`); + lines.push(` (Use BrowserAgent with this browser_id to interact with it, or CreateBrowserAgent for a new browser)`); + } else if (el.semanticType && el.semanticData) { + const typeLabel = { + 'agent-card': 'Agent Card', + 'message': 'Message', + 'tool-call': 'Tool Call', + 'tool-group': 'Tool Group', + 'view-card': 'App Card', + 'browser-card': 'Browser Card', + 'dom-element': 'Element', + }[el.semanticType] || el.semanticType; + lines.push(`${i + 1}. [${typeLabel}] ${el.semanticLabel || ''}`); + const { selectId, ...rest } = el.semanticData; + if (selectId) lines.push(` ID: ${selectId}`); + const metaStr = Object.entries(rest) + .filter(([, v]) => v != null) + .map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`) + .join(', '); + if (metaStr) lines.push(` ${metaStr}`); + if (el.semanticType === 'agent-card' && selectId) { + lines.push(` (Use InvokeAgent with session_id "${selectId}" to query this agent with full conversation context)`); + } + } else { + const styleStr = Object.entries(el.computedStyles) + .map(([k, v]) => `${k}: ${v}`) + .join('; '); + lines.push(`${i + 1}. \`${el.selectorPath}\` (${el.tagName.toLowerCase()})`); + lines.push(` Selector: ${el.selectorPath}`); + lines.push(` HTML: ${el.outerHTML.length > 500 ? el.outerHTML.slice(0, 500) + '...' : el.outerHTML}`); + if (styleStr) lines.push(` Key styles: ${styleStr}`); + } + lines.push(''); + + if (el.screenshot) { + const base64 = el.screenshot.replace(/^data:image\/\w+;base64,/, ''); + allImages.push({ data: base64, media_type: 'image/png' }); + } + } + trimmed += lines.join('\n'); + } + + const sendImages = allImages.length > 0 ? allImages : undefined; + const allForcedToolNames = forcedTools.flatMap((ft) => ft.tools); + const currentSkills = Object.values(attachedSkillsRef.current); + const sendSkills = currentSkills.length > 0 + ? currentSkills.map((s) => ({ id: s.id, name: s.name, content: s.content })) + : undefined; + const browserIds = selectedEls + .filter((el) => el.semanticType === 'browser-card' && el.semanticData?.selectId) + .map((el) => el.semanticData!.selectId as string); + onSend( + trimmed, + sendImages, + contextPaths.length > 0 ? contextPaths : undefined, + allForcedToolNames.length > 0 ? allForcedToolNames : undefined, + sendSkills, + browserIds.length > 0 ? browserIds : undefined, + ); + editor.innerHTML = ''; + _draftStore.delete(ownerId); + setImages([]); + setContextPaths([]); + setForcedTools([]); + setAttachedSkills({}); + setHasContent(false); + elementSelection?.clearOwnerElements(ownerId); + }, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId]); + + const detectTrigger = useCallback(() => { + const result = detectEditorTrigger(); + if (result) { + setPicker(result); + } else { + setPicker((p) => ({ ...p, visible: false })); + } + }, []); + + const handleInput = useCallback(() => { + updateHasContent(); + detectTrigger(); + syncAttachedSkills(); + // Persist draft so it survives unmount/remount (card collapse, navigation) + const editor = editorRef.current; + if (editor) { + const html = editor.innerHTML; + if (html && html !== '
') { + _draftStore.set(ownerId, html); + } else { + _draftStore.delete(ownerId); + } + } + }, [updateHasContent, detectTrigger, syncAttachedSkills, ownerId]); + + const handleEditorClick = useCallback(() => { + detectTrigger(); + }, [detectTrigger]); + + const handlePickerSelect = (item: CommandPickerItem) => { + setPicker((p) => ({ ...p, visible: false })); + const editor = editorRef.current; + if (!editor) return; + + editor.focus(); + + const { triggerNode, triggerOffset, filter } = picker; + if (triggerNode && triggerNode.parentNode && editor.contains(triggerNode)) { + const endOffset = Math.min(triggerOffset + 1 + filter.length, triggerNode.length); + const range = document.createRange(); + range.setStart(triggerNode, triggerOffset); + range.setEnd(triggerNode, endOffset); + range.deleteContents(); + const sel = window.getSelection(); + if (sel) { sel.removeAllRanges(); sel.addRange(range); } + } + + if (item.type === 'skill') { + const skill = skills[item.id]; + if (!skill) return; + if (editor.querySelector(`[${SKILL_PILL_ATTR}="${skill.id}"]`)) return; + + const pill = createSkillPillElement( + { id: skill.id, name: skill.name, content: skill.content }, + removeSkillPill, + c.font.mono, + c.status.error, + ); + + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + range.collapse(false); + range.insertNode(pill); + const spacer = document.createTextNode('\u200B'); + pill.after(spacer); + const newRange = document.createRange(); + newRange.setStartAfter(spacer); + newRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(newRange); + } + + setAttachedSkills((prev) => ({ + ...prev, + [skill.id]: { id: skill.id, name: skill.name, content: skill.content }, + })); + } else if (item.type === 'mode') { + onModeChange(item.id); + } else if (item.type === 'context') { + if (item.command === 'file') { + generalFileInputRef.current?.click(); + } else if (item.toolNames && item.toolNames.length > 0) { + setForcedTools((prev) => [...prev, { label: item.name, tools: item.toolNames!, icon: item.icon, iconKey: item.iconKey }]); + } else { + document.execCommand('insertText', false, `@${item.command} `); + } + } + + updateHasContent(); + setTimeout(() => editor.focus(), 0); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (picker.visible && ['ArrowDown', 'ArrowUp', 'Escape', 'Tab', 'Enter'].includes(e.key)) { + e.preventDefault(); + return; + } + if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) { + e.preventDefault(); + return; + } + if (e.key === 'Enter' && !e.shiftKey && !autoRunMode) { + e.preventDefault(); + handleSend(); + } + }; + + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const copied = getClipboardCards(); + if (copied.length > 0 && elementSelection) { + e.preventDefault(); + for (const card of copied) { + const semanticTypeMap: Record = { + agent: 'agent-card', + view: 'view-card', + browser: 'browser-card', + }; + const semanticType = semanticTypeMap[card.type]; + if (!semanticType) continue; + const labelMap: Record = { + 'agent-card': 'Agent', + 'view-card': 'View', + 'browser-card': 'Browser', + }; + const semanticLabel = (labelMap[semanticType] || semanticType) + ': ' + card.name; + const el: SelectedElement = { + id: `sel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + selectorPath: `[data-select-type="${semanticType}"][data-select-id="${card.id}"]`, + tagName: 'DIV', + className: '', + outerHTML: '', + computedStyles: {}, + boundingRect: { x: 0, y: 0, width: 0, height: 0 }, + semanticType, + semanticLabel, + semanticData: { ...card.meta, selectId: card.id }, + }; + elementSelection.addElementForOwner(ownerId, el); + } + clearClipboard(); + return; + } + + const items = e.clipboardData?.items; + if (!items) return; + const imageFiles: File[] = []; + for (let i = 0; i < items.length; i++) { + if (items[i].type.startsWith('image/')) { + const file = items[i].getAsFile(); + if (file) imageFiles.push(file); + } + } + if (imageFiles.length > 0) { + e.preventDefault(); + addImageFiles(imageFiles); + return; + } + e.preventDefault(); + const plain = e.clipboardData.getData('text/plain'); + if (plain) document.execCommand('insertText', false, plain); + }, [addImageFiles, elementSelection, ownerId]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types.includes('Files')) { + setIsDragOver(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + if (e.dataTransfer.files.length === 0) return; + const allFiles = Array.from(e.dataTransfer.files); + const imageFiles = allFiles.filter((f) => f.type.startsWith('image/')); + const otherFiles = allFiles.filter((f) => !f.type.startsWith('image/')); + if (imageFiles.length > 0) addImageFiles(imageFiles); + if (otherFiles.length > 0) uploadAndAttachFiles(otherFiles); + }, [addImageFiles, uploadAndAttachFiles]); + + const removeImage = useCallback((idx: number) => { + setImages((prev) => prev.filter((_, i) => i !== idx)); + }, []); + + const menuPaperProps = { + sx: { + bgcolor: c.bg.surface, + border: `1px solid ${c.border.subtle}`, + borderRadius: '10px', + minWidth: 180, + maxHeight: 400, + boxShadow: c.shadow.lg, + '& .MuiMenuItem-root': { + fontSize: '0.8rem', + color: c.text.secondary, + py: 0.75, + px: 1.5, + '&:hover': { bgcolor: c.bg.secondary }, + }, + }, + }; + + const selectedElements = elementSelection?.elementsByOwner?.[ownerId] ?? []; + const hasAttachments = images.length > 0 || contextPaths.length > 0 || forcedTools.length > 0 || selectedElements.length > 0; + + return ( + + {isDragOver && ( + + + + Drop files here + + + )} + + {isUploading && ( + + + + Attaching files… + + + )} + + setPicker((p) => ({ ...p, visible: false }))} + visible={picker.visible} + /> + + {images.length > 0 && ( + + {images.map((img, idx) => ( + setLightboxSrc(img.preview)} + > + + { e.stopPropagation(); removeImage(idx); }} + sx={{ + position: 'absolute', + top: -2, + right: -2, + width: 18, + height: 18, + bgcolor: c.bg.surface, + border: `1px solid ${c.border.medium}`, + color: c.text.tertiary, + '&:hover': { bgcolor: c.bg.secondary, color: c.text.primary }, + }} + > + + + + ))} + + )} + + {contextPaths.length > 0 && ( + 0 ? 0.25 : 1, pb: 0 }}> + {contextPaths.map((cp, idx) => { + const label = cp.path.split('/').filter(Boolean).slice(-2).join('/'); + return ( + + + : + } + label={label} + size="small" + onClick={() => { + navigator.clipboard.writeText(cp.path); + setCopiedPathIdx(idx); + setTimeout(() => setCopiedPathIdx((cur) => cur === idx ? null : cur), 1200); + }} + onDelete={() => setContextPaths((prev) => prev.filter((_, i) => i !== idx))} + sx={{ + bgcolor: `${c.accent.primary}12`, + color: c.accent.primary, + fontSize: '0.72rem', + fontFamily: c.font.mono, + height: 26, + maxWidth: 220, + cursor: 'pointer', + '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, + '& .MuiChip-deleteIcon': { + color: c.accent.primary, + fontSize: 16, + '&:hover': { color: c.status.error }, + }, + }} + /> + + ); + })} + + )} + + {forcedTools.length > 0 && ( + 0 || contextPaths.length > 0) ? 0.25 : 1, pb: 0 }}> + {forcedTools.map((ft, idx) => ( + {ft.icon || getToolGroupIcon(ft.iconKey || ft.label, 14)}} + label={`@${ft.label.toLowerCase()}`} + size="small" + onDelete={() => setForcedTools((prev) => prev.filter((_, i) => i !== idx))} + sx={{ + bgcolor: `${c.status.info}15`, + color: c.status.info, + fontSize: '0.72rem', + fontFamily: c.font.mono, + height: 26, + maxWidth: 220, + '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, + '& .MuiChip-deleteIcon': { + color: c.status.info, + fontSize: 16, + '&:hover': { color: c.status.error }, + }, + }} + /> + ))} + + )} + + {selectedElements.length > 0 && ( + 0 || contextPaths.length > 0 || forcedTools.length > 0) ? 0.25 : 1, pb: 0 }}> + {selectedElements.map((el) => { + const chipLabel = el.semanticLabel + ? el.semanticLabel + : el.className + ? `${el.tagName.toLowerCase()}.${el.className.split(' ')[0]}` + : el.tagName.toLowerCase(); + const tooltipText = el.semanticType + ? `${el.semanticType}: ${el.semanticLabel || el.selectorPath}` + : el.selectorPath; + return ( + + } + label={chipLabel} + size="small" + onDelete={() => elementSelection?.removeOwnerElement(ownerId, el.id)} + sx={{ + bgcolor: 'rgba(59, 130, 246, 0.1)', + color: '#3b82f6', + fontSize: '0.72rem', + fontFamily: c.font.mono, + height: 26, + maxWidth: 220, + '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, + '& .MuiChip-deleteIcon': { + color: '#3b82f6', + fontSize: 16, + '&:hover': { color: c.status.error }, + }, + '& .MuiChip-icon': { + color: '#3b82f6', + }, + }} + /> + + ); + })} + + )} + + +
+ {!hasContent && ( +
+ {disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued — type another or wait…` : 'Agent is working — messages will queue…') : `${modeConf.label}, @ for context, / for commands`} +
+ )} + + + + setModeAnchor(e.currentTarget)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + px: 1, + py: 0.375, + borderRadius: '999px', + cursor: 'pointer', + userSelect: 'none', + color: modeConf.color, + bgcolor: `${modeConf.color}14`, + '&:hover': { bgcolor: `${modeConf.color}22` }, + transition: 'background 0.15s', + }} + > + {modeConf.icon} + + {modeConf.label} + + + + + setModeAnchor(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'left' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: menuPaperProps }} + > + {modesArr.map((m) => { + const icon = ICON_MAP[m.icon] || ICON_MAP.smart_toy; + return ( + { + onModeChange(m.id); + setModeAnchor(null); + }} + > + + {icon} + + + + ); + })} + + + setModelAnchor(e.currentTarget)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.25, + px: 0.75, + py: 0.25, + borderRadius: '6px', + cursor: 'pointer', + userSelect: 'none', + color: c.text.muted, + '&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }, + transition: 'background 0.15s', + }} + > + + {(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()} + + + + + setModelAnchor(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'left' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: menuPaperProps }} + > + {Object.entries(allModelOptions.grouped).map(([prov, models]) => { + // Non-interactive provider headers followed by their models. + // All groups always shown — no collapse/expand. Keeping the + // menu layout static avoids the "cursor chases a moving + // target" problem that happens when items above the cursor + // appear/disappear. + const isOpenSwarmPro = prov === 'OpenSwarm Pro'; + const brandColor = PROVIDER_COLORS[prov.toLowerCase()] ?? c.text.tertiary; + // OpenSwarm Pro uses a warm blue→pink→orange gradient to stand + // out as the recommended paid tier (distinct from plain provider + // brand dots). + const OPENSWARM_GRADIENT = + 'linear-gradient(135deg, #8FB3FF 0%, #E56BC4 45%, #FFA85C 100%)'; + return [ + + + + + {prov} + + + , + ...models.map((opt) => ( + { + onModelChange(opt.value); + if (onProviderChange) { + const provLower = prov.toLowerCase(); + const providerMap: Record = { + anthropic: 'anthropic', + 'openswarm pro': 'anthropic', + openai: 'openai', + google: 'gemini', + xai: 'openrouter', + meta: 'openrouter', + deepseek: 'openrouter', + mistral: 'openrouter', + qwen: 'openrouter', + cohere: 'openrouter', + }; + onProviderChange(providerMap[provLower] || provLower); + } + setModelAnchor(null); + }} + > + + + )), + ]; + }).flat()} + + + {/* Thinking-level picker — only rendered for reasoning-capable models */} + {(() => { + const currentModel = allModelOptions.flat.find((m: any) => m.value === model) as any; + if (!currentModel?.reasoning || !onThinkingLevelChange) return null; + const levels: Array<{ value: 'off' | 'low' | 'medium' | 'high' | 'auto'; label: string; desc: string }> = [ + { value: 'auto', label: 'Auto', desc: 'Model decides (recommended)' }, + { value: 'off', label: 'Off', desc: 'No thinking (fastest)' }, + { value: 'low', label: 'Low', desc: 'Minimal thinking' }, + { value: 'medium', label: 'Medium', desc: 'Balanced' }, + { value: 'high', label: 'High', desc: 'Extensive thinking (slowest)' }, + ]; + const current = levels.find((l) => l.value === thinkingLevel) || levels[0]; + return ( + <> + setThinkingAnchor(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', + }} + > + + + {current.label} + + + + setThinkingAnchor(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'left' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: menuPaperProps }} + > + + + Thinking Level + + + {levels.map((lvl) => ( + { onThinkingLevelChange(lvl.value); setThinkingAnchor(null); }} + sx={{ py: 0.6 }} + > + + + {lvl.label} + + + {lvl.desc} + + + + ))} + + + ); + })()} + + + + {contextEstimate && ( + + )} + + {elementSelection && !autoRunMode && (() => { + const isMySelectMode = elementSelection.selectMode && elementSelection.activeOwnerId === ownerId; + return ( + + e.preventDefault()} + onClick={() => { + if (isMySelectMode) { + elementSelection.setSelectMode(false); + } else { + if (elementSelection.activeOwnerId !== ownerId) { + elementSelection.clearOwnerElements(ownerId); + } + elementSelection.setActiveOwnerId(ownerId); + if (sessionId) { + elementSelection.setExcludeSelectId(sessionId); + } else { + elementSelection.setExcludeSelectId(null); + } + elementSelection.setSelectMode(true); + } + }} + sx={{ + p: 0.5, + ...(isMySelectMode + ? { + bgcolor: '#3b82f6', + color: '#fff', + '&:hover': { bgcolor: '#2563eb' }, + animation: 'selectBtnPulse 2s ease-in-out infinite', + '@keyframes selectBtnPulse': { + '0%, 100%': { boxShadow: '0 0 0 0 rgba(59,130,246,0.4)' }, + '50%': { boxShadow: '0 0 0 4px rgba(59,130,246,0.1)' }, + }, + } + : { + color: c.text.tertiary, + '&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' }, + }), + transition: 'background-color 0.15s, color 0.15s', + }} + > + + + + ); + })()} + + { + if (!e.target.files) return; + const all = Array.from(e.target.files); + const imgs = all.filter((f) => f.type.startsWith('image/')); + const rest = all.filter((f) => !f.type.startsWith('image/')); + if (imgs.length > 0) addImageFiles(imgs); + if (rest.length > 0) uploadAndAttachFiles(rest); + e.target.value = ''; + }} + /> + + generalFileInputRef.current?.click()} + sx={{ + color: c.text.tertiary, + p: 0.5, + '&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' }, + }} + > + + + + {!autoRunMode && ( + + {hasContent && ( + + + + + + )} + {isRunning ? ( + + + + + + ) : !hasContent ? ( + + + + + + + + ) : null} + + )} + + + setLightboxSrc(null)} + sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }} + > + setLightboxSrc(null)} + sx={{ position: 'relative', outline: 'none', maxWidth: '90vw', maxHeight: '90vh' }} + > + setLightboxSrc(null)} + sx={{ + position: 'absolute', + top: -16, + right: -16, + bgcolor: c.bg.surface, + border: `1px solid ${c.border.medium}`, + color: c.text.secondary, + width: 32, + height: 32, + zIndex: 1, + '&:hover': { bgcolor: c.bg.secondary }, + boxShadow: c.shadow.md, + }} + > + + + e.stopPropagation()} + style={{ + maxWidth: '90vw', + maxHeight: '90vh', + borderRadius: 8, + boxShadow: '0 8px 32px rgba(0,0,0,0.4)', + display: 'block', + }} + /> + + + + + ); +}); + +ChatInput.displayName = 'ChatInput'; + +export default ChatInput; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput/ChatInput.tsx deleted file mode 100644 index 147c8dc4..00000000 --- a/frontend/src/app/pages/AgentChat/ChatInput/ChatInput.tsx +++ /dev/null @@ -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; - 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 = ({ - 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(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 ( -
- - - -
- {hasAttachments && ( - - )} - - -
- -
-
-
-
-
- ); -}; - -export default ChatInput; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/components/ComposerParts.tsx b/frontend/src/app/pages/AgentChat/ChatInput/components/ComposerParts.tsx deleted file mode 100644 index d83dbf17..00000000 --- a/frontend/src/app/pages/AgentChat/ChatInput/components/ComposerParts.tsx +++ /dev/null @@ -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 = () => ( - - - ← Back - - - {(categories) => - categories.map((cat) => ( - - {cat.label} - - )) - } - - - {(items) => - items.map((item) => ( - - {item.label} - {item.description && ( - {item.description} - )} - - )) - } - - -); - -const Chip: FC<{ label: string; onRemove: () => void }> = ({ label, onRemove }) => ( - - {label} - - -); - -export const ComposerAttachmentChips: FC<{ - images: { preview: string }[]; - contextPaths: { path: string; type: string }[]; - forcedTools: { label: string }[]; - attachedSkills: Record; - onRemoveImage: (idx: number) => void; - onRemoveContextPath: (idx: number) => void; - onRemoveForcedTool: (idx: number) => void; - onRemoveSkill: (id: string) => void; -}> = ({ - images, contextPaths, forcedTools, attachedSkills, - onRemoveImage, onRemoveContextPath, onRemoveForcedTool, onRemoveSkill, -}) => ( -
- {images.map((img, i) => ( -
- - -
- ))} - {contextPaths.map((cp, i) => ( - onRemoveContextPath(i)} /> - ))} - {forcedTools.map((ft, i) => ( - onRemoveForcedTool(i)} /> - ))} - {Object.entries(attachedSkills).map(([id, s]) => ( - onRemoveSkill(id)} /> - ))} -
-); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/components/ModelModeSelector/ContextRing.tsx b/frontend/src/app/pages/AgentChat/ChatInput/components/ModelModeSelector/ContextRing.tsx deleted file mode 100644 index c7bb9e85..00000000 --- a/frontend/src/app/pages/AgentChat/ChatInput/components/ModelModeSelector/ContextRing.tsx +++ /dev/null @@ -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 ( - - - - - - - - - ); -}; - -export default ContextRing; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/components/ModelModeSelector/ModelModeSelector.tsx b/frontend/src/app/pages/AgentChat/ChatInput/components/ModelModeSelector/ModelModeSelector.tsx deleted file mode 100644 index c2237421..00000000 --- a/frontend/src/app/pages/AgentChat/ChatInput/components/ModelModeSelector/ModelModeSelector.tsx +++ /dev/null @@ -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 = { - smart_toy: , - question_answer: , - map: , - category: , - tune: , -}; -const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy }; -const FALLBACK_MODELS = [ - { value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000 }, - { value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000 }, - { value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000 }, -]; - -interface Props { - mode: string; onModeChange: (mode: string) => void; - model: string; onModelChange: (model: string) => void; - provider?: string; onProviderChange?: (provider: string) => void; - contextEstimate?: { used: number; limit: number }; - ownerId: string; sessionId?: string; - autoRunMode?: boolean; hasContent: boolean; - isRunning?: boolean; disabled?: boolean; - onSend: () => void; onStop?: () => void; - browseAndAttachFiles: () => void; - queueLength?: number; -} - -const ModelModeSelector: React.FC = ({ - 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(null); - const [modelAnchor, setModelAnchor] = useState(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> = {}; - for (const [prov, models] of Object.entries(modelsByProvider)) { - grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000 })); - for (const m of models) flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov }); - } - return { flat, grouped }; - }, [modelsByProvider, modelsLoaded]); - - const currentMode = modesMap[mode]; - const FALLBACK_MODE = { ...FALLBACK_MODE_BASE, color: c.accent.primary }; - const modeConf = currentMode - ? { label: currentMode.name, icon: ICON_MAP[currentMode.icon] || ICON_MAP.smart_toy, color: currentMode.color } - : FALLBACK_MODE; - - const menuPaperProps = { sx: { - bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: '10px', - minWidth: 180, maxHeight: 400, boxShadow: c.shadow.lg, - '& .MuiMenuItem-root': { fontSize: '0.8rem', color: c.text.secondary, py: 0.75, px: 1.5, '&:hover': { bgcolor: c.bg.secondary } }, - }}; - - const isMySelectMode = elementSelection?.selectMode && elementSelection.activeOwnerId === ownerId; - - return ( - - setModeAnchor(e.currentTarget)} sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.5, px: 1, py: 0.375, - borderRadius: '999px', cursor: 'pointer', userSelect: 'none', - color: modeConf.color, bgcolor: `${modeConf.color}14`, - '&:hover': { bgcolor: `${modeConf.color}22` }, transition: 'background 0.15s', - }}> - {modeConf.icon} - {modeConf.label} - - - - setModeAnchor(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'left' }} - transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} - slotProps={{ paper: menuPaperProps }}> - {modesArr.map((m) => ( - { onModeChange(m.id); setModeAnchor(null); }}> - {ICON_MAP[m.icon] || ICON_MAP.smart_toy} - - - ))} - - - setModelAnchor(e.currentTarget)} sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.25, px: 0.75, py: 0.25, - borderRadius: '6px', cursor: 'pointer', userSelect: 'none', color: c.text.muted, - '&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }, transition: 'background 0.15s', - }}> - - {(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()} - - - - - setModelAnchor(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'left' }} - transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} - slotProps={{ paper: menuPaperProps }}> - {Object.entries(allModelOptions.grouped).map(([prov, models]) => [ - - {prov} - , - ...models.map((opt) => ( - { - onModelChange(opt.value); - if (onProviderChange) { - const provLower = prov.toLowerCase(); - const providerMap: Record = { anthropic: 'anthropic', openai: 'openai', google: 'gemini', xai: 'openrouter', meta: 'openrouter', deepseek: 'openrouter', mistral: 'openrouter', qwen: 'openrouter', cohere: 'openrouter' }; - onProviderChange(providerMap[provLower] || provLower); - } - setModelAnchor(null); - }}> - - - )), - ]).flat()} - - - - - {contextEstimate && ( - - )} - - {elementSelection && !autoRunMode && ( - - e.preventDefault()} onClick={() => { - if (isMySelectMode) { elementSelection.setSelectMode(false); return; } - if (elementSelection.activeOwnerId !== ownerId) elementSelection.clearOwnerElements(ownerId); - elementSelection.setActiveOwnerId(ownerId); - elementSelection.setExcludeSelectId(sessionId || null); - elementSelection.setSelectMode(true); - }} sx={{ - p: 0.5, - ...(isMySelectMode - ? { bgcolor: '#3b82f6', color: '#fff', '&:hover': { bgcolor: '#2563eb' }, - animation: 'selectBtnPulse 2s ease-in-out infinite', - '@keyframes selectBtnPulse': { '0%, 100%': { boxShadow: '0 0 0 0 rgba(59,130,246,0.4)' }, '50%': { boxShadow: '0 0 0 4px rgba(59,130,246,0.1)' } } } - : { color: c.text.tertiary, '&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' } }), - transition: 'background-color 0.15s, color 0.15s', - }}> - - - - )} - - - - - - - - {!autoRunMode && ( - - {hasContent && ( - - - - - - )} - {isRunning ? ( - - - - - - ) : !hasContent ? ( - - - - - - ) : null} - - )} - - ); -}; - -export default ModelModeSelector; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/components/OpenSwarmMentionAdapter.ts b/frontend/src/app/pages/AgentChat/ChatInput/components/OpenSwarmMentionAdapter.ts deleted file mode 100644 index 3d63fba7..00000000 --- a/frontend/src/app/pages/AgentChat/ChatInput/components/OpenSwarmMentionAdapter.ts +++ /dev/null @@ -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 = {}; - 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 | undefined; - if (!services) continue; - const perms = tool.tool_permissions as Record; - const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record; - 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(); - 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( - () => ({ - 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], - ); -} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/components/useComposerAttachments.ts b/frontend/src/app/pages/AgentChat/ChatInput/components/useComposerAttachments.ts deleted file mode 100644 index 422af839..00000000 --- a/frontend/src/app/pages/AgentChat/ChatInput/components/useComposerAttachments.ts +++ /dev/null @@ -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([]); - const [contextPaths, setContextPaths] = useState([]); - const [forcedTools, setForcedTools] = useState([]); - const [attachedSkills, setAttachedSkills] = useState>({}); - 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, - }; -} diff --git a/frontend/src/app/pages/AgentChat/DiffViewer.tsx b/frontend/src/app/pages/AgentChat/DiffViewer.tsx new file mode 100644 index 00000000..30d8b821 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/DiffViewer.tsx @@ -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 = ({ sessionId }) => { + const c = useClaudeTokens(); + const [diff, setDiff] = useState(''); + 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 ( + + setOpen(true)} sx={{ color: c.text.tertiary }}> + + + + ); + } + + return ( + + + + Worktree Changes + + + + + + + + setOpen(false)} sx={{ color: c.text.tertiary }}> + × + + + + + {loading ? ( + Loading... + ) : diff ? ( +
+            {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 (
+                
+                  {line}
+                  {'\n'}
+                
+              );
+            })}
+          
+ ) : ( + + No changes detected in the worktree. + + )} +
+
+ ); +}; + +export default DiffViewer; diff --git a/frontend/src/app/pages/AgentChat/MessageActionBar.tsx b/frontend/src/app/pages/AgentChat/MessageActionBar.tsx new file mode 100644 index 00000000..5ff19921 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/MessageActionBar.tsx @@ -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) => ({ + color: c.text.tertiary, + p: 0.4, + '&:hover': { color: c.text.secondary, bgcolor: 'transparent' }, + '&.Mui-disabled': { color: c.border.medium }, +}); + +const MessageActionBar: React.FC = ({ + 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 ( + + {isUser ? ( + <> + + + + + + + + + + {copied ? : } + + + {onEdit && ( + + + + + + )} + {branchNav && branchNav.totalBranches > 1 && ( + + + + + + {branchNav.currentIndex + 1} / {branchNav.totalBranches} + + + + + + )} + + ) : ( + <> + + + {copied ? : } + + + {onRegenerate && ( + + + + + + )} + {onBranch && ( + + + + + + )} + + )} + + ); +}; + +export default MessageActionBar; diff --git a/frontend/src/app/pages/AgentChat/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/MessageBubble.tsx new file mode 100644 index 00000000..2dc5a44f --- /dev/null +++ b/frontend/src/app/pages/AgentChat/MessageBubble.tsx @@ -0,0 +1,1002 @@ +import React, { useState, useMemo } from 'react'; +import { trackEvent } from '@/shared/analytics'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Tooltip from '@mui/material/Tooltip'; +import Collapse from '@mui/material/Collapse'; +import Modal from '@mui/material/Modal'; +import CloseIcon from '@mui/icons-material/Close'; +import AdsClickIcon from '@mui/icons-material/AdsClick'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; +import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; +import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined'; +import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { AgentMessage } from '@/shared/state/agentsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { SKILL_COLOR } from '@/app/components/richEditorUtils'; +import ViewBubble from './ViewBubble'; +import PlanPicker from '@/app/components/PlanPicker'; +import { ErrorSlime } from '@/app/components/ErrorSlime'; + +const streamingCursorKeyframes = ` +@keyframes blink-cursor { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} +`; + +// Claude.ai-style shimmer that sweeps left → right across text while the +// model is actively thinking. Uses background-clip: text to mask a moving +// linear gradient onto the text glyphs so the effect looks like a light +// wave traveling through the letters. +const thinkingShimmerKeyframes = ` +@keyframes thinking-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +`; + +const StreamingCursor: React.FC = () => { + const c = useClaudeTokens(); + return ( + <> + + + + ); +}; + +const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n'; + +interface OpenSwarmErrorInfo { + kind: 'cap' | 'capacity' | 'auth' | 'network'; + title: string; + detail: string; + ctaLabel?: string; + ctaAction?: 'upgrade' | 'retry' | 'settings' | 'waitlist'; +} + +// Turn a raw Claude-CLI / cloud error string into a user-friendly card. +// Returns null for things that aren't obviously our errors — those fall +// through to normal markdown rendering. +function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null { + if (!text) return null; + // Rate-limit cap from our cloud + if (/rate_limit_error|reached your OpenSwarm.*plan limit|Usage cap exceeded/i.test(text)) { + const reset = text.match(/Resets in ([\dhms\s]+)/)?.[1]; + return { + kind: 'cap', + title: "You've hit your plan limit", + detail: reset + ? `Your usage resets in ${reset}. Upgrade to keep going now, or wait for the window to reset.` + : 'Upgrade to keep going now, or wait for your usage window to reset.', + ctaLabel: 'Upgrade plan', + ctaAction: 'upgrade', + }; + } + // Upstream capacity / 503 + if (/at capacity|Try again shortly|503|service unavailable/i.test(text)) { + return { + kind: 'capacity', + title: 'OpenSwarm servers maxed', + detail: "We're at full capacity right now. Thanks for your patience — we'll get you back in as soon as we can. Join our Discord and we'll let you know the moment things open up.", + ctaLabel: 'Join waitlist', + ctaAction: 'waitlist', + }; + } + // Auth / subscription problems + if (/No active subscription|Subscription canceled|Subscription past_due|Invalid.*token|Missing bearer token/i.test(text)) { + return { + kind: 'auth', + title: 'Subscription issue', + detail: "We can't find an active OpenSwarm subscription. Check your billing status.", + ctaLabel: 'Open Settings', + ctaAction: 'settings', + }; + } + // Genuine, hard network failures only. The bare word `network` used to + // match anything mentioning "network" (Python traces, MCP tool output, + // ffmpeg lines, etc.), and `fetch failed` / `ETIMEDOUT` alone fire for + // transient upstream blips the backend now silently retries — surfacing + // a card for those just confuses the user. So: require the specific + // errno codes at word boundaries, and only match `fetch failed` when + // paired with a concrete cause so we don't swallow every Node-level + // transient. The backend's capacity/transient retry layer handles the + // rest without ever reaching this classifier. + if (/\b(?:ECONNREFUSED|ENETUNREACH|ENOTFOUND|EAI_AGAIN)\b|Could\s+not\s+reach\s+OpenSwarm|Unable\s+to\s+connect\s+to\s+OpenSwarm/i.test(text)) { + return { + kind: 'network', + title: 'Connection issue', + detail: "We couldn't reach the service. Once your connection is back, send a new message to continue.", + }; + } + return null; +} + +interface ParsedElement { + label: string; + selector: string; + isSemantic?: boolean; +} + +function parseElementContext(text: string): { userMessage: string; elements: ParsedElement[] } { + const sepIdx = text.indexOf(ELEMENT_SEPARATOR); + if (sepIdx === -1) return { userMessage: text, elements: [] }; + + const userMessage = text.slice(0, sepIdx); + const elementSection = text.slice(sepIdx + ELEMENT_SEPARATOR.length); + + const elements: ParsedElement[] = []; + const blocks = elementSection.split(/\n(?=\d+\.\s)/).filter(Boolean); + for (const block of blocks) { + const semanticMatch = block.match(/\d+\.\s+\[([^\]]+)\]\s*(.*)/); + if (semanticMatch) { + const typeLabel = semanticMatch[1]; + const rest = semanticMatch[2].trim(); + elements.push({ + label: `${typeLabel}: ${rest.split('\n')[0]}`, + selector: typeLabel, + isSemantic: true, + }); + continue; + } + + const labelMatch = block.match(/`([^`]+)`\s+\((\w+)\)/); + const selectorMatch = block.match(/Selector:\s*(.+)/); + if (labelMatch) { + elements.push({ + label: labelMatch[1], + selector: selectorMatch?.[1]?.trim() ?? labelMatch[1], + }); + } + } + + return { userMessage, elements }; +} + +const SKILL_PILL_RE = /\{\{skill:([^}]+)\}\}/g; + +function renderUserTextWithPills(text: string, c: ReturnType): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + const re = new RegExp(SKILL_PILL_RE.source, 'g'); + while ((match = re.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + const skillName = match[1]; + parts.push( + } + label={skillName} + size="small" + sx={{ + bgcolor: `${SKILL_COLOR}18`, + color: SKILL_COLOR, + fontSize: '0.72rem', + fontFamily: c.font.mono, + height: 20, + mx: 0.25, + verticalAlign: 'baseline', + '& .MuiChip-icon': { color: SKILL_COLOR }, + }} + />, + ); + lastIndex = re.lastIndex; + } + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + return parts; +} + +interface ContextGroup { + key: string; + icon: React.ReactNode; + color: string; + label: string; + chips: Array<{ label: string; tooltip?: string; icon: React.ReactNode }>; +} + +function buildContextGroups( + elements: ParsedElement[], + message: AgentMessage, +): ContextGroup[] { + const groups: ContextGroup[] = []; + + if (elements.length > 0) { + groups.push({ + key: 'elements', + icon: , + color: '#3b82f6', + label: `${elements.length} element${elements.length > 1 ? 's' : ''} selected`, + chips: elements.map((el) => ({ + label: el.label, + tooltip: el.selector, + icon: , + })), + }); + } + + const contextPaths = message.context_paths; + if (contextPaths && contextPaths.length > 0) { + const files = contextPaths.filter((cp) => cp.type === 'file'); + const dirs = contextPaths.filter((cp) => cp.type === 'directory'); + const allPaths = [...dirs, ...files]; + const label = [ + dirs.length > 0 ? `${dirs.length} folder${dirs.length > 1 ? 's' : ''}` : '', + files.length > 0 ? `${files.length} file${files.length > 1 ? 's' : ''}` : '', + ].filter(Boolean).join(', ') + ' attached'; + groups.push({ + key: 'paths', + icon: , + color: '#10b981', + label, + chips: allPaths.map((cp) => { + const name = cp.path.split('/').filter(Boolean).pop() || cp.path; + return { + label: name, + tooltip: cp.path, + icon: cp.type === 'directory' + ? + : , + }; + }), + }); + } + + const skills = message.attached_skills; + if (skills && skills.length > 0) { + groups.push({ + key: 'skills', + icon: , + color: SKILL_COLOR, + label: `${skills.length} skill${skills.length > 1 ? 's' : ''}`, + chips: skills.map((s) => ({ + label: s.name, + icon: , + })), + }); + } + + const forcedTools = message.forced_tools; + if (forcedTools && forcedTools.length > 0) { + groups.push({ + key: 'tools', + icon: , + color: '#f59e0b', + label: `${forcedTools.length} action${forcedTools.length > 1 ? 's' : ''} requested`, + chips: forcedTools.map((t) => ({ + label: t, + icon: , + })), + }); + } + + return groups; +} + +const AttachedContextSection: React.FC<{ + elements: ParsedElement[]; + message: AgentMessage; + c: ReturnType; +}> = ({ elements, message, c }) => { + const [expanded, setExpanded] = useState(false); + const groups = useMemo(() => buildContextGroups(elements, message), [elements, message]); + + if (groups.length === 0) return null; + + return ( + + setExpanded(!expanded)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + cursor: 'pointer', + mb: 0.5, + '&:hover': { opacity: 0.8 }, + }} + > + {groups.map((g) => ( + + {g.icon} + + ))} + + {groups.map((g) => g.label).join(' · ')} + + + + + {groups.map((g) => ( + + + {g.label} + + + {g.chips.map((chip, i) => ( + + + + ))} + + + ))} + + + ); +}; + +const ImageLightbox: React.FC<{ + open: boolean; + src: string; + onClose: () => void; + c: ReturnType; +}> = ({ open, src, onClose, c }) => ( + + + + + + e.stopPropagation()} + style={{ + maxWidth: '90vw', + maxHeight: '90vh', + borderRadius: 8, + boxShadow: '0 8px 32px rgba(0,0,0,0.4)', + display: 'block', + }} + /> + + +); + +const MessageImageThumbnails: React.FC<{ + images: Array<{ data: string; media_type: string }>; + c: ReturnType; +}> = ({ images, c }) => { + const [lightboxSrc, setLightboxSrc] = useState(null); + + if (images.length === 0) return null; + + return ( + <> + + {images.map((img, idx) => { + const src = `data:${img.media_type};base64,${img.data}`; + return ( + setLightboxSrc(src)} + sx={{ + width: 64, + height: 64, + flexShrink: 0, + borderRadius: '8px', + overflow: 'hidden', + border: `1px solid ${c.border.subtle}`, + cursor: 'pointer', + transition: 'opacity 0.15s, transform 0.15s', + '&:hover': { opacity: 0.85, transform: 'scale(1.04)' }, + }} + > + + + ); + })} + + setLightboxSrc(null)} + c={c} + /> + + ); +}; + +// ── ThinkingBubble ────────────────────────────────────────────────── +// Collapsible reasoning section styled after Claude.ai / ChatGPT / +// Gemini. Defaults to expanded so thinking is always visible when +// present. User can click the header to collapse. If we observed the +// stream live we show "Thought for Ns"; otherwise (history replay) we +// just show "Thoughts". +const ThinkingBubble: React.FC<{ + content: string; + isStreaming?: boolean; + timestamp?: string; +}> = ({ content, isStreaming }) => { + const c = useClaudeTokens(); + + // Only time a think-session that we actually saw start live. For saved + // messages loaded from history, we don't have reliable start/end, so + // we fall back to a generic "Thoughts" label. + const [startedStreamingAt, setStartedStreamingAt] = useState( + isStreaming ? Date.now() : null + ); + const [elapsed, setElapsed] = useState(0); + const [frozenElapsed, setFrozenElapsed] = useState(null); + + // Record start time the first time we see streaming + React.useEffect(() => { + if (isStreaming && startedStreamingAt === null) { + setStartedStreamingAt(Date.now()); + } + }, [isStreaming, startedStreamingAt]); + + // Tick the timer while streaming + React.useEffect(() => { + if (!isStreaming || startedStreamingAt === null) return; + const iv = setInterval(() => { + setElapsed(Math.floor((Date.now() - startedStreamingAt) / 1000)); + }, 250); + return () => clearInterval(iv); + }, [isStreaming, startedStreamingAt]); + + // Freeze elapsed when streaming ends + React.useEffect(() => { + if (!isStreaming && startedStreamingAt !== null && frozenElapsed === null) { + setFrozenElapsed(Math.max(1, Math.floor((Date.now() - startedStreamingAt) / 1000))); + } + }, [isStreaming, startedStreamingAt, frozenElapsed]); + + // Always default to expanded — user can click to collapse + const [userOverride, setUserOverride] = useState(null); + const expanded = userOverride ?? true; + const toggle = () => setUserOverride(!expanded); + + const displayedSeconds = frozenElapsed ?? elapsed; + const label = isStreaming + ? 'Thinking...' + : startedStreamingAt !== null + ? `Thought for ${displayedSeconds}s` + : 'Thoughts'; + + const text = typeof content === 'string' ? content : JSON.stringify(content); + + // Shimmer colors — use a bright mid-tone against the muted base to make + // the sweep visible without being loud. The base color matches the + // static "Thought for Ns" state so the only visible change is the moving + // highlight band. + const shimmerBase = c.text.tertiary; + const shimmerHighlight = c.text.primary; + + return ( + + + + + + {label} + + + + + + {text} + {isStreaming && } + + + + ); +}; + +interface Props { + message: AgentMessage; + editing?: boolean; + onSaveEdit?: (messageId: string, newContent: string) => void; + onCancelEdit?: () => void; + isStreaming?: boolean; +} + +const MessageBubble: React.FC = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming }) => { + const c = useClaudeTokens(); + const [editText, setEditText] = useState(''); + const [pickerOpen, setPickerOpen] = useState(false); + const { role, content } = message; + + if (role === 'system') { + return ( + + + {typeof content === 'string' ? content : JSON.stringify(content)} + + + ); + } + + if (role === 'thinking') { + return ( + + ); + } + + if (role === 'tool_call') { + const toolData = typeof content === 'object' ? content : {}; + const toolInput = toolData.input || {}; + if (toolData.tool === 'RenderOutput') { + return ; + } + return null; + } + + if (role === 'tool_result') { + let parsedContent: any = null; + try { parsedContent = typeof content === 'string' ? JSON.parse(content) : content; } catch {} + if (parsedContent?.output_id && parsedContent?.frontend_code) { + return ( + + ); + } + return null; + } + + const isUser = role === 'user'; + const rawText = typeof content === 'string' ? content : JSON.stringify(content); + const { userMessage: displayText, elements: selectedElements } = isUser + ? parseElementContext(rawText) + : { userMessage: rawText, elements: [] }; + + const renderedMarkdown = useMemo(() => ( + ( + {children} + ), + }} + >{rawText} + ), [rawText]); + + // Detect friendly OpenSwarm / upstream errors and render a card instead of + // raw "API Error: ..." text. Checks both the wrapped format the Claude CLI + // uses ("API Error: NNN …") and the raw JSON body. + const openswarmError = !isUser ? parseOpenSwarmError(rawText) : null; + + // Fire subscription.rate_limit_hit exactly once per rate-limit error + // card mount. Dependency on (message.id, kind) ensures we don't re-fire + // on re-renders or content edits. + React.useEffect(() => { + if (openswarmError?.kind === 'cap') { + trackEvent('subscription.rate_limit_hit', { message_id: message.id }); + } + }, [message.id, openswarmError?.kind]); + + React.useEffect(() => { + if (editing) setEditText(rawText); + }, [editing, rawText]); + + const handleCancelEdit = () => { + setEditText(''); + onCancelEdit?.(); + }; + + const handleSaveEdit = () => { + const trimmed = editText.trim(); + if (trimmed && trimmed !== rawText && onSaveEdit) { + onSaveEdit(message.id, trimmed); + } + setEditText(''); + onCancelEdit?.(); + }; + + const truncatedContent = typeof content === 'string' + ? content.slice(0, 200) + : JSON.stringify(content).slice(0, 200); + + return ( + + + {isUser ? ( + editing ? ( + + setEditText(e.target.value)} + variant="outlined" + size="small" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSaveEdit(); + } + if (e.key === 'Escape') handleCancelEdit(); + }} + sx={{ + '& .MuiOutlinedInput-root': { + color: c.text.primary, + fontSize: '0.875rem', + '& fieldset': { borderColor: c.border.strong }, + '&:hover fieldset': { borderColor: c.text.tertiary }, + '&.Mui-focused fieldset': { borderColor: c.accent.primary }, + }, + }} + /> + + + + + + ) : ( + + {message.images && message.images.length > 0 && ( + + )} + + {renderUserTextWithPills(displayText, c)} + + + + ) + ) : ( + + {openswarmError ? ( + + + + + {openswarmError.title} + + + + {openswarmError.detail} + + {openswarmError.ctaLabel && ( + + + + )} + + ) : ( + <> + {isStreaming ? ( + + {rawText} + + ) : ( + renderedMarkdown + )} + {isStreaming && } + + )} + + )} + + + setPickerOpen(false)} + sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: 2 }} + > + + + + Upgrade your plan + + setPickerOpen(false)} + sx={{ color: c.text.tertiary }} + aria-label="Close" + > + + + + + Pick a plan to keep going. Cancel anytime from Stripe. + + setPickerOpen(false)} + /> + + + + ); +}); + +export default MessageBubble; diff --git a/frontend/src/app/pages/AgentChat/MessageQueue.tsx b/frontend/src/app/pages/AgentChat/MessageQueue.tsx deleted file mode 100644 index 7a844110..00000000 --- a/frontend/src/app/pages/AgentChat/MessageQueue.tsx +++ /dev/null @@ -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; - queueLength: number; - setQueueLength: (len: number) => void; - children: React.ReactNode; -} - -const MessageQueue: React.FC = ({ messageQueueRef, queueLength, setQueueLength, children }) => { - const c = useClaudeTokens(); - const [queueExpanded, setQueueExpanded] = useState(false); - const [editingQueueIdx, setEditingQueueIdx] = useState(null); - const [editingQueueText, setEditingQueueText] = useState(''); - const [dragIdx, setDragIdx] = useState(null); - const [dropTargetIdx, setDropTargetIdx] = useState(null); - - return ( - { if (queueExpanded) { setQueueExpanded(false); setEditingQueueIdx(null); } }}> - - {queueLength > 0 && ( - - { setQueueExpanded((v) => !v); setEditingQueueIdx(null); }} - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.5, px: 1.25, py: 0.25, - borderRadius: '8px 8px 0 0', bgcolor: c.bg.surface, - border: `1px solid ${c.border.subtle}`, borderBottom: 'none', - cursor: 'pointer', userSelect: 'none', - '&:hover': { bgcolor: c.bg.secondary }, transition: 'background 0.12s', - }} - > - {queueExpanded - ? - : - } - - {queueLength} queued - - - { e.stopPropagation(); messageQueueRef.current = []; setQueueLength(0); setQueueExpanded(false); setEditingQueueIdx(null); }} - sx={{ p: 0.15, color: c.text.tertiary, '&:hover': { color: c.status.error } }} - > - - - - - {queueExpanded && ( - - {messageQueueRef.current.map((msg, idx) => ( - { setDragIdx(idx); e.dataTransfer.effectAllowed = 'move'; }} - onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragIdx !== null && dragIdx !== idx) setDropTargetIdx(idx); }} - onDragLeave={() => { if (dropTargetIdx === idx) setDropTargetIdx(null); }} - onDrop={(e) => { - e.preventDefault(); - if (dragIdx !== null && dragIdx !== idx) { - const q = messageQueueRef.current; - const [item] = q.splice(dragIdx, 1); - q.splice(idx, 0, item); - setQueueLength(q.length); - } - setDragIdx(null); - setDropTargetIdx(null); - }} - onDragEnd={() => { setDragIdx(null); setDropTargetIdx(null); }} - sx={{ - display: 'flex', alignItems: 'flex-start', gap: 0.75, px: 1.5, py: 1, - borderBottom: idx < queueLength - 1 ? `1px solid ${c.border.subtle}` : 'none', - '&:hover': { bgcolor: c.bg.secondary }, - transition: 'background 0.1s, opacity 0.15s', - ...(dragIdx === idx ? { opacity: 0.35 } : {}), - ...(dropTargetIdx === idx && dragIdx !== null && dragIdx !== idx - ? { borderTop: `2px solid ${c.accent.primary}` } : {}), - }} - > - - - - {editingQueueIdx === idx ? ( - - setEditingQueueText(e.target.value)} - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - const trimmed = editingQueueText.trim(); - if (trimmed) { - messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed }; - setQueueLength(messageQueueRef.current.length); - } - setEditingQueueIdx(null); - } - if (e.key === 'Escape') setEditingQueueIdx(null); - }} - sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.78rem', color: c.text.primary, '& fieldset': { borderColor: c.border.medium }, '&.Mui-focused fieldset': { borderColor: c.accent.primary } } }} - /> - { - const trimmed = editingQueueText.trim(); - if (trimmed) { - messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed }; - setQueueLength(messageQueueRef.current.length); - } - setEditingQueueIdx(null); - }} - sx={{ p: 0.25, color: c.accent.primary, mt: 0.25 }} - > - - - - ) : ( - - {msg.prompt} - - )} - {editingQueueIdx !== idx && ( - - - { setEditingQueueIdx(idx); setEditingQueueText(msg.prompt); }} sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.text.primary } }}> - - - - - { - messageQueueRef.current.splice(idx, 1); - setQueueLength(messageQueueRef.current.length); - if (messageQueueRef.current.length === 0) setQueueExpanded(false); - }} - sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.status.error } }} - > - - - - - )} - - ))} - - )} - - )} - {children} - - - ); -}; - -export default MessageQueue; diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/OpenSwarmThread.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/OpenSwarmThread.tsx deleted file mode 100644 index e5e750b7..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/OpenSwarmThread.tsx +++ /dev/null @@ -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 = ({ - sessionId, - onBranchChat, - children, -}) => { - return ( - - - - - - s.thread.isEmpty}> - - - - - - - - {children} - - - - - - - ); -}; - -const ThreadWelcome: FC = () => ( -
-

How can I help you today?

-
-); - -const ThreadScrollToBottom: FC = () => ( - - - - - -); - -const EditComposer: FC = () => ( - - - -
- - - - - - -
-
-
-); - -export default OpenSwarmThread; diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/AssistantMessage.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/AssistantMessage.tsx deleted file mode 100644 index 2f9bf8e9..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/AssistantMessage.tsx +++ /dev/null @@ -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 ( - -
- - {({ part }) => { - if (part.type === 'text') return ; - if (part.type === 'tool-call') - return part.toolUI ?? ; - return null; - }} - - -
- -
- - -
-
- ); -}; - -const MessageError: FC = () => ( - - - - - -); diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/MarkdownText/DEFAULT_COMPONENTS/CodeHeader.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/MarkdownText/DEFAULT_COMPONENTS/CodeHeader.tsx deleted file mode 100644 index 066b0b6e..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/MarkdownText/DEFAULT_COMPONENTS/CodeHeader.tsx +++ /dev/null @@ -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(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 = ({ language, code }) => { - const { isCopied, copyToClipboard } = useCopyToClipboard(); - const onCopy = () => { - if (!code || isCopied) return; - copyToClipboard(code); - }; - - return ( -
- - {language} - - - {!isCopied && } - {isCopied && } - -
- ); -}; diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/MarkdownText/DEFAULT_COMPONENTS/DEFAULT_COMPONENTS.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/MarkdownText/DEFAULT_COMPONENTS/DEFAULT_COMPONENTS.tsx deleted file mode 100644 index f474fca9..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/MarkdownText/DEFAULT_COMPONENTS/DEFAULT_COMPONENTS.tsx +++ /dev/null @@ -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 }) => ( -

- ), - h2: ({ className, ...props }) => ( -

- ), - h3: ({ className, ...props }) => ( -

- ), - h4: ({ className, ...props }) => ( -

- ), - h5: ({ className, ...props }) => ( -

- ), - h6: ({ className, ...props }) => ( -
- ), - p: ({ className, ...props }) => ( -

- ), - a: ({ className, ...props }) => ( - - ), - blockquote: ({ className, ...props }) => ( -

- ), - ul: ({ className, ...props }) => ( -
    li]:mt-1", - className, - )} - {...props} - /> - ), - ol: ({ className, ...props }) => ( -
      li]:mt-1", - className, - )} - {...props} - /> - ), - hr: ({ className, ...props }) => ( -
      - ), - table: ({ className, ...props }) => ( - - ), - th: ({ className, ...props }) => ( - td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg", - className, - )} - {...props} - /> - ), - li: ({ className, ...props }) => ( -
    1. - ), - sup: ({ className, ...props }) => ( - a]:text-xs [&>a]:no-underline", className)} - {...props} - /> - ), - pre: ({ className, ...props }) => ( -
      -  ),
      -  code: function Code({ className, ...props }) {
      -    const isCodeBlock = useIsMarkdownCodeBlock();
      -    return (
      -      
      -    );
      -  },
      -  CodeHeader,
      -});
      diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/MarkdownText/MarkdownText.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/MarkdownText/MarkdownText.tsx
      deleted file mode 100644
      index ec22e54e..00000000
      --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/MarkdownText/MarkdownText.tsx
      +++ /dev/null
      @@ -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 = () => (
      -  
      -);
      \ No newline at end of file
      diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/ToolFallback.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/ToolFallback.tsx
      deleted file mode 100644
      index 7c29bc6f..00000000
      --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/ToolFallback.tsx
      +++ /dev/null
      @@ -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 (
      -    
      -      
      -      
      -        
      -        
      -        {!isCancelled && }
      -      
      -    
      -  );
      -};
      -
      -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,
      -};
      diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackArgs.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackArgs.tsx
      deleted file mode 100644
      index 6b82a047..00000000
      --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackArgs.tsx
      +++ /dev/null
      @@ -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 (
      -    
      -
      -        {argsText}
      -      
      -
      - ); -} \ No newline at end of file diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackContent.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackContent.tsx deleted file mode 100644 index ac54a653..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackContent.tsx +++ /dev/null @@ -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) { - return ( - -
      {children}
      -
      - ); -} \ No newline at end of file diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackError.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackError.tsx deleted file mode 100644 index 5d8d6ec5..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackError.tsx +++ /dev/null @@ -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 ( -
      -

      - {headerText} -

      -

      - {errorText} -

      -
      - ); -} \ No newline at end of file diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackResult.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackResult.tsx deleted file mode 100644 index a8cb1b93..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackResult.tsx +++ /dev/null @@ -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 ( -
      -

      Result:

      -
      -        {typeof result === "string" ? result : JSON.stringify(result, null, 2)}
      -      
      -
      - ); -} - diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackRoot.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackRoot.tsx deleted file mode 100644 index 51480105..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackRoot.tsx +++ /dev/null @@ -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, - "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(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 ( - - {children} - - ); -} diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackTrigger.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackTrigger.tsx deleted file mode 100644 index fe9b1d45..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/AssistantMessage/ToolFallback/components/ToolFallbackTrigger.tsx +++ /dev/null @@ -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 = { - running: LoaderIcon, - complete: CheckIcon, - incomplete: XCircleIcon, - "requires-action": AlertCircleIcon, -}; - -export function ToolFallbackTrigger({ - toolName, - status, - className, - ...props -}: React.ComponentProps & { - 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 ( - - - - - {label}: {toolName} - - {isRunning && ( - - {label}: {toolName} - - )} - - - - ); -} \ No newline at end of file diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/BranchPicker.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/BranchPicker.tsx deleted file mode 100644 index 87582432..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/BranchPicker.tsx +++ /dev/null @@ -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 = ({ - className, - ...rest -}) => ( - - - - - - - - / - - - - - - - -); diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/MessageActions.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/MessageActions.tsx deleted file mode 100644 index 365f5383..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/MessageActions.tsx +++ /dev/null @@ -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 = () => ( - - - - - - - -); - -export const AssistantActionBar: FC = () => ( - - - - s.message.isCopied}> - - - !s.message.isCopied}> - - - - - - - - - - - -); - -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 ( - - - - ); -}; diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/TooltipIconButton.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/TooltipIconButton.tsx deleted file mode 100644 index 7993c599..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/TooltipIconButton.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { ComponentPropsWithRef, forwardRef } from "react"; -import { Slot } from "radix-ui"; - -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "./tooltip"; -import { Button } from "@/app/pages/AgentChat/_shared/Button"; -import { cn } from "@/lib/utils"; - -type TooltipIconButtonProps = ComponentPropsWithRef & { - tooltip: string; - side?: "top" | "bottom" | "left" | "right"; -}; - -export const TooltipIconButton = forwardRef< - HTMLButtonElement, - TooltipIconButtonProps ->(({ children, tooltip, side = "bottom", className, ...rest }, ref) => { - return ( - - - - - {tooltip} - - ); -}); - -TooltipIconButton.displayName = "TooltipIconButton"; diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/UserMessage.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/UserMessage.tsx deleted file mode 100644 index fb28cf55..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/UserMessage.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { type FC } from 'react'; -import { - MessagePrimitive, - useAui, - useMessagePartText, -} from '@assistant-ui/react'; -import { UserMessageAttachments } from './UserMessageAttachments'; -import { useAppSelector } from '@/shared/hooks'; -import type { AgentMessage } from '@/shared/state/agentsSlice'; -import { useSessionId } from '../../utils'; -import { UserActionBar } from '../MessageActions'; -import { BranchPicker } from '../BranchPicker'; - -const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n'; -const SKILL_PILL_RE = /\{\{skill:([^}]+)\}\}/g; - -interface ParsedElement { - label: string; - selector: string; -} - -function parseElementContext(text: string): { - userMessage: string; - elements: ParsedElement[]; -} { - const sepIdx = text.indexOf(ELEMENT_SEPARATOR); - if (sepIdx === -1) return { userMessage: text, elements: [] }; - - const userMessage = text.slice(0, sepIdx); - const elementSection = text.slice(sepIdx + ELEMENT_SEPARATOR.length); - - const elements: ParsedElement[] = []; - const blocks = elementSection.split(/\n(?=\d+\.\s)/).filter(Boolean); - for (const block of blocks) { - const semanticMatch = block.match(/\d+\.\s+\[([^\]]+)\]\s*(.*)/); - if (semanticMatch) { - elements.push({ - label: `${semanticMatch[1]}: ${semanticMatch[2].trim().split('\n')[0]}`, - selector: semanticMatch[1], - }); - continue; - } - const labelMatch = block.match(/`([^`]+)`\s+\((\w+)\)/); - const selectorMatch = block.match(/Selector:\s*(.+)/); - if (labelMatch) { - elements.push({ - label: labelMatch[1], - selector: selectorMatch?.[1]?.trim() ?? labelMatch[1], - }); - } - } - return { userMessage, elements }; -} - -function renderTextWithSkillPills(text: string): React.ReactNode[] { - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; - const re = new RegExp(SKILL_PILL_RE.source, 'g'); - while ((match = re.exec(text)) !== null) { - if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index)); - parts.push( - - {match[1]} - , - ); - lastIndex = re.lastIndex; - } - if (lastIndex < text.length) parts.push(text.slice(lastIndex)); - return parts; -} - -function useOriginalMessage(): AgentMessage | undefined { - const sessionId = useSessionId(); - const aui = useAui(); - - let messageId: string | undefined; - try { - messageId = aui.message().getState().id; - } catch { - // intentionally empty — messageId stays undefined - } - - return useAppSelector((state) => { - if (!sessionId || !messageId) return undefined; - return state.agents.sessions[sessionId]?.messages.find( - (m) => m.id === messageId, - ); - }); -} - -const UserTextContent: FC = () => { - const { text } = useMessagePartText(); - const { userMessage, elements } = parseElementContext(text); - - return ( - <> -

      - {renderTextWithSkillPills(userMessage)} -

      - {elements.length > 0 && ( -
      - {elements.map((el, i) => ( - - {el.label} - - ))} -
      - )} - - ); -}; - -const ContextPills: FC = () => { - const msg = useOriginalMessage(); - if (!msg) return null; - - const contextPaths = msg.context_paths; - const attachedSkills = msg.attached_skills; - const forcedTools = msg.forced_tools; - const hasContext = - (contextPaths && contextPaths.length > 0) || - (attachedSkills && attachedSkills.length > 0) || - (forcedTools && forcedTools.length > 0); - - if (!hasContext) return null; - - return ( -
      - {contextPaths?.map((cp, i) => ( - - {cp.type === 'directory' ? '📁' : '📄'} - {cp.path.split('/').filter(Boolean).pop()} - - ))} - {attachedSkills?.map((skill, i) => ( - - 🧠 {skill.name} - - ))} - {forcedTools?.map((tool, i) => ( - - 🔧 {tool} - - ))} -
      - ); -}; - -const ImageThumbnails: FC = () => { - const msg = useOriginalMessage(); - if (!msg?.images?.length) return null; - - return ( -
      - {msg.images.map((img, i) => ( - - ))} -
      - ); -}; - -export const UserMessage: FC = () => { - return ( - - - -
      -
      - - - -
      -
      - -
      -
      - - -
      - ); -}; diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/UserMessageAttachments.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/UserMessageAttachments.tsx deleted file mode 100644 index 8789f68a..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/UserMessageAttachments.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client"; - -import { PropsWithChildren, useEffect, useState, type FC } from "react"; -import { XIcon, FileText } from "lucide-react"; -import { - AttachmentPrimitive, - MessagePrimitive, - useAuiState, - useAui, -} from "@assistant-ui/react"; -import { useShallow } from "zustand/shallow"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "../tooltip"; -import { - Dialog, - DialogTitle, - DialogContent, - DialogTrigger, -} from "./dialog"; -import { Avatar, AvatarImage, AvatarFallback } from "./avatar"; -import { TooltipIconButton } from "../TooltipIconButton"; -import { cn } from "@/lib/utils"; - -const useFileSrc = (file: File | undefined) => { - const [src, setSrc] = useState(undefined); - - useEffect(() => { - if (!file) { - return; - } - - let revoked = false; - const objectUrl = URL.createObjectURL(file); - queueMicrotask(() => { - if (!revoked) setSrc(objectUrl); - }); - - return () => { - revoked = true; - URL.revokeObjectURL(objectUrl); - queueMicrotask(() => setSrc(undefined)); - }; - }, [file]); - - return file ? src : undefined; -}; - -const useAttachmentSrc = () => { - const { file, src } = useAuiState( - useShallow((s): { file?: File; src?: string } => { - if (s.attachment.type !== "image") return {}; - if (s.attachment.file) return { file: s.attachment.file }; - const src = s.attachment.content?.filter((c) => c.type === "image")[0] - ?.image; - if (!src) return {}; - return { src }; - }), - ); - - return useFileSrc(file) ?? src; -}; - -type AttachmentPreviewProps = { - src: string; -}; - -const AttachmentPreview: FC = ({ src }) => { - const [isLoaded, setIsLoaded] = useState(false); - return ( - Image Preview setIsLoaded(true)} - /> - ); -}; - -const AttachmentPreviewDialog: FC = ({ children }) => { - const src = useAttachmentSrc(); - - if (!src) return children; - - return ( - - - {children} - - - - Image Attachment Preview - -
      - -
      -
      -
      - ); -}; - -const AttachmentThumb: FC = () => { - const isImage = useAuiState((s) => s.attachment.type === "image"); - const src = useAttachmentSrc(); - - return ( - - - - - - - ); -}; - -const AttachmentUI: FC = () => { - const aui = useAui(); - const isComposer = aui.attachment.source !== "message"; - - const isImage = useAuiState((s) => s.attachment.type === "image"); - const typeLabel = useAuiState((s) => { - const type = s.attachment.type; - switch (type) { - case "image": - return "Image"; - case "document": - return "Document"; - case "file": - return "File"; - default: - return type; - } - }); - - return ( - - - - -
      - -
      -
      -
      - {isComposer && } -
      - - - -
      - ); -}; - -const AttachmentRemove: FC = () => { - return ( - - - - - - ); -}; - -export const UserMessageAttachments: FC = () => { - return ( -
      - - {() => } - -
      - ); -}; \ No newline at end of file diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/avatar.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/avatar.tsx deleted file mode 100644 index 747c2d8e..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/avatar.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from "react" -import { Avatar as AvatarPrimitive } from "radix-ui" - -import { cn } from "@/lib/utils" - -function Avatar({ - className, - size = "default", - ...props -}: React.ComponentProps & { - size?: "default" | "sm" | "lg" -}) { - return ( - - ) -} - -function AvatarImage({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AvatarFallback({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - Avatar, - AvatarImage, - AvatarFallback, -} diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/dialog.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/dialog.tsx deleted file mode 100644 index d975aeae..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/UserMessage/dialog.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import * as React from "react" -import { XIcon } from "lucide-react" -import { Dialog as DialogPrimitive } from "radix-ui" - -import { cn } from "@/lib/utils" - -function Dialog({ - ...props -}: React.ComponentProps) { - return -} - -function DialogTrigger({ - ...props -}: React.ComponentProps) { - return -} - -function DialogPortal({ - ...props -}: React.ComponentProps) { - return -} - -function DialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DialogContent({ - className, - children, - showCloseButton = true, - ...props -}: React.ComponentProps & { - showCloseButton?: boolean -}) { - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ) -} - -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - Dialog, - DialogContent, - DialogTitle, - DialogTrigger, -} diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/tooltip.tsx b/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/tooltip.tsx deleted file mode 100644 index ec65c1e4..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/components/tooltip.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client" - -import * as React from "react" -import { Tooltip as TooltipPrimitive } from "radix-ui" - -import { cn } from "@/lib/utils" - -function TooltipProvider({ - delayDuration = 0, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function Tooltip({ - ...props -}: React.ComponentProps) { - return -} - -function TooltipTrigger({ - ...props -}: React.ComponentProps) { - return -} - -function TooltipContent({ - className, - sideOffset = 0, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - - ) -} - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/frontend/src/app/pages/AgentChat/OpenSwarmThread/utils.ts b/frontend/src/app/pages/AgentChat/OpenSwarmThread/utils.ts deleted file mode 100644 index 334478cd..00000000 --- a/frontend/src/app/pages/AgentChat/OpenSwarmThread/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createContext, useContext } from 'react'; - -export const SessionIdContext = createContext(undefined); -export const useSessionId = () => useContext(SessionIdContext); - -export const BranchChatContext = createContext< - ((newSessionId: string) => void) | undefined ->(undefined); -export const useBranchChatCallback = () => useContext(BranchChatContext); \ No newline at end of file diff --git a/frontend/src/app/pages/AgentChat/ToolCallBubble.tsx b/frontend/src/app/pages/AgentChat/ToolCallBubble.tsx new file mode 100644 index 00000000..1b2bf7ef --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ToolCallBubble.tsx @@ -0,0 +1,2182 @@ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Collapse from '@mui/material/Collapse'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import BlockIcon from '@mui/icons-material/Block'; +import EmailIcon from '@mui/icons-material/Email'; +import EventIcon from '@mui/icons-material/Event'; +import FolderIcon from '@mui/icons-material/Folder'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import SearchIcon from '@mui/icons-material/Search'; +import SendIcon from '@mui/icons-material/Send'; +import CallSplitIcon from '@mui/icons-material/CallSplit'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { AgentMessage, expandSession, collapseSession, fetchSession } from '@/shared/state/agentsSlice'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { placeCard, removeCard, setGlowingAgentCard, clearGlowingAgentCard, DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice'; +import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext'; +import BrowserAgentInlineFeed from './BrowserAgentInlineFeed'; + +const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 14 }) => { + if (service === 'gmail') { + return ( + + {/* Left blue bar */} + + {/* Right green bar */} + + {/* Red M chevron */} + + {/* Top-left blue triangle */} + + {/* Top-right yellow triangle */} + + {/* Top red V */} + + + ); + } + if (service === 'calendar') { + return ( + + + + 31 + + ); + } + if (service === 'drive' || service === 'sheets') { + return ( + + + + + + + ); + } + return null; +}; + +export interface ToolPair { + type: 'tool_pair'; + id: string; + call: AgentMessage; + result: AgentMessage | null; +} + +let toolCallKeyframesInjected = false; +function ensureToolCallKeyframes() { + if (toolCallKeyframesInjected) return; + toolCallKeyframesInjected = true; + const style = document.createElement('style'); + style.setAttribute('data-tool-call-keyframes', ''); + style.textContent = ` +@keyframes tool-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +@keyframes border-glow { + 0%, 100% { box-shadow: 0 0 0 0 rgba(var(--glow-rgb), 0); } + 50% { box-shadow: 0 0 10px 2px rgba(var(--glow-rgb), 0.12); } +} +@keyframes blink-cursor { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} +`; + document.head.appendChild(style); +} + +const ElapsedTimer: React.FC<{ startTime: string }> = ({ startTime }) => { + const c = useClaudeTokens(); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + const start = new Date(startTime).getTime(); + const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000)); + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [startTime]); + + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + const display = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; + + return ( + + + + {display} + + + ); +}; + +function formatElapsed(ms: number): string { + if (ms >= 60000) return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`; + if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`; + return `${ms}ms`; +} + +function getToolData(call: AgentMessage) { + const content = typeof call.content === 'object' ? call.content : {}; + return { + toolName: content.tool || 'Unknown', + input: content.input || {}, + isDenied: content.approved === false, + toolId: content.id, + }; +} + +function isBashTool(name: string) { + return name === 'Bash' || name === 'bash'; +} + +export interface McpToolInfo { + isMcp: boolean; + serverSlug: string; + action: string; + service: string; + displayName: string; +} + +export function parseMcpToolName(rawName: string): McpToolInfo { + const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/); + if (!m) return { isMcp: false, serverSlug: '', action: '', service: '', displayName: rawName }; + const serverSlug = m[1]; + const action = m[2]; + const display = action.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase()); + + const lower = action.toLowerCase(); + let service = ''; + if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) service = 'gmail'; + else if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) service = 'calendar'; + else if (lower.includes('drive') || lower.includes('file')) service = 'drive'; + else if (lower.includes('sheet') || lower.includes('spreadsheet')) service = 'sheets'; + else if (lower.includes('doc') || lower.includes('paragraph')) service = 'docs'; + else if (lower.includes('contact')) service = 'contacts'; + + return { isMcp: true, serverSlug, action, service, displayName: display }; +} + +function getMcpInputSummary(input: any): string { + if (!input || typeof input !== 'object') return ''; + const keys = Object.keys(input); + if (keys.length === 0) return ''; + if (keys.length === 1) { + const v = input[keys[0]]; + const s = typeof v === 'string' ? v : JSON.stringify(v); + return s.length > 60 ? s.slice(0, 60) + '…' : s; + } + return keys.slice(0, 3).map((k) => { + const v = input[k]; + const s = typeof v === 'string' ? v : JSON.stringify(v); + return `${k}: ${s.length > 30 ? s.slice(0, 30) + '…' : s}`; + }).join(' '); +} + +function getInputSummary(toolName: string, input: any): string { + try { + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) return getMcpInputSummary(input); + + const n = toolName.toLowerCase(); + if (isBashTool(toolName)) { + const cmd = input.command || ''; + return `$ ${cmd.slice(0, 80)}${cmd.length > 80 ? '…' : ''}`; + } + if (n === 'read') return input.file_path || input.path || ''; + if (n === 'write') return input.file_path || input.path || ''; + if (n === 'edit' || n === 'multiedit' || n === 'strreplace') + return input.file_path || input.path || ''; + if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || ''; + if (n === 'grep' || n === 'ripgrep') { + const pat = input.pattern || input.regex || ''; + const path = input.path || input.directory || ''; + return path ? `/${pat}/ in ${path}` : `/${pat}/`; + } + if (n === 'websearch') return input.query || input.search_term || ''; + if (n === 'webfetch') return input.url || ''; + if (n === 'todoread' || n === 'todowrite') return 'todos'; + if (n === 'ls') return input.path || '.'; + return ''; + } catch { + return ''; + } +} + +function formatMcpInputDisplay(input: any): string { + if (!input || typeof input !== 'object') return String(input ?? ''); + return Object.entries(input) + .map(([k, v]) => { + const s = typeof v === 'string' ? v : JSON.stringify(v, null, 2); + return `${k}: ${s}`; + }) + .join('\n'); +} + +function formatInputDisplay(toolName: string, input: any): string { + try { + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) return formatMcpInputDisplay(input); + + const n = toolName.toLowerCase(); + if (isBashTool(toolName)) return input.command || ''; + if (n === 'read') { + const p = input.file_path || input.path || ''; + const parts = [p]; + if (input.offset) parts.push(`offset: ${input.offset}`); + if (input.limit) parts.push(`limit: ${input.limit}`); + return parts.join(' '); + } + if (n === 'write') { + const p = input.file_path || input.path || ''; + const content = input.content || ''; + const preview = content.length > 300 ? content.slice(0, 300) + '\n…' : content; + return `${p}\n\n${preview}`; + } + if (n === 'edit' || n === 'strreplace') { + const p = input.file_path || input.path || ''; + const old = input.old_string || input.old_text || ''; + const nw = input.new_string || input.new_text || ''; + const lines = [p, '']; + if (old) { + const oldPreview = old.length > 200 ? old.slice(0, 200) + '…' : old; + lines.push(`- ${oldPreview.split('\n').join('\n- ')}`); + } + if (nw) { + const nwPreview = nw.length > 200 ? nw.slice(0, 200) + '…' : nw; + lines.push(`+ ${nwPreview.split('\n').join('\n+ ')}`); + } + return lines.join('\n'); + } + if (n === 'multiedit') { + const p = input.file_path || input.path || ''; + const edits = input.edits || []; + const lines = [p]; + for (const e of edits.slice(0, 3)) { + const old = e.old_string || e.old_text || ''; + lines.push(` - ${old.split('\n')[0].slice(0, 60)}…`); + } + if (edits.length > 3) lines.push(` … +${edits.length - 3} more edits`); + return lines.join('\n'); + } + if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || ''; + if (n === 'grep' || n === 'ripgrep') { + const pat = input.pattern || input.regex || ''; + const path = input.path || input.directory || ''; + const parts = [`pattern: ${pat}`]; + if (path) parts.push(`path: ${path}`); + if (input.include) parts.push(`include: ${input.include}`); + return parts.join('\n'); + } + if (n === 'websearch') return input.query || input.search_term || ''; + if (n === 'webfetch') return input.url || ''; + } catch {} + if (typeof input === 'string') return input; + return JSON.stringify(input, null, 2); +} + +interface ParsedBashResult { + type: 'bash'; + stdout: string; + stderr: string; + exitCode: number | null; +} + +interface ParsedTextResult { + type: 'text'; + content: string; + isError?: boolean; +} + +interface ParsedMcpResult { + type: 'mcp'; + service: string; + action: string; + data: Record; + rawText: string; +} + +type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult; + +function parseToolResult(toolName: string, rawText: string): ParsedResult { + if (isBashTool(toolName)) { + try { + const parsed = JSON.parse(rawText); + if (typeof parsed === 'object' && parsed !== null && 'stdout' in parsed) { + const exitMatch = (parsed.stdout || '').match(/[Ee]xit code:\s*(\d+)/); + return { + type: 'bash', + stdout: parsed.stdout || '', + stderr: parsed.stderr || '', + exitCode: exitMatch ? parseInt(exitMatch[1], 10) : null, + }; + } + } catch {} + } + + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) { + try { + let parsed = JSON.parse(rawText); + + if (Array.isArray(parsed) && parsed.some((b: any) => b?.type === 'text' && typeof b?.text === 'string')) { + const textContent = parsed + .filter((b: any) => b?.type === 'text') + .map((b: any) => b.text) + .join('\n'); + try { + parsed = JSON.parse(textContent); + } catch { + return { type: 'mcp', service: mcp.service, action: mcp.action, data: {}, rawText: textContent }; + } + } + + if (typeof parsed === 'object' && parsed !== null) { + return { type: 'mcp', service: mcp.service, action: mcp.action, data: parsed, rawText }; + } + } catch {} + return { type: 'mcp', service: mcp.service, action: mcp.action, data: {}, rawText }; + } + + try { + const parsed = JSON.parse(rawText); + if (typeof parsed === 'object' && parsed !== null) { + if ('stdout' in parsed) { + return { type: 'text', content: parsed.stdout || '' }; + } + if ('content' in parsed && typeof parsed.content === 'string') { + return { type: 'text', content: parsed.content, isError: !!parsed.is_error }; + } + if ('result' in parsed && typeof parsed.result === 'string') { + return { type: 'text', content: parsed.result }; + } + if ('output' in parsed && typeof parsed.output === 'string') { + return { type: 'text', content: parsed.output }; + } + const n = toolName.toLowerCase(); + if (n === 'glob' && Array.isArray(parsed)) { + return { type: 'text', content: parsed.join('\n') }; + } + } + } catch {} + + return { type: 'text', content: rawText }; +} + +export function getMcpShortAction(mcpInfo: McpToolInfo): string { + const { action, service } = mcpInfo; + let short = action; + if (service && action.toLowerCase().startsWith(service.toLowerCase() + '_')) { + short = action.slice(service.length + 1); + } + return short.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase()); +} + +export function getResultSummary(toolName: string, rawText: string): string { + const parsed = parseToolResult(toolName, rawText); + + if (parsed.type === 'bash') { + const lines = parsed.stdout.split('\n').filter((l) => l.trim()).length; + if (parsed.exitCode !== null && parsed.exitCode !== 0) return `✗ exit ${parsed.exitCode}`; + if (parsed.stderr && !parsed.stdout) return '✗ stderr'; + return `✓ ${lines} line${lines !== 1 ? 's' : ''}`; + } + + if (parsed.type === 'mcp') { + const d = parsed.data; + if (parsed.service === 'gmail') { + const subj = d.subject || getGmailHeader(d, 'Subject'); + if (subj) return subj; + if (Array.isArray(d.messages)) return `${d.messages.length} email${d.messages.length !== 1 ? 's' : ''}`; + if (d.id || d.messageId) return '✓ done'; + } + if (parsed.service === 'calendar') { + if (d.summary) return d.summary.slice(0, 40); + if (Array.isArray(d.items)) return `${d.items.length} event${d.items.length !== 1 ? 's' : ''}`; + } + if (parsed.service === 'drive') { + if (d.name) return d.name; + if (Array.isArray(d.files)) return `${d.files.length} file${d.files.length !== 1 ? 's' : ''}`; + } + if (d.error || d.is_error) return '✗ error'; + return '✓ done'; + } + + const text = parsed.content; + const lines = text.split('\n'); + const lineCount = lines.length; + const n = toolName.toLowerCase(); + + try { + if (n === 'glob') { + const fileCount = lines.filter((l) => l.trim()).length; + return `${fileCount} file${fileCount !== 1 ? 's' : ''}`; + } + if (n === 'grep' || n === 'ripgrep') { + const matchCount = lines.filter((l) => l.trim()).length; + return `${matchCount} match${matchCount !== 1 ? 'es' : ''}`; + } + if (n === 'read') return `${lineCount} lines`; + if (n === 'write') { + if (text.toLowerCase().includes('success') || text.toLowerCase().includes('written')) + return '✓ written'; + return '✓ done'; + } + if (n === 'edit' || n === 'multiedit' || n === 'strreplace') { + if (text.toLowerCase().includes('success') || text.toLowerCase().includes('applied')) + return '✓ applied'; + return '✓ done'; + } + if (n === 'websearch') return 'results'; + if (n === 'webfetch') return `${lineCount} lines`; + if (parsed.isError) return '✗ error'; + } catch {} + + return `${lineCount} line${lineCount !== 1 ? 's' : ''}`; +} + +function getPromptPrefix(toolName: string): string { + if (isBashTool(toolName)) return '$ '; + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) return `❯ ${mcp.displayName} `; + return `❯ ${toolName} `; +} + +interface ToolCallBubbleProps { + call: AgentMessage; + result?: AgentMessage | null; + isPending?: boolean; + isStreaming?: boolean; + mcpCompact?: boolean; + sessionId?: string; +} + +interface TermColors { + TERM_BG: string; + TERM_BORDER: string; + PROMPT_COLOR: string; + CMD_COLOR: string; + OUTPUT_COLOR: string; + PATH_COLOR: string; + ADD_COLOR: string; + DEL_COLOR: string; + STDERR_COLOR: string; + WARN_COLOR: string; + NUM_COLOR: string; + DIM_COLOR: string; + DIFF_HEADER_COLOR: string; + SCROLLBAR_THUMB: string; +} + +const darkTermColors: TermColors = { + TERM_BG: '#131520', + TERM_BORDER: '#1e2030', + PROMPT_COLOR: '#7ec699', + CMD_COLOR: '#e8ecf4', + OUTPUT_COLOR: '#a0aab8', + PATH_COLOR: '#82aaff', + ADD_COLOR: '#7ec699', + DEL_COLOR: '#ff8787', + STDERR_COLOR: '#ff8787', + WARN_COLOR: '#ffcb6b', + NUM_COLOR: '#f78c6c', + DIM_COLOR: '#555b6e', + DIFF_HEADER_COLOR: '#c792ea', + SCROLLBAR_THUMB: '#2a2d3e', +}; + +const lightTermColors: TermColors = { + TERM_BG: '#f4f3ee', + TERM_BORDER: '#e2e0d8', + PROMPT_COLOR: '#2d7a3e', + CMD_COLOR: '#2a2a28', + OUTPUT_COLOR: '#555550', + PATH_COLOR: '#3060a8', + ADD_COLOR: '#2d7a3e', + DEL_COLOR: '#c03030', + STDERR_COLOR: '#c03030', + WARN_COLOR: '#8a6518', + NUM_COLOR: '#c05020', + DIM_COLOR: '#9e9c95', + DIFF_HEADER_COLOR: '#7c4daa', + SCROLLBAR_THUMB: '#ccc9c0', +}; + +function useTermColors(): TermColors { + const { mode } = useThemeMode(); + return mode === 'dark' ? darkTermColors : lightTermColors; +} + +function colorizeInput(toolName: string, text: string, tc: TermColors): React.ReactNode { + const n = toolName.toLowerCase(); + const mcp = parseMcpToolName(toolName); + + if (mcp.isMcp) { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + const colonIdx = line.indexOf(':'); + if (colonIdx > 0 && colonIdx < 30) { + return ( + + {line.slice(0, colonIdx + 1)} + {line.slice(colonIdx + 1)} + {nl} + + ); + } + return {line}{nl}; + })} + + ); + } + + if (isBashTool(toolName)) return {text}; + + if (n === 'edit' || n === 'strreplace' || n === 'multiedit') { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + if (i === 0 && (line.startsWith('/') || line.includes('.'))) + return {line}{nl}; + if (line.startsWith('+ ')) + return {line}{nl}; + if (line.startsWith('- ')) + return {line}{nl}; + return {line}{nl}; + })} + + ); + } + + if (n === 'write') { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + if (i === 0 && (line.startsWith('/') || line.includes('.'))) + return {line}{nl}; + return {line}{nl}; + })} + + ); + } + + if (n === 'read' || n === 'glob' || n === 'webfetch') { + if (/^\//.test(text) || text.includes('/')) + return {text}; + } + + if (n === 'grep' || n === 'ripgrep') { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + if (line.startsWith('pattern:')) + return ( + + pattern: + {line.slice(9)} + {nl} + + ); + if (line.startsWith('path:')) + return ( + + path: + {line.slice(6)} + {nl} + + ); + return {line}{nl}; + })} + + ); + } + + return {text}; +} + +function colorizeOutput(toolName: string, text: string, tc: TermColors): React.ReactNode { + if (!text) return (empty); + + const lines = text.split('\n'); + const n = toolName.toLowerCase(); + + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + const trimmed = line.trimStart(); + + if (/^\/\S+/.test(trimmed)) + return {line}{nl}; + + if (n === 'grep' || n === 'ripgrep') { + const grepMatch = line.match(/^(\S+?:\d+[:-])/); + if (grepMatch) { + return ( + + {grepMatch[1]} + {line.slice(grepMatch[1].length)} + {nl} + + ); + } + const fileHeader = line.match(/^(\S+\.\w+)$/); + if (fileHeader) + return {line}{nl}; + } + + if (line.startsWith('@@') && line.includes('@@')) + return {line}{nl}; + if (line.startsWith('+')) + return {line}{nl}; + if (line.startsWith('-')) + return {line}{nl}; + + if (/\b[Ee]rror\b/.test(line)) + return {line}{nl}; + if (/\b[Ww]arning\b/.test(line)) + return {line}{nl}; + + if (n === 'read') { + const lineNumMatch = line.match(/^(\s*\d+\s*[|:])/); + if (lineNumMatch) { + return ( + + {lineNumMatch[1]} + {line.slice(lineNumMatch[1].length)} + {nl} + + ); + } + } + + return {line}{nl}; + })} + + ); +} + + +function formatTimestamp(ts: string | number | undefined): string { + if (!ts) return ''; + try { + const d = typeof ts === 'number' ? new Date(ts) : new Date(ts); + if (isNaN(d.getTime())) return String(ts); + return d.toLocaleDateString('en-US', { + weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', + hour: 'numeric', minute: '2-digit', + }); + } catch { return String(ts); } +} + +function stripHtml(html: string): string { + const tmp = document.createElement('div'); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ''; +} + +interface CardColors { + TC_BG: string; + TC_BORDER: string; + TC_HOVER: string; + TC_HEADING: string; + TC_BODY: string; + TC_MUTED: string; + TC_DIM: string; + TC_ACCENT: string; + TC_SUCCESS: string; + TC_WARNING: string; +} + +const darkCardColors: CardColors = { + TC_BG: 'rgba(255,255,255,0.03)', + TC_BORDER: 'rgba(255,255,255,0.06)', + TC_HOVER: 'rgba(255,255,255,0.05)', + TC_HEADING: '#C2C0B6', + TC_BODY: '#9C9A92', + TC_MUTED: '#85837C', + TC_DIM: 'rgba(156,154,146,0.5)', + TC_ACCENT: '#c4633a', + TC_SUCCESS: '#7AB948', + TC_WARNING: '#D1A041', +}; + +const lightCardColors: CardColors = { + TC_BG: 'rgba(0,0,0,0.03)', + TC_BORDER: 'rgba(0,0,0,0.08)', + TC_HOVER: 'rgba(0,0,0,0.05)', + TC_HEADING: '#3D3D3A', + TC_BODY: '#555550', + TC_MUTED: '#73726C', + TC_DIM: 'rgba(115,114,108,0.5)', + TC_ACCENT: '#ae5630', + TC_SUCCESS: '#265B19', + TC_WARNING: '#805C1F', +}; + +function useCardColors(): CardColors { + const { mode } = useThemeMode(); + return mode === 'dark' ? darkCardColors : lightCardColors; +} + +function getGmailHeader(msg: any, name: string): string { + if (msg.payload?.headers && Array.isArray(msg.payload.headers)) { + const h = msg.payload.headers.find( + (hdr: any) => (hdr.name || '').toLowerCase() === name.toLowerCase() + ); + if (h) return h.value || ''; + } + if (msg.headers && typeof msg.headers === 'object' && !Array.isArray(msg.headers)) { + return msg.headers[name] || msg.headers[name.toLowerCase()] || ''; + } + return ''; +} + +function extractEmailFields(msg: any) { + const subject = msg.subject || getGmailHeader(msg, 'Subject') || '(no subject)'; + const from = msg.from || msg.sender || getGmailHeader(msg, 'From') || ''; + const to = msg.to || msg.recipient || getGmailHeader(msg, 'To') || ''; + const rawDate = msg.date || msg.internalDate || msg.receivedAt || getGmailHeader(msg, 'Date') || ''; + const date = formatTimestamp(rawDate); + const snippet = msg.snippet || ''; + const body = msg.body || msg.text || msg.textBody || ''; + const htmlBody = msg.htmlBody || msg.html || ''; + const bodyPreview = body || (htmlBody ? stripHtml(htmlBody) : ''); + return { subject, from, to, date, snippet, bodyPreview }; +} + +const GmailCard: React.FC<{ data: Record; action: string; hideSubjectHeader?: boolean }> = ({ data, action, hideSubjectHeader }) => { + const c = useClaudeTokens(); + const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_BODY, TC_MUTED, TC_DIM, TC_ACCENT, TC_SUCCESS, TC_WARNING } = useCardColors(); + const email = extractEmailFields(data); + const labels = data.labelIds || data.labels || []; + const attachments = data.attachments || []; + + const isSend = action.includes('send'); + const isSearch = action.includes('search') || action.includes('list'); + const messages: any[] = data.messages || (isSearch && data.results ? data.results : []); + + if (messages.length > 0) { + return ( + + {messages.slice(0, 5).map((msg: any, i: number) => { + const m = extractEmailFields(msg); + return ( + + + + {m.subject} + + {m.date && ( + + {m.date} + + )} + + {m.from && ( + + {m.from} + + )} + {(m.snippet || m.bodyPreview) && ( + + {(m.snippet || m.bodyPreview).slice(0, 120)} + {(m.snippet || m.bodyPreview).length > 120 ? '…' : ''} + + )} + + ); + })} + {messages.length > 5 && ( + + +{messages.length - 5} more + + )} + + ); + } + + return ( + + {!hideSubjectHeader && ( + + {isSend ? ( + + ) : ( + + )} + + {email.subject} + + + )} + + + {(email.from || email.to || email.date) && ( + + {email.from && ( + + From + {email.from} + + )} + {email.to && ( + + To + {email.to} + + )} + {email.date && ( + + Date + {email.date} + + )} + + )} + + {labels.length > 0 && ( + + {labels.map((l: string, i: number) => ( + + {l} + + ))} + + )} + + {(email.snippet || email.bodyPreview) && ( + + {children} }} + > + {email.bodyPreview || email.snippet} + + + )} + + {attachments.length > 0 && ( + + {attachments.map((a: any, i: number) => ( + + + + {a.filename || a.name || 'attachment'} + + + ))} + + )} + + + ); +}; + +const CalendarCard: React.FC<{ data: Record; hideHeader?: boolean }> = ({ data, hideHeader }) => { + const c = useClaudeTokens(); + const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_BODY, TC_DIM, TC_SUCCESS } = useCardColors(); + const items: any[] = data.items || (Array.isArray(data) ? data : []); + const single = !items.length ? data : null; + + if (single && (single.summary || single.start)) { + const start = single.start?.dateTime || single.start?.date || single.start || ''; + const end = single.end?.dateTime || single.end?.date || single.end || ''; + return ( + + {!hideHeader && ( + + + + {single.summary || '(no title)'} + + + )} + + {start && ( + + Start + {formatTimestamp(start)} + + )} + {end && ( + + End + {formatTimestamp(end)} + + )} + {single.location && ( + + Where + {single.location} + + )} + {single.description && ( + +
      +                {single.description.slice(0, 300)}
      +                {single.description.length > 300 ? '…' : ''}
      +              
      +
      + )} +
      +
      + ); + } + + if (items.length > 0) { + return ( + + {items.slice(0, 6).map((item: any, i: number) => ( + + + {item.summary || '(no title)'} + + + {formatTimestamp(item.start?.dateTime || item.start?.date || item.start)} + + + ))} + {items.length > 6 && ( + + +{items.length - 6} more + + )} + + ); + } + + return null; +}; + +const DriveCard: React.FC<{ data: Record }> = ({ data }) => { + const c = useClaudeTokens(); + const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_DIM, TC_WARNING } = useCardColors(); + const files: any[] = data.files || (Array.isArray(data) ? data : []); + const single = !files.length && data.name ? data : null; + + if (single) { + return ( + + + + + {single.name} + + {single.mimeType && ( + {single.mimeType} + )} + + + ); + } + + if (files.length > 0) { + return ( + + {files.slice(0, 8).map((f: any, i: number) => ( + + + {f.name || f.id} + {f.mimeType && ( + + {f.mimeType.split('/').pop()} + + )} + + ))} + + ); + } + + return null; +}; + +const GenericMcpCard: React.FC<{ data: Record }> = ({ data }) => { + const c = useClaudeTokens(); + const { TC_DIM, TC_BODY } = useCardColors(); + const entries = Object.entries(data).filter(([, v]) => v != null); + + if (entries.length === 0) + return (empty response); + + return ( + + {entries.slice(0, 20).map(([key, val], i) => { + const isLong = typeof val === 'string' && val.length > 100; + const isObj = typeof val === 'object'; + return ( + + + {key} + + {isObj ? ( +
      +                {JSON.stringify(val, null, 2).slice(0, 500)}
      +              
      + ) : isLong ? ( +
      +                {String(val).slice(0, 500)}{String(val).length > 500 ? '…' : ''}
      +              
      + ) : ( + {String(val)} + )} +
      + ); + })} + {entries.length > 20 && ( + + +{entries.length - 20} more fields + + )} +
      + ); +}; + +const McpResultCard: React.FC<{ parsed: ParsedMcpResult; compact?: boolean }> = ({ parsed, compact }) => { + const tc = useTermColors(); + const { service, action, data } = parsed; + + if (data.error || data.is_error) { + return ( + + + {data.error || data.message || JSON.stringify(data, null, 2)} + + + ); + } + + if (service === 'gmail') return ; + if (service === 'calendar') return ; + if (service === 'drive' || service === 'sheets') return ; + + return ; +}; + +function isBrowserAgentTool(name: string): boolean { + if (name === 'CreateBrowserAgent' || name === 'BrowserAgent' || name === 'BrowserAgents') return true; + const mcp = parseMcpToolName(name); + return mcp.isMcp && mcp.serverSlug === 'openswarm-browser-agent'; +} + +function isInvokeAgentTool(name: string): boolean { + if (name === 'InvokeAgent') return true; + const mcp = parseMcpToolName(name); + return mcp.isMcp && mcp.serverSlug === 'openswarm-invoke-agent'; +} + +function isCreateAgentTool(name: string): boolean { + return name === 'Agent'; +} + +function parseInvokedSessionId(rawText: string): string | null { + const match = rawText.match(/\(forked session:\s*([a-f0-9]+)\)/); + return match ? match[1] : null; +} + +interface InvokeAgentParsed { + agentName: string; + sessionId: string | null; + cost: string | null; + response: string; +} + +function parseCreateAgentResult(rawText: string): string { + if (!rawText) return ''; + try { + const parsed = JSON.parse(rawText); + if (typeof parsed === 'string') return parsed; + if (typeof parsed === 'object' && parsed !== null) { + if (parsed.text) return parsed.text; + if (parsed.content) return typeof parsed.content === 'string' ? parsed.content : JSON.stringify(parsed.content); + if (parsed.result) return typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result); + } + } catch {} + return rawText; +} + +function parseInvokeAgentResult(rawText: string): InvokeAgentParsed | null { + const headerMatch = rawText.match( + /\*\*Invoked Agent Result\*\*(?:\s*—\s*(.+?))?\s*\(forked session:\s*([a-f0-9]+)\)/, + ); + if (!headerMatch) return null; + + const agentName = headerMatch[1]?.trim() || 'Agent'; + const sessionId = headerMatch[2]; + + const costMatch = rawText.match(/\*Cost:\s*\$([0-9.]+)\*/); + const cost = costMatch ? costMatch[1] : null; + + const bodyStart = rawText.indexOf('\n\n'); + let response = bodyStart >= 0 ? rawText.slice(bodyStart + 2).trim() : ''; + if (response.startsWith('*Cost:')) { + const afterCost = response.indexOf('\n'); + response = afterCost >= 0 ? response.slice(afterCost + 1).trim() : ''; + } + + return { agentName, sessionId, cost, response }; +} + +const ToolCallBubble: React.FC = React.memo( + ({ call, result = null, isPending = false, isStreaming = false, mcpCompact = false, sessionId }) => { + ensureToolCallKeyframes(); + + const c = useClaudeTokens(); + const tc = useTermColors(); + const dispatch = useAppDispatch(); + const cards = useAppSelector((s) => s.dashboardLayout.cards); + const [expanded, setExpanded] = useState(false); + const bubbleRef = useRef(null); + + const { toolName, input, isDenied } = getToolData(call); + const mcpInfo = useMemo(() => parseMcpToolName(toolName), [toolName]); + const inputSummary = getInputSummary(toolName, input); + const formattedInput = useMemo(() => formatInputDisplay(toolName, input), [toolName, input]); + const showTimer = isPending && !isDenied && !isStreaming; + + const isBrowserAgent = isBrowserAgentTool(toolName); + const isInvokeAgent = isInvokeAgentTool(toolName); + const isCreateAgent = isCreateAgentTool(toolName); + const browserAgentAutoExpand = isBrowserAgent && isPending && !isStreaming; + const showBody = expanded || isStreaming || browserAgentAutoExpand; + + const resultContent = result?.content; + const hasStructuredResult = + resultContent && typeof resultContent === 'object' && 'text' in resultContent; + const resultRawText: string = hasStructuredResult + ? resultContent.text + : typeof resultContent === 'string' + ? resultContent + : resultContent + ? JSON.stringify(resultContent, null, 2) + : ''; + const resultElapsedMs: number | null = hasStructuredResult + ? resultContent.elapsed_ms ?? null + : null; + + const parsedResult = useMemo( + () => (result ? parseToolResult(toolName, resultRawText) : null), + [result, toolName, resultRawText], + ); + const resultSummary = result ? getResultSummary(toolName, resultRawText) : null; + const isError = + resultSummary?.startsWith('✗') || + (parsedResult?.type === 'bash' && parsedResult.exitCode !== null && parsedResult.exitCode !== 0) || + (parsedResult?.type === 'text' && parsedResult.isError); + + const invokedSessionId = useMemo( + () => (isInvokeAgent && result ? parseInvokedSessionId(resultRawText) : null), + [isInvokeAgent, result, resultRawText], + ); + + const invokeAgentParsed = useMemo( + () => (isInvokeAgent && result ? parseInvokeAgentResult(resultRawText) : null), + [isInvokeAgent, result, resultRawText], + ); + + const createAgentResponse = useMemo( + () => (isCreateAgent && result ? parseCreateAgentResult(resultRawText) : ''), + [isCreateAgent, result, resultRawText], + ); + + const createAgentSessionId: string | null = useMemo( + () => (isCreateAgent && hasStructuredResult && resultContent?.sub_session_id) ? resultContent.sub_session_id : null, + [isCreateAgent, hasStructuredResult, resultContent], + ); + + const revealTargetSessionId = invokedSessionId || createAgentSessionId; + + const sessions = useAppSelector((s) => s.agents.sessions); + + const handleRevealAgent = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!revealTargetSessionId || !sessionId) return; + + if (cards[revealTargetSessionId]) { + dispatch(collapseSession(revealTargetSessionId)); + dispatch(removeCard(revealTargetSessionId)); + setTimeout(() => { + dispatch(clearGlowingAgentCard(revealTargetSessionId)); + }, 500); + return; + } + + let sourceYRatio: number | undefined; + if (bubbleRef.current) { + const bubbleEl = bubbleRef.current; + const cardEl = bubbleEl.closest('[data-select-type="agent-card"]') as HTMLElement | null; + if (cardEl) { + const cardRect = cardEl.getBoundingClientRect(); + const bubbleRect = bubbleEl.getBoundingClientRect(); + const bubbleCenterY = bubbleRect.top + bubbleRect.height / 2; + const ratio = (bubbleCenterY - cardRect.top) / cardRect.height; + sourceYRatio = Math.max(0, Math.min(1, ratio)); + } + } + + const doPlace = () => { + const parentCard = cards[sessionId]; + const targetX = parentCard + ? parentCard.x + parentCard.width + GRID_GAP * 12 + : 40; + let targetY = parentCard ? parentCard.y : 100; + if (parentCard) { + const columnCards = Object.values(cards).filter( + (c) => Math.abs(c.x - targetX) < 50 && c.session_id !== revealTargetSessionId, + ); + if (columnCards.length > 0) { + const lowestBottom = Math.max( + ...columnCards.map((c) => c.y + Math.max(EXPANDED_CARD_MIN_H, c.height)), + ); + targetY = lowestBottom + GRID_GAP; + } + } + dispatch(placeCard({ + sessionId: revealTargetSessionId, + x: targetX, + y: targetY, + width: DEFAULT_CARD_W, + height: DEFAULT_CARD_H, + })); + dispatch(expandSession(revealTargetSessionId)); + const label = isCreateAgent ? 'Create Agent' : isInvokeAgent ? 'Invoke Agent' : 'Agent'; + dispatch(setGlowingAgentCard({ sessionId: revealTargetSessionId, sourceId: sessionId, sourceYRatio, label })); + }; + + if (!sessions[revealTargetSessionId]) { + dispatch(fetchSession(revealTargetSessionId)).then(doPlace); + } else { + doPlace(); + } + }, + [revealTargetSessionId, sessionId, cards, sessions, dispatch], + ); + + const toggle = useCallback(() => { + if (!isStreaming) setExpanded((v) => !v); + }, [isStreaming]); + + const accentRgb = c.accent.primary + .replace('#', '') + .match(/.{2}/g) + ?.map((h) => parseInt(h, 16)) + .join(', ') || '189, 100, 57'; + + const promptPrefix = getPromptPrefix(toolName); + const shortAction = mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName; + + const serviceLabel = mcpInfo.isMcp && mcpInfo.service + ? mcpInfo.service.charAt(0).toUpperCase() + mcpInfo.service.slice(1) + : shortAction; + + const ServiceIcon = mcpInfo.isMcp && mcpInfo.service + ? + : null; + + const selectAttrs = { + 'data-select-type': 'tool-call' as const, + 'data-select-id': call.id, + 'data-select-meta': JSON.stringify({ tool: toolName, inputSummary }), + }; + + if (isInvokeAgent) { + const agentName = invokeAgentParsed?.agentName || input?.session_id || 'Agent'; + const responsePreview = invokeAgentParsed?.response || ''; + const costLabel = invokeAgentParsed?.cost ? `$${invokeAgentParsed.cost}` : null; + const hasResponse = !!invokeAgentParsed; + + return ( + + + {/* Header */} + + + + InvokeAgent + + + + {agentName} + + + + {!hasResponse && !showTimer && } + + {hasResponse && responsePreview && !expanded && ( + + {responsePreview.slice(0, 100)}{responsePreview.length > 100 ? '…' : ''} + + )} + {expanded && } + + {isDenied && ( + + + denied + + )} + + {hasResponse && !isDenied && ( + + {isError ? ( + + ) : ( + + )} + {resultElapsedMs != null && ( + + {formatElapsed(resultElapsedMs)} + + )} + {costLabel && ( + + {costLabel} + + )} + + )} + + {showTimer && } + + {invokedSessionId && ( + + + + + + )} + + {hasResponse && ( + + {expanded ? : } + + )} + + + {/* Expanded body — markdown rendered, not terminal */} + + + ( + {children} + ), + }} + > + {responsePreview} + + + + + + ); + } + + if (isCreateAgent) { + const taskPrompt = input?.prompt || input?.task || input?.message || ''; + const taskLabel = taskPrompt + ? taskPrompt.length > 40 ? taskPrompt.slice(0, 40) + '…' : taskPrompt + : 'Sub-agent'; + const hasResponse = !!createAgentResponse; + + return ( + + + + + + CreateAgent + + + + {taskLabel} + + + + {!hasResponse && !showTimer && } + + {hasResponse && createAgentResponse && !expanded && ( + + {createAgentResponse.slice(0, 100)}{createAgentResponse.length > 100 ? '…' : ''} + + )} + {expanded && } + + {isDenied && ( + + + denied + + )} + + {hasResponse && !isDenied && ( + + {isError ? ( + + ) : ( + + )} + {resultElapsedMs != null && ( + + {formatElapsed(resultElapsedMs)} + + )} + + )} + + {showTimer && } + + {createAgentSessionId && ( + + + + + + )} + + {hasResponse && ( + + {expanded ? : } + + )} + + + + + ( + {children} + ), + }} + > + {createAgentResponse} + + + + + + ); + } + + if (mcpCompact && mcpInfo.isMcp) { + return ( + + + {ServiceIcon} + + {serviceLabel} + + {resultSummary && !isError && ( + + {resultSummary} + + )} + {!resultSummary && !showTimer && } + {showTimer && ( + <> + + + + )} + {isDenied && ( + + + denied + + )} + {result && !isDenied && ( + + {isError ? ( + + ) : ( + + )} + {resultElapsedMs != null && ( + + {formatElapsed(resultElapsedMs)} + + )} + + )} + + {showBody ? : } + + + + + + {isBrowserAgent && sessionId && ( + + )} + {parsedResult && parsedResult.type === 'mcp' ? ( + + ) : parsedResult ? ( +
      +                  {parsedResult.type === 'text' ? parsedResult.content : ''}
      +                
      + ) : null} + {!parsedResult && isPending && !isStreaming && !isBrowserAgent && ( + + + + )} + +
      +
      + ); + } + + return ( + + + {/* Header */} + + {mcpInfo.isMcp && mcpInfo.service + ? + : (() => { + const n = toolName.toLowerCase(); + if (n.includes('search') || n === 'grep' || n === 'glob') + return ; + return ; + })() + } + + {mcpInfo.isMcp ? mcpInfo.displayName : toolName} + + {mcpInfo.isMcp && ( + + {mcpInfo.serverSlug} + + )} + {inputSummary && !isStreaming && ( + + {inputSummary} + + )} + {!inputSummary && } + {isStreaming && } + + {isDenied && ( + + + + denied + + + )} + {result && !isDenied && ( + + {isError ? ( + + ) : ( + + )} + + {resultSummary} + + {resultElapsedMs != null && ( + + {formatElapsed(resultElapsedMs)} + + )} + + )} + {showTimer && } + + {!isStreaming && ( + + {showBody ? ( + + ) : ( + + )} + + )} + + + {/* Unified terminal body */} + + + {/* Prompt + command */} +
      +                
      +                  {promptPrefix}
      +                
      +                {isStreaming ? (
      +                  {call.content?.input ?? ''}
      +                ) : (
      +                  colorizeInput(toolName, formattedInput, tc)
      +                )}
      +                {isStreaming && (
      +                  
      +                )}
      +              
      + + {/* Browser agent inline feed */} + {isBrowserAgent && sessionId && ( + + )} + + {/* Output */} + {parsedResult && parsedResult.type === 'mcp' ? ( + + ) : parsedResult ? ( +
      +                  {parsedResult.type === 'bash' ? (
      +                    <>
      +                      {parsedResult.stdout.trim() &&
      +                        colorizeOutput(toolName, parsedResult.stdout, tc)}
      +                      {parsedResult.stderr.trim() && (
      +                        <>
      +                          {parsedResult.stdout.trim() && '\n'}
      +                          {parsedResult.stderr}
      +                        
      +                      )}
      +                      {!parsedResult.stdout.trim() && !parsedResult.stderr.trim() && (
      +                        (no output)
      +                      )}
      +                    
      +                  ) : (
      +                    <>
      +                      {parsedResult.isError ? (
      +                        {parsedResult.content || '(empty)'}
      +                      ) : (
      +                        colorizeOutput(toolName, parsedResult.content, tc)
      +                      )}
      +                    
      +                  )}
      +                
      + ) : null} + + {/* Pending indicator when waiting for result (skip for browser agent — feed replaces it) */} + {!parsedResult && isPending && !isStreaming && !isBrowserAgent && ( + + + + )} + +
      +
      +
      + ); + } +); + +export default ToolCallBubble; diff --git a/frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx b/frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx new file mode 100644 index 00000000..aa37a05b --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx @@ -0,0 +1,200 @@ +import React, { useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Collapse from '@mui/material/Collapse'; +import IconButton from '@mui/material/IconButton'; +import Chip from '@mui/material/Chip'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import { AgentMessage, ToolGroupMeta } from '@/shared/state/agentsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { sanitizeSvgString } from '@/shared/sanitizeSvg'; +import ToolCallBubble, { ToolPair } from './ToolCallBubble'; + +export interface ToolGroup { + type: 'tool_group'; + id: string; + pairs: ToolPair[]; + label: string; + callCount: number; + mcpServer?: string; +} + +export type RenderItem = AgentMessage | ToolGroup | ToolPair; + +export function isToolGroup(item: RenderItem): item is ToolGroup { + return (item as ToolGroup).type === 'tool_group'; +} + +export function isToolPair(item: RenderItem): item is ToolPair { + return (item as ToolPair).type === 'tool_pair'; +} + +const GeneratedSvgIcon: React.FC<{ svg: string; size?: number; color: string }> = ({ svg, size = 16, color }) => { + const sanitized = useMemo(() => sanitizeSvgString(svg), [svg]); + if (!sanitized) return null; + return ( + + ); +}; + +const SkeletonPulse: React.FC<{ width: number; height: number; borderRadius?: number }> = ({ width, height, borderRadius = 4 }) => ( + +); + +interface Props { + group: ToolGroup; + isSessionRunning?: boolean; + meta?: ToolGroupMeta; + sessionId?: string; +} + +const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = false, meta, sessionId }) => { + const c = useClaudeTokens(); + const isMcp = !!group.mcpServer; + const [expanded, setExpanded] = useState(isMcp); + + const completedCount = group.pairs.filter((p) => p.result !== null).length; + const pendingCount = group.pairs.filter((p) => p.result === null).length; + const deniedCount = group.pairs.filter( + (p) => typeof p.call.content === 'object' && p.call.content.approved === false + ).length; + const allDone = pendingCount === 0 || !isSessionRunning; + + const displayName = meta?.name || group.label; + const hasSvg = !!meta?.svg; + + const toolNames = group.pairs.map((p) => { + const c2 = typeof p.call.content === 'object' ? p.call.content : {}; + return c2.tool || 'unknown'; + }); + + return ( + + + setExpanded(!expanded)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.75, + px: 1.5, + py: 0.7, + cursor: 'pointer', + '&:hover': { bgcolor: 'rgba(0,0,0,0.02)' }, + }} + > + {!meta ? ( + + ) : hasSvg ? ( + + ) : ( + + )} + + {!meta ? ( + + + + ) : ( + + {displayName} + + )} + + {deniedCount > 0 && ( + + {deniedCount} denied + + )} + {allDone && completedCount > 0 && ( + + + + {completedCount}/{group.callCount} + + + )} + {!allDone && pendingCount > 0 && ( + + {completedCount}/{group.callCount} + + )} + + + {expanded ? : } + + + + + + {group.pairs.map((pair) => ( + + ))} + + + + + ); +}); + +export default ToolGroupBubble; diff --git a/frontend/src/app/pages/AgentChat/toolkit/customToolkit/components/ViewBubble.tsx b/frontend/src/app/pages/AgentChat/ViewBubble.tsx similarity index 62% rename from frontend/src/app/pages/AgentChat/toolkit/customToolkit/components/ViewBubble.tsx rename to frontend/src/app/pages/AgentChat/ViewBubble.tsx index 22515790..ec7b2062 100644 --- a/frontend/src/app/pages/AgentChat/toolkit/customToolkit/components/ViewBubble.tsx +++ b/frontend/src/app/pages/AgentChat/ViewBubble.tsx @@ -3,14 +3,16 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import Collapse from '@mui/material/Collapse'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; import Icon from '@mui/material/Icon'; import OpenInFullIcon from '@mui/icons-material/OpenInFull'; +import CloseIcon from '@mui/icons-material/Close'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { useAppSelector } from '@/shared/hooks'; -import { getAppServeUrl } from '@/shared/backend-bridge/apps/app_builder'; +import { SERVE_BASE } from '@/shared/state/outputsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import ViewPreview from '@/app/pages/Views/ViewPreview'; -import { StreamingPlaceholder, ViewBubbleDialog } from './ViewBubbleParts'; +import ViewPreview from '../Views/ViewPreview'; interface Props { toolInput: Record; @@ -25,7 +27,7 @@ const ViewBubble: React.FC = ({ toolInput, toolResult, isStreaming }) => const outputId = toolInput?.output_id; const inputData = toolInput?.input_data || {}; - const outputsMap = useAppSelector((state) => state.apps.items); + const outputsMap = useAppSelector((state) => state.outputs.items); const output = outputId ? outputsMap[outputId] : null; const parsedResult = useMemo(() => { @@ -40,16 +42,51 @@ const ViewBubble: React.FC = ({ toolInput, toolResult, isStreaming }) => const outputColor = c.accent.primary; const outputIcon = output?.icon || 'view_quilt'; const hasPreview = !!frontendCode.trim(); - const serveUrl = outputId ? getAppServeUrl(outputId) : undefined; + const serveUrl = outputId ? `${SERVE_BASE}/${outputId}/serve/index.html` : undefined; const inputEntries = Object.entries(inputData); if (isStreaming && !hasPreview) { return ( - + + + {outputIcon} + + {outputName} + + + + Rendering… + + + ); } @@ -156,7 +193,13 @@ const ViewBubble: React.FC = ({ toolInput, toolResult, isStreaming }) => {/* Preview */} {hasPreview && ( - + = ({ toolInput, toolResult, isStreaming }) => - setExpanded(false)} - outputColor={outputColor} - outputIcon={outputIcon} - outputName={outputName} - serveUrl={serveUrl} - frontendCode={frontendCode} - inputData={inputData} - backendResult={backendResult} - /> + maxWidth="lg" + fullWidth + PaperProps={{ + sx: { + height: '85vh', + display: 'flex', + flexDirection: 'column', + borderRadius: '12px', + overflow: 'hidden', + }, + }} + > + + {outputIcon} + {outputName} + setExpanded(false)} size="small" sx={{ color: c.text.tertiary }}> + + + + + + + ); }; diff --git a/frontend/src/app/pages/AgentChat/_shared/Button.tsx b/frontend/src/app/pages/AgentChat/_shared/Button.tsx deleted file mode 100644 index d3a24626..00000000 --- a/frontend/src/app/pages/AgentChat/_shared/Button.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { Slot } from "radix-ui" - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8", - "icon-lg": "size-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -const Button = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - } ->(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot.Root : "button" - - return ( - - ) -}) -Button.displayName = "Button" - -export { Button } diff --git a/frontend/src/app/pages/AgentChat/_shared/collapsible.tsx b/frontend/src/app/pages/AgentChat/_shared/collapsible.tsx deleted file mode 100644 index 63fc8eff..00000000 --- a/frontend/src/app/pages/AgentChat/_shared/collapsible.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Collapsible as CollapsiblePrimitive } from "radix-ui" - -function Collapsible({ - ...props -}: React.ComponentProps) { - return -} - -function CollapsibleTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CollapsibleContent({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/frontend/src/app/pages/AgentChat/hooks/useAgentChat.ts b/frontend/src/app/pages/AgentChat/hooks/useAgentChat.ts deleted file mode 100644 index 6d10fdab..00000000 --- a/frontend/src/app/pages/AgentChat/hooks/useAgentChat.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; -import { useParams } from 'react-router-dom'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import type { AgentConfig } from '@/shared/state/agentsTypes'; -import { - SEND_MESSAGE, - STOP_AGENT, - HANDLE_APPROVAL, - EDIT_MESSAGE, - GET_SESSION, - META_LAUNCH_AND_SEND -} from '@/shared/backend-bridge/apps/agents'; -import { updateSessionMode, updateSessionModel } from '@/shared/state/agentsSlice'; -import { LIST_MODES } from '@/shared/state/modesSlice'; -import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice'; - -export interface QueuedMessage { - prompt: string; - images?: Array<{ data: string; media_type: string }>; - contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>; - forcedTools?: string[]; - attachedSkills?: Array<{ id: string; name: string; content: string }>; - selectedBrowserIds?: string[]; -} - -interface UseAgentChatParams { - sessionId?: string; -} - -export function useAgentChat({ sessionId: sessionIdProp }: UseAgentChatParams) { - const { id: routeId } = useParams<{ id: string }>(); - const id = sessionIdProp || routeId; - const dispatch = useAppDispatch(); - const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined)); - const modesMap = useAppSelector((state) => state.modes.items); - const [showResumeBubble, setShowResumeBubble] = useState(false); - const [awaitingResponse, setAwaitingResponse] = useState(false); - const mode = session?.mode ?? 'agent'; - const model = session?.model ?? 'sonnet'; - const messageQueueRef = useRef([]); - const [queueLength, setQueueLength] = useState(0); - const [editingMessageId, setEditingMessageId] = useState(null); - - const isDraft = session?.status === 'draft'; - - useEffect(() => { - if (!id || isDraft) return; - dispatch(GET_SESSION(id)); - }, [id, isDraft, dispatch]); - - useEffect(() => { if (Object.keys(modesMap).length === 0) dispatch(LIST_MODES()); }, [dispatch, modesMap]); - - const sessionSystemPrompt = session?.system_prompt; - const sessionTargetDirectory = session?.target_directory; - - const dispatchMessage = useCallback((msg: QueuedMessage) => { - if (!id) return; - setShowResumeBubble(false); - setAwaitingResponse(true); - if (isDraft) { - const config: AgentConfig = { - model: model, - mode: mode, - system_prompt: sessionSystemPrompt ?? undefined, - target_directory: sessionTargetDirectory ?? undefined, - }; - if (sessionSystemPrompt) config.system_prompt = sessionSystemPrompt; - if (sessionTargetDirectory) config.target_directory = sessionTargetDirectory; - dispatch( - META_LAUNCH_AND_SEND({ - draftId: id, - config, - prompt: msg.prompt, - mode, - model, - images: msg.images, - contextPaths: msg.contextPaths, - forcedTools: msg.forcedTools, - attachedSkills: msg.attachedSkills, - selectedBrowserIds: msg.selectedBrowserIds - }) - ).then((action) => { - if (META_LAUNCH_AND_SEND.fulfilled.match(action)) { - const realId = action.payload.session.session_id; - // TODO: Implement title generation - // dispatch(generateTitle({ - // sessionId: realId, - // prompt: msg.prompt - // })); - if (msg.selectedBrowserIds?.length) { - dispatch(setGlowingBrowserCards({ - browserIds: msg.selectedBrowserIds, - sessionId: realId, - label: 'Use Browser' - })); - } - } - }); - } else { - if (msg.selectedBrowserIds?.length) { - dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' })); - } - dispatch(SEND_MESSAGE({ - sessionId: id, - prompt: msg.prompt, - mode: mode, - model: model, - images: msg.images?.map((img) => img.data), - imageMediaTypes: msg.images?.map((img) => img.media_type), - contextPaths: msg.contextPaths, - forcedTools: msg.forcedTools, - attachedSkills: msg.attachedSkills, - // TODO: Implement the selectedBrowserIds below - // selectedBrowserIds: msg.selectedBrowserIds - })) - .then((action) => { if (SEND_MESSAGE.rejected.match(action)) setAwaitingResponse(false); }); - } - }, [id, isDraft, mode, model, sessionSystemPrompt, sessionTargetDirectory, dispatch]); - - const agentBusy = awaitingResponse || (!isDraft && (session?.status === 'running' || session?.status === 'waiting_approval')); - - const prevStatusRef = useRef(session?.status); - useEffect(() => { - const prev = prevStatusRef.current; - const curr = session?.status; - prevStatusRef.current = curr; - let didDispatchQueued = false; - const wasActive = prev === 'running' || prev === 'waiting_approval'; - const isTerminal = curr === 'completed' || curr === 'stopped' || curr === 'error'; - if (wasActive && isTerminal) { - if (id) { - dispatch(fadeGlowingBrowserCards(id)); - setTimeout(() => dispatch(clearGlowingBrowserCards(id)), 2800); - } - const nextQueued = messageQueueRef.current.shift(); - if (nextQueued) { - setQueueLength(messageQueueRef.current.length); - dispatchMessage(nextQueued); - didDispatchQueued = true; - } else if (curr === 'stopped') { - setShowResumeBubble(true); - } - const currentMode = modesMap[mode]; - if (currentMode?.default_next_mode && modesMap[currentMode.default_next_mode]) { - if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: currentMode.default_next_mode })); - } - } - if (curr === 'running') setShowResumeBubble(false); - if (curr !== 'draft' && !didDispatchQueued) setAwaitingResponse(false); - }, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]); - - const handleSend = (prompt: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>, forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => { - if (!id) return; - const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }; - if (agentBusy) { - messageQueueRef.current.push(msg); - setQueueLength(messageQueueRef.current.length); - return; - } - dispatchMessage(msg); - }; - - const handleModeChange = useCallback((newMode: string) => { - if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: newMode })); - }, [id, isDraft, dispatch]); - - const handleModelChange = useCallback((newModel: string) => { - if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel })); - }, [id, isDraft, dispatch]); - - const handleApprove = (requestId: string, updatedInput?: Record) => { - dispatch(HANDLE_APPROVAL({ requestId, behavior: 'allow', updatedInput })); - }; - const handleDeny = (requestId: string, message?: string) => { - dispatch(HANDLE_APPROVAL({ requestId, behavior: 'deny', message })); - }; - const handleStop = () => { if (id) dispatch(STOP_AGENT(id)); }; - - const handleResume = useCallback(() => { - if (!id) return; - setShowResumeBubble(false); - dispatch(SEND_MESSAGE({ - sessionId: id, - prompt: "Continue where you left off. Start you're response EXACTLY with 'Sorry, let me pick up where I left off", - mode, model, hidden: true, - })); - }, [id, mode, model, dispatch]); - - const handleSaveEdit = useCallback( - (messageId: string, newContent: string) => { - if (!id) return; - dispatch(EDIT_MESSAGE({ sessionId: id, messageId, content: newContent })); - setEditingMessageId(null); - }, [id, dispatch] - ); - const handleCancelEdit = useCallback(() => { setEditingMessageId(null); }, []); - - return { - id, - session, - isDraft, - dispatch, - mode, - model, - messageQueueRef, - showResumeBubble, - awaitingResponse, - editingMessageId, - queueLength, - setQueueLength, - agentBusy, - handleSend, - handleModeChange, - handleModelChange, - handleApprove, - handleDeny, - handleStop, - handleResume, - handleSaveEdit, - handleCancelEdit, - setEditingMessageId, - }; -} diff --git a/frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts b/frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts deleted file mode 100644 index ca651047..00000000 --- a/frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { useCallback, useMemo, type MutableRefObject } from 'react'; -import { - useExternalStoreRuntime, - type ThreadMessageLike, - type AppendMessage, -} from '@assistant-ui/react'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { - type AgentMessage, - type StreamingMessage, -} from '@/shared/state/agentsSlice'; -import { SEND_MESSAGE, EDIT_MESSAGE, STOP_AGENT } from '@/shared/backend-bridge/apps/agents'; - -export interface ComposerExtras { - images?: Array<{ data: string; media_type: string }>; - contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>; - forcedTools?: string[]; - attachedSkills?: Array<{ id: string; name: string; content: string }>; - selectedBrowserIds?: string[]; -} - -export interface DispatchableMessage { - prompt: string; - images?: Array<{ data: string; media_type: string }>; - contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>; - forcedTools?: string[]; - attachedSkills?: Array<{ id: string; name: string; content: string }>; - selectedBrowserIds?: string[]; -} - -interface RuntimeOptions { - composerExtrasRef?: MutableRefObject; - dispatchMessage?: (msg: DispatchableMessage) => void; -} - -type RawMessage = AgentMessage | (StreamingMessage & { _streaming: true }); - -function convertMessage(msg: RawMessage): ThreadMessageLike { - if ('_streaming' in msg) { - const streaming = msg as StreamingMessage & { _streaming: true }; - if (streaming.role === 'tool_call') { - return { - role: 'assistant', - id: streaming.id, - content: [ - { - type: 'tool-call', - toolCallId: streaming.id, - toolName: streaming.tool_name || 'unknown', - args: {}, - }, - ], - status: { type: 'running' }, - }; - } - return { - role: 'assistant', - id: streaming.id, - content: [{ type: 'text', text: streaming.content }], - status: { type: 'running' }, - }; - } - - const createdAt = msg.timestamp ? new Date(msg.timestamp) : undefined; - const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); - - switch (msg.role) { - case 'user': - return { role: 'user', id: msg.id, createdAt, content: [{ type: 'text', text }] }; - - case 'assistant': - return { role: 'assistant', id: msg.id, createdAt, content: [{ type: 'text', text }] }; - - case 'tool_call': { - const content = typeof msg.content === 'object' && msg.content !== null ? msg.content : {}; - return { - role: 'assistant', - id: msg.id, - createdAt, - content: [ - { - type: 'tool-call', - toolCallId: msg.id, - toolName: content.tool || content.name || 'unknown', - args: content.input ?? content.args ?? content, - }, - ], - }; - } - - case 'tool_result': { - const content = typeof msg.content === 'object' && msg.content !== null ? msg.content : {}; - const parentToolCallId = msg.parent_id || msg.id; - return { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: parentToolCallId, - result: content.output ?? content.result ?? content, - }, - ], - }; - } - - case 'system': - return { role: 'system', id: msg.id, content: [{ type: 'text', text }] }; - - default: - return { role: 'system', id: msg.id, content: [{ type: 'text', text: '' }] }; - } -} - -function extractText(message: AppendMessage): string { - for (const part of message.content) { - if (part.type === 'text') return part.text; - } - return ''; -} - -export function useOpenSwarmRuntime( - sessionId: string | undefined, - options?: RuntimeOptions, -) { - const dispatch = useAppDispatch(); - const session = useAppSelector((state) => - sessionId ? state.agents.sessions[sessionId] : undefined, - ); - - const rawMessages = useMemo(() => { - if (!session) return []; - const msgs: RawMessage[] = session.messages.filter((m) => !m.hidden); - if (session.streamingMessage) { - msgs.push({ ...session.streamingMessage, _streaming: true as const }); - } - return msgs; - }, [session]); - - const isRunning = session?.status === 'running'; - - const onNew = useCallback( - async (message: AppendMessage) => { - if (!sessionId) return; - const text = extractText(message); - if (!text) return; - - if (options?.dispatchMessage) { - const extras = options.composerExtrasRef?.current ?? {}; - if (options.composerExtrasRef) { - options.composerExtrasRef.current = {}; - } - options.dispatchMessage({ prompt: text, ...extras }); - } else { - dispatch(SEND_MESSAGE({ sessionId, prompt: text })); - } - }, - [sessionId, dispatch, options], - ); - - const onEdit = useCallback( - async (message: AppendMessage) => { - if (!sessionId || !message.parentId) return; - const text = extractText(message); - dispatch(EDIT_MESSAGE({ sessionId, messageId: message.parentId, content: text })); - }, - [sessionId, dispatch], - ); - - const onCancel = useCallback(async () => { - if (!sessionId) return; - dispatch(STOP_AGENT(sessionId)); - }, [sessionId, dispatch]); - - const runtime = useExternalStoreRuntime({ - messages: rawMessages, - convertMessage, - isRunning, - onNew, - onEdit, - onCancel, - }); - - return runtime; -} diff --git a/frontend/src/app/pages/AgentChat/runtime/useStandaloneComposerRuntime.ts b/frontend/src/app/pages/AgentChat/runtime/useStandaloneComposerRuntime.ts deleted file mode 100644 index 705c0e36..00000000 --- a/frontend/src/app/pages/AgentChat/runtime/useStandaloneComposerRuntime.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useCallback } from 'react'; -import { - useExternalStoreRuntime, - type ThreadMessageLike, - type AppendMessage, -} from '@assistant-ui/react'; -import type { MutableRefObject } from 'react'; -import type { ComposerExtras, DispatchableMessage } from './useOpenSwarmRuntime'; - -const EMPTY_MESSAGES: ThreadMessageLike[] = []; - -function extractText(message: AppendMessage): string { - for (const part of message.content) { - if (part.type === 'text') return part.text; - } - return ''; -} - -export function useStandaloneComposerRuntime( - composerExtrasRef: MutableRefObject, - dispatchMessage: (msg: DispatchableMessage) => void, -) { - const onNew = useCallback( - async (message: AppendMessage) => { - const text = extractText(message); - if (!text) return; - const extras = composerExtrasRef.current; - composerExtrasRef.current = {}; - dispatchMessage({ prompt: text, ...extras }); - }, - [composerExtrasRef, dispatchMessage], - ); - - return useExternalStoreRuntime({ - messages: EMPTY_MESSAGES, - isRunning: false, - onNew, - }); -} diff --git a/frontend/src/app/pages/AgentChat/toolCallUtils.ts b/frontend/src/app/pages/AgentChat/toolCallUtils.ts deleted file mode 100644 index 77e06b46..00000000 --- a/frontend/src/app/pages/AgentChat/toolCallUtils.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { AgentMessage } from '@/shared/state/agentsSlice'; - -export interface ToolCallBubbleProps { - call: AgentMessage; result?: AgentMessage | null; isPending?: boolean; - isStreaming?: boolean; mcpCompact?: boolean; sessionId?: string; -} - -interface McpToolInfo { isMcp: boolean; serverSlug: string; action: string; service: string; displayName: string; } -interface ParsedBashResult { type: 'bash'; stdout: string; stderr: string; exitCode: number | null; } -interface ParsedTextResult { type: 'text'; content: string; isError?: boolean; } -interface ParsedMcpResult { type: 'mcp'; service: string; action: string; data: Record; rawText: string; } -type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult; - - -interface InvokeAgentParsed { agentName: string; sessionId: string | null; cost: string | null; response: string; } - -let toolCallKeyframesInjected = false; -export function ensureToolCallKeyframes() { - if (toolCallKeyframesInjected) return; - toolCallKeyframesInjected = true; - const style = document.createElement('style'); - style.setAttribute('data-tool-call-keyframes', ''); - style.textContent = ` -@keyframes tool-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } -@keyframes border-glow { 0%, 100% { box-shadow: 0 0 0 0 rgba(var(--glow-rgb), 0); } 50% { box-shadow: 0 0 10px 2px rgba(var(--glow-rgb), 0.12); } } -@keyframes blink-cursor { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }`; - document.head.appendChild(style); -} -export function formatElapsed(ms: number): string { - if (ms >= 60000) return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`; - return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`; -} -export function getToolData(call: AgentMessage) { - const content = typeof call.content === 'object' ? call.content : {}; - return { toolName: content.tool || 'Unknown', input: content.input || {}, isDenied: content.approved === false, toolId: content.id }; -} -function isBashTool(name: string) { return name === 'Bash' || name === 'bash'; } - -function parseMcpToolName(rawName: string): McpToolInfo { - const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/); - if (!m) return { isMcp: false, serverSlug: '', action: '', service: '', displayName: rawName }; - const serverSlug = m[1], action = m[2]; - const display = action.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase()); - const lower = action.toLowerCase(); - let service = ''; - if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) service = 'gmail'; - else if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) service = 'calendar'; - else if (lower.includes('drive') || lower.includes('file')) service = 'drive'; - else if (lower.includes('sheet') || lower.includes('spreadsheet')) service = 'sheets'; - else if (lower.includes('doc') || lower.includes('paragraph')) service = 'docs'; - else if (lower.includes('contact')) service = 'contacts'; - return { isMcp: true, serverSlug, action, service, displayName: display }; -} - -export function parseToolResult(toolName: string, rawText: string): ParsedResult { - if (isBashTool(toolName)) { - try { - const p = JSON.parse(rawText); - if (typeof p === 'object' && p !== null && 'stdout' in p) { - const em = (p.stdout || '').match(/[Ee]xit code:\s*(\d+)/); - return { type: 'bash', stdout: p.stdout || '', stderr: p.stderr || '', exitCode: em ? parseInt(em[1], 10) : null }; - } - } catch {} - } - const mcp = parseMcpToolName(toolName); - if (mcp.isMcp) { - try { - let p = JSON.parse(rawText); - if (Array.isArray(p) && p.some((b: any) => b?.type === 'text' && typeof b?.text === 'string')) { - const tc = p.filter((b: any) => b?.type === 'text').map((b: any) => b.text).join('\n'); - try { p = JSON.parse(tc); } catch { return { type: 'mcp', service: mcp.service, action: mcp.action, data: {}, rawText: tc }; } - } - if (typeof p === 'object' && p !== null) return { type: 'mcp', service: mcp.service, action: mcp.action, data: p, rawText }; - } catch {} - return { type: 'mcp', service: mcp.service, action: mcp.action, data: {}, rawText }; - } - try { - const p = JSON.parse(rawText); - if (typeof p === 'object' && p !== null) { - if ('stdout' in p) return { type: 'text', content: p.stdout || '' }; - if ('content' in p && typeof p.content === 'string') return { type: 'text', content: p.content, isError: !!p.is_error }; - if ('result' in p && typeof p.result === 'string') return { type: 'text', content: p.result }; - if ('output' in p && typeof p.output === 'string') return { type: 'text', content: p.output }; - if (toolName.toLowerCase() === 'glob' && Array.isArray(p)) return { type: 'text', content: p.join('\n') }; - } - } catch {} - return { type: 'text', content: rawText }; -} - -interface GmailHeaderSource { - payload?: { headers?: Array<{ name: string; value: string }> }; - headers?: Record; - [key: string]: unknown; -} -function getGmailHeader(msg: GmailHeaderSource, name: string): string { - if (msg.payload?.headers && Array.isArray(msg.payload.headers)) { const h = msg.payload.headers.find((hdr) => (hdr.name || '').toLowerCase() === name.toLowerCase()); if (h) return h.value || ''; } - if (msg.headers && typeof msg.headers === 'object' && !Array.isArray(msg.headers)) return msg.headers[name] || msg.headers[name.toLowerCase()] || ''; - return ''; -} -export function getResultSummary(toolName: string, rawText: string): string { - const parsed = parseToolResult(toolName, rawText); - if (parsed.type === 'bash') { - const lc = parsed.stdout.split('\n').filter((l) => l.trim()).length; - if (parsed.exitCode !== null && parsed.exitCode !== 0) return `✗ exit ${parsed.exitCode}`; - if (parsed.stderr && !parsed.stdout) return '✗ stderr'; - return `✓ ${lc} line${lc !== 1 ? 's' : ''}`; - } - if (parsed.type === 'mcp') { - const d = parsed.data; - if (parsed.service === 'gmail') { const subj = d.subject || getGmailHeader(d, 'Subject'); if (subj) return subj; if (Array.isArray(d.messages)) return `${d.messages.length} email${d.messages.length !== 1 ? 's' : ''}`; if (d.id || d.messageId) return '✓ done'; } - if (parsed.service === 'calendar') { if (d.summary) return d.summary.slice(0, 40); if (Array.isArray(d.items)) return `${d.items.length} event${d.items.length !== 1 ? 's' : ''}`; } - if (parsed.service === 'drive') { if (d.name) return d.name; if (Array.isArray(d.files)) return `${d.files.length} file${d.files.length !== 1 ? 's' : ''}`; } - if (d.error || d.is_error) return '✗ error'; - return '✓ done'; - } - const text = parsed.content, lines = text.split('\n'), lc = lines.length, n = toolName.toLowerCase(); - try { - if (n === 'glob') { const fc = lines.filter((l) => l.trim()).length; return `${fc} file${fc !== 1 ? 's' : ''}`; } - if (n === 'grep' || n === 'ripgrep') { const mc = lines.filter((l) => l.trim()).length; return `${mc} match${mc !== 1 ? 'es' : ''}`; } - if (n === 'read') return `${lc} lines`; - if (n === 'write') return text.toLowerCase().includes('success') || text.toLowerCase().includes('written') ? '✓ written' : '✓ done'; - if (n === 'edit' || n === 'multiedit' || n === 'strreplace') return text.toLowerCase().includes('success') || text.toLowerCase().includes('applied') ? '✓ applied' : '✓ done'; - if (n === 'websearch') return 'results'; - if (n === 'webfetch') return `${lc} lines`; - if (parsed.isError) return '✗ error'; - } catch {} - return `${lc} line${lc !== 1 ? 's' : ''}`; -} - -export function parseInvokedSessionId(rawText: string): string | null { return rawText.match(/\(forked session:\s*([a-f0-9]+)\)/)?.[1] || null; } -export function parseCreateAgentResult(rawText: string): string { - if (!rawText) return ''; - try { - const p = JSON.parse(rawText); - if (typeof p === 'string') return p; - if (typeof p === 'object' && p !== null) { - if (p.text) return p.text; - if (p.content) return typeof p.content === 'string' ? p.content : JSON.stringify(p.content); - if (p.result) return typeof p.result === 'string' ? p.result : JSON.stringify(p.result); - } - } catch {} - return rawText; -} -export function parseInvokeAgentResult(rawText: string): InvokeAgentParsed | null { - const hm = rawText.match(/\*\*Invoked Agent Result\*\*(?:\s*—\s*(.+?))?\s*\(forked session:\s*([a-f0-9]+)\)/); - if (!hm) return null; - const agentName = hm[1]?.trim() || 'Agent', sessionId = hm[2]; - const costMatch = rawText.match(/\*Cost:\s*\$([0-9.]+)\*/); - const cost = costMatch ? costMatch[1] : null; - const bodyStart = rawText.indexOf('\n\n'); - let response = bodyStart >= 0 ? rawText.slice(bodyStart + 2).trim() : ''; - if (response.startsWith('*Cost:')) { - const ac = response.indexOf('\n'); - response = ac >= 0 ? response.slice(ac + 1).trim() : ''; - } - return { agentName, sessionId, cost, response }; -} diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/ApprovalRouter.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/ApprovalRouter.tsx deleted file mode 100644 index 9945502b..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/ApprovalRouter.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import type { ApprovalRequest } from '@/shared/state/agentsSlice'; -import { ToolQuestion } from './components/ToolQuestion/ToolQuestion'; -import { ToolApproval } from './components/ToolApproval/ToolApproval'; - -interface ApprovalRouterProps { - request: ApprovalRequest; - onApprove: (requestId: string, updatedInput?: Record) => void; - onDeny: (requestId: string, message?: string) => void; -} - -export const ApprovalRouter: React.FC = (props) => { - if (props.request.tool_name === 'AskUserQuestion') { - return ; - } - return ; -}; diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/BatchApprovalWrapper.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/BatchApprovalWrapper.tsx deleted file mode 100644 index 2fbd7090..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/BatchApprovalWrapper.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useMemo, useCallback } from 'react'; -import type { ApprovalRequest } from '@/shared/state/agentsSlice'; -import { ToolQuestion } from './components/ToolQuestion/ToolQuestion'; -import { ToolApproval } from './components/ToolApproval/ToolApproval'; -import { ApprovalRouter } from './ApprovalRouter'; - -interface BatchApprovalWrapperProps { - requests: ApprovalRequest[]; - onApprove: (requestId: string, updatedInput?: Record) => void; - onDeny: (requestId: string, message?: string) => void; -} - -export const BatchApprovalWrapper: React.FC = ({ - requests, onApprove, onDeny, -}) => { - const questionReqs = useMemo( - () => requests.filter((r) => r.tool_name === 'AskUserQuestion'), - [requests], - ); - const approvalReqs = useMemo( - () => requests.filter((r) => r.tool_name !== 'AskUserQuestion'), - [requests], - ); - - const handleApproveAll = useCallback(() => { - for (const req of approvalReqs) onApprove(req.id); - }, [approvalReqs, onApprove]); - - const handleDenyAll = useCallback(() => { - for (const req of approvalReqs) onDeny(req.id); - }, [approvalReqs, onDeny]); - - return ( -
      - {questionReqs.map((req) => ( - - ))} - - {approvalReqs.length > 1 && ( -
      -
      - - {approvalReqs.length} pending approvals - -
      - - -
      -
      - {approvalReqs.map((req) => ( - - ))} -
      - )} - - {approvalReqs.length === 1 && ( - - )} -
      - ); -}; \ No newline at end of file diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ActionButtons/ActionButtons.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ActionButtons/ActionButtons.tsx deleted file mode 100644 index 3cf2bdc1..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ActionButtons/ActionButtons.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import type { Action } from "../../utils/types"; -import { useActionButtons } from "./useActionButtons"; -import { cn } from "@/lib/utils"; -import { Button } from "@/app/pages/AgentChat/_shared/Button"; - -interface ActionButtonsProps { - actions: Action[]; - onAction: (actionId: string) => void | Promise; - onBeforeAction?: (actionId: string) => boolean | Promise; - confirmTimeout?: number; - align?: "left" | "center" | "right"; - className?: string; -} - -export function ActionButtons({ - actions, - onAction, - onBeforeAction, - confirmTimeout = 3000, - align = "right", - className, -}: ActionButtonsProps) { - const { actions: resolvedActions, runAction } = useActionButtons({ - actions, - onAction, - onBeforeAction, - confirmTimeout, - }); - - return ( -
      - {resolvedActions.map((action) => { - const label = action.currentLabel; - const variant = action.variant || "default"; - - return ( - - ); - })} -
      - ); -} diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ActionButtons/useActionButtons.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ActionButtons/useActionButtons.tsx deleted file mode 100644 index 96bab940..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ActionButtons/useActionButtons.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Action } from "@/app/pages/AgentChat/toolkit/approvalToolkit/utils/types"; - -type UseActionButtonsOptions = { - actions: Action[]; - onAction: (actionId: string) => void | Promise; - onBeforeAction?: (actionId: string) => boolean | Promise; - confirmTimeout?: number; -}; - -type UseActionButtonsResult = { - actions: Array< - Action & { - currentLabel: string; - isConfirming: boolean; - isExecuting: boolean; - isDisabled: boolean; - isLoading: boolean; - } - >; - runAction: (actionId: string) => Promise; - confirmingActionId: string | null; - executingActionId: string | null; -}; - -type ActionExecutionLock = { - tryAcquire: () => boolean; - release: () => void; -}; - -function createActionExecutionLock(): ActionExecutionLock { - let locked = false; - - return { - tryAcquire: () => { - if (locked) return false; - locked = true; - return true; - }, - release: () => { - locked = false; - }, - }; -} - -export function useActionButtons( - options: UseActionButtonsOptions, -): UseActionButtonsResult { - const { actions, onAction, onBeforeAction, confirmTimeout = 3000 } = options; - - const [confirmingActionId, setConfirmingActionId] = useState( - null, - ); - const [executingActionId, setExecutingActionId] = useState( - null, - ); - const executionLockRef = useRef( - createActionExecutionLock(), - ); - - useEffect(() => { - if (!confirmingActionId) return; - const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout); - return () => clearTimeout(id); - }, [confirmingActionId, confirmTimeout]); - - useEffect(() => { - if (!confirmingActionId) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - setConfirmingActionId(null); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [confirmingActionId]); - - const runAction = useCallback( - async (actionId: string) => { - const action = actions.find((a) => a.id === actionId); - if (!action) return; - - const isAnyActionExecuting = executingActionId !== null; - if (action.disabled || action.loading || isAnyActionExecuting) { - return; - } - - if (action.confirmLabel && confirmingActionId !== action.id) { - setConfirmingActionId(action.id); - return; - } - - if (!executionLockRef.current.tryAcquire()) { - return; - } - - if (onBeforeAction) { - const shouldProceed = await onBeforeAction(action.id); - if (!shouldProceed) { - setConfirmingActionId(null); - executionLockRef.current.release(); - return; - } - } - - try { - setExecutingActionId(action.id); - await onAction(action.id); - } finally { - executionLockRef.current.release(); - setExecutingActionId(null); - setConfirmingActionId(null); - } - }, - [actions, confirmingActionId, executingActionId, onAction, onBeforeAction], - ); - - const resolvedActions = useMemo( - () => - actions.map((action) => { - const isConfirming = confirmingActionId === action.id; - const isThisActionExecuting = executingActionId === action.id; - const isLoading = action.loading || isThisActionExecuting; - const isDisabled = - action.disabled || - (executingActionId !== null && !isThisActionExecuting); - const currentLabel = - isConfirming && action.confirmLabel - ? action.confirmLabel - : action.label; - - return { - ...action, - currentLabel, - isConfirming, - isExecuting: isThisActionExecuting, - isDisabled, - isLoading, - }; - }), - [actions, confirmingActionId, executingActionId], - ); - - return { - actions: resolvedActions, - runAction, - confirmingActionId, - executingActionId, - }; -} diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/ApprovalCard.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/ApprovalCard.tsx deleted file mode 100644 index c856f337..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/ApprovalCard.tsx +++ /dev/null @@ -1,212 +0,0 @@ -"use client"; - -import * as React from "react"; -import { cn, Separator } from "./_adapter"; -import type { ApprovalCardProps, ApprovalDecision } from "./schema"; -import { ActionButtons } from "../../ActionButtons/ActionButtons"; -import { type Action } from "../../../utils/types"; - -import { icons, Check, X } from "lucide-react"; - -type LucideIcon = React.ComponentType<{ className?: string }>; - -function getLucideIcon(name: string): LucideIcon | null { - const pascalName = name - .split("-") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); - - const Icon = icons[pascalName as keyof typeof icons]; - return Icon ?? null; -} - -interface ApprovalCardReceiptProps { - id: string; - title: string; - choice: ApprovalDecision; - actionLabel?: string; - className?: string; -} - -function ApprovalCardReceipt({ - id, - title, - choice, - actionLabel, - className, -}: ApprovalCardReceiptProps) { - const isApproved = choice === "approved"; - const displayLabel = actionLabel ?? (isApproved ? "Approved" : "Denied"); - - return ( -
      -
      - - {isApproved ? : } - -
      - {displayLabel} - {title} -
      -
      -
      - ); -} - -export function ApprovalCard({ - id, - title, - description, - icon, - metadata, - variant, - confirmLabel, - cancelLabel, - className, - choice, - onConfirm, - onCancel, -}: ApprovalCardProps) { - const resolvedVariant = variant ?? "default"; - const resolvedConfirmLabel = confirmLabel ?? "Approve"; - const resolvedCancelLabel = cancelLabel ?? "Deny"; - const Icon = icon ? getLucideIcon(icon) : null; - - const handleAction = React.useCallback( - async (actionId: string) => { - if (actionId === "confirm") { - await onConfirm?.(); - } else if (actionId === "cancel") { - await onCancel?.(); - } - }, - [onConfirm, onCancel], - ); - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - onCancel?.(); - } - }, - [onCancel], - ); - - const isDestructive = resolvedVariant === "destructive"; - - const actions: Action[] = [ - { - id: "cancel", - label: resolvedCancelLabel, - variant: "ghost", - }, - { - id: "confirm", - label: resolvedConfirmLabel, - variant: isDestructive ? "destructive" : "default", - }, - ]; - - const viewKey = choice ? `receipt-${choice}` : "interactive"; - - return ( -
      - {choice ? ( - - ) : ( -
      -
      -
      - {Icon && ( - - - - )} -
      -

      - {title} -

      - {description && ( -

      - {description} -

      - )} -
      -
      - - {metadata && metadata.length > 0 && ( - <> - -
      - {metadata.map((item, index) => ( -
      -
      - {item.key} -
      -
      {item.value}
      -
      - ))} -
      - - )} -
      -
      - -
      -
      - )} -
      - ); -} diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/README.md b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/README.md deleted file mode 100644 index 070bdded..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Approval Card - -Implementation for the "approval-card" Tool UI surface. - -## Files - -- public exports: components/tool-ui/approval-card/index.tsx -- serializable schema + parse helpers: components/tool-ui/approval-card/schema.ts - -## Companion assets - -- Docs page: app/docs/approval-card/content.mdx -- Preset payload: lib/presets/approval-card.ts - -## Quick check - -Run this after edits: - -pnpm test diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/_adapter.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/_adapter.tsx deleted file mode 100644 index 4453de82..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/_adapter.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { cn } from "@/lib/utils"; -export { Separator } from "../../separator"; diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/schema.ts b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/schema.ts deleted file mode 100644 index 4e5f3914..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ApprovalCard/schema.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from "zod"; -import { ToolUIIdSchema, ToolUIRoleSchema } from "@/app/pages/AgentChat/toolkit/utils/schema"; - -const MetadataItemSchema = z.object({ - key: z.string().min(1), - value: z.string(), -}); - -export const ApprovalDecisionSchema = z.enum(["approved", "denied"]); - -export type ApprovalDecision = z.infer; - -export const SerializableApprovalCardSchema = z.object({ - id: ToolUIIdSchema, - role: ToolUIRoleSchema.optional(), - - title: z.string().min(1), - description: z.string().optional(), - icon: z.string().optional(), - metadata: z.array(MetadataItemSchema).optional(), - - variant: z.enum(["default", "destructive"]).optional(), - - confirmLabel: z.string().optional(), - cancelLabel: z.string().optional(), - - choice: ApprovalDecisionSchema.optional(), -}); - -export type SerializableApprovalCard = z.infer< - typeof SerializableApprovalCardSchema ->; - -export interface ApprovalCardProps extends SerializableApprovalCard { - className?: string; - onConfirm?: () => void | Promise; - onCancel?: () => void | Promise; -} diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ToolApproval.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ToolApproval.tsx deleted file mode 100644 index fa1382b4..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolApproval/ToolApproval.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useMemo } from 'react'; -import { ApprovalCard } from './ApprovalCard/ApprovalCard'; -import type { ApprovalRequest } from '@/shared/state/agentsSlice'; -import { useMcpToolMeta } from '../../utils/useMcpToolMeta'; -import { - parseMcpToolName, -} from '../../utils'; -import { getMcpInputSummary } from '../../utils/getMcpInputSummary'; -// TODO: what is this even supposed to try and import/use ??? -import type { MetadataItem } from '@/components/tool-ui/ApprovalCard/schema'; - - - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - - -const TOOL_ICON_MAP: Record = { - Bash: 'terminal', - Read: 'file-text', - Write: 'file-pen', - Edit: 'file-pen', - Grep: 'search', - Glob: 'search', - AskUserQuestion: 'message-circle-question', -}; - -function getToolIconName(toolName: string): string { - return TOOL_ICON_MAP[toolName] ?? 'wrench'; -} - -function buildMetadata(toolInput: Record): MetadataItem[] { - return Object.entries(toolInput) - .filter(([, v]) => v != null) - .slice(0, 5) - .map(([key, value]) => ({ - key, - value: typeof value === 'string' - ? value.slice(0, 200) - : JSON.stringify(value).slice(0, 200), - })); -} - -const DANGEROUS_PATTERNS = /\b(rm\s|rmdir|del\s|delete|drop\s|truncate|format)\b/i; - -function isDangerous(toolName: string, toolInput: Record): boolean { - if (toolName === 'Bash') { - const cmd = toolInput.command || ''; - return DANGEROUS_PATTERNS.test(cmd); - } - return false; -} - - - -// --------------------------------------------------------------------------- -// Core -// --------------------------------------------------------------------------- - - - -interface ToolApprovalProps { - request: ApprovalRequest; - onApprove: (requestId: string, updatedInput?: Record) => void; - onDeny: (requestId: string, message?: string) => void; -} - -export const ToolApproval: React.FC = ({ request, onApprove, onDeny }) => { - const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]); - const meta = useMcpToolMeta(parsed); - const summary = parsed.isMcp - ? getMcpInputSummary(parsed.actionName, request.tool_input) - : ''; - - return ( - onApprove(request.id)} - onCancel={() => onDeny(request.id)} - /> - ); -}; \ No newline at end of file diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/OptionList.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/OptionList.tsx deleted file mode 100644 index 7687299e..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/OptionList.tsx +++ /dev/null @@ -1,623 +0,0 @@ -"use client"; - -import { - useMemo, - useState, - useCallback, - useRef, - Fragment, -} from "react"; -import type { KeyboardEvent } from "react"; -import type { - OptionListProps, - OptionListSelection, - OptionListOption, -} from "./schema"; -import { - normalizeSelectionForOptions, - parseSelectionToIdSet, -} from "./selection"; -import { ActionButtons } from "../../ActionButtons/ActionButtons"; -import { normalizeActionsConfig } from "./utils/actions-config"; -import type { Action } from "../../../utils/types"; - -import { cn, Button, Separator } from "./_adapter"; -import { Check } from "lucide-react"; - -function convertIdSetToSelection( - selected: Set, - mode: "multi" | "single", -): OptionListSelection { - if (mode === "single") { - const [first] = selected; - return first ?? null; - } - return Array.from(selected); -} - -function areSetsEqual(a: Set, b: Set) { - if (a.size !== b.size) return false; - for (const val of a) { - if (!b.has(val)) return false; - } - return true; -} - -interface SelectionIndicatorProps { - mode: "multi" | "single"; - isSelected: boolean; - disabled?: boolean; -} - -function SelectionIndicator({ - mode, - isSelected, - disabled, -}: SelectionIndicatorProps) { - const shape = mode === "single" ? "rounded-full" : "rounded"; - - return ( -
      - {mode === "multi" && isSelected && } - {mode === "single" && isSelected && ( - - )} -
      - ); -} - -interface OptionItemProps { - option: OptionListOption; - isSelected: boolean; - isDisabled: boolean; - selectionMode: "multi" | "single"; - isFirst: boolean; - isLast: boolean; - onToggle: () => void; - tabIndex?: number; - onFocus?: () => void; - buttonRef?: (el: HTMLButtonElement | null) => void; -} - -function OptionItem({ - option, - isSelected, - isDisabled, - selectionMode, - isFirst, - isLast, - onToggle, - tabIndex, - onFocus, - buttonRef, -}: OptionItemProps) { - const hasAdjacentOptions = !isFirst && !isLast; - - return ( - - ); -} - -interface OptionListConfirmationProps { - id: string; - options: OptionListOption[]; - selectedIds: Set; - className?: string; -} - -function OptionListConfirmation({ - id, - options, - selectedIds, - className, -}: OptionListConfirmationProps) { - const confirmedOptions = options.filter((opt) => selectedIds.has(opt.id)); - - return ( -
      -
      - {confirmedOptions.map((option, index) => ( - - {index > 0 && ( - - )} -
      - - - - {option.icon && ( - {option.icon} - )} -
      - - {option.label} - - {option.description && ( - - {option.description} - - )} -
      -
      -
      - ))} -
      -
      - ); -} - -export function OptionList({ - id, - options, - selectionMode = "multi", - minSelections = 1, - maxSelections, - value, - defaultValue, - choice, - onChange, - actions, - onAction, - onBeforeAction, - className, -}: OptionListProps) { - if (import.meta.env.DEV) { - if (value !== undefined && defaultValue !== undefined) { - console.warn( - "[OptionList] Both `value` (controlled) and `defaultValue` (uncontrolled) were provided. `defaultValue` is ignored when `value` is set.", - ); - } - if (value !== undefined && !onChange) { - console.warn( - "[OptionList] `value` was provided without `onChange`. This makes OptionList controlled; selection will not update unless the parent updates `value`.", - ); - } - } - - const effectiveMaxSelections = selectionMode === "single" ? 1 : maxSelections; - const optionIds = useMemo( - () => new Set(options.map((option) => option.id)), - [options], - ); - - const [uncontrolledSelected, setUncontrolledSelected] = useState>( - () => - normalizeSelectionForOptions( - parseSelectionToIdSet( - defaultValue, - selectionMode, - effectiveMaxSelections, - ), - optionIds, - ), - ); - - const selectedIds = useMemo(() => { - const parsed = - value !== undefined - ? parseSelectionToIdSet(value, selectionMode, effectiveMaxSelections) - : uncontrolledSelected; - return normalizeSelectionForOptions(parsed, optionIds); - }, [ - value, - uncontrolledSelected, - selectionMode, - effectiveMaxSelections, - optionIds, - ]); - - const selectedCount = selectedIds.size; - - const optionStates = useMemo(() => { - return options.map((option) => { - const isSelected = selectedIds.has(option.id); - const isSelectionLocked = - selectionMode === "multi" && - effectiveMaxSelections !== undefined && - selectedCount >= effectiveMaxSelections && - !isSelected; - const isDisabled = option.disabled || isSelectionLocked; - - return { option, isSelected, isDisabled }; - }); - }, [ - options, - selectedIds, - selectionMode, - effectiveMaxSelections, - selectedCount, - ]); - - const optionRefs = useRef>([]); - const [rawActiveIndex, setActiveIndex] = useState(() => { - const firstSelected = optionStates.findIndex( - (s) => s.isSelected && !s.isDisabled, - ); - if (firstSelected >= 0) return firstSelected; - const firstEnabled = optionStates.findIndex((s) => !s.isDisabled); - return firstEnabled >= 0 ? firstEnabled : 0; - }); - - const activeIndex = useMemo(() => { - if ( - optionStates.length === 0 || - (rawActiveIndex >= 0 && - rawActiveIndex < optionStates.length && - !optionStates[rawActiveIndex].isDisabled) - ) { - return rawActiveIndex; - } - const firstEnabled = optionStates.findIndex((s) => !s.isDisabled); - return firstEnabled >= 0 ? firstEnabled : 0; - }, [rawActiveIndex, optionStates]); - - const updateSelection = useCallback( - (next: Set) => { - const normalizedNext = normalizeSelectionForOptions( - parseSelectionToIdSet( - Array.from(next), - selectionMode, - effectiveMaxSelections, - ), - optionIds, - ); - - if (value === undefined) { - if (!areSetsEqual(uncontrolledSelected, normalizedNext)) { - setUncontrolledSelected(normalizedNext); - } - } - - onChange?.(convertIdSetToSelection(normalizedNext, selectionMode)); - }, - [ - effectiveMaxSelections, - selectionMode, - uncontrolledSelected, - value, - onChange, - optionIds, - ], - ); - - const toggleSelection = useCallback( - (optionId: string) => { - const next = new Set(selectedIds); - const isSelected = next.has(optionId); - - if (selectionMode === "single") { - if (isSelected) { - next.delete(optionId); - } else { - next.clear(); - next.add(optionId); - } - } else { - if (isSelected) { - next.delete(optionId); - } else { - if (effectiveMaxSelections && next.size >= effectiveMaxSelections) { - return; - } - next.add(optionId); - } - } - - updateSelection(next); - }, - [effectiveMaxSelections, selectedIds, selectionMode, updateSelection], - ); - - const toSelectionState = useCallback( - (selected: Set): OptionListSelection => - convertIdSetToSelection(selected, selectionMode), - [selectionMode], - ); - - const handleCancel = useCallback((): OptionListSelection => { - const empty = new Set(); - updateSelection(empty); - return toSelectionState(empty); - }, [toSelectionState, updateSelection]); - - const customActions = useMemo( - () => normalizeActionsConfig(actions), - [actions], - ); - - const handleFooterAction = useCallback( - async (actionId: string) => { - let nextState = toSelectionState(selectedIds); - - if (actionId === "cancel") { - nextState = handleCancel(); - } - - await onAction?.(actionId, nextState); - }, - [handleCancel, onAction, selectedIds, toSelectionState], - ); - - const normalizedFooterActions = useMemo(() => { - if (customActions) return customActions; - return { - items: [ - { id: "cancel", label: "Clear", variant: "ghost" as const }, - { id: "confirm", label: "Confirm", variant: "default" as const }, - ], - align: "right" as const, - } satisfies ReturnType; - }, [customActions]); - - const isConfirmDisabled = - selectedCount < minSelections || selectedCount === 0; - const hasNothingToClear = selectedCount === 0; - - const focusOptionAt = useCallback((index: number) => { - const el = optionRefs.current[index]; - if (el) el.focus(); - setActiveIndex(index); - }, []); - - const findFirstEnabledIndex = useCallback(() => { - const idx = optionStates.findIndex((s) => !s.isDisabled); - return idx >= 0 ? idx : 0; - }, [optionStates]); - - const findLastEnabledIndex = useCallback(() => { - for (let i = optionStates.length - 1; i >= 0; i--) { - if (!optionStates[i].isDisabled) return i; - } - return 0; - }, [optionStates]); - - const findNextEnabledIndex = useCallback( - (start: number, direction: 1 | -1) => { - const len = optionStates.length; - if (len === 0) return 0; - for (let step = 1; step <= len; step++) { - const idx = (start + direction * step + len) % len; - if (!optionStates[idx].isDisabled) return idx; - } - return start; - }, - [optionStates], - ); - - const handleListboxKeyDown = useCallback( - (e: KeyboardEvent) => { - if (optionStates.length === 0) return; - - const key = e.key; - - if (key === "ArrowDown") { - e.preventDefault(); - e.stopPropagation(); - focusOptionAt(findNextEnabledIndex(activeIndex, 1)); - return; - } - - if (key === "ArrowUp") { - e.preventDefault(); - e.stopPropagation(); - focusOptionAt(findNextEnabledIndex(activeIndex, -1)); - return; - } - - if (key === "Home") { - e.preventDefault(); - e.stopPropagation(); - focusOptionAt(findFirstEnabledIndex()); - return; - } - - if (key === "End") { - e.preventDefault(); - e.stopPropagation(); - focusOptionAt(findLastEnabledIndex()); - return; - } - - if (key === "Enter" || key === " ") { - e.preventDefault(); - e.stopPropagation(); - const current = optionStates[activeIndex]; - if (!current || current.isDisabled) return; - toggleSelection(current.option.id); - return; - } - - if (key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - if (!hasNothingToClear) { - handleCancel(); - } - } - }, - [ - activeIndex, - findFirstEnabledIndex, - findLastEnabledIndex, - findNextEnabledIndex, - focusOptionAt, - handleCancel, - hasNothingToClear, - optionStates, - toggleSelection, - ], - ); - - const actionsWithDisabledState = useMemo((): Action[] => { - return normalizedFooterActions.items.map((action) => { - const isDisabledByValidation = - (action.id === "confirm" && isConfirmDisabled) || - (action.id === "cancel" && hasNothingToClear); - return { - ...action, - disabled: action.disabled || isDisabledByValidation, - label: - action.id === "confirm" && - selectionMode === "multi" && - selectedCount > 0 - ? `${action.label} (${selectedCount})` - : action.label, - }; - }); - }, [ - normalizedFooterActions.items, - isConfirmDisabled, - hasNothingToClear, - selectionMode, - selectedCount, - ]); - - const isReceipt = choice !== undefined && choice !== null; - const viewKey = isReceipt ? `receipt-${String(choice)}` : "interactive"; - - return ( -
      - {isReceipt ? ( - - ) : ( -
      -
      - {optionStates.map(({ option, isSelected, isDisabled }, index) => { - return ( - - {index > 0 && ( - - )} - setActiveIndex(index)} - buttonRef={(el) => { - optionRefs.current[index] = el; - }} - onToggle={() => toggleSelection(option.id)} - /> - - ); - })} -
      - -
      - - onBeforeAction(actionId, toSelectionState(selectedIds)) - : undefined - } - /> -
      -
      - )} -
      - ); -} diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/README.md b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/README.md deleted file mode 100644 index 6d8d4574..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Option List - -Implementation for the "option-list" Tool UI surface. - -## Files - -- public exports: components/tool-ui/option-list/index.tsx -- serializable schema + parse helpers: components/tool-ui/option-list/schema.ts - -## Companion assets - -- Docs page: app/docs/option-list/content.mdx -- Preset payload: lib/presets/option-list.ts - -## Quick check - -Run this after edits: - -pnpm test diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/_adapter.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/_adapter.tsx deleted file mode 100644 index db2afbbc..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/_adapter.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export { cn } from "@/lib/utils"; -export { Button } from "@/app/pages/AgentChat/_shared/Button"; -export { Separator } from "../../separator"; diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/schema.ts b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/schema.ts deleted file mode 100644 index c039b8ab..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/schema.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { z } from "zod"; -import type { ReactNode } from "react"; -import type { ActionsProp } from "./utils/actions-config"; -import type { EmbeddedActionsProps } from "./utils/embedded-actions"; -import { - ToolUIIdSchema, - ToolUIReceiptSchema, - ToolUIRoleSchema, -} from "@/app/pages/AgentChat/toolkit/utils/schema"; -import type { ActionSchema, SerializableActionsConfigSchema } from "../../../utils/types"; - -export const OptionListOptionSchema = z.object({ - id: z.string().min(1), - label: z.string().min(1), - description: z.string().optional(), - icon: z.custom().optional(), - disabled: z.boolean().optional(), -}); - -export type OptionListSelection = string[] | string | null; - -const OptionListSelectionSchema = z - .union([z.array(z.string()), z.string(), z.null()]) - .optional(); - -type OptionListSchemaInvariantInput = { - options: Array<{ id: string }>; - minSelections?: number; - maxSelections?: number; - value?: OptionListSelection; - defaultValue?: OptionListSelection; - choice?: OptionListSelection; -}; - -function selectionToIds(selection: OptionListSelection | undefined): string[] { - if (selection == null) return []; - if (typeof selection === "string") return [selection]; - return Array.isArray(selection) ? selection : []; -} - -function validateOptionListInvariants( - data: OptionListSchemaInvariantInput, - ctx: z.RefinementCtx, -) { - if ( - data.minSelections !== undefined && - data.maxSelections !== undefined && - data.minSelections > data.maxSelections - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["minSelections"], - message: "`minSelections` cannot be greater than `maxSelections`.", - }); - } - - const optionIds = new Set(); - for (let index = 0; index < data.options.length; index++) { - const optionId = data.options[index]?.id; - if (!optionId) continue; - - if (optionIds.has(optionId)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["options", index, "id"], - message: `Duplicate option id "${optionId}" is not allowed.`, - }); - } else { - optionIds.add(optionId); - } - } - - const selectionFields: Array< - ["value" | "defaultValue" | "choice", OptionListSelection | undefined] - > = [ - ["value", data.value], - ["defaultValue", data.defaultValue], - ["choice", data.choice], - ]; - - for (const [fieldName, selection] of selectionFields) { - if (selection == null) continue; - - const ids = selectionToIds(selection); - ids.forEach((selectionId, index) => { - if (!optionIds.has(selectionId)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: - typeof selection === "string" ? [fieldName] : [fieldName, index], - message: `Selection id "${selectionId}" must exist in options.`, - }); - } - }); - } -} - -const OptionListPropsSchemaBase = z.object({ - /** - * Unique identifier for this tool UI instance in the conversation. - * - * Used for: - * - Assistant referencing ("the options above") - * - Receipt generation (linking selections to their source) - * - Narration context - * - * Should be stable across re-renders, meaningful, and unique within the conversation. - * - * @example "option-list-deploy-target", "format-selection" - */ - id: ToolUIIdSchema, - role: ToolUIRoleSchema.optional(), - receipt: ToolUIReceiptSchema.optional(), - options: z.array(OptionListOptionSchema).min(1), - selectionMode: z.enum(["multi", "single"]).optional(), - /** - * Controlled selection value (advanced / runtime only). - * - * For Tool UI tool payloads, prefer `defaultValue` (initial selection) and - * `choice` (receipt state). Controlled `value` is intentionally excluded - * from `SerializableOptionListSchema` to avoid accidental "controlled but - * non-interactive" states when an LLM includes `value` in args. - */ - value: OptionListSelectionSchema, - defaultValue: OptionListSelectionSchema, - /** - * When set, renders the component in receipt state showing the user's choice. - * - * In receipt state: - * - Only the chosen option(s) are shown - * - Actions are hidden - * - The component is read-only - * - * Use this with assistant-ui's `addResult` to show the outcome of a decision. - * - * @example - * ```tsx - * // In a toolkit render function: - * if (result) { - * return ; - * } - * ``` - */ - choice: OptionListSelectionSchema, - actions: z - .union([z.array(ActionSchema), SerializableActionsConfigSchema]) - .optional(), - minSelections: z.number().min(0).optional(), - maxSelections: z.number().min(1).optional(), -}); - -export const OptionListPropsSchema = OptionListPropsSchemaBase.superRefine( - validateOptionListInvariants, -); - -export type OptionListOption = z.infer; - -export type OptionListProps = Omit< - z.infer, - "value" | "defaultValue" | "choice" | "actions" -> & { - /** @see OptionListPropsSchema.id */ - id: string; - value?: OptionListSelection; - defaultValue?: OptionListSelection; - /** @see OptionListPropsSchema.choice */ - choice?: OptionListSelection; - onChange?: (value: OptionListSelection) => void; - actions?: ActionsProp; - onAction?: EmbeddedActionsProps["onAction"]; - onBeforeAction?: EmbeddedActionsProps["onBeforeAction"]; - className?: string; -}; \ No newline at end of file diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/selection.ts b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/selection.ts deleted file mode 100644 index 48ff8ece..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/selection.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { OptionListSelection } from "./schema"; - -export function parseSelectionToIdSet( - value: OptionListSelection | undefined, - mode: "multi" | "single", - maxSelections?: number, -): Set { - if (mode === "single") { - const single = - typeof value === "string" - ? value - : Array.isArray(value) - ? value[0] - : null; - return single ? new Set([single]) : new Set(); - } - - const arr = - typeof value === "string" ? [value] : Array.isArray(value) ? value : []; - - return new Set(maxSelections ? arr.slice(0, maxSelections) : arr); -} - -export function normalizeSelectionForOptions( - selection: Set, - optionIds: Set, -): Set { - const normalized = new Set(); - for (const id of selection) { - if (optionIds.has(id)) { - normalized.add(id); - } - } - return normalized; -} diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/utils/actions-config.ts b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/utils/actions-config.ts deleted file mode 100644 index ad211826..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/utils/actions-config.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Action, ActionsConfig } from "@/app/pages/AgentChat/toolkit/approvalToolkit/utils/types"; - -export type ActionsProp = ActionsConfig | Action[]; - -const NEGATORY_ACTION_IDS = new Set([ - "cancel", - "dismiss", - "skip", - "no", - "reset", - "close", - "decline", - "reject", - "back", - "later", - "not-now", - "maybe-later", -]); - -function inferVariant(action: Action): Action { - if (action.variant) return action; - if (NEGATORY_ACTION_IDS.has(action.id)) { - return { ...action, variant: "ghost" }; - } - return action; -} - -export function normalizeActionsConfig( - actions?: ActionsProp, -): ActionsConfig | null { - if (!actions) return null; - - const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []); - - if (rawItems.length === 0) { - return null; - } - - const items = rawItems.map(inferVariant); - - return Array.isArray(actions) - ? { items } - : { - items, - align: actions.align, - confirmTimeout: actions.confirmTimeout, - }; -} diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/utils/embedded-actions.ts b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/utils/embedded-actions.ts deleted file mode 100644 index f0d318ac..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/OptionList/utils/embedded-actions.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ActionsProp } from "./actions-config"; - -export type EmbeddedActionHandler = ( - actionId: string, - state: TState, -) => void | Promise; - -export type EmbeddedBeforeActionHandler = ( - actionId: string, - state: TState, -) => boolean | Promise; - -export interface EmbeddedActionsProps { - actions?: ActionsProp; - onAction?: EmbeddedActionHandler; - onBeforeAction?: EmbeddedBeforeActionHandler; -} diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/QuestionFlow.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/QuestionFlow.tsx deleted file mode 100644 index 216a0c0c..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/QuestionFlow.tsx +++ /dev/null @@ -1,793 +0,0 @@ -"use client"; - -import { - useMemo, - useState, - useCallback, - useRef, - useEffect, - Fragment, -} from "react"; -import type { KeyboardEvent } from "react"; -import type { - QuestionFlowProps, - QuestionFlowProgressiveProps, - QuestionFlowUpfrontProps, - QuestionFlowReceiptProps, - QuestionFlowOption, -} from "./schema"; -import { cn, Button, Separator } from "./_adapter"; -import { Check, ChevronLeft } from "lucide-react"; - -interface SelectionIndicatorProps { - mode: "single" | "multi"; - isSelected: boolean; - disabled?: boolean; -} - -interface ProgressBarProps { - current: number; - total: number; -} - -function ProgressBar({ current, total }: ProgressBarProps) { - return ( -
      - {Array.from({ length: total }).map((_, i) => ( -
      -
      -
      - ))} -
      - ); -} - -function SelectionIndicator({ - mode, - isSelected, - disabled, -}: SelectionIndicatorProps) { - const shape = mode === "single" ? "rounded-full" : "rounded"; - - return ( -
      - {mode === "multi" && isSelected && ( - - )} - {mode === "single" && isSelected && ( - - )} -
      - ); -} - -interface OptionItemProps { - option: QuestionFlowOption; - isSelected: boolean; - isDisabled: boolean; - selectionMode: "single" | "multi"; - isFirst: boolean; - isLast: boolean; - onToggle: () => void; - tabIndex?: number; - onFocus?: () => void; - buttonRef?: (el: HTMLButtonElement | null) => void; -} - -function OptionItem({ - option, - isSelected, - isDisabled, - selectionMode, - isFirst, - isLast, - onToggle, - tabIndex, - onFocus, - buttonRef, -}: OptionItemProps) { - const hasAdjacentOptions = !isFirst && !isLast; - - return ( - - ); -} - -function QuestionFlowReceipt({ - id, - choice, - className, -}: QuestionFlowReceiptProps) { - return ( -
      -
      -
      - {choice.title} - - - Complete - -
      -
      - {choice.summary.map((item, index) => ( - - {index > 0 && } -
      - {item.label} - {item.value} -
      -
      - ))} -
      -
      -
      - ); -} - -interface StepBodyData { - stepKey: string; - title: string; - description?: string; - options: QuestionFlowOption[]; - selectionMode: "single" | "multi"; - selectedIds: Set; -} - -function getQuestionFlowStepIds(id: string, stepKey: string) { - const safeId = encodeURIComponent(id).replace(/%/g, "_"); - const safeStepKey = encodeURIComponent(stepKey).replace(/%/g, "_"); - return { - titleId: `${safeId}-${safeStepKey}-title`, - descriptionId: `${safeId}-${safeStepKey}-description`, - }; -} - -interface StepContentProps { - step: number; - totalSteps?: number; - title: string; - description?: string; - options: QuestionFlowOption[]; - selectionMode: "single" | "multi"; - selectedIds: Set; - onToggle: (optionId: string) => void; - onBack?: () => void; - onNext: () => void; - showBack: boolean; - isLastStep: boolean; - id: string; - className?: string; - stepKey?: string; - exitingStepData?: StepBodyData | null; - transitionDirection?: "forward" | "backward"; -} - -function StepBodyContent({ - stepKey, - title, - description, - options, - selectionMode, - selectedIds, - onToggle, - id, - isExiting, - transitionDirection, -}: { - stepKey: string; - title: string; - description?: string; - options: QuestionFlowOption[]; - selectionMode: "single" | "multi"; - selectedIds: Set; - onToggle?: (optionId: string) => void; - id: string; - isExiting?: boolean; - transitionDirection?: "forward" | "backward"; -}) { - const optionRefs = useRef>([]); - const { titleId, descriptionId } = getQuestionFlowStepIds(id, stepKey); - - const optionStates = useMemo(() => { - return options.map((option) => { - const isSelected = selectedIds.has(option.id); - const isDisabled = option.disabled ?? false; - return { option, isSelected, isDisabled }; - }); - }, [options, selectedIds]); - - const [activeIndex, setActiveIndex] = useState(() => { - const firstSelected = optionStates.findIndex( - (s) => s.isSelected && !s.isDisabled, - ); - if (firstSelected >= 0) return firstSelected; - const firstEnabled = optionStates.findIndex((s) => !s.isDisabled); - return firstEnabled >= 0 ? firstEnabled : 0; - }); - - const focusOptionAt = useCallback((index: number) => { - const el = optionRefs.current[index]; - if (el) el.focus(); - setActiveIndex(index); - }, []); - - const findNextEnabledIndex = useCallback( - (start: number, direction: 1 | -1) => { - const len = optionStates.length; - if (len === 0) return 0; - for (let s = 1; s <= len; s++) { - const idx = (start + direction * s + len) % len; - if (!optionStates[idx].isDisabled) return idx; - } - return start; - }, - [optionStates], - ); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (optionStates.length === 0 || isExiting) return; - - const key = e.key; - - if (key === "ArrowDown") { - e.preventDefault(); - focusOptionAt(findNextEnabledIndex(activeIndex, 1)); - return; - } - - if (key === "ArrowUp") { - e.preventDefault(); - focusOptionAt(findNextEnabledIndex(activeIndex, -1)); - return; - } - - if (key === "Home") { - e.preventDefault(); - const first = optionStates.findIndex((s) => !s.isDisabled); - focusOptionAt(first >= 0 ? first : 0); - return; - } - - if (key === "End") { - e.preventDefault(); - for (let i = optionStates.length - 1; i >= 0; i--) { - if (!optionStates[i].isDisabled) { - focusOptionAt(i); - return; - } - } - return; - } - - if (key === "Enter" || key === " ") { - e.preventDefault(); - const current = optionStates[activeIndex]; - if (!current || current.isDisabled) return; - onToggle?.(current.option.id); - return; - } - }, - [ - activeIndex, - findNextEnabledIndex, - focusOptionAt, - isExiting, - onToggle, - optionStates, - ], - ); - - const isTransitioning = transitionDirection !== undefined; - - const enterClass = - transitionDirection === "forward" - ? "motion-safe:slide-in-from-right-4" - : "motion-safe:slide-in-from-left-4"; - - const exitClass = - transitionDirection === "forward" - ? "motion-safe:slide-out-to-left-4" - : "motion-safe:slide-out-to-right-4"; - - return ( -
      -
      -

      - {title} -

      - {description && ( -

      - {description} -

      - )} -
      - -
      - {optionStates.map(({ option, isSelected, isDisabled }, index) => ( - - {index > 0 && ( - - )} - !isExiting && setActiveIndex(index)} - buttonRef={(el) => { - optionRefs.current[index] = el; - }} - onToggle={() => !isExiting && onToggle?.(option.id)} - /> - - ))} -
      -
      - ); -} - -function StepContent({ - step, - totalSteps, - title, - description, - options, - selectionMode, - selectedIds, - onToggle, - onBack, - onNext, - showBack, - isLastStep, - id, - className, - stepKey, - exitingStepData, - transitionDirection = "forward", -}: StepContentProps) { - const isTransitioning = - exitingStepData !== null && exitingStepData !== undefined; - const canProceed = selectedIds.size > 0; - const resolvedStepKey = stepKey ?? "current"; - const { titleId, descriptionId } = getQuestionFlowStepIds( - id, - resolvedStepKey, - ); - - const stepLabel = totalSteps - ? `Step ${step} of ${totalSteps}` - : `Step ${step}`; - - return ( -
      -
      -
      -
      - - {stepLabel} - - {totalSteps && } -
      -
      - -
      - {exitingStepData && ( - - )} - -
      - -
      - {showBack ? ( - - ) : ( -
      - )} - -
      -
      -
      - ); -} - -function QuestionFlowProgressive({ - id, - step, - title, - description, - options, - selectionMode = "single", - defaultValue, - onSelect, - onBack, - className, -}: QuestionFlowProgressiveProps) { - const [selectedIds, setSelectedIds] = useState>( - () => new Set(defaultValue ?? []), - ); - - const handleToggle = useCallback( - (optionId: string) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (selectionMode === "single") { - if (next.has(optionId)) { - next.delete(optionId); - } else { - next.clear(); - next.add(optionId); - } - } else { - if (next.has(optionId)) { - next.delete(optionId); - } else { - next.add(optionId); - } - } - return next; - }); - }, - [selectionMode], - ); - - const handleNext = useCallback(() => { - if (selectedIds.size === 0) return; - const selection = Array.from(selectedIds); - onSelect?.(selection); - }, [onSelect, selectedIds]); - - return ( - 1 && onBack !== undefined} - isLastStep={false} - className={className} - /> - ); -} - -function QuestionFlowUpfront({ - id, - steps, - onStepChange, - onComplete, - className, -}: QuestionFlowUpfrontProps) { - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [answers, setAnswers] = useState>({}); - const [exitingStepData, setExitingStepData] = useState( - null, - ); - const [transitionDirection, setTransitionDirection] = useState< - "forward" | "backward" - >("forward"); - - const currentStep = steps[currentStepIndex]; - const isLastStep = currentStepIndex === steps.length - 1; - const totalSteps = steps.length; - - useEffect(() => { - if (exitingStepData) { - const timer = setTimeout(() => setExitingStepData(null), 250); - return () => clearTimeout(timer); - } - }, [exitingStepData]); - - const currentSelection = useMemo(() => { - const answer = answers[currentStep.id]; - return new Set(answer ?? []); - }, [answers, currentStep.id]); - - const handleToggle = useCallback( - (optionId: string) => { - const mode = currentStep.selectionMode ?? "single"; - setAnswers((prev) => { - const current = prev[currentStep.id] ?? []; - let next: string[]; - - if (mode === "single") { - next = current.includes(optionId) ? [] : [optionId]; - } else { - next = current.includes(optionId) - ? current.filter((id) => id !== optionId) - : [...current, optionId]; - } - - return { ...prev, [currentStep.id]: next }; - }); - }, - [currentStep.id, currentStep.selectionMode], - ); - - const handleBack = useCallback(() => { - if (currentStepIndex > 0) { - const currentStepData = steps[currentStepIndex]; - const stepOptions: QuestionFlowOption[] = currentStepData.options.map( - (opt) => ({ - ...opt, - icon: undefined, - }), - ); - - setExitingStepData({ - stepKey: currentStepData.id, - title: currentStepData.title, - description: currentStepData.description, - options: stepOptions, - selectionMode: currentStepData.selectionMode ?? "single", - selectedIds: new Set(answers[currentStepData.id] ?? []), - }); - setTransitionDirection("backward"); - const prevIndex = currentStepIndex - 1; - setCurrentStepIndex(prevIndex); - onStepChange?.(steps[prevIndex].id); - } - }, [answers, currentStepIndex, onStepChange, steps]); - - const handleNext = useCallback(() => { - if (currentSelection.size === 0) return; - - if (isLastStep) { - onComplete?.(answers); - } else { - const currentStepData = steps[currentStepIndex]; - const stepOptions: QuestionFlowOption[] = currentStepData.options.map( - (opt) => ({ - ...opt, - icon: undefined, - }), - ); - - setExitingStepData({ - stepKey: currentStepData.id, - title: currentStepData.title, - description: currentStepData.description, - options: stepOptions, - selectionMode: currentStepData.selectionMode ?? "single", - selectedIds: new Set(answers[currentStepData.id] ?? []), - }); - setTransitionDirection("forward"); - const nextIndex = currentStepIndex + 1; - setCurrentStepIndex(nextIndex); - onStepChange?.(steps[nextIndex].id); - } - }, [ - answers, - currentSelection.size, - currentStepIndex, - isLastStep, - onComplete, - onStepChange, - steps, - ]); - - const stepOptions: QuestionFlowOption[] = currentStep.options.map((opt) => ({ - ...opt, - icon: undefined, - })); - - return ( - 0} - isLastStep={isLastStep} - className={className} - stepKey={currentStep.id} - exitingStepData={exitingStepData} - transitionDirection={transitionDirection} - /> - ); -} - -export function QuestionFlow(props: QuestionFlowProps) { - if ("choice" in props && props.choice !== undefined) { - return ; - } - - if ("steps" in props && props.steps !== undefined) { - return ; - } - - return ( - - ); -} diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/README.md b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/README.md deleted file mode 100644 index 13b5e551..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Question Flow - -Implementation for the "question-flow" Tool UI surface. - -## Files - -- public exports: components/tool-ui/question-flow/index.tsx -- serializable schema + parse helpers: components/tool-ui/question-flow/schema.ts - -## Companion assets - -- Docs page: app/docs/question-flow/content.mdx -- Preset payload: lib/presets/question-flow.ts - -## Quick check - -Run this after edits: - -pnpm test diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/_adapter.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/_adapter.tsx deleted file mode 100644 index db2afbbc..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/_adapter.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export { cn } from "@/lib/utils"; -export { Button } from "@/app/pages/AgentChat/_shared/Button"; -export { Separator } from "../../separator"; diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/schema.ts b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/schema.ts deleted file mode 100644 index 6d752e5d..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/QuestionFlow/schema.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { z } from "zod"; -import type { ReactNode } from "react"; -import { ToolUIIdSchema, ToolUIRoleSchema } from "@/app/pages/AgentChat/toolkit/utils/schema"; - -export const QuestionFlowOptionSchema = z.object({ - id: z.string().min(1), - label: z.string().min(1), - description: z.string().optional(), - icon: z.custom().optional(), - disabled: z.boolean().optional(), -}); - -export type QuestionFlowOption = z.infer; - -const QuestionFlowStepDefinitionSchema = z.object({ - id: z.string().min(1), - title: z.string().min(1), - description: z.string().optional(), - options: z.array(QuestionFlowOptionSchema.omit({ icon: true })).min(1), - selectionMode: z.enum(["single", "multi"]).optional(), -}); - -const QuestionFlowSummaryItemSchema = z.object({ - label: z.string().min(1), - value: z.string().min(1), -}); - -const QuestionFlowChoiceSchema = z.object({ - title: z.string().min(1), - summary: z.array(QuestionFlowSummaryItemSchema).min(1), -}); - -const BaseSchema = z.object({ - id: ToolUIIdSchema, - role: ToolUIRoleSchema.optional(), -}); - -const SerializableProgressiveModeSchema = BaseSchema.extend({ - step: z.number().min(1), - title: z.string().min(1), - description: z.string().optional(), - options: z.array(QuestionFlowOptionSchema.omit({ icon: true })).min(1), - selectionMode: z.enum(["single", "multi"]).optional(), -}); - -type SerializableProgressiveMode = z.infer< - typeof SerializableProgressiveModeSchema ->; - -export const SerializableUpfrontModeSchema = BaseSchema.extend({ - steps: z.array(QuestionFlowStepDefinitionSchema).min(1), -}); - -export type SerializableUpfrontMode = z.infer< - typeof SerializableUpfrontModeSchema ->; - -export const SerializableReceiptModeSchema = BaseSchema.extend({ - choice: QuestionFlowChoiceSchema, -}); - -export type SerializableReceiptMode = z.infer< - typeof SerializableReceiptModeSchema ->; - -interface BaseRuntimeProps { - className?: string; -} - -export interface QuestionFlowProgressiveProps - extends BaseRuntimeProps, Omit { - options: QuestionFlowOption[]; - defaultValue?: string[]; - onSelect?: (optionIds: string[]) => void | Promise; - onBack?: () => void; - steps?: never; - choice?: never; -} - -export interface QuestionFlowUpfrontProps - extends BaseRuntimeProps, SerializableUpfrontMode { - onStepChange?: (stepId: string) => void; - onComplete?: (answers: Record) => void | Promise; - step?: never; - choice?: never; -} - -export interface QuestionFlowReceiptProps - extends BaseRuntimeProps, SerializableReceiptMode { - step?: never; - steps?: never; -} - -export type QuestionFlowProps = - | QuestionFlowProgressiveProps - | QuestionFlowUpfrontProps - | QuestionFlowReceiptProps; diff --git a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/ToolQuestion.tsx b/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/ToolQuestion.tsx deleted file mode 100644 index ed122ccc..00000000 --- a/frontend/src/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/ToolQuestion.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import React, { useMemo, useState, useCallback } from 'react'; -import { OptionList } from './OptionList/OptionList'; -import type { OptionListSelection } from './OptionList/schema'; -import { QuestionFlow } from './QuestionFlow/QuestionFlow'; -import type { ApprovalRequest } from '@/shared/state/agentsSlice'; - -function optionKey(opt: any): string { - return opt.id || opt.value || opt.label || opt.text || String(opt); -} - -function optionLabel(opt: any): string { - return opt.label || opt.value || opt.text || String(opt); -} - -// --------------------------------------------------------------------------- -// FreeTextQuestion (fallback for questions without options) -// --------------------------------------------------------------------------- - -const FreeTextQuestion: React.FC<{ - id: string; - question: string; - header?: string; - onSubmit: (answer: string) => void; - onDismiss: () => void; -}> = ({ id, question, header, onSubmit, onDismiss }) => { - const [text, setText] = useState(''); - - return ( -
      -
      - {header && ( - - {header} - - )} -

      {question}

      -
    2. - ), - td: ({ className, ...props }) => ( - - ), - tr: ({ className, ...props }) => ( -