feat: enhance extension installation and uninstallation flows

- Refactored plugin and MCP server installation/uninstallation handlers to use async/await for improved readability and error handling.
- Added cache invalidation logic upon successful installation of plugins and MCP servers to ensure up-to-date state.
- Introduced error message handling for installation failures, providing users with clearer feedback on issues encountered during the process.
- Updated UI components to display error messages related to installation and uninstallation, enhancing user experience.
- Implemented sanitization for MCP server names to ensure compliance with CLI requirements.
This commit is contained in:
iliya 2026-03-08 01:44:58 +02:00
parent 6cb3896bd7
commit d3f19834ff
22 changed files with 1524 additions and 741 deletions

View file

@ -208,14 +208,18 @@ async function handlePluginInstall(
_event: IpcMainInvokeEvent,
request?: PluginInstallRequest
): Promise<IpcResult<OperationResult>> {
return wrapHandler('pluginInstall', () => {
return wrapHandler('pluginInstall', async () => {
if (!request || typeof request.pluginId !== 'string' || !request.pluginId) {
throw new Error('Invalid install request: pluginId is required');
}
if (request.scope && !VALID_SCOPES.has(request.scope)) {
throw new Error(`Invalid scope: "${request.scope}"`);
}
return getPluginInstaller().install(request);
const result = await getPluginInstaller().install(request);
if (result.state === 'success') {
getFacade().invalidateInstalledCache();
}
return result;
});
}
@ -225,18 +229,22 @@ async function handlePluginUninstall(
scope?: string,
projectPath?: string
): Promise<IpcResult<OperationResult>> {
return wrapHandler('pluginUninstall', () => {
return wrapHandler('pluginUninstall', async () => {
if (typeof pluginId !== 'string' || !pluginId) {
throw new Error('pluginId is required');
}
if (scope && !VALID_SCOPES.has(scope)) {
throw new Error(`Invalid scope: "${scope}"`);
}
return getPluginInstaller().uninstall(
const result = await getPluginInstaller().uninstall(
pluginId,
typeof scope === 'string' ? scope : undefined,
typeof projectPath === 'string' ? projectPath : undefined
);
if (result.state === 'success') {
getFacade().invalidateInstalledCache();
}
return result;
});
}
@ -244,7 +252,7 @@ async function handleMcpInstall(
_event: IpcMainInvokeEvent,
request?: McpInstallRequest
): Promise<IpcResult<OperationResult>> {
return wrapHandler('mcpInstall', () => {
return wrapHandler('mcpInstall', async () => {
if (!request || typeof request.registryId !== 'string' || !request.registryId) {
throw new Error('Invalid install request: registryId is required');
}
@ -254,7 +262,11 @@ async function handleMcpInstall(
if (request.scope && !VALID_SCOPES.has(request.scope)) {
throw new Error(`Invalid scope: "${request.scope}"`);
}
return getMcpInstaller().install(request);
const result = await getMcpInstaller().install(request);
if (result.state === 'success') {
getFacade().invalidateInstalledCache();
}
return result;
});
}
@ -264,17 +276,21 @@ async function handleMcpUninstall(
scope?: string,
projectPath?: string
): Promise<IpcResult<OperationResult>> {
return wrapHandler('mcpUninstall', () => {
return wrapHandler('mcpUninstall', async () => {
if (typeof name !== 'string' || !name) {
throw new Error('Server name is required');
}
if (scope && !VALID_SCOPES.has(scope)) {
throw new Error(`Invalid scope: "${scope}"`);
}
return getMcpInstaller().uninstall(
const result = await getMcpInstaller().uninstall(
name,
typeof scope === 'string' ? scope : undefined,
typeof projectPath === 'string' ? projectPath : undefined
);
if (result.state === 'success') {
getFacade().invalidateInstalledCache();
}
return result;
});
}

View file

@ -17,7 +17,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { Puzzle, RefreshCw, Server } from 'lucide-react';
import { AlertTriangle, Puzzle, RefreshCw, Server } from 'lucide-react';
import { McpServersPanel } from './mcp/McpServersPanel';
import { PluginsPanel } from './plugins/PluginsPanel';
@ -28,6 +28,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading);
const cliStatus = useStore((s) => s.cliStatus);
const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked
const tabState = useExtensionsTabState();
@ -85,6 +87,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {
{/* Sub-tabs */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{/* CLI not installed warning */}
{!cliInstalled && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-400">
<AlertTriangle className="size-4 shrink-0" />
Claude CLI is required to install or uninstall extensions. Install it from Settings.
</div>
)}
<Tabs
value={tabState.activeSubTab}
onValueChange={(v) => tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers')}

View file

@ -6,6 +6,13 @@
import { Check, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@renderer/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import type { ExtensionOperationState } from '@shared/types/extensions';
@ -16,6 +23,7 @@ interface InstallButtonProps {
onUninstall: () => void;
disabled?: boolean;
size?: 'sm' | 'default';
errorMessage?: string;
}
export function InstallButton({
@ -25,7 +33,11 @@ export function InstallButton({
onUninstall,
disabled,
size = 'sm',
errorMessage,
}: InstallButtonProps) {
const cliStatus = useStore((s) => s.cliStatus);
const cliMissing = cliStatus !== null && !cliStatus.installed;
const isDisabled = disabled || cliMissing;
if (state === 'pending') {
return (
<Button size={size} variant="outline" disabled>
@ -45,7 +57,7 @@ export function InstallButton({
}
if (state === 'error') {
return (
const retryButton = (
<Button
size={size}
variant="outline"
@ -54,33 +66,44 @@ export function InstallButton({
e.stopPropagation();
(isInstalled ? onUninstall : onInstall)();
}}
disabled={disabled}
disabled={isDisabled}
>
<span>Retry</span>
</Button>
);
if (errorMessage) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{retryButton}</span>
</TooltipTrigger>
<TooltipContent className="max-w-64 text-red-300">{errorMessage}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return retryButton;
}
// idle
if (isInstalled) {
return (
<Button
size={size}
variant="outline"
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
onClick={(e) => {
e.stopPropagation();
onUninstall();
}}
disabled={disabled}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="ml-1.5">Uninstall</span>
</Button>
);
}
return (
// idle — wrap in tooltip when CLI missing
const button = isInstalled ? (
<Button
size={size}
variant="outline"
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
onClick={(e) => {
e.stopPropagation();
onUninstall();
}}
disabled={isDisabled}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="ml-1.5">Uninstall</span>
</Button>
) : (
<Button
size={size}
variant="default"
@ -88,9 +111,24 @@ export function InstallButton({
e.stopPropagation();
onInstall();
}}
disabled={disabled}
disabled={isDisabled}
>
Install
</Button>
);
if (cliMissing) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{button}</span>
</TooltipTrigger>
<TooltipContent>Claude CLI required</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
}

View file

@ -11,6 +11,7 @@ import { Lock, Server, Wrench } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
import type { McpCatalogItem } from '@shared/types/extensions';
@ -28,6 +29,7 @@ export const McpServerCard = ({
const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle');
const installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
const installError = useStore((s) => s.installErrors[server.id]);
const canAutoInstall = !!server.installSpec;
const [imgError, setImgError] = useState(false);
@ -99,16 +101,15 @@ export const McpServerCard = ({
onInstall={() =>
installMcpServer({
registryId: server.id,
serverName: server.name.toLowerCase().replaceAll(/\s+/g, '-'),
serverName: sanitizeMcpServerName(server.name),
scope: 'user',
envValues: {},
headers: [],
})
}
onUninstall={() =>
uninstallMcpServer(server.id, server.name.toLowerCase().replaceAll(/\s+/g, '-'))
}
onUninstall={() => uninstallMcpServer(server.id, sanitizeMcpServerName(server.name))}
size="sm"
errorMessage={installError}
/>
</div>
)}

View file

@ -29,6 +29,7 @@ import { ExternalLink, Lock, Plus, Server, Trash2, Wrench } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
import type { McpCatalogItem, McpHeaderDef } from '@shared/types/extensions';
@ -58,6 +59,7 @@ export const McpServerDetailDialog = ({
);
const installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined));
const [scope, setScope] = useState<Scope>('user');
const [serverName, setServerName] = useState('');
@ -69,7 +71,7 @@ export const McpServerDetailDialog = ({
const [lastServerId, setLastServerId] = useState<string | null>(null);
if (server && server.id !== lastServerId) {
setLastServerId(server.id);
setServerName(server.name.toLowerCase().replaceAll(/\s+/g, '-'));
setServerName(sanitizeMcpServerName(server.name));
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
setHeaders([]);
setImgError(false);
@ -303,6 +305,7 @@ export const McpServerDetailDialog = ({
onUninstall={handleUninstall}
disabled={!serverName.trim()}
size="default"
errorMessage={installError}
/>
</div>
</div>

View file

@ -23,6 +23,7 @@ import { McpServerCard } from './McpServerCard';
import { McpServerDetailDialog } from './McpServerDetailDialog';
import type { McpCatalogItem } from '@shared/types/extensions';
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc';
@ -89,17 +90,21 @@ export const McpServersPanel = ({
const isLoading = isSearching ? mcpSearchLoading : browseLoading;
const warnings = isSearching ? mcpSearchWarnings : [];
// Installed lookup set
// Installed lookup set (lowercase CLI names)
const installedNames = useMemo(
() => new Set(installedServers.map((s) => s.name)),
() => new Set(installedServers.map((s) => s.name.toLowerCase())),
[installedServers]
);
/** Check if a catalog server is installed by comparing sanitized names */
const isServerInstalled = (server: McpCatalogItem): boolean =>
installedNames.has(sanitizeMcpServerName(server.name));
// Sort + filter
const displayServers = useMemo(() => {
let result = rawServers;
if (mcpInstalledOnly) {
result = result.filter((s) => installedNames.has(s.name));
result = result.filter(isServerInstalled);
}
return sortMcpServers(result, mcpSort);
}, [rawServers, mcpSort, mcpInstalledOnly, installedNames]);
@ -236,7 +241,7 @@ export const McpServersPanel = ({
<McpServerCard
key={server.id}
server={server}
isInstalled={installedNames.has(server.name)}
isInstalled={isServerInstalled(server)}
onClick={setSelectedMcpServerId}
/>
))}
@ -260,7 +265,7 @@ export const McpServersPanel = ({
{/* Detail dialog */}
<McpServerDetailDialog
server={selectedServer}
isInstalled={selectedServer ? installedNames.has(selectedServer.name) : false}
isInstalled={selectedServer ? isServerInstalled(selectedServer) : false}
open={selectedMcpServerId !== null}
onClose={() => setSelectedMcpServerId(null)}
/>

View file

@ -26,6 +26,7 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem
const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle');
const installPlugin = useStore((s) => s.installPlugin);
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
return (
<button
@ -81,6 +82,7 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
onUninstall={() => uninstallPlugin(plugin.pluginId)}
size="sm"
errorMessage={installError}
/>
</div>
</div>

View file

@ -61,6 +61,7 @@ export const PluginDetailDialog = ({
);
const installPlugin = useStore((s) => s.installPlugin);
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
const [scope, setScope] = useState<InstallScope>('user');
@ -152,6 +153,7 @@ export const PluginDetailDialog = ({
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })}
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)}
size="default"
errorMessage={installError}
/>
</div>

View file

@ -9,6 +9,7 @@ import { DashboardView } from '../dashboard/DashboardView';
import { ExtensionStoreView } from '../extensions/ExtensionStoreView';
import { NotificationsView } from '../notifications/NotificationsView';
import { SessionReportTab } from '../report/SessionReportTab';
import { SchedulesView } from '../schedules/SchedulesView';
import { SettingsView } from '../settings/SettingsView';
import { TeamDetailView } from '../team/TeamDetailView';
import { TeamListView } from '../team/TeamListView';
@ -63,6 +64,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
<ExtensionStoreView />
</TabUIProvider>
)}
{tab.type === 'schedules' && <SchedulesView />}
</div>
);
})}

View file

@ -14,6 +14,7 @@ import { nameColorSet } from '@renderer/utils/projectColor';
import {
Activity,
Bell,
Calendar,
FileText,
LayoutDashboard,
Pin,
@ -50,6 +51,7 @@ const TAB_ICONS = {
team: Users,
report: Activity,
extensions: Puzzle,
schedules: Calendar,
} as const;
export const SortableTab = ({

View file

@ -15,7 +15,7 @@ import { isElectronMode } from '@renderer/api';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
import { useStore } from '@renderer/store';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { Bell, PanelLeft, Plus, Puzzle, RefreshCw, Settings, Users } from 'lucide-react';
import { Bell, Calendar, PanelLeft, Plus, Puzzle, RefreshCw, Settings, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { MoreMenu } from './MoreMenu';
@ -45,6 +45,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
openNotificationsTab,
openTeamsTab,
openExtensionsTab,
openSchedulesTab,
openSettingsTab,
sidebarCollapsed,
toggleSidebar,
@ -73,6 +74,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
openNotificationsTab: s.openNotificationsTab,
openTeamsTab: s.openTeamsTab,
openExtensionsTab: s.openExtensionsTab,
openSchedulesTab: s.openSchedulesTab,
openSettingsTab: s.openSettingsTab,
sidebarCollapsed: s.sidebarCollapsed,
toggleSidebar: s.toggleSidebar,
@ -106,6 +108,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
const [newTabHover, setNewTabHover] = useState(false);
const [notificationsHover, setNotificationsHover] = useState(false);
const [teamsHover, setTeamsHover] = useState(false);
const [schedulesHover, setSchedulesHover] = useState(false);
const [extensionsHover, setExtensionsHover] = useState(false);
const [githubHover, setGithubHover] = useState(false);
const [settingsHover, setSettingsHover] = useState(false);
@ -419,6 +422,21 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
<Users className="size-4" />
</button>
{/* Schedules icon */}
<button
onClick={openSchedulesTab}
onMouseEnter={() => setSchedulesHover(true)}
onMouseLeave={() => setSchedulesHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: schedulesHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: schedulesHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Schedules"
>
<Calendar className="size-4" />
</button>
{/* Extensions icon */}
<button
onClick={openExtensionsTab}

View file

@ -0,0 +1,559 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { nameColorSet } from '@renderer/utils/projectColor';
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
import {
Calendar,
ChevronDown,
ChevronRight,
Filter,
MoreHorizontal,
Pause,
Pencil,
Play,
Plus,
Search,
Trash2,
Zap,
} from 'lucide-react';
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
// =============================================================================
// Constants
// =============================================================================
const STATUS_OPTIONS: Array<{ value: ScheduleStatus | 'all'; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'paused', label: 'Paused' },
{ value: 'disabled', label: 'Disabled' },
];
// =============================================================================
// ScheduleListItem
// =============================================================================
interface ScheduleListItemProps {
schedule: Schedule;
onEdit: (schedule: Schedule) => void;
onDelete: (id: string) => void;
onPause: (id: string) => void;
onResume: (id: string) => void;
onTriggerNow: (id: string) => void;
onTeamClick: (teamName: string) => void;
teamColor: string;
}
const ScheduleListItem = ({
schedule,
onEdit,
onDelete,
onPause,
onResume,
onTriggerNow,
onTeamClick,
teamColor,
}: ScheduleListItemProps): React.JSX.Element => {
const [expanded, setExpanded] = useState(false);
const [selectedRun, setSelectedRun] = useState<ScheduleRun | null>(null);
const runs = useStore((s) => s.scheduleRuns[schedule.id] ?? []);
const runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false);
const fetchRunHistory = useStore((s) => s.fetchRunHistory);
const handleExpand = useCallback(() => {
const next = !expanded;
setExpanded(next);
if (next && runs.length === 0 && !runsLoading) {
void fetchRunHistory(schedule.id);
}
}, [expanded, runs.length, runsLoading, fetchRunHistory, schedule.id]);
return (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
{/* Main row */}
<div className="flex items-center gap-3 px-4 py-3">
{/* Expand toggle */}
<button
type="button"
className="shrink-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={handleExpand}
>
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
</button>
{/* Status badge */}
<ScheduleStatusBadge status={schedule.status} />
{/* Label & cron description */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-[var(--color-text)]">
{schedule.label || getCronDescription(schedule.cronExpression)}
</span>
</div>
{schedule.label ? (
<span className="text-xs text-[var(--color-text-muted)]">
{getCronDescription(schedule.cronExpression)}
</span>
) : null}
</div>
{/* Team badge */}
<button
type="button"
className="flex shrink-0 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 py-0.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => onTeamClick(schedule.teamName)}
>
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: teamColor }} />
{schedule.teamName}
</button>
{/* Next run */}
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 text-xs text-[var(--color-text-muted)]">
Next: {formatNextRun(schedule.nextRunAt)}
</span>
</TooltipTrigger>
{schedule.nextRunAt ? (
<TooltipContent side="top" className="text-xs">
{new Date(schedule.nextRunAt).toLocaleString()}
</TooltipContent>
) : null}
</Tooltip>
{/* Timezone */}
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] lg:inline">
{schedule.timezone}
</span>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0"
onClick={() => onTriggerNow(schedule.id)}
disabled={schedule.status !== 'active'}
>
<Zap className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Run now</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="size-7 p-0">
<MoreHorizontal className="size-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-1">
<button
type="button"
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-surface-raised)]"
onClick={() => onEdit(schedule)}
>
<Pencil className="mr-2 size-3.5" />
Edit
</button>
{schedule.status === 'active' ? (
<button
type="button"
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-surface-raised)]"
onClick={() => onPause(schedule.id)}
>
<Pause className="mr-2 size-3.5" />
Pause
</button>
) : (
<button
type="button"
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-surface-raised)]"
onClick={() => onResume(schedule.id)}
>
<Play className="mr-2 size-3.5" />
Resume
</button>
)}
<button
type="button"
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-red-400 hover:bg-[var(--color-surface-raised)]"
onClick={() => onDelete(schedule.id)}
>
<Trash2 className="mr-2 size-3.5" />
Delete
</button>
</PopoverContent>
</Popover>
</div>
</div>
{/* Expanded: Run history */}
{expanded ? (
<div className="border-t border-[var(--color-border)]">
{runsLoading ? (
<div className="flex items-center justify-center py-4 text-xs text-[var(--color-text-muted)]">
Loading run history...
</div>
) : runs.length === 0 ? (
<div className="flex items-center justify-center py-4 text-xs text-[var(--color-text-muted)]">
No runs yet
</div>
) : (
<div className="max-h-[240px] overflow-y-auto">
{runs.slice(0, 15).map((run) => (
<ScheduleRunRow key={run.id} run={run} onClick={setSelectedRun} />
))}
</div>
)}
</div>
) : null}
{/* Run Log Dialog */}
<ScheduleRunLogDialog
open={selectedRun != null}
run={selectedRun}
scheduleId={schedule.id}
onClose={() => setSelectedRun(null)}
/>
</div>
);
};
// =============================================================================
// SchedulesView
// =============================================================================
export const SchedulesView = (): React.JSX.Element => {
const schedules = useStore((s) => s.schedules);
const schedulesLoading = useStore((s) => s.schedulesLoading);
const fetchSchedules = useStore((s) => s.fetchSchedules);
const pauseSchedule = useStore((s) => s.pauseSchedule);
const resumeSchedule = useStore((s) => s.resumeSchedule);
const deleteSchedule = useStore((s) => s.deleteSchedule);
const triggerNow = useStore((s) => s.triggerNow);
const openTeamTab = useStore((s) => s.openTeamTab);
const teamByName = useStore((s) => s.teamByName);
/** Resolve team color dot style for a given team name */
const getTeamColor = useCallback(
(teamName: string): string => {
const team = teamByName[teamName];
if (team?.color) return getTeamColorSet(team.color).text;
return nameColorSet(team?.displayName || teamName).text;
},
[teamByName]
);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<ScheduleStatus | 'all'>('all');
const [teamFilter, setTeamFilter] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
// Fetch schedules on mount
useEffect(() => {
void fetchSchedules();
}, [fetchSchedules]);
// Derive unique team names
const teamNames = useMemo(
() => [...new Set(schedules.map((s) => s.teamName))].sort(),
[schedules]
);
// Filter and sort schedules
const filteredSchedules = useMemo(() => {
let result = schedules;
// Filter by status
if (statusFilter !== 'all') {
result = result.filter((s) => s.status === statusFilter);
}
// Filter by team
if (teamFilter) {
result = result.filter((s) => s.teamName === teamFilter);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(s) =>
(s.label ?? '').toLowerCase().includes(query) ||
s.teamName.toLowerCase().includes(query) ||
s.launchConfig.prompt.toLowerCase().includes(query) ||
getCronDescription(s.cronExpression).toLowerCase().includes(query)
);
}
// Sort: active first, then by next run ascending
return [...result].sort((a, b) => {
// Active schedules first
const statusOrder = { active: 0, paused: 1, disabled: 2 };
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
if (statusDiff !== 0) return statusDiff;
// Then by next run (soonest first)
if (a.nextRunAt && b.nextRunAt) {
return new Date(a.nextRunAt).getTime() - new Date(b.nextRunAt).getTime();
}
if (a.nextRunAt) return -1;
if (b.nextRunAt) return 1;
return 0;
});
}, [schedules, statusFilter, teamFilter, searchQuery]);
// Counts per status
const statusCounts = useMemo(() => {
const counts = { all: schedules.length, active: 0, paused: 0, disabled: 0 };
for (const s of schedules) {
counts[s.status]++;
}
return counts;
}, [schedules]);
const handleEdit = useCallback((schedule: Schedule) => {
setEditingSchedule(schedule);
setDialogOpen(true);
}, []);
const handleCreate = useCallback(() => {
setEditingSchedule(null);
setDialogOpen(true);
}, []);
const handleClose = useCallback(() => {
setDialogOpen(false);
setEditingSchedule(null);
}, []);
const handleDelete = useCallback(
async (id: string) => {
try {
await deleteSchedule(id);
} catch (err) {
console.error('Failed to delete schedule:', err);
}
},
[deleteSchedule]
);
const handleTriggerNow = useCallback(
async (id: string) => {
try {
await triggerNow(id);
} catch (err) {
console.error('Failed to trigger schedule:', err);
}
},
[triggerNow]
);
const handleTeamClick = useCallback(
(teamName: string) => {
openTeamTab(teamName);
},
[openTeamTab]
);
return (
<div className="flex h-full flex-col overflow-hidden bg-[var(--color-surface)]">
{/* Header */}
<div className="shrink-0 border-b border-[var(--color-border)] px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Calendar className="size-5 text-[var(--color-text-muted)]" />
<h1 className="text-lg font-semibold text-[var(--color-text)]">Schedules</h1>
{schedules.length > 0 && (
<span className="rounded-full bg-[var(--color-surface-raised)] px-2 py-0.5 text-xs text-[var(--color-text-muted)]">
{schedules.length}
</span>
)}
</div>
<Button size="sm" className="gap-1.5" onClick={handleCreate}>
<Plus className="size-3.5" />
Add Schedule
</Button>
</div>
{/* Filters row */}
{schedules.length > 0 && (
<div className="mt-3 flex items-center gap-3">
{/* Search */}
<div className="relative max-w-xs flex-1">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-[var(--color-text-muted)]" />
<Input
placeholder="Search schedules..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
{/* Status filter chips */}
<div className="flex items-center gap-1">
{STATUS_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
statusFilter === opt.value
? 'bg-[var(--color-surface-raised)] font-medium text-[var(--color-text)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => setStatusFilter(opt.value)}
>
{opt.label}
{statusCounts[opt.value] > 0 && (
<span className="ml-1 text-[10px] opacity-60">{statusCounts[opt.value]}</span>
)}
</button>
))}
</div>
{/* Team filter */}
{teamNames.length > 1 && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs">
<Filter className="size-3" />
{teamFilter ? (
<>
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: getTeamColor(teamFilter) }}
/>
{teamFilter}
</>
) : (
'All teams'
)}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-48 p-1">
<button
type="button"
className={`flex w-full items-center rounded-sm px-2 py-1.5 text-xs ${
!teamFilter
? 'font-medium text-[var(--color-text)]'
: 'text-[var(--color-text-secondary)]'
} hover:bg-[var(--color-surface-raised)]`}
onClick={() => setTeamFilter(null)}
>
All teams
</button>
{teamNames.map((name) => (
<button
key={name}
type="button"
className={`flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs ${
teamFilter === name
? 'font-medium text-[var(--color-text)]'
: 'text-[var(--color-text-secondary)]'
} hover:bg-[var(--color-surface-raised)]`}
onClick={() => setTeamFilter(name)}
>
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: getTeamColor(name) }}
/>
{name}
</button>
))}
</PopoverContent>
</Popover>
)}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{schedulesLoading && schedules.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
Loading schedules...
</div>
) : schedules.length === 0 ? (
/* Global empty state */
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<Calendar className="size-12 text-[var(--color-text-muted)]" />
<div className="space-y-1.5">
<p className="text-sm font-medium text-[var(--color-text-secondary)]">
No scheduled tasks
</p>
<p className="max-w-sm text-xs text-[var(--color-text-muted)]">
Create a schedule on any team to automate Claude task execution with cron
expressions. Schedules from all teams will appear here.
</p>
</div>
<Button size="sm" variant="outline" className="mt-2 gap-1.5" onClick={handleCreate}>
<Plus className="size-3.5" />
Create Schedule
</Button>
</div>
) : filteredSchedules.length === 0 ? (
/* No results for current filters */
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<Search className="size-8 text-[var(--color-text-muted)]" />
<p className="text-sm text-[var(--color-text-muted)]">
No schedules match the current filters
</p>
<button
type="button"
className="text-xs text-[var(--color-text-secondary)] underline hover:text-[var(--color-text)]"
onClick={() => {
setSearchQuery('');
setStatusFilter('all');
setTeamFilter(null);
}}
>
Clear filters
</button>
</div>
) : (
<div className="space-y-2">
{filteredSchedules.map((schedule) => (
<ScheduleListItem
key={schedule.id}
schedule={schedule}
onEdit={handleEdit}
onDelete={(id) => void handleDelete(id)}
onPause={(id) => void pauseSchedule(id)}
onResume={(id) => void resumeSchedule(id)}
onTriggerNow={(id) => void handleTriggerNow(id)}
onTeamClick={handleTeamClick}
teamColor={getTeamColor(schedule.teamName)}
/>
))}
</div>
)}
</div>
{/* Create/Edit Dialog */}
<LaunchTeamDialog
mode="schedule"
open={dialogOpen}
teamName={editingSchedule?.teamName}
schedule={editingSchedule}
onClose={handleClose}
/>
</div>
);
};

View file

@ -1818,6 +1818,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</Dialog>
<LaunchTeamDialog
mode="launch"
open={launchDialogOpen}
teamName={teamName}
members={data?.members ?? []}

File diff suppressed because it is too large Load diff

View file

@ -1,446 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { Textarea } from '@renderer/components/ui/textarea';
import { useStore } from '@renderer/store';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { EffortLevelSelector } from '../dialogs/EffortLevelSelector';
import { ProjectPathSelector } from '../dialogs/ProjectPathSelector';
import { SkipPermissionsCheckbox } from '../dialogs/SkipPermissionsCheckbox';
import { TeamModelSelector } from '../dialogs/TeamModelSelector';
import { CronScheduleInput } from './CronScheduleInput';
import type { CwdMode } from '../dialogs/ProjectPathSelector';
import type {
CreateScheduleInput,
EffortLevel,
Project,
Schedule,
UpdateSchedulePatch,
} from '@shared/types';
// =============================================================================
// Props
// =============================================================================
interface ScheduleDialogProps {
open: boolean;
teamName: string;
/** When provided, dialog works in edit mode */
schedule?: Schedule | null;
onClose: () => void;
}
// =============================================================================
// Helpers
// =============================================================================
function getLocalTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'UTC';
}
}
// =============================================================================
// Component
// =============================================================================
export const ScheduleDialog = ({
open,
teamName,
schedule,
onClose,
}: ScheduleDialogProps): React.JSX.Element => {
const isEditing = !!schedule;
// --- Form state ---
const [label, setLabel] = useState('');
const [cronExpression, setCronExpression] = useState('0 9 * * 1-5');
const [timezone, setTimezone] = useState(getLocalTimezone);
const [warmUpMinutes, setWarmUpMinutes] = useState(15);
const [maxTurns, setMaxTurns] = useState(50);
const [maxBudgetUsd, setMaxBudgetUsd] = useState('');
const [prompt, setPrompt] = useState('');
const [cwdMode, setCwdMode] = useState<CwdMode>('project');
const [selectedProjectPath, setSelectedProjectPath] = useState('');
const [customCwd, setCustomCwd] = useState('');
const [selectedModel, setSelectedModelRaw] = useState(() => {
const stored = localStorage.getItem('schedule:lastSelectedModel') ?? '';
return stored === '__default__' ? '' : stored;
});
const [skipPermissions, setSkipPermissionsRaw] = useState(true);
const [selectedEffort, setSelectedEffortRaw] = useState(
() => localStorage.getItem('schedule:lastSelectedEffort') ?? ''
);
// --- Projects state ---
const [projects, setProjects] = useState<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
// --- Submission state ---
const [localError, setLocalError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// --- Store actions ---
const createSchedule = useStore((s) => s.createSchedule);
const updateSchedule = useStore((s) => s.updateSchedule);
// --- Persist preferences ---
const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value);
localStorage.setItem('schedule:lastSelectedModel', value);
};
const setSkipPermissions = (value: boolean): void => {
setSkipPermissionsRaw(value);
};
const setSelectedEffort = (value: string): void => {
setSelectedEffortRaw(value);
localStorage.setItem('schedule:lastSelectedEffort', value);
};
// --- Populate form in edit mode ---
useEffect(() => {
if (!open) return;
if (schedule) {
setLabel(schedule.label ?? '');
setCronExpression(schedule.cronExpression);
setTimezone(schedule.timezone);
setWarmUpMinutes(schedule.warmUpMinutes);
setMaxTurns(schedule.maxTurns);
setMaxBudgetUsd(schedule.maxBudgetUsd != null ? String(schedule.maxBudgetUsd) : '');
setPrompt(schedule.launchConfig.prompt);
setCustomCwd(schedule.launchConfig.cwd);
setCwdMode('custom');
setSelectedModelRaw(schedule.launchConfig.model ?? '');
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
} else {
// Reset for create mode
setLabel('');
setCronExpression('0 9 * * 1-5');
setTimezone(getLocalTimezone());
setWarmUpMinutes(15);
setMaxTurns(50);
setMaxBudgetUsd('');
setPrompt('');
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
}
setLocalError(null);
setIsSubmitting(false);
}, [open, schedule]);
// --- Load projects ---
const repositoryGroups = useStore((s) => s.repositoryGroups);
useEffect(() => {
if (!open) return;
setProjectsLoading(true);
setProjectsError(null);
let cancelled = false;
void (async () => {
try {
const apiProjects = await api.getProjects();
if (cancelled) return;
const pathSet = new Set(apiProjects.map((p) => p.path));
const extras: Project[] = [];
for (const repo of repositoryGroups) {
for (const wt of repo.worktrees) {
if (!pathSet.has(wt.path)) {
pathSet.add(wt.path);
extras.push({
id: wt.id,
path: wt.path,
name: wt.name,
sessions: [],
totalSessions: 0,
createdAt: wt.createdAt ?? Date.now(),
});
}
}
}
setProjects([...apiProjects, ...extras]);
} catch (error) {
if (cancelled) return;
setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
setProjects([]);
} finally {
if (!cancelled) setProjectsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open, repositoryGroups]);
// --- Pre-select project ---
useEffect(() => {
if (!open || cwdMode !== 'project' || selectedProjectPath || projects.length === 0) return;
setSelectedProjectPath(projects[0].path);
}, [open, cwdMode, projects, selectedProjectPath]);
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
// --- Validation ---
const validationErrors = useMemo(() => {
const errors: string[] = [];
if (!effectiveCwd) errors.push('Working directory is required');
if (!prompt.trim()) errors.push('Prompt is required');
if (!cronExpression.trim()) errors.push('Cron expression is required');
return errors;
}, [effectiveCwd, prompt, cronExpression]);
// --- Submit ---
const handleSubmit = (): void => {
if (validationErrors.length > 0) {
setLocalError(validationErrors[0]);
return;
}
setLocalError(null);
setIsSubmitting(true);
const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined;
void (async () => {
try {
if (isEditing && schedule) {
const patch: UpdateSchedulePatch = {
label: label.trim() || undefined,
cronExpression: cronExpression.trim(),
timezone,
warmUpMinutes,
maxTurns,
maxBudgetUsd: parsedBudget,
launchConfig: {
cwd: effectiveCwd,
prompt: prompt.trim(),
model: selectedModel || undefined,
effort: (selectedEffort as EffortLevel) || undefined,
skipPermissions,
},
};
await updateSchedule(schedule.id, patch);
} else {
const input: CreateScheduleInput = {
teamName,
label: label.trim() || undefined,
cronExpression: cronExpression.trim(),
timezone,
warmUpMinutes,
maxTurns,
maxBudgetUsd: parsedBudget,
launchConfig: {
cwd: effectiveCwd,
prompt: prompt.trim(),
model: selectedModel || undefined,
effort: (selectedEffort as EffortLevel) || undefined,
skipPermissions,
},
};
await createSchedule(input);
}
onClose();
} catch (err) {
setLocalError(err instanceof Error ? err.message : 'Failed to save schedule');
} finally {
setIsSubmitting(false);
}
})();
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm">
{isEditing ? 'Edit Schedule' : 'Create Schedule'}
</DialogTitle>
<DialogDescription className="text-xs">
{isEditing
? `Editing schedule for team "${teamName}"`
: `Schedule automatic runs for team "${teamName}"`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Label */}
<div className="space-y-1.5">
<Label htmlFor="schedule-label" className="label-optional">
Label (optional)
</Label>
<Input
id="schedule-label"
className="h-8 text-xs"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g., Daily code review, Nightly tests..."
/>
</div>
{/* Cron + Timezone + Warmup */}
<CronScheduleInput
cronExpression={cronExpression}
onCronExpressionChange={setCronExpression}
timezone={timezone}
onTimezoneChange={setTimezone}
warmUpMinutes={warmUpMinutes}
onWarmUpMinutesChange={setWarmUpMinutes}
/>
{/* Project / Working directory */}
<ProjectPathSelector
cwdMode={cwdMode}
onCwdModeChange={setCwdMode}
selectedProjectPath={selectedProjectPath}
onSelectedProjectPathChange={setSelectedProjectPath}
customCwd={customCwd}
onCustomCwdChange={setCustomCwd}
projects={projects}
projectsLoading={projectsLoading}
projectsError={projectsError}
/>
{/* Prompt (required for schedule) */}
<div className="space-y-1.5">
<Label htmlFor="schedule-prompt">
Prompt <span className="text-red-400">*</span>
</Label>
<Textarea
id="schedule-prompt"
className="min-h-[100px] text-xs"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Instructions for Claude to execute on schedule..."
rows={4}
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
This prompt will be passed to <code className="font-mono">claude -p</code> for
one-shot execution
</p>
</div>
{/* Model + Effort + Skip Permissions */}
<div>
<TeamModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
id="schedule-model"
/>
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="schedule-effort"
/>
<SkipPermissionsCheckbox
id="schedule-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
</div>
{/* Execution limits — single row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label
htmlFor="schedule-max-turns"
className="text-[11px] text-[var(--color-text-muted)]"
>
Max turns
</Label>
<Input
id="schedule-max-turns"
type="number"
min={1}
max={500}
className="h-8 text-xs"
value={maxTurns}
onChange={(e) => setMaxTurns(Math.max(1, parseInt(e.target.value) || 50))}
/>
</div>
<div className="space-y-1">
<Label
htmlFor="schedule-max-budget"
className="text-[11px] text-[var(--color-text-muted)]"
>
Max budget (USD)
</Label>
<Input
id="schedule-max-budget"
type="number"
min={0}
step={0.5}
className="h-8 text-xs"
value={maxBudgetUsd}
onChange={(e) => setMaxBudgetUsd(e.target.value)}
placeholder="No limit"
/>
</div>
</div>
</div>
{/* Error display */}
{localError ? (
<div className="flex items-start gap-2 rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{localError}</span>
</div>
) : null}
<DialogFooter className="pt-4">
<Button variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
size="sm"
className="bg-emerald-600 text-white hover:bg-emerald-700"
disabled={isSubmitting || validationErrors.length > 0}
onClick={handleSubmit}
>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
{isEditing ? 'Saving...' : 'Creating...'}
</>
) : isEditing ? (
'Save Changes'
) : (
'Create Schedule'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
import {
ChevronDown,
ChevronRight,
@ -15,9 +16,8 @@ import {
Trash2,
Zap,
} from 'lucide-react';
import cronstrue from 'cronstrue/i18n';
import { ScheduleDialog } from './ScheduleDialog';
import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog';
import { ScheduleEmptyState } from './ScheduleEmptyState';
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
import { ScheduleRunRow } from './ScheduleRunRow';
@ -33,47 +33,6 @@ interface ScheduleSectionProps {
teamName: string;
}
// =============================================================================
// Helpers
// =============================================================================
function formatNextRun(isoString?: string): string {
if (!isoString) return 'N/A';
try {
const date = new Date(isoString);
const now = Date.now();
const diffMs = date.getTime() - now;
if (diffMs < 0) return 'overdue';
const hours = Math.floor(diffMs / 3600_000);
const minutes = Math.floor((diffMs % 3600_000) / 60_000);
if (hours > 24) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
if (hours > 0) return `in ${hours}h ${minutes}m`;
if (minutes > 0) return `in ${minutes}m`;
return 'soon';
} catch {
return isoString;
}
}
function getCronDescription(expression: string): string {
try {
return cronstrue.toString(expression, { locale: 'en', use24HourTimeFormat: true });
} catch {
return expression;
}
}
// =============================================================================
// ScheduleRow
// =============================================================================
@ -341,7 +300,8 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
)}
{/* Create/Edit Dialog */}
<ScheduleDialog
<LaunchTeamDialog
mode="schedule"
open={dialogOpen}
teamName={teamName}
schedule={editingSchedule}

View file

@ -41,6 +41,7 @@ export interface ExtensionsSlice {
// ── Install progress ──
pluginInstallProgress: Record<string, ExtensionOperationState>;
mcpInstallProgress: Record<string, ExtensionOperationState>;
installErrors: Record<string, string>; // keyed by pluginId or registryId
// ── Read actions ──
fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise<void>;
@ -93,6 +94,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
pluginInstallProgress: {},
mcpInstallProgress: {},
installErrors: {},
// ── Plugin catalog fetch ──
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
@ -202,6 +204,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
if (result.state === 'error') {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
installErrors: {
...prev.installErrors,
[request.pluginId]: result.error ?? 'Install failed',
},
}));
return;
}
@ -219,9 +225,11 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch {
} catch (err) {
const message = err instanceof Error ? err.message : 'Install failed';
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
installErrors: { ...prev.installErrors, [request.pluginId]: message },
}));
}
},
@ -239,6 +247,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
if (result.state === 'error') {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
installErrors: { ...prev.installErrors, [pluginId]: result.error ?? 'Uninstall failed' },
}));
return;
}
@ -255,16 +264,27 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch {
} catch (err) {
const message = err instanceof Error ? err.message : 'Uninstall failed';
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
installErrors: { ...prev.installErrors, [pluginId]: message },
}));
}
},
// ── MCP install ──
installMcpServer: async (request: McpInstallRequest) => {
if (!api.mcpRegistry) return;
if (!api.mcpRegistry) {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
installErrors: {
...prev.installErrors,
[request.registryId]: 'MCP Registry not available',
},
}));
return;
}
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' },
@ -275,6 +295,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
if (result.state === 'error') {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
installErrors: {
...prev.installErrors,
[request.registryId]: result.error ?? 'Install failed',
},
}));
return;
}
@ -291,9 +315,11 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch {
} catch (err) {
const message = err instanceof Error ? err.message : 'Install failed';
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
installErrors: { ...prev.installErrors, [request.registryId]: message },
}));
}
},
@ -305,7 +331,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
scope?: string,
projectPath?: string
) => {
if (!api.mcpRegistry) return;
if (!api.mcpRegistry) {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
installErrors: { ...prev.installErrors, [registryId]: 'MCP Registry not available' },
}));
return;
}
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' },
@ -316,6 +348,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
if (result.state === 'error') {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
installErrors: {
...prev.installErrors,
[registryId]: result.error ?? 'Uninstall failed',
},
}));
return;
}
@ -331,9 +367,11 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch {
} catch (err) {
const message = err instanceof Error ? err.message : 'Uninstall failed';
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
installErrors: { ...prev.installErrors, [registryId]: message },
}));
}
},

View file

@ -36,6 +36,9 @@ export interface ScheduleSlice {
/** Optimistic in-memory update from SCHEDULE_CHANGE events */
applyScheduleChange(scheduleId: string): Promise<void>;
/** Open a standalone Schedules tab (or focus existing) */
openSchedulesTab(): void;
}
// =============================================================================
@ -174,4 +177,22 @@ export const createScheduleSlice: StateCreator<AppState, [], [], ScheduleSlice>
logger.error('applyScheduleChange failed:', err);
}
},
openSchedulesTab: () => {
const state = get();
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'schedules');
if (existingTab) {
state.setActiveTab(existingTab.id);
return;
}
state.openTab({
type: 'schedules',
label: 'Schedules',
});
// Ensure schedules are fresh when opening
void get().fetchSchedules();
},
});

View file

@ -84,7 +84,8 @@ export interface Tab {
| 'teams'
| 'team'
| 'report'
| 'extensions';
| 'extensions'
| 'schedules';
/** Session ID (required when type === 'session') */
sessionId?: string;

View file

@ -0,0 +1,45 @@
import cronstrue from 'cronstrue/i18n';
/**
* Format an ISO date string as a human-readable "next run" label.
* Shows relative time for runs within 24h, absolute date otherwise.
*/
export function formatNextRun(isoString?: string): string {
if (!isoString) return 'N/A';
try {
const date = new Date(isoString);
const now = Date.now();
const diffMs = date.getTime() - now;
if (diffMs < 0) return 'overdue';
const hours = Math.floor(diffMs / 3600_000);
const minutes = Math.floor((diffMs % 3600_000) / 60_000);
if (hours > 24) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
if (hours > 0) return `in ${hours}h ${minutes}m`;
if (minutes > 0) return `in ${minutes}m`;
return 'soon';
} catch {
return isoString;
}
}
/**
* Convert a cron expression to a human-readable description.
*/
export function getCronDescription(expression: string): string {
try {
return cronstrue.toString(expression, { locale: 'en', use24HourTimeFormat: true });
} catch {
return expression;
}
}

View file

@ -89,3 +89,14 @@ export function normalizeCategory(raw: string | undefined): string {
export function buildPluginId(pluginName: string, marketplaceName: string): string {
return `${pluginName}@${marketplaceName}`;
}
/**
* Sanitize an MCP server display name into a CLI-safe server name.
* Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService.
*/
export function sanitizeMcpServerName(displayName: string): string {
return displayName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w.-]/g, '');
}

View file

@ -10,6 +10,7 @@ import {
inferCapabilities,
normalizeCategory,
normalizeRepoUrl,
sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers';
describe('normalizeRepoUrl', () => {
@ -145,3 +146,24 @@ describe('buildPluginId', () => {
);
});
});
describe('sanitizeMcpServerName', () => {
it('lowercases and replaces spaces with dashes', () => {
expect(sanitizeMcpServerName('My Server')).toBe('my-server');
});
it('strips special characters', () => {
expect(sanitizeMcpServerName('Stripe (Beta)')).toBe('stripe-beta');
expect(sanitizeMcpServerName('MCP/Server')).toBe('mcpserver');
expect(sanitizeMcpServerName("O'Reilly")).toBe('oreilly');
});
it('keeps dots, underscores, and dashes', () => {
expect(sanitizeMcpServerName('v2.0-server_name')).toBe('v2.0-server_name');
});
it('handles simple names', () => {
expect(sanitizeMcpServerName('Alpic')).toBe('alpic');
expect(sanitizeMcpServerName('Context7')).toBe('context7');
});
});