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:
parent
6cb3896bd7
commit
d3f19834ff
22 changed files with 1524 additions and 741 deletions
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
559
src/renderer/components/schedules/SchedulesView.tsx
Normal file
559
src/renderer/components/schedules/SchedulesView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ export interface Tab {
|
|||
| 'teams'
|
||||
| 'team'
|
||||
| 'report'
|
||||
| 'extensions';
|
||||
| 'extensions'
|
||||
| 'schedules';
|
||||
|
||||
/** Session ID (required when type === 'session') */
|
||||
sessionId?: string;
|
||||
|
|
|
|||
45
src/renderer/utils/scheduleFormatters.ts
Normal file
45
src/renderer/utils/scheduleFormatters.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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, '');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue