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,
|
_event: IpcMainInvokeEvent,
|
||||||
request?: PluginInstallRequest
|
request?: PluginInstallRequest
|
||||||
): Promise<IpcResult<OperationResult>> {
|
): Promise<IpcResult<OperationResult>> {
|
||||||
return wrapHandler('pluginInstall', () => {
|
return wrapHandler('pluginInstall', async () => {
|
||||||
if (!request || typeof request.pluginId !== 'string' || !request.pluginId) {
|
if (!request || typeof request.pluginId !== 'string' || !request.pluginId) {
|
||||||
throw new Error('Invalid install request: pluginId is required');
|
throw new Error('Invalid install request: pluginId is required');
|
||||||
}
|
}
|
||||||
if (request.scope && !VALID_SCOPES.has(request.scope)) {
|
if (request.scope && !VALID_SCOPES.has(request.scope)) {
|
||||||
throw new Error(`Invalid scope: "${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,
|
scope?: string,
|
||||||
projectPath?: string
|
projectPath?: string
|
||||||
): Promise<IpcResult<OperationResult>> {
|
): Promise<IpcResult<OperationResult>> {
|
||||||
return wrapHandler('pluginUninstall', () => {
|
return wrapHandler('pluginUninstall', async () => {
|
||||||
if (typeof pluginId !== 'string' || !pluginId) {
|
if (typeof pluginId !== 'string' || !pluginId) {
|
||||||
throw new Error('pluginId is required');
|
throw new Error('pluginId is required');
|
||||||
}
|
}
|
||||||
if (scope && !VALID_SCOPES.has(scope)) {
|
if (scope && !VALID_SCOPES.has(scope)) {
|
||||||
throw new Error(`Invalid scope: "${scope}"`);
|
throw new Error(`Invalid scope: "${scope}"`);
|
||||||
}
|
}
|
||||||
return getPluginInstaller().uninstall(
|
const result = await getPluginInstaller().uninstall(
|
||||||
pluginId,
|
pluginId,
|
||||||
typeof scope === 'string' ? scope : undefined,
|
typeof scope === 'string' ? scope : undefined,
|
||||||
typeof projectPath === 'string' ? projectPath : undefined
|
typeof projectPath === 'string' ? projectPath : undefined
|
||||||
);
|
);
|
||||||
|
if (result.state === 'success') {
|
||||||
|
getFacade().invalidateInstalledCache();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,7 +252,7 @@ async function handleMcpInstall(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
request?: McpInstallRequest
|
request?: McpInstallRequest
|
||||||
): Promise<IpcResult<OperationResult>> {
|
): Promise<IpcResult<OperationResult>> {
|
||||||
return wrapHandler('mcpInstall', () => {
|
return wrapHandler('mcpInstall', async () => {
|
||||||
if (!request || typeof request.registryId !== 'string' || !request.registryId) {
|
if (!request || typeof request.registryId !== 'string' || !request.registryId) {
|
||||||
throw new Error('Invalid install request: registryId is required');
|
throw new Error('Invalid install request: registryId is required');
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +262,11 @@ async function handleMcpInstall(
|
||||||
if (request.scope && !VALID_SCOPES.has(request.scope)) {
|
if (request.scope && !VALID_SCOPES.has(request.scope)) {
|
||||||
throw new Error(`Invalid scope: "${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,
|
scope?: string,
|
||||||
projectPath?: string
|
projectPath?: string
|
||||||
): Promise<IpcResult<OperationResult>> {
|
): Promise<IpcResult<OperationResult>> {
|
||||||
return wrapHandler('mcpUninstall', () => {
|
return wrapHandler('mcpUninstall', async () => {
|
||||||
if (typeof name !== 'string' || !name) {
|
if (typeof name !== 'string' || !name) {
|
||||||
throw new Error('Server name is required');
|
throw new Error('Server name is required');
|
||||||
}
|
}
|
||||||
if (scope && !VALID_SCOPES.has(scope)) {
|
if (scope && !VALID_SCOPES.has(scope)) {
|
||||||
throw new Error(`Invalid scope: "${scope}"`);
|
throw new Error(`Invalid scope: "${scope}"`);
|
||||||
}
|
}
|
||||||
return getMcpInstaller().uninstall(
|
const result = await getMcpInstaller().uninstall(
|
||||||
name,
|
name,
|
||||||
typeof scope === 'string' ? scope : undefined,
|
typeof scope === 'string' ? scope : undefined,
|
||||||
typeof projectPath === 'string' ? projectPath : undefined
|
typeof projectPath === 'string' ? projectPath : undefined
|
||||||
);
|
);
|
||||||
|
if (result.state === 'success') {
|
||||||
|
getFacade().invalidateInstalledCache();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} 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 { McpServersPanel } from './mcp/McpServersPanel';
|
||||||
import { PluginsPanel } from './plugins/PluginsPanel';
|
import { PluginsPanel } from './plugins/PluginsPanel';
|
||||||
|
|
@ -28,6 +28,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
|
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
|
||||||
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
|
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
|
||||||
const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading);
|
const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading);
|
||||||
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
|
const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked
|
||||||
|
|
||||||
const tabState = useExtensionsTabState();
|
const tabState = useExtensionsTabState();
|
||||||
|
|
||||||
|
|
@ -85,6 +87,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
|
|
||||||
{/* Sub-tabs */}
|
{/* Sub-tabs */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<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
|
<Tabs
|
||||||
value={tabState.activeSubTab}
|
value={tabState.activeSubTab}
|
||||||
onValueChange={(v) => tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers')}
|
onValueChange={(v) => tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers')}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,13 @@
|
||||||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
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';
|
import type { ExtensionOperationState } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
@ -16,6 +23,7 @@ interface InstallButtonProps {
|
||||||
onUninstall: () => void;
|
onUninstall: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'sm' | 'default';
|
size?: 'sm' | 'default';
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InstallButton({
|
export function InstallButton({
|
||||||
|
|
@ -25,7 +33,11 @@ export function InstallButton({
|
||||||
onUninstall,
|
onUninstall,
|
||||||
disabled,
|
disabled,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
|
errorMessage,
|
||||||
}: InstallButtonProps) {
|
}: InstallButtonProps) {
|
||||||
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
|
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
||||||
|
const isDisabled = disabled || cliMissing;
|
||||||
if (state === 'pending') {
|
if (state === 'pending') {
|
||||||
return (
|
return (
|
||||||
<Button size={size} variant="outline" disabled>
|
<Button size={size} variant="outline" disabled>
|
||||||
|
|
@ -45,7 +57,7 @@ export function InstallButton({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === 'error') {
|
if (state === 'error') {
|
||||||
return (
|
const retryButton = (
|
||||||
<Button
|
<Button
|
||||||
size={size}
|
size={size}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -54,33 +66,44 @@ export function InstallButton({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
(isInstalled ? onUninstall : onInstall)();
|
(isInstalled ? onUninstall : onInstall)();
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
<span>Retry</span>
|
<span>Retry</span>
|
||||||
</Button>
|
</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
|
// idle — wrap in tooltip when CLI missing
|
||||||
if (isInstalled) {
|
const button = isInstalled ? (
|
||||||
return (
|
<Button
|
||||||
<Button
|
size={size}
|
||||||
size={size}
|
variant="outline"
|
||||||
variant="outline"
|
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
onUninstall();
|
||||||
onUninstall();
|
}}
|
||||||
}}
|
disabled={isDisabled}
|
||||||
disabled={disabled}
|
>
|
||||||
>
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<span className="ml-1.5">Uninstall</span>
|
||||||
<span className="ml-1.5">Uninstall</span>
|
</Button>
|
||||||
</Button>
|
) : (
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
<Button
|
||||||
size={size}
|
size={size}
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|
@ -88,9 +111,24 @@ export function InstallButton({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onInstall();
|
onInstall();
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
Install
|
Install
|
||||||
</Button>
|
</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 { InstallButton } from '../common/InstallButton';
|
||||||
import { SourceBadge } from '../common/SourceBadge';
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
|
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
import type { McpCatalogItem } from '@shared/types/extensions';
|
import type { McpCatalogItem } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
@ -28,6 +29,7 @@ export const McpServerCard = ({
|
||||||
const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle');
|
const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle');
|
||||||
const installMcpServer = useStore((s) => s.installMcpServer);
|
const installMcpServer = useStore((s) => s.installMcpServer);
|
||||||
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
||||||
|
const installError = useStore((s) => s.installErrors[server.id]);
|
||||||
const canAutoInstall = !!server.installSpec;
|
const canAutoInstall = !!server.installSpec;
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
|
|
||||||
|
|
@ -99,16 +101,15 @@ export const McpServerCard = ({
|
||||||
onInstall={() =>
|
onInstall={() =>
|
||||||
installMcpServer({
|
installMcpServer({
|
||||||
registryId: server.id,
|
registryId: server.id,
|
||||||
serverName: server.name.toLowerCase().replaceAll(/\s+/g, '-'),
|
serverName: sanitizeMcpServerName(server.name),
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
envValues: {},
|
envValues: {},
|
||||||
headers: [],
|
headers: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onUninstall={() =>
|
onUninstall={() => uninstallMcpServer(server.id, sanitizeMcpServerName(server.name))}
|
||||||
uninstallMcpServer(server.id, server.name.toLowerCase().replaceAll(/\s+/g, '-'))
|
|
||||||
}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
|
errorMessage={installError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { ExternalLink, Lock, Plus, Server, Trash2, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { SourceBadge } from '../common/SourceBadge';
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
|
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
import type { McpCatalogItem, McpHeaderDef } from '@shared/types/extensions';
|
import type { McpCatalogItem, McpHeaderDef } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
@ -58,6 +59,7 @@ export const McpServerDetailDialog = ({
|
||||||
);
|
);
|
||||||
const installMcpServer = useStore((s) => s.installMcpServer);
|
const installMcpServer = useStore((s) => s.installMcpServer);
|
||||||
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
||||||
|
const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined));
|
||||||
|
|
||||||
const [scope, setScope] = useState<Scope>('user');
|
const [scope, setScope] = useState<Scope>('user');
|
||||||
const [serverName, setServerName] = useState('');
|
const [serverName, setServerName] = useState('');
|
||||||
|
|
@ -69,7 +71,7 @@ export const McpServerDetailDialog = ({
|
||||||
const [lastServerId, setLastServerId] = useState<string | null>(null);
|
const [lastServerId, setLastServerId] = useState<string | null>(null);
|
||||||
if (server && server.id !== lastServerId) {
|
if (server && server.id !== lastServerId) {
|
||||||
setLastServerId(server.id);
|
setLastServerId(server.id);
|
||||||
setServerName(server.name.toLowerCase().replaceAll(/\s+/g, '-'));
|
setServerName(sanitizeMcpServerName(server.name));
|
||||||
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
|
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
|
||||||
setHeaders([]);
|
setHeaders([]);
|
||||||
setImgError(false);
|
setImgError(false);
|
||||||
|
|
@ -303,6 +305,7 @@ export const McpServerDetailDialog = ({
|
||||||
onUninstall={handleUninstall}
|
onUninstall={handleUninstall}
|
||||||
disabled={!serverName.trim()}
|
disabled={!serverName.trim()}
|
||||||
size="default"
|
size="default"
|
||||||
|
errorMessage={installError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { McpServerCard } from './McpServerCard';
|
||||||
import { McpServerDetailDialog } from './McpServerDetailDialog';
|
import { McpServerDetailDialog } from './McpServerDetailDialog';
|
||||||
|
|
||||||
import type { McpCatalogItem } from '@shared/types/extensions';
|
import type { McpCatalogItem } from '@shared/types/extensions';
|
||||||
|
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc';
|
type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc';
|
||||||
|
|
||||||
|
|
@ -89,17 +90,21 @@ export const McpServersPanel = ({
|
||||||
const isLoading = isSearching ? mcpSearchLoading : browseLoading;
|
const isLoading = isSearching ? mcpSearchLoading : browseLoading;
|
||||||
const warnings = isSearching ? mcpSearchWarnings : [];
|
const warnings = isSearching ? mcpSearchWarnings : [];
|
||||||
|
|
||||||
// Installed lookup set
|
// Installed lookup set (lowercase CLI names)
|
||||||
const installedNames = useMemo(
|
const installedNames = useMemo(
|
||||||
() => new Set(installedServers.map((s) => s.name)),
|
() => new Set(installedServers.map((s) => s.name.toLowerCase())),
|
||||||
[installedServers]
|
[installedServers]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Check if a catalog server is installed by comparing sanitized names */
|
||||||
|
const isServerInstalled = (server: McpCatalogItem): boolean =>
|
||||||
|
installedNames.has(sanitizeMcpServerName(server.name));
|
||||||
|
|
||||||
// Sort + filter
|
// Sort + filter
|
||||||
const displayServers = useMemo(() => {
|
const displayServers = useMemo(() => {
|
||||||
let result = rawServers;
|
let result = rawServers;
|
||||||
if (mcpInstalledOnly) {
|
if (mcpInstalledOnly) {
|
||||||
result = result.filter((s) => installedNames.has(s.name));
|
result = result.filter(isServerInstalled);
|
||||||
}
|
}
|
||||||
return sortMcpServers(result, mcpSort);
|
return sortMcpServers(result, mcpSort);
|
||||||
}, [rawServers, mcpSort, mcpInstalledOnly, installedNames]);
|
}, [rawServers, mcpSort, mcpInstalledOnly, installedNames]);
|
||||||
|
|
@ -236,7 +241,7 @@ export const McpServersPanel = ({
|
||||||
<McpServerCard
|
<McpServerCard
|
||||||
key={server.id}
|
key={server.id}
|
||||||
server={server}
|
server={server}
|
||||||
isInstalled={installedNames.has(server.name)}
|
isInstalled={isServerInstalled(server)}
|
||||||
onClick={setSelectedMcpServerId}
|
onClick={setSelectedMcpServerId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -260,7 +265,7 @@ export const McpServersPanel = ({
|
||||||
{/* Detail dialog */}
|
{/* Detail dialog */}
|
||||||
<McpServerDetailDialog
|
<McpServerDetailDialog
|
||||||
server={selectedServer}
|
server={selectedServer}
|
||||||
isInstalled={selectedServer ? installedNames.has(selectedServer.name) : false}
|
isInstalled={selectedServer ? isServerInstalled(selectedServer) : false}
|
||||||
open={selectedMcpServerId !== null}
|
open={selectedMcpServerId !== null}
|
||||||
onClose={() => setSelectedMcpServerId(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 installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle');
|
||||||
const installPlugin = useStore((s) => s.installPlugin);
|
const installPlugin = useStore((s) => s.installPlugin);
|
||||||
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
||||||
|
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -81,6 +82,7 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem
|
||||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
||||||
onUninstall={() => uninstallPlugin(plugin.pluginId)}
|
onUninstall={() => uninstallPlugin(plugin.pluginId)}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
errorMessage={installError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ export const PluginDetailDialog = ({
|
||||||
);
|
);
|
||||||
const installPlugin = useStore((s) => s.installPlugin);
|
const installPlugin = useStore((s) => s.installPlugin);
|
||||||
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
||||||
|
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
|
||||||
|
|
||||||
const [scope, setScope] = useState<InstallScope>('user');
|
const [scope, setScope] = useState<InstallScope>('user');
|
||||||
|
|
||||||
|
|
@ -152,6 +153,7 @@ export const PluginDetailDialog = ({
|
||||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })}
|
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })}
|
||||||
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)}
|
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)}
|
||||||
size="default"
|
size="default"
|
||||||
|
errorMessage={installError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { DashboardView } from '../dashboard/DashboardView';
|
||||||
import { ExtensionStoreView } from '../extensions/ExtensionStoreView';
|
import { ExtensionStoreView } from '../extensions/ExtensionStoreView';
|
||||||
import { NotificationsView } from '../notifications/NotificationsView';
|
import { NotificationsView } from '../notifications/NotificationsView';
|
||||||
import { SessionReportTab } from '../report/SessionReportTab';
|
import { SessionReportTab } from '../report/SessionReportTab';
|
||||||
|
import { SchedulesView } from '../schedules/SchedulesView';
|
||||||
import { SettingsView } from '../settings/SettingsView';
|
import { SettingsView } from '../settings/SettingsView';
|
||||||
import { TeamDetailView } from '../team/TeamDetailView';
|
import { TeamDetailView } from '../team/TeamDetailView';
|
||||||
import { TeamListView } from '../team/TeamListView';
|
import { TeamListView } from '../team/TeamListView';
|
||||||
|
|
@ -63,6 +64,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
|
||||||
<ExtensionStoreView />
|
<ExtensionStoreView />
|
||||||
</TabUIProvider>
|
</TabUIProvider>
|
||||||
)}
|
)}
|
||||||
|
{tab.type === 'schedules' && <SchedulesView />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bell,
|
Bell,
|
||||||
|
Calendar,
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Pin,
|
Pin,
|
||||||
|
|
@ -50,6 +51,7 @@ const TAB_ICONS = {
|
||||||
team: Users,
|
team: Users,
|
||||||
report: Activity,
|
report: Activity,
|
||||||
extensions: Puzzle,
|
extensions: Puzzle,
|
||||||
|
schedules: Calendar,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SortableTab = ({
|
export const SortableTab = ({
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { isElectronMode } from '@renderer/api';
|
||||||
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
|
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatShortcut } from '@renderer/utils/stringUtils';
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { MoreMenu } from './MoreMenu';
|
import { MoreMenu } from './MoreMenu';
|
||||||
|
|
@ -45,6 +45,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
openNotificationsTab,
|
openNotificationsTab,
|
||||||
openTeamsTab,
|
openTeamsTab,
|
||||||
openExtensionsTab,
|
openExtensionsTab,
|
||||||
|
openSchedulesTab,
|
||||||
openSettingsTab,
|
openSettingsTab,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
|
|
@ -73,6 +74,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
openNotificationsTab: s.openNotificationsTab,
|
openNotificationsTab: s.openNotificationsTab,
|
||||||
openTeamsTab: s.openTeamsTab,
|
openTeamsTab: s.openTeamsTab,
|
||||||
openExtensionsTab: s.openExtensionsTab,
|
openExtensionsTab: s.openExtensionsTab,
|
||||||
|
openSchedulesTab: s.openSchedulesTab,
|
||||||
openSettingsTab: s.openSettingsTab,
|
openSettingsTab: s.openSettingsTab,
|
||||||
sidebarCollapsed: s.sidebarCollapsed,
|
sidebarCollapsed: s.sidebarCollapsed,
|
||||||
toggleSidebar: s.toggleSidebar,
|
toggleSidebar: s.toggleSidebar,
|
||||||
|
|
@ -106,6 +108,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
const [newTabHover, setNewTabHover] = useState(false);
|
const [newTabHover, setNewTabHover] = useState(false);
|
||||||
const [notificationsHover, setNotificationsHover] = useState(false);
|
const [notificationsHover, setNotificationsHover] = useState(false);
|
||||||
const [teamsHover, setTeamsHover] = useState(false);
|
const [teamsHover, setTeamsHover] = useState(false);
|
||||||
|
const [schedulesHover, setSchedulesHover] = useState(false);
|
||||||
const [extensionsHover, setExtensionsHover] = useState(false);
|
const [extensionsHover, setExtensionsHover] = useState(false);
|
||||||
const [githubHover, setGithubHover] = useState(false);
|
const [githubHover, setGithubHover] = useState(false);
|
||||||
const [settingsHover, setSettingsHover] = useState(false);
|
const [settingsHover, setSettingsHover] = useState(false);
|
||||||
|
|
@ -419,6 +422,21 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
<Users className="size-4" />
|
<Users className="size-4" />
|
||||||
</button>
|
</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 */}
|
{/* Extensions icon */}
|
||||||
<button
|
<button
|
||||||
onClick={openExtensionsTab}
|
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>
|
</Dialog>
|
||||||
|
|
||||||
<LaunchTeamDialog
|
<LaunchTeamDialog
|
||||||
|
mode="launch"
|
||||||
open={launchDialogOpen}
|
open={launchDialogOpen}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={data?.members ?? []}
|
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 { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -15,9 +16,8 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import cronstrue from 'cronstrue/i18n';
|
|
||||||
|
|
||||||
import { ScheduleDialog } from './ScheduleDialog';
|
import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog';
|
||||||
import { ScheduleEmptyState } from './ScheduleEmptyState';
|
import { ScheduleEmptyState } from './ScheduleEmptyState';
|
||||||
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
|
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
|
||||||
import { ScheduleRunRow } from './ScheduleRunRow';
|
import { ScheduleRunRow } from './ScheduleRunRow';
|
||||||
|
|
@ -33,47 +33,6 @@ interface ScheduleSectionProps {
|
||||||
teamName: string;
|
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
|
// ScheduleRow
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -341,7 +300,8 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* Create/Edit Dialog */}
|
||||||
<ScheduleDialog
|
<LaunchTeamDialog
|
||||||
|
mode="schedule"
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
schedule={editingSchedule}
|
schedule={editingSchedule}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export interface ExtensionsSlice {
|
||||||
// ── Install progress ──
|
// ── Install progress ──
|
||||||
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
||||||
mcpInstallProgress: Record<string, ExtensionOperationState>;
|
mcpInstallProgress: Record<string, ExtensionOperationState>;
|
||||||
|
installErrors: Record<string, string>; // keyed by pluginId or registryId
|
||||||
|
|
||||||
// ── Read actions ──
|
// ── Read actions ──
|
||||||
fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise<void>;
|
fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise<void>;
|
||||||
|
|
@ -93,6 +94,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
pluginInstallProgress: {},
|
pluginInstallProgress: {},
|
||||||
mcpInstallProgress: {},
|
mcpInstallProgress: {},
|
||||||
|
installErrors: {},
|
||||||
|
|
||||||
// ── Plugin catalog fetch ──
|
// ── Plugin catalog fetch ──
|
||||||
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
|
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
|
||||||
|
|
@ -202,6 +204,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
if (result.state === 'error') {
|
if (result.state === 'error') {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||||
|
installErrors: {
|
||||||
|
...prev.installErrors,
|
||||||
|
[request.pluginId]: result.error ?? 'Install failed',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -219,9 +225,11 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' },
|
||||||
}));
|
}));
|
||||||
}, SUCCESS_DISPLAY_MS);
|
}, SUCCESS_DISPLAY_MS);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Install failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
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') {
|
if (result.state === 'error') {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||||
|
installErrors: { ...prev.installErrors, [pluginId]: result.error ?? 'Uninstall failed' },
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -255,16 +264,27 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
|
||||||
}));
|
}));
|
||||||
}, SUCCESS_DISPLAY_MS);
|
}, SUCCESS_DISPLAY_MS);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||||
|
installErrors: { ...prev.installErrors, [pluginId]: message },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── MCP install ──
|
// ── MCP install ──
|
||||||
installMcpServer: async (request: McpInstallRequest) => {
|
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) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' },
|
||||||
|
|
@ -275,6 +295,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
if (result.state === 'error') {
|
if (result.state === 'error') {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
|
||||||
|
installErrors: {
|
||||||
|
...prev.installErrors,
|
||||||
|
[request.registryId]: result.error ?? 'Install failed',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -291,9 +315,11 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' },
|
||||||
}));
|
}));
|
||||||
}, SUCCESS_DISPLAY_MS);
|
}, SUCCESS_DISPLAY_MS);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Install failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
|
||||||
|
installErrors: { ...prev.installErrors, [request.registryId]: message },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -305,7 +331,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
scope?: string,
|
scope?: string,
|
||||||
projectPath?: 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) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' },
|
||||||
|
|
@ -316,6 +348,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
if (result.state === 'error') {
|
if (result.state === 'error') {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
|
||||||
|
installErrors: {
|
||||||
|
...prev.installErrors,
|
||||||
|
[registryId]: result.error ?? 'Uninstall failed',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -331,9 +367,11 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },
|
||||||
}));
|
}));
|
||||||
}, SUCCESS_DISPLAY_MS);
|
}, SUCCESS_DISPLAY_MS);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
|
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 */
|
/** Optimistic in-memory update from SCHEDULE_CHANGE events */
|
||||||
applyScheduleChange(scheduleId: string): Promise<void>;
|
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);
|
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'
|
| 'teams'
|
||||||
| 'team'
|
| 'team'
|
||||||
| 'report'
|
| 'report'
|
||||||
| 'extensions';
|
| 'extensions'
|
||||||
|
| 'schedules';
|
||||||
|
|
||||||
/** Session ID (required when type === 'session') */
|
/** Session ID (required when type === 'session') */
|
||||||
sessionId?: string;
|
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 {
|
export function buildPluginId(pluginName: string, marketplaceName: string): string {
|
||||||
return `${pluginName}@${marketplaceName}`;
|
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,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
normalizeRepoUrl,
|
normalizeRepoUrl,
|
||||||
|
sanitizeMcpServerName,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
describe('normalizeRepoUrl', () => {
|
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