[eric] merge #118: move customization into settings (via rebased resolution)

This commit is contained in:
ciregenz
2026-07-05 16:14:57 -07:00
27 changed files with 90 additions and 1051 deletions
Binary file not shown.
Binary file not shown.
-17
View File
@@ -27,10 +27,6 @@ import DashboardSelection from './pages/DashboardSelection/DashboardSelection';
import ErrorBoundary from './components/feedback/ErrorBoundary';
import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboardingProgressSlice';
const Skills = React.lazy(() => import('./pages/Skills/Skills'));
const Tools = React.lazy(() => import('./pages/Tools/Tools'));
const Modes = React.lazy(() => import('./pages/Modes/Modes'));
const Customization = React.lazy(() => import('./pages/Customization/Customization'));
const Analytics = React.lazy(() => import('./pages/Analytics/Analytics'));
const OnboardingRoot = React.lazy(() =>
import('./components/Onboarding').then((m) => ({ default: m.OnboardingRoot })),
@@ -54,20 +50,11 @@ if (typeof window !== 'undefined') {
(window as any).__openswarmPrefetchRoute = (path: string) => {
switch (path) {
case '/skills': void import('./pages/Skills/Skills'); return;
case '/actions':
case '/tools': void import('./pages/Tools/Tools'); return;
case '/modes': void import('./pages/Modes/Modes'); return;
case '/views':
case '/customization': void import('./pages/Customization/Customization'); return;
case '/analytics': void import('./pages/Analytics/Analytics'); return;
}
};
const prefetchAll = () => {
void import('./pages/Skills/Skills');
void import('./pages/Tools/Tools');
void import('./pages/Modes/Modes');
void import('./pages/Customization/Customization');
void import('./pages/Analytics/Analytics');
};
const ric = (window as any).requestIdleCallback as
@@ -528,10 +515,6 @@ const ThemedApp: React.FC = () => {
<Route path="/" element={<DashboardSelection />} />
{/* Dashboard renders persistently in AppShell so webviews survive nav. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
<Route path="/actions" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/analytics" element={<Analytics />} />
</Route>
</Routes>
@@ -18,12 +18,9 @@ import Alert from '@mui/material/Alert';
import InputBase from '@mui/material/InputBase';
// One outlined icon language for the sidebar: thin monoline glyphs (not the filled Material clip-art) so the rail reads as designed, not assembled.
import { LayoutDashboard } from 'lucide-react';
import PsychologyIcon from '@mui/icons-material/PsychologyOutlined';
import BuildIcon from '@mui/icons-material/BuildOutlined';
import { LayoutGrid } from 'lucide-react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Settings as LucideSettings } from 'lucide-react';
import { Palette } from 'lucide-react';
import { ArrowLeft, ArrowRight, Plus, Clock } from 'lucide-react';
import { AnimatedPanelLeft } from './animatedIcons';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
@@ -59,13 +56,6 @@ const SIDEBAR_DEFAULT = 260;
const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width';
const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed';
const CUSTOMIZATION_ITEMS = [
{ label: 'Skills', path: '/skills', icon: <PsychologyIcon />, onboarding: 'sidebar-skills' },
{ label: 'Actions', path: '/actions', icon: <BuildIcon />, onboarding: 'sidebar-actions' },
];
const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path));
const AppShell: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -88,8 +78,6 @@ const AppShell: React.FC = () => {
const canGoForward = historyIdx < maxHistoryIdx.current;
const [dashboardsExpanded, setDashboardsExpanded] = useState(true);
const [appsExpanded, setAppsExpanded] = useState(true);
// Collapsed by default: config rows are progressive disclosure, not daily nav. Onboarding reads data-expanded and clicks to open when it needs them.
const [customizationExpanded, setCustomizationExpanded] = useState(false);
// Starts collapsed so a fresh boot lands on a clean canvas; the toggle brings it back.
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const [renamingDashboardId, setRenamingDashboardId] = useState<string | null>(null);
@@ -433,7 +421,6 @@ const AppShell: React.FC = () => {
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/');
const isDashboardViewActive = location.pathname.startsWith('/dashboard/');
const isAppsRoute = false; // /apps route removed; app cards live on the dashboard now.
const isCustomizationRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname);
const activeDashboardId = location.pathname.startsWith('/dashboard/')
? location.pathname.split('/dashboard/')[1]
: null;
@@ -819,9 +806,6 @@ const AppShell: React.FC = () => {
'& [data-onboarding="sidebar-dashboards"]:hover .MuiListItemIcon-root svg': {
transform: 'scale(1.14)',
},
'& [data-onboarding="sidebar-customization"]:hover .MuiListItemIcon-root svg': {
transform: 'rotate(-14deg) scale(1.06)',
},
'& [data-onboarding="sidebar-apps"]:hover .MuiListItemIcon-root svg': {
transform: 'rotate(8deg) scale(1.08)',
},
@@ -982,106 +966,6 @@ const AppShell: React.FC = () => {
{/* Sections separate with air, not lines. */}
<Box sx={{ my: 0.75 }} />
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={() => {
if (isCustomizationRoute) {
setCustomizationExpanded((prev) => !prev);
} else {
navigate('/customization');
setCustomizationExpanded(true);
}
}}
data-onboarding="sidebar-customization"
data-expanded={customizationExpanded ? 'true' : 'false'}
aria-expanded={customizationExpanded}
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',
}}
>
<ListItemIcon sx={{ color: isCustomizationRoute ? c.accent.primary : c.text.tertiary, minWidth: 28 }}>
<Palette size={18} />
</ListItemIcon>
<ListItemText
primary="Customization"
sx={{
'& .MuiListItemText-primary': {
color: isCustomizationRoute ? c.text.primary : c.text.muted,
fontSize: '0.9rem',
fontWeight: isCustomizationRoute ? 600 : 400,
},
}}
/>
<ExpandMoreIcon
sx={{
color: c.text.ghost,
fontSize: 16,
transition: 'transform 0.2s',
transform: customizationExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
</ListItemButton>
<Collapse in={customizationExpanded} timeout={200}>
<Box sx={{ ml: 2, mt: 0.25, mb: 0.5 }}>
{CUSTOMIZATION_ITEMS.map((item) => {
// Manual click handler instead of NavLink: NavLink's internal navigate bypasses our startTransition wrapper.
const isActive = location.pathname === item.path;
return (
<Box
key={item.path}
data-onboarding={item.onboarding}
onClick={() => navigate(item.path)}
onMouseEnter={() => {
// Hover-prefetch lazy chunk so click is ~0ms (see Main.tsx for path -> import map).
const fn = (window as any).__openswarmPrefetchRoute;
if (typeof fn === 'function') fn(item.path);
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
pl: 1.25,
pr: 1,
py: 0.5,
mx: 0.5,
cursor: 'pointer',
// 25% accent alpha needed for readable contrast on dark-mode bg.secondary; 10% muddied to grey.
borderRadius: `${c.radius.md}px`,
bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
'&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
transition: 'background-color 0.12s',
}}
>
<Typography
sx={{
color: isActive ? c.text.secondary : c.text.ghost,
fontSize: '0.86rem',
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{item.label}
</Typography>
</Box>
);
})}
</Box>
</Collapse>
</Box>
{/* Sections separate with air, not lines. */}
<Box sx={{ my: 0.75 }} />
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleAppsClick}
@@ -64,6 +64,7 @@ const OnboardingPanel: React.FC = () => {
const lastShowMeClickRef = useRef<number>(0);
const unlockedIds = useUnlockedStepIds();
const liveStepIds = useMemo(() => new Set(STEPS.map((s) => s.id)), []);
const currentStep = useMemo(() => {
// Spotlight only lands on an unlocked, not-yet-done step, so we never tell the user to "Show me" something they haven't unlocked yet.
const explicit = progress.currentStepId
@@ -87,8 +88,11 @@ const OnboardingPanel: React.FC = () => {
const stageOf = currentStep?.stage ?? 'get_started';
// Count only what's UNLOCKED, not all 8. A brand-new user sees "0/2" (launch + connect), and the denominator grows as the first win unlocks the rest, so we never dump the whole feature surface on someone before their first output. Guard: never let completed exceed the shown total (data-weirdness safety).
const done = progress.completedSteps.length;
const total = Math.max(unlockedIds.size, done);
const done = progress.completedSteps.filter((id) => liveStepIds.has(id)).length;
const total = Math.max(
Array.from(unlockedIds).filter((id) => liveStepIds.has(id)).length,
done,
);
// Timer lives inside CelebrationView so parent re-renders can't cancel it.
const justDoneStepId = progress.justCompletedStepId;
@@ -32,7 +32,8 @@ const OnboardingRoadmapModal: React.FC = () => {
return STEPS.find((s) => !progress.completedSteps.includes(s.id) && unlockedIds.has(s.id));
})();
const totalDone = progress.completedSteps.length;
// Filter to live steps so a user who finished a since-removed step can't read e.g. 8/6.
const totalDone = progress.completedSteps.filter((id) => findStepById(id)).length;
const total = STEPS.length;
const jumpToCurrent = () => {
@@ -13,6 +13,7 @@ import {
persistToStorage,
markStepCompleted,
setPanelMode,
setCurrentStep,
markRevealedAfterWin,
} from '@/shared/state/onboardingProgressSlice';
import AgenticCursor, { type AgenticCursorHandle } from './ac/AgenticCursor';
@@ -98,6 +99,13 @@ const OnboardingRoot: React.FC = () => {
);
}, [progress.initialized, settingsLoaded, dispatch, store]);
useEffect(() => {
if (!progress.initialized || !progress.currentStepId) return;
if (STEPS.some((step) => step.id === progress.currentStepId)) return;
const nextStep = STEPS.find((step) => !(progress.completedSteps ?? []).includes(step.id));
dispatch(setCurrentStep(nextStep?.id ?? null));
}, [progress.initialized, progress.currentStepId, progress.completedSteps, dispatch]);
// Bridge Redux signals to bus + auto-mark on skipIf. Coalesces microtask-bursts of dispatches.
useEffect(() => {
let last = new Set(progress.completedSteps);
@@ -311,15 +311,11 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
switch (op.kind) {
case 'move_to': {
// Order matters: open the whole sidebar first (sub-section markers must exist in DOM), THEN expand Customization, THEN target.
// Open the whole sidebar first so its markers exist in the DOM before we target one.
const expandSidebarOps = maybeBuildExpandSidebarOps(op.target);
if (expandSidebarOps) {
await runOps(expandSidebarOps, ctx);
}
const expandOps = maybeBuildExpandCustomizationOps(op.target);
if (expandOps) {
await runOps(expandOps, ctx);
}
const el = await waitForSelector(op.target);
const scrolled = scrollIntoViewIfNeeded(el);
const offX = op.offset?.x ?? 0;
@@ -755,25 +751,15 @@ function buildOpenDashboardOps(): ACOp[] {
return ops;
}
const CUSTOMIZATION_AREA_TARGETS = new Set<string>([
'sidebar-actions',
'sidebar-skills',
'sidebar-modes',
]);
// `sidebar-toggle` excluded: it lives in the top bar (we click it to expand). Recursing would loop.
const SIDEBAR_AREA_TARGETS = new Set<string>([
'sidebar-settings-button',
'sidebar-dashboards',
'sidebar-customization',
'sidebar-skills',
'sidebar-actions',
'sidebar-modes',
'sidebar-apps',
'dashboard-row-first',
]);
/** MUST run before maybeBuildExpandCustomizationOps: Customization header is inside the collapsible panel, so expand-check on hidden panel queues an impossible click. */
/** Expands the collapsed sidebar so its row markers exist before a move_to targets one. */
function maybeBuildExpandSidebarOps(target: string): ACOp[] | null {
if (!SIDEBAR_AREA_TARGETS.has(target)) return null;
const toggle = document.querySelector<HTMLElement>(
@@ -789,26 +775,6 @@ function maybeBuildExpandSidebarOps(target: string): ACOp[] | null {
];
}
function maybeBuildExpandCustomizationOps(target: string): ACOp[] | null {
if (!CUSTOMIZATION_AREA_TARGETS.has(target)) return null;
const header = document.querySelector<HTMLElement>(
'[data-onboarding="sidebar-customization"]',
);
const expanded =
header?.dataset.expanded === 'true' ||
header?.getAttribute('aria-expanded') === 'true';
if (expanded) return null;
return [
{ kind: 'move_to', target: 'sidebar-customization' },
{ kind: 'popup', text: 'Open Customization.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: 'sidebar-customization' },
timeoutMs: 60000,
},
];
}
interface WaitResult {
timedOut: boolean;
}
@@ -1,9 +1,6 @@
// Central registry of data-onboarding / data-select-type selectors. Step files import S.*; never inline.
export const S = {
sidebarSkills: 'sidebar-skills',
sidebarActions: 'sidebar-actions',
sidebarModes: 'sidebar-modes',
sidebarApps: 'sidebar-apps',
sidebarSettingsButton: 'sidebar-settings-button',
@@ -35,22 +32,9 @@ export const S = {
chatSendButton: 'chat-send-button',
elementSelectionToggle: 'element-selection-toggle',
actionsRedditToggle: 'actions-reddit-toggle',
actionsRedditChevron: 'actions-reddit-chevron',
actionsSubredditsChevron: 'actions-subreddits-chevron',
actionsPermissionToggle: 'actions-permission-toggle',
actionsYoutubeToggle: 'actions-youtube-toggle',
actionsYoutubeChevron: 'actions-youtube-chevron',
canvasFitToView: 'canvas-fit-to-view',
canvasTidyLayout: 'canvas-tidy-layout',
canvasMinimapToggle: 'canvas-minimap-toggle',
/** Header for sidebar's Customization section; runtime auto-expands before targeting children. */
sidebarCustomization: 'sidebar-customization',
skillItemPdf: 'skill-item-pdf',
skillInstallButton: 'skill-install-button',
skillBuilderFab: 'skill-builder-fab',
appsNewButton: 'apps-new-button',
appCardLatest: 'app-card-latest',
@@ -1,11 +1,9 @@
import type { OnboardingStep, StepStage } from './types';
import { step01 } from './step01_connectModel';
import { step02 } from './step02_enableActions';
import { step03 } from './step03_launchAgent';
import { step04 } from './step04_useBrowser';
import { step05 } from './step05_agentUseBrowser';
import { step06 } from './step06_agentControlAgents';
import { step07 } from './step07_installSkill';
import { step08 } from './step08_makeApp';
import { welcomeOpenStep } from './step00_welcomeNudge';
@@ -13,11 +11,9 @@ import { welcomeOpenStep } from './step00_welcomeNudge';
export const STEPS: OnboardingStep[] = [
step03,
step01,
step02,
step04,
step05,
step06,
step07,
step08,
];
@@ -76,18 +76,6 @@ export function hasAnySkillInstalled(s: RootState): boolean {
return Object.keys(items).length > 0;
}
/** True if PDF skill installed (id/name/command); step 7 uses this so other skills don't auto-skip. */
export function hasPdfSkillInstalled(s: RootState): boolean {
const items = s.skills?.items as any;
const list: any[] = Array.isArray(items) ? items : Object.values(items ?? {});
return list.some((sk: any) => {
const id = (sk?.id ?? '').toString().toLowerCase();
const name = (sk?.name ?? '').toString().toLowerCase();
const cmd = (sk?.command ?? '').toString().toLowerCase();
return id.includes('pdf') || name.includes('pdf') || cmd.includes('pdf');
});
}
/** True if a browser card exists; step 4 auto-skips the open-a-browser walkthrough. */
export function hasAnyBrowserSpawned(s: RootState): boolean {
const cards = (s as any).dashboardLayout?.browserCards ?? {};
@@ -1,39 +0,0 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { isYoutubeEnabled } from './skipPredicates';
export const step02: OnboardingStep = {
id: 'enable_actions',
// Demoted out of the first-run path: a feature to discover after the first win.
stage: 'learn_features',
index: 3,
title: 'Enable agentic actions',
description: 'Allow agents to work across your apps.',
videoSrc: './onboarding-videos/v2/02.mp4',
videoDurationLabel: '0:24',
// Narrowed to YouTube so users with other tools still get walked.
skipIf: isYoutubeEnabled,
// Two beats only (open Actions, flip YouTube on); the chevron-peek and permission fine-tune popups were trimmed to give the step room to breathe.
ops: [
{ kind: 'move_to', target: S.sidebarActions },
{ kind: 'popup', text: 'Open Actions.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.sidebarActions },
},
// YouTube on the throughline; step 3 needs it. Waits on Redux state, not click, so toggling stays synced.
{ kind: 'move_to', target: S.actionsYoutubeToggle },
{ kind: 'popup', text: 'Flip YouTube on.' },
{
kind: 'wait_user',
condition: {
kind: 'redux_predicate',
selector: isYoutubeEnabled,
truthy: true,
},
timeoutMs: 90000,
},
{ kind: 'delay', ms: 1200 },
{ kind: 'outro' },
],
};
@@ -1,48 +0,0 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasPdfSkillInstalled } from './skipPredicates';
export const step07: OnboardingStep = {
id: 'install_skill',
stage: 'learn_features',
index: 7,
title: 'Install a skill',
description: 'Teach agents how to handle specific tasks.',
videoSrc: './onboarding-videos/v2/07.mp4',
videoDurationLabel: '0:24',
// Narrowed to PDF so other-skill users still walk through this demo.
skipIf: hasPdfSkillInstalled,
ops: [
{ kind: 'move_to', target: S.sidebarSkills },
{ kind: 'popup', text: 'Wander into Skills.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.sidebarSkills },
},
{ kind: 'move_to', target: S.skillItemPdf },
{ kind: 'popup', text: 'Pick the PDF one.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.skillItemPdf },
},
{ kind: 'move_to', target: S.skillInstallButton },
{ kind: 'popup', text: 'Install it!' },
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'skill:installed' },
timeoutMs: 60000,
},
{
kind: 'popup',
text: 'Boom! Now any chat is way better with PDFs.',
},
{ kind: 'move_to', target: S.skillBuilderFab },
{ kind: 'click', target: S.skillBuilderFab, simulate: true },
{
kind: 'popup',
text: 'Got an idea? Type it here and the skill builder whips one up.',
},
{ kind: 'delay', ms: 3500 },
{ kind: 'outro' },
],
};
@@ -53,7 +53,6 @@ const ACTIONS: ActionResult[] = [
{ kind: 'action', id: 'settings-models', name: 'Connect a model', keywords: 'settings models api key provider subscription' },
{ kind: 'action', id: 'go-skills', name: 'Go to Skills', keywords: 'customize skills' },
{ kind: 'action', id: 'go-actions', name: 'Go to Actions', keywords: 'customize tools actions mcp' },
{ kind: 'action', id: 'go-modes', name: 'Go to Modes', keywords: 'customize modes' },
{ kind: 'action', id: 'all-dashboards', name: 'All dashboards', keywords: 'overview picker browse boards' },
];
@@ -149,9 +148,9 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
break;
case 'settings': dispatch(openSettingsModal()); break;
case 'settings-models': dispatch(openSettingsModal('models')); break;
case 'go-skills': navigate('/skills'); break;
case 'go-actions': navigate('/actions'); break;
case 'go-modes': navigate('/modes'); break;
// Skills/Actions live in Settings now (the sidebar Customization section moved there).
case 'go-skills': dispatch(openSettingsModal('skills')); break;
case 'go-actions': dispatch(openSettingsModal('tools')); break;
case 'all-dashboards': navigate('/'); break;
}
}, [dispatch, navigate]);
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
@@ -279,7 +279,6 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
);
});
const testState = useAppSelector((s) => (id ? s.agents.sessions[id]?.workflow_test_state : null) ?? null);
const navigate = useNavigate();
const dispatch = useAppDispatch();
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
const modesMap = useAppSelector((state) => state.modes.items);
@@ -1721,7 +1720,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
setActivateError(`Activation failed (${r.status})`);
} else if (body?.status === 'unknown_server') {
// Not yet connected; jump to Actions so the user can finish OAuth.
navigate('/actions');
dispatch(openSettingsModal('tools'));
} else if (id) {
dispatch(clearMcpSuggestions({ sessionId: id }));
}
@@ -2198,7 +2197,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
setActivateError(`Activation failed (${r.status})`);
} else if (body?.status === 'unknown_server') {
// Not yet connected; jump straight to Actions so the user can finish OAuth. Nothing here can do it on their behalf.
navigate('/actions');
dispatch(openSettingsModal('tools'));
} else if (id) {
// Activation succeeded; clear the banner so the user gets visual confirmation the click did something.
dispatch(clearMcpSuggestions({ sessionId: id }));
@@ -1,105 +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 Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import PsychologyIcon from '@mui/icons-material/Psychology';
import BuildIcon from '@mui/icons-material/Build';
import TuneIcon from '@mui/icons-material/Tune';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const PANELS = [
{
label: 'Skills',
path: '/skills',
icon: <PsychologyIcon />,
description:
'Install or author reusable skill packages that teach your agents new capabilities and workflows.',
},
{
label: 'Actions',
path: '/actions',
icon: <BuildIcon />,
description:
'Define and manage the actions your agents can take.',
},
{
label: 'Modes',
path: '/modes',
icon: <TuneIcon />,
description:
'Configure agent interaction modes with custom system prompts, allowed actions, and auto-switching rules.',
},
];
const Customization: React.FC = () => {
const c = useClaudeTokens();
const navigate = useNavigate();
return (
<Box sx={{ height: '100%', overflow: 'auto', p: 4 }}>
<Box sx={{ maxWidth: 900, mx: 'auto' }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: c.text.primary }}>
Customization
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem', mt: 0.5 }}>
Tailor how your agents behave, what they can do, and how they interact.
</Typography>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 2.5,
}}
>
{PANELS.map((panel) => (
<Card
key={panel.path}
sx={{
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: 2.5,
boxShadow: c.shadow.sm,
willChange: 'transform',
'&:hover': { borderColor: c.accent.primary },
transition: 'border-color 0.2s',
}}
>
<CardActionArea
onClick={() => navigate(panel.path)}
sx={{ p: 3, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1.5 }}
>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${c.accent.primary}12`,
color: c.accent.primary,
}}
>
{React.cloneElement(panel.icon, { sx: { fontSize: 24 } })}
</Box>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1.05rem' }}>
{panel.label}
</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.55 }}>
{panel.description}
</Typography>
</CardActionArea>
</Card>
))}
</Box>
</Box>
</Box>
);
};
export default Customization;
-604
View File
@@ -1,604 +0,0 @@
import React, { useEffect, useState, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardActions from '@mui/material/CardActions';
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 TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Tooltip from '@mui/material/Tooltip';
import { Skeleton } from '@/app/components/feedback/Loading';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Checkbox from '@mui/material/Checkbox';
import ListItemText from '@mui/material/ListItemText';
import OutlinedInput from '@mui/material/OutlinedInput';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import TuneIcon from '@mui/icons-material/Tune';
import LockIcon from '@mui/icons-material/Lock';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RestoreIcon from '@mui/icons-material/Restore';
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 { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
fetchModes,
createMode,
updateMode,
deleteMode,
resetMode,
Mode,
} from '@/shared/state/modesSlice';
import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice';
import { fetchSkills } from '@/shared/state/skillsSlice';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import ExtensionIcon from '@mui/icons-material/Extension';
import ListSubheader from '@mui/material/ListSubheader';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import DirectoryBrowser from '@/app/components/editor/DirectoryBrowser';
import RichPromptEditor from '@/app/components/editor/RichPromptEditor';
const ICON_MAP: Record<string, React.ReactNode> = {
smart_toy: <SmartToyOutlinedIcon sx={{ fontSize: 20 }} />,
question_answer: <QuestionAnswerOutlinedIcon sx={{ fontSize: 20 }} />,
map: <MapOutlinedIcon sx={{ fontSize: 20 }} />,
category: <CategoryOutlinedIcon sx={{ fontSize: 20 }} />,
tune: <TuneIcon sx={{ fontSize: 20 }} />,
};
const ICON_OPTIONS = [
{ value: 'smart_toy', label: 'Robot' },
{ value: 'question_answer', label: 'Q&A' },
{ value: 'map', label: 'Map' },
{ value: 'category', label: 'Category' },
{ value: 'tune', label: 'Tune' },
];
const COLOR_OPTIONS = [
{ value: '#ae5630', label: 'Terra Cotta' },
{ value: '#4ade80', label: 'Green' },
{ value: '#fbbf24', label: 'Amber' },
{ value: '#f87171', label: 'Red' },
{ value: '#38bdf8', label: 'Sky' },
{ value: '#c084fc', label: 'Purple' },
{ value: '#fb923c', label: 'Orange' },
{ value: '#2dd4bf', label: 'Teal' },
];
interface ModeForm {
name: string;
description: string;
system_prompt: string;
tools: string[];
toolsEnabled: boolean;
default_next_mode: string;
icon: string;
color: string;
default_folder: string;
}
const emptyForm: ModeForm = {
name: '',
description: '',
system_prompt: '',
tools: [],
toolsEnabled: false,
default_next_mode: '',
icon: 'smart_toy',
color: '#ae5630',
default_folder: '',
};
const ALL_BUILTIN_TOOL_NAMES = ['Read', 'Edit', 'Write', 'Bash', 'Glob', 'Grep', 'AskUserQuestion'];
const Modes: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { items, builtinDefaults, loading } = useAppSelector((s) => s.modes);
const toolItems = useAppSelector((s) => s.tools.items);
const modes = useMemo(() => Object.values(items), [items]);
const mcpToolNames = useMemo(() => {
return Object.values(toolItems)
.filter((t) => t.mcp_config && Object.keys(t.mcp_config).length > 0 && t.auth_status !== 'none')
.map((t) => `mcp:${t.name}`);
}, [toolItems]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<ModeForm>(emptyForm);
const [browseOpen, setBrowseOpen] = useState(false);
useEffect(() => {
dispatch(fetchModes());
dispatch(fetchBuiltinTools());
dispatch(fetchTools());
dispatch(fetchSkills());
}, [dispatch]);
const openCreate = () => {
setEditingId(null);
setForm(emptyForm);
setDialogOpen(true);
};
const openEdit = (mode: Mode) => {
setEditingId(mode.id);
setForm({
name: mode.name,
description: mode.description,
system_prompt: mode.system_prompt ?? '',
tools: mode.tools ?? [],
toolsEnabled: mode.tools !== null,
default_next_mode: mode.default_next_mode ?? '',
icon: mode.icon,
color: mode.color,
default_folder: mode.default_folder ?? '',
});
setDialogOpen(true);
};
const handleSave = async () => {
const payload = {
name: form.name,
description: form.description,
system_prompt: form.system_prompt || null,
tools: form.toolsEnabled ? form.tools : null,
default_next_mode: form.default_next_mode || null,
icon: form.icon,
color: form.color,
default_folder: form.default_folder || null,
};
if (editingId) {
await dispatch(updateMode({ id: editingId, ...payload }));
} else {
await dispatch(createMode(payload as any));
}
setDialogOpen(false);
};
const handleDelete = async (id: string) => {
await dispatch(deleteMode(id));
};
const editingIsBuiltin = editingId ? items[editingId]?.is_builtin ?? false : false;
const hasDiverged = useMemo(() => {
if (!editingId || !editingIsBuiltin) return false;
const defaults = builtinDefaults[editingId];
if (!defaults) return false;
const current = items[editingId];
if (!current) return false;
return (
current.name !== defaults.name ||
current.description !== defaults.description ||
(current.system_prompt ?? '') !== (defaults.system_prompt ?? '') ||
JSON.stringify(current.tools) !== JSON.stringify(defaults.tools) ||
(current.default_next_mode ?? '') !== (defaults.default_next_mode ?? '') ||
current.icon !== defaults.icon ||
current.color !== defaults.color ||
(current.default_folder ?? '') !== (defaults.default_folder ?? '')
);
}, [editingId, editingIsBuiltin, items, builtinDefaults]);
const handleReset = async () => {
if (!editingId) return;
const action = await dispatch(resetMode(editingId));
if (resetMode.fulfilled.match(action)) {
const m = action.payload;
setForm({
name: m.name,
description: m.description,
system_prompt: m.system_prompt ?? '',
tools: m.tools ?? [],
toolsEnabled: m.tools !== null,
default_next_mode: m.default_next_mode ?? '',
icon: m.icon,
color: m.color,
default_folder: m.default_folder ?? '',
});
}
};
const otherModes = modes.filter((m) => m.id !== editingId);
return (
<Box sx={{ p: 3, height: '100%', overflow: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 700, mb: 0.5 }}>
Modes
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>
Configure agent interaction modes with custom system prompts, actions, and auto-switching.
</Typography>
</Box>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={openCreate}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 2,
}}
>
New Mode
</Button>
</Box>
{loading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2, mt: 1 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="card" height={120} />
))}
</Box>
) : modes.length === 0 ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 8,
color: c.text.ghost,
gap: 2,
}}
>
<TuneIcon sx={{ fontSize: 48, opacity: 0.4 }} />
<Typography>No modes defined yet. Create one to get started.</Typography>
</Box>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 2,
}}
>
{modes.map((mode) => (
<Card
key={mode.id}
sx={{
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: 2,
boxShadow: c.shadow.sm,
// Own compositor layer per card so hover-cross re-paints one card, not the whole grid.
willChange: 'transform',
// Hover animates only border-color; box-shadow animation caused per-frame CPU paint.
'&:hover': { borderColor: mode.color },
transition: 'border-color 0.2s',
}}
>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<Box sx={{ color: mode.color, display: 'flex', alignItems: 'center' }}>
{ICON_MAP[mode.icon] || ICON_MAP.smart_toy}
</Box>
<Typography variant="h6" sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem', flex: 1 }}>
{mode.name}
</Typography>
{mode.is_builtin && (
<Chip
icon={<LockIcon sx={{ fontSize: 12 }} />}
label="Built-in"
size="small"
sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 22 }}
/>
)}
</Box>
{mode.description && (
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', mb: 1.5 }}>
{mode.description}
</Typography>
)}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{mode.tools !== null ? (
<Chip
label={`${mode.tools.length} action${mode.tools.length !== 1 ? 's' : ''}`}
size="small"
sx={{ bgcolor: `${mode.color}18`, color: mode.color, fontSize: '0.75rem', height: 24 }}
/>
) : (
<Chip
label="All actions"
size="small"
sx={{ bgcolor: `${mode.color}18`, color: mode.color, fontSize: '0.75rem', height: 24 }}
/>
)}
{mode.system_prompt && (
<Chip
label="System prompt"
size="small"
sx={{ bgcolor: 'rgba(174,86,48,0.15)', color: c.accent.hover, fontSize: '0.75rem', height: 24 }}
/>
)}
{mode.default_next_mode && (
<Chip
icon={<ArrowForwardIcon sx={{ fontSize: 12 }} />}
label={items[mode.default_next_mode]?.name || mode.default_next_mode}
size="small"
sx={{ bgcolor: 'rgba(251,191,36,0.15)', color: '#fbbf24', fontSize: '0.75rem', height: 24 }}
/>
)}
{mode.default_folder && (
<Chip
icon={<FolderOpenIcon sx={{ fontSize: 12 }} />}
label={mode.default_folder.split('/').pop() || mode.default_folder}
size="small"
sx={{ bgcolor: 'rgba(56,189,248,0.15)', color: '#38bdf8', fontSize: '0.75rem', height: 24 }}
/>
)}
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 1.5 }}>
<Tooltip title="Edit">
<IconButton size="small" onClick={() => openEdit(mode)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
{!mode.is_builtin && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(mode.id)} sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</CardActions>
</Card>
))}
</Box>
)}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
maxWidth="md"
fullWidth
PaperProps={{
sx: { bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: 4, border: `1px solid ${c.border.subtle}` },
}}
>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600 }}>
{editingId ? 'Edit Mode' : 'New Mode'}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
fullWidth
size="small"
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page } }}
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
fullWidth
size="small"
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page } }}
/>
<RichPromptEditor
label="System Prompt"
value={form.system_prompt}
onChange={(v) => setForm({ ...form, system_prompt: v })}
placeholder="Instructions for the agent when using this mode... (@ for context, / for commands)"
minRows={3}
maxRows={8}
/>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Checkbox
checked={form.toolsEnabled}
onChange={(e) => setForm({ ...form, toolsEnabled: e.target.checked, tools: e.target.checked ? form.tools : [] })}
size="small"
sx={{ color: c.text.tertiary, '&.Mui-checked': { color: c.accent.primary }, p: 0 }}
/>
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem' }}>
Restrict actions {!form.toolsEnabled && <span style={{ color: c.text.tertiary }}>(all actions allowed)</span>}
</Typography>
</Box>
{form.toolsEnabled && (
<FormControl fullWidth size="small">
<InputLabel sx={{ color: c.text.tertiary }}>Allowed Actions</InputLabel>
<Select
multiple
value={form.tools}
onChange={(e) => setForm({ ...form, tools: typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value })}
input={<OutlinedInput label="Allowed Actions" />}
renderValue={(selected) => selected.join(', ')}
sx={{ bgcolor: c.bg.page }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
<ListSubheader sx={{ bgcolor: c.bg.page, color: c.text.tertiary, fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', lineHeight: '32px' }}>Built-in Actions</ListSubheader>
{ALL_BUILTIN_TOOL_NAMES.map((name) => (
<MenuItem key={name} value={name}>
<Checkbox checked={form.tools.includes(name)} size="small" sx={{ '&.Mui-checked': { color: c.accent.primary } }} />
<ListItemText primary={name} />
</MenuItem>
))}
{mcpToolNames.length > 0 && (
<ListSubheader sx={{ bgcolor: c.bg.page, color: '#f59e0b', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', lineHeight: '32px', display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ExtensionIcon sx={{ fontSize: 14 }} /> MCP Actions
</ListSubheader>
)}
{mcpToolNames.map((name) => (
<MenuItem key={name} value={name}>
<Checkbox checked={form.tools.includes(name)} size="small" sx={{ '&.Mui-checked': { color: '#f59e0b' } }} />
<ListItemText primary={name} primaryTypographyProps={{ sx: { display: 'flex', alignItems: 'center', gap: 0.5 } }}>
{name}
</ListItemText>
</MenuItem>
))}
</Select>
</FormControl>
)}
</Box>
<FormControl fullWidth size="small">
<InputLabel sx={{ color: c.text.tertiary }}>Default Next Mode</InputLabel>
<Select
value={form.default_next_mode}
label="Default Next Mode"
onChange={(e) => setForm({ ...form, default_next_mode: e.target.value })}
sx={{ bgcolor: c.bg.page }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{otherModes.map((m) => (
<MenuItem key={m.id} value={m.id}>{m.name}</MenuItem>
))}
</Select>
</FormControl>
<Box>
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem', mb: 0.75 }}>
Default Folder
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
value={form.default_folder}
onChange={(e) => setForm({ ...form, default_folder: e.target.value })}
fullWidth
size="small"
placeholder="Not set (uses global default)"
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: c.bg.page,
fontFamily: 'monospace',
fontSize: '0.85rem',
},
}}
/>
<Button
variant="outlined"
onClick={() => setBrowseOpen(true)}
startIcon={<FolderOpenIcon />}
sx={{
color: c.accent.primary,
borderColor: c.border.medium,
textTransform: 'none',
whiteSpace: 'nowrap',
minWidth: 'auto',
}}
>
Browse
</Button>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<FormControl size="small" sx={{ flex: 1 }}>
<InputLabel sx={{ color: c.text.tertiary }}>Icon</InputLabel>
<Select
value={form.icon}
label="Icon"
onChange={(e) => setForm({ ...form, icon: e.target.value })}
sx={{ bgcolor: c.bg.page }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
{ICON_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{ICON_MAP[opt.value]}
<span>{opt.label}</span>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ flex: 1 }}>
<InputLabel sx={{ color: c.text.tertiary }}>Color</InputLabel>
<Select
value={form.color}
label="Color"
onChange={(e) => setForm({ ...form, color: e.target.value })}
sx={{ bgcolor: c.bg.page }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
{COLOR_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 14, height: 14, borderRadius: '50%', bgcolor: opt.value }} />
<span>{opt.label}</span>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'space-between' }}>
<Box>
{editingIsBuiltin && (
<Tooltip title={hasDiverged ? 'Restore this mode to its original built-in defaults' : 'Mode matches built-in defaults'}>
<span>
<Button
startIcon={<RestoreIcon sx={{ fontSize: 16 }} />}
onClick={handleReset}
disabled={!hasDiverged}
sx={{
color: hasDiverged ? c.text.muted : c.text.ghost,
textTransform: 'none',
fontSize: '0.82rem',
'&:hover': hasDiverged ? { color: c.status.error, bgcolor: `${c.status.error}10` } : {},
}}
>
Reset to Default
</Button>
</span>
</Tooltip>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: c.text.tertiary, textTransform: 'none' }}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!form.name}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 2,
}}
>
{editingId ? 'Save Changes' : 'Create Mode'}
</Button>
</Box>
</DialogActions>
</Dialog>
<DirectoryBrowser
open={browseOpen}
onClose={() => setBrowseOpen(false)}
onSelect={(item) => setForm({ ...form, default_folder: item.path })}
initialPath={form.default_folder || ''}
/>
</Box>
);
};
export default Modes;
+22 -1
View File
@@ -4,6 +4,7 @@ import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import CircularProgress from '@mui/material/CircularProgress';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettingsPatch, closeSettingsModal, AppSettings } from '@/shared/state/settingsSlice';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
@@ -18,6 +19,10 @@ import UsageStats from './sections/usage/UsageStats';
import SettingsHeader from './sections/SettingsHeader';
import { makeSettingsStyles } from './sections/settingsStyles';
// Skills/Tools moved here from the old sidebar Customization section; lazy since both pull heavy deps and Settings opens nearly every session.
const SkillsTab = React.lazy(() => import('@/app/pages/Skills/Skills'));
const ToolsTab = React.lazy(() => import('@/app/pages/Tools/Tools'));
// Brand colors for provider group headers; mirrors ChatInput picker.
const PROVIDER_COLORS: Record<string, string> = {
anthropic: '#E8927A',
@@ -85,7 +90,7 @@ const Settings: React.FC = () => {
}, [modelsByProvider, modelsLoaded, settings.connection_mode, settings.default_model]);
const initialTab = useAppSelector((s) => s.settings.initialTab);
const TAB_VALUES = ['general', 'models', 'usage', 'commands'] as const;
const TAB_VALUES = ['general', 'models', 'skills', 'tools', 'commands', 'usage'] as const;
type SettingsTab = typeof TAB_VALUES[number];
const isValidTab = (t: string | null | undefined): t is SettingsTab =>
!!t && (TAB_VALUES as readonly string[]).includes(t);
@@ -219,6 +224,8 @@ const Settings: React.FC = () => {
sx: {
width: 780,
height: '85vh',
display: 'flex',
flexDirection: 'column',
bgcolor: c.bg.page,
borderRadius: 2,
border: `1px solid ${c.border.subtle}`,
@@ -236,6 +243,8 @@ const Settings: React.FC = () => {
<DialogContent sx={{
px: 3,
py: 0,
flex: 1,
minHeight: 0,
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3, '&:hover': { background: c.border.strong } },
@@ -265,6 +274,18 @@ const Settings: React.FC = () => {
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<UsageStats />
</Box>
) : activeTab === 'skills' ? (
<Box sx={{ height: '100%', mx: -3, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<React.Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}><CircularProgress size={24} /></Box>}>
<SkillsTab />
</React.Suspense>
</Box>
) : activeTab === 'tools' ? (
<Box sx={{ height: '100%', mx: -3, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<React.Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}><CircularProgress size={24} /></Box>}>
<ToolsTab />
</React.Suspense>
</Box>
) : (
<Box sx={{ pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<CommandsContent />
@@ -54,8 +54,10 @@ const SettingsHeader: React.FC<{
>
<Tab label="General" value="general" disableRipple />
<Tab label="Models" value="models" disableRipple data-onboarding="settings-models-tab" />
<Tab label="Usage" value="usage" disableRipple />
<Tab label="Skills" value="skills" disableRipple />
<Tab label="Tools" value="tools" disableRipple />
<Tab label="Commands" value="commands" disableRipple />
<Tab label="Usage" value="usage" disableRipple />
</Tabs>
</DialogTitle>
);
+2 -2
View File
@@ -128,7 +128,7 @@ const Skills: React.FC = () => {
const regGrouped = useMemo(() => {
const groups: Record<string, RegistrySkill[]> = {};
const q = searchFilter.toLowerCase();
const q = searchFilter.trim().toLowerCase();
for (const sk of regSkills) {
if (q && !sk.name.toLowerCase().includes(q) && !sk.description.toLowerCase().includes(q)) continue;
const cat = sk.category || 'General';
@@ -139,7 +139,7 @@ const Skills: React.FC = () => {
}, [regSkills, searchFilter]);
const filteredLocal = useMemo(() => {
const q = searchFilter.toLowerCase();
const q = searchFilter.trim().toLowerCase();
if (!q) return localSkills;
return localSkills.filter((s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
}, [localSkills, searchFilter]);
+8 -8
View File
@@ -106,8 +106,8 @@ const Tools: React.FC = () => {
<Box sx={{ p: 3, height: '100%', overflow: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 700, mb: 0.5 }}>Action Library</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>Define and manage custom actions for your Claude Code agents.</Typography>
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 700, mb: 0.5 }}>Tool Library</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>Define and manage custom tools for your Claude Code agents.</Typography>
</Box>
<Box>
<Button
@@ -117,7 +117,7 @@ const Tools: React.FC = () => {
onClick={handleMenuOpen}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed }, textTransform: 'none', borderRadius: 2 }}
>
New Action
New Tool
</Button>
<Menu
anchorEl={menuAnchor}
@@ -144,18 +144,18 @@ const Tools: React.FC = () => {
>
{builtinSectionOpen ? <KeyboardArrowDownIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} /> : <KeyboardArrowRightIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} />}
<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Built-in Action Sets</Typography>
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Built-in</Typography>
<Chip label={coreTools.length + deferredTools.length + browserTools.length} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
</Box>
<Collapse in={builtinSectionOpen} timeout={0} unmountOnExit>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1 }}>
{coreTools.length > 0 && (
<ToolSection label="Core Actions" icon={<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={coreTools.length} open={coreSectionOpen} onToggle={() => setCoreSectionOpen((v) => !v)} grouped={groupedCore} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} builtinPermissions={builtinPermissions} onPermissionChange={a.handleBuiltinPermissionChange} onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} enabled={coreSectionEnabled} onEnabledChange={(v) => a.handleSectionEnabledChange(coreTools, v)} />
<ToolSection label="Core Tools" icon={<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={coreTools.length} open={coreSectionOpen} onToggle={() => setCoreSectionOpen((v) => !v)} grouped={groupedCore} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} builtinPermissions={builtinPermissions} onPermissionChange={a.handleBuiltinPermissionChange} onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} enabled={coreSectionEnabled} onEnabledChange={(v) => a.handleSectionEnabledChange(coreTools, v)} />
)}
{deferredTools.length > 0 && (
<ToolSection label="Extended Actions" icon={<HourglassEmptyIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={deferredTools.length} open={deferredSectionOpen} onToggle={() => setDeferredSectionOpen((v) => !v)} grouped={groupedDeferred} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} deferred builtinPermissions={builtinPermissions} onPermissionChange={a.handleBuiltinPermissionChange} onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} enabled={deferredSectionEnabled} onEnabledChange={(v) => a.handleSectionEnabledChange(deferredTools, v)} />
<ToolSection label="Extended Tools" icon={<HourglassEmptyIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={deferredTools.length} open={deferredSectionOpen} onToggle={() => setDeferredSectionOpen((v) => !v)} grouped={groupedDeferred} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} deferred builtinPermissions={builtinPermissions} onPermissionChange={a.handleBuiltinPermissionChange} onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} enabled={deferredSectionEnabled} onEnabledChange={(v) => a.handleSectionEnabledChange(deferredTools, v)} />
)}
{browserTools.length > 0 && (
@@ -183,7 +183,7 @@ const Tools: React.FC = () => {
<Box onClick={() => setCustomSectionOpen((v) => !v)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1, cursor: 'pointer', userSelect: 'none', '&:hover .section-arrow': { color: c.text.secondary } }}>
{customSectionOpen ? <KeyboardArrowDownIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} /> : <KeyboardArrowRightIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} />}
<BuildIcon sx={{ fontSize: 14, color: c.text.tertiary }} />
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Custom Action Sets</Typography>
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Connections</Typography>
<Chip label={tools.length + uninstalledIntegrations.length} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
</Box>
<Collapse in={customSectionOpen} timeout={0} unmountOnExit>
@@ -196,7 +196,7 @@ const Tools: React.FC = () => {
) : (tools.length === 0 && uninstalledIntegrations.length === 0) ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 6, color: c.text.ghost, gap: 1.5 }}>
<BuildIcon sx={{ fontSize: 40, opacity: 0.3 }} />
<Typography sx={{ fontSize: '0.9rem' }}>No custom actions defined yet. Create one to get started.</Typography>
<Typography sx={{ fontSize: '0.9rem' }}>No custom tools defined yet. Create one to get started.</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1 }}>
@@ -64,9 +64,9 @@ const BrowserPermissionCard: React.FC<BrowserPermissionCardProps> = ({
<Box sx={{ flex: 1, minWidth: 0, opacity: browserSectionEnabled ? 1 : 0.4, transition: 'opacity 0.2s' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}>Browser</Typography>
<Chip label={`${browserTools.length} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
<Chip label={`${browserTools.length} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>Browser automation delegation and individual browser actions</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>Browser automation delegation and individual browser tools</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
<Switch
@@ -90,8 +90,8 @@ const BrowserPermissionCard: React.FC<BrowserPermissionCardProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<SecurityIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Action Permissions</Typography>
<Chip label={`${browserTools.length} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Tool Permissions</Typography>
<Chip label={`${browserTools.length} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
@@ -156,7 +156,7 @@ const BrowserPermissionCard: React.FC<BrowserPermissionCardProps> = ({
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<KeyboardArrowDownIcon sx={{ fontSize: 16, color: c.text.ghost, transition: 'transform 0.15s', transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }} />
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 600 }}>Browser Actions</Typography>
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 600 }}>Browser Tools</Typography>
<Chip label={browserActionTools.length} size="small" sx={{ bgcolor: c.bg.page, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
<Box sx={{ display: 'flex', gap: 0.25 }} onClick={(e) => e.stopPropagation()}>
@@ -129,7 +129,7 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
<Chip icon={<SettingsIcon sx={{ fontSize: 12 }} />} label="Configured" size="small" sx={{ bgcolor: c.status.warningBg, color: c.status.warning, fontSize: '0.7rem', height: 20, '& .MuiChip-icon': { color: c.status.warning } }} />
)}
{ig && totalToolCount > 0 && (
<Chip label={`${totalToolCount} actions`} size="small" sx={{ bgcolor: `${ig.color}15`, color: ig.color, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
<Chip label={`${totalToolCount} tools`} size="small" sx={{ bgcolor: `${ig.color}15`, color: ig.color, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
)}
{ig && (
<Chip component="a" href={ig.website} clickable icon={<OpenInNewIcon sx={{ fontSize: 10 }} />} label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} />
@@ -190,13 +190,13 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<SecurityIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Action Permissions</Typography>
{hasPerms && <Chip label={`${totalToolCount} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />}
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Tool Permissions</Typography>
{hasPerms && <Chip label={`${totalToolCount} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{hasPerms && (
<>
<Tooltip title="Allow all read-only actions">
<Tooltip title="Allow all read-only tools">
<Button size="small" onClick={() => handleBulkReadOnly(tool.id)} sx={{ color: c.status.info, textTransform: 'none', fontSize: '0.7rem', minWidth: 'auto', px: 1, py: 0.25 }}>
Allow reads
</Button>
@@ -208,7 +208,7 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
</Tooltip>
</>
)}
<Tooltip title="Discover / refresh actions from MCP server">
<Tooltip title="Discover / refresh tools from MCP server">
<IconButton
size="small"
onClick={() => handleDiscover(tool.id)}
@@ -224,7 +224,7 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
{!hasPerms ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 3, gap: 1.5 }}>
<ExtensionIcon sx={{ fontSize: 28, color: c.text.ghost, opacity: 0.4 }} />
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem' }}>No actions discovered yet</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem' }}>No tools discovered yet</Typography>
<Button
size="small"
variant="outlined"
@@ -233,10 +233,10 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
disabled={discovering || !canDiscover}
sx={{ borderColor: c.border.medium, color: c.text.secondary, '&:hover': { borderColor: c.accent.primary, color: c.accent.primary }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5 }}
>
Discover Actions
Discover Tools
</Button>
{!canDiscover && (
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Add an MCP configuration to enable action discovery</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Add an MCP configuration to enable tool discovery</Typography>
)}
</Box>
) : (
@@ -84,8 +84,8 @@ const ToolSection: React.FC<ToolSectionProps> = ({
const overallPolicy = getCatGroupPolicy(allSectionTools);
const categoryCount = CATEGORY_ORDER.filter((cat) => grouped[cat]).length;
const sectionDescription = deferred
? 'On-demand actions loaded via ToolSearch for planning, scheduling, and extended operations'
: 'Built-in Claude Agent SDK actions for file operations, shell commands, and search';
? 'On-demand tools loaded via ToolSearch for planning, scheduling, and extended operations'
: 'Built-in Claude Agent SDK tools for file operations, shell commands, and search';
const firstSentence = (desc: string) => {
if (!desc) return '';
@@ -110,7 +110,7 @@ const ToolSection: React.FC<ToolSectionProps> = ({
<Box sx={{ flex: 1, minWidth: 0, opacity: enabled ? 1 : 0.4, transition: 'opacity 0.2s' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}>{label}</Typography>
<Chip label={`${count} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
<Chip label={`${count} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
{deferred && (
<Chip label="on-demand" size="small" sx={{ bgcolor: c.status.warningBg, color: c.status.warning, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
)}
@@ -139,8 +139,8 @@ const ToolSection: React.FC<ToolSectionProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<SecurityIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Action Permissions</Typography>
<Chip label={`${count} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Tool Permissions</Typography>
<Chip label={`${count} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
@@ -140,10 +140,10 @@ export function useRegistryBrowser({ regServersRaw, setSnackbar, setEditingId, s
}));
if (createTool.fulfilled.match(result)) {
const newTool = result.payload;
setSnackbar({ open: true, message: `Installed "${f.name}", discovering actions…` });
setSnackbar({ open: true, message: `Installed "${f.name}", discovering tools…` });
const discoverResult = await dispatch(discoverTools(newTool.id));
if (discoverTools.fulfilled.match(discoverResult)) {
setSnackbar({ open: true, message: `${f.name} ready, actions discovered` });
setSnackbar({ open: true, message: `${f.name} ready, tools discovered` });
} else {
const detail = (discoverResult as any).error?.message
|| 'discovery failed; the MCP server may need setup first';
@@ -45,7 +45,7 @@ export function useToolConnections({ items, setSnackbar, setExpandedToolId }: De
const afterConnect = async () => {
const statusResult = await dispatch(fetchToolStatus(toolId));
if (fetchToolStatus.fulfilled.match(statusResult) && statusResult.payload.auth_status === 'connected') {
setSnackbar({ open: true, message: 'Account connected! Discovering actions…' });
setSnackbar({ open: true, message: 'Account connected! Discovering tools…' });
setExpandedToolId(toolId);
dispatch(discoverTools(toolId));
} else {
@@ -97,7 +97,7 @@ export function useToolConnections({ items, setSnackbar, setExpandedToolId }: De
if (status === 'connected') {
clearInterval(poll);
setDeviceCodeStatus('connected');
setSnackbar({ open: true, message: `Connected to Microsoft 365${email ? ` as ${email}` : ''}! Discovering actions…` });
setSnackbar({ open: true, message: `Connected to Microsoft 365${email ? ` as ${email}` : ''}! Discovering tools…` });
setDeviceCodeDialogOpen(false);
setExpandedToolId(toolId);
await dispatch(fetchToolStatus(toolId));
@@ -148,7 +148,7 @@ export function useToolConnections({ items, setSnackbar, setExpandedToolId }: De
}));
if (updateTool.fulfilled.match(result)) {
setCredDialogOpen(false);
setSnackbar({ open: true, message: `${credDialogIntegration.name} connected! Re-discovering actions…` });
setSnackbar({ open: true, message: `${credDialogIntegration.name} connected! Re-discovering tools…` });
dispatch(discoverTools(credDialogToolId));
} else {
setSnackbar({ open: true, message: 'Failed to save credentials', severity: 'error' });
@@ -177,7 +177,7 @@ export function useToolConnections({ items, setSnackbar, setExpandedToolId }: De
}));
if (updateTool.fulfilled.match(result)) {
setCredDialogOpen(false);
setSnackbar({ open: true, message: 'Slack connected! Re-discovering actions…' });
setSnackbar({ open: true, message: 'Slack connected! Re-discovering tools…' });
dispatch(discoverTools(credDialogToolId));
} else {
setSnackbar({ open: true, message: 'Failed to save Slack credentials', severity: 'error' });
@@ -56,12 +56,12 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
} else if (existing && existing.enabled === false) {
await dispatch(updateTool({ id: existing.id, enabled: true }));
if (integration.authType === 'oauth2' && existing.auth_status !== 'connected') {
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover actions` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover tools` });
} else {
setSnackbar({ open: true, message: `Enabled ${integration.name}, re-discovering actions…` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, re-discovering tools…` });
const discoverResult = await dispatch(discoverTools(existing.id));
if (discoverTools.fulfilled.match(discoverResult)) {
setSnackbar({ open: true, message: `${integration.name} ready, actions discovered` });
setSnackbar({ open: true, message: `${integration.name} ready, tools discovered` });
} else {
const detail = (discoverResult as any).error?.message || 'discovery failed';
setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' });
@@ -80,12 +80,12 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
if (createTool.fulfilled.match(result)) {
const newTool = result.payload;
if (integration.authType === 'oauth2' || integration.authType === 'device_code') {
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover actions` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover tools` });
} else {
setSnackbar({ open: true, message: `Enabled ${integration.name}, discovering actions…` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, discovering tools…` });
const discoverResult = await dispatch(discoverTools(newTool.id));
if (discoverTools.fulfilled.match(discoverResult)) {
setSnackbar({ open: true, message: `${integration.name} ready, actions discovered` });
setSnackbar({ open: true, message: `${integration.name} ready, tools discovered` });
} else {
const detail = (discoverResult as any).error?.message
|| `discovery failed; is ${integration.mcp_config.command || 'the server'} installed?`;
@@ -104,7 +104,7 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
try {
const result = await dispatch(discoverTools(toolId));
if (discoverTools.fulfilled.match(result)) {
setSnackbar({ open: true, message: 'Actions discovered successfully' });
setSnackbar({ open: true, message: 'Tools discovered successfully' });
} else {
const detail = (result as any).error?.message || 'Discovery failed; is the MCP server running?';
setSnackbar({ open: true, message: detail, severity: 'error' });