mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-29 19:29:48 +02:00
[eric] merge #118: move customization into settings (via rebased resolution)
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user