diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index ed2818ac..bce3da19 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -208,14 +208,18 @@ async function handlePluginInstall( _event: IpcMainInvokeEvent, request?: PluginInstallRequest ): Promise> { - 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> { - 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> { - 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> { - 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; }); } diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 5d3da713..f02da4c3 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -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 */}
+ {/* CLI not installed warning */} + {!cliInstalled && ( +
+ + Claude CLI is required to install or uninstall extensions. Install it from Settings. +
+ )} tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers')} diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index 1aafa577..ba65ae09 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -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 ( ); + + if (errorMessage) { + return ( + + + + {retryButton} + + {errorMessage} + + + ); + } + + return retryButton; } - // idle - if (isInstalled) { - return ( - - ); - } - - return ( + // idle — wrap in tooltip when CLI missing + const button = isInstalled ? ( + + ) : ( ); + + if (cliMissing) { + return ( + + + + {button} + + Claude CLI required + + + ); + } + + return button; } diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index 6109c356..c41aeb81 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -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} />
)} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index ad3f8aa5..ce2473ec 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -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('user'); const [serverName, setServerName] = useState(''); @@ -69,7 +71,7 @@ export const McpServerDetailDialog = ({ const [lastServerId, setLastServerId] = useState(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} /> diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 3ea91731..17083121 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -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 = ({ ))} @@ -260,7 +265,7 @@ export const McpServersPanel = ({ {/* Detail dialog */} setSelectedMcpServerId(null)} /> diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index f82f1c08..0bcb142e 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -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 ( + {/* Schedules icon */} + + {/* Extensions icon */} + + {/* Status badge */} + + + {/* Label & cron description */} +
+
+ + {schedule.label || getCronDescription(schedule.cronExpression)} + +
+ {schedule.label ? ( + + {getCronDescription(schedule.cronExpression)} + + ) : null} +
+ + {/* Team badge */} + + + {/* Next run */} + + + + Next: {formatNextRun(schedule.nextRunAt)} + + + {schedule.nextRunAt ? ( + + {new Date(schedule.nextRunAt).toLocaleString()} + + ) : null} + + + {/* Timezone */} + + {schedule.timezone} + + + {/* Actions */} +
+ + + + + Run now + + + + + + + + + {schedule.status === 'active' ? ( + + ) : ( + + )} + + + +
+ + + {/* Expanded: Run history */} + {expanded ? ( +
+ {runsLoading ? ( +
+ Loading run history... +
+ ) : runs.length === 0 ? ( +
+ No runs yet +
+ ) : ( +
+ {runs.slice(0, 15).map((run) => ( + + ))} +
+ )} +
+ ) : null} + + {/* Run Log Dialog */} + setSelectedRun(null)} + /> + + ); +}; + +// ============================================================================= +// 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('all'); + const [teamFilter, setTeamFilter] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingSchedule, setEditingSchedule] = useState(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 ( +
+ {/* Header */} +
+
+
+ +

Schedules

+ {schedules.length > 0 && ( + + {schedules.length} + + )} +
+ +
+ + {/* Filters row */} + {schedules.length > 0 && ( +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+ + {/* Status filter chips */} +
+ {STATUS_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Team filter */} + {teamNames.length > 1 && ( + + + + + + + {teamNames.map((name) => ( + + ))} + + + )} +
+ )} +
+ + {/* Content */} +
+ {schedulesLoading && schedules.length === 0 ? ( +
+ Loading schedules... +
+ ) : schedules.length === 0 ? ( + /* Global empty state */ +
+ +
+

+ No scheduled tasks +

+

+ Create a schedule on any team to automate Claude task execution with cron + expressions. Schedules from all teams will appear here. +

+
+ +
+ ) : filteredSchedules.length === 0 ? ( + /* No results for current filters */ +
+ +

+ No schedules match the current filters +

+ +
+ ) : ( +
+ {filteredSchedules.map((schedule) => ( + void handleDelete(id)} + onPause={(id) => void pauseSchedule(id)} + onResume={(id) => void resumeSchedule(id)} + onTriggerNow={(id) => void handleTriggerNow(id)} + onTeamClick={handleTeamClick} + teamColor={getTeamColor(schedule.teamName)} + /> + ))} +
+ )} +
+ + {/* Create/Edit Dialog */} + +
+ ); +}; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 90e4d039..20512114 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1818,6 +1818,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele void; +} + +interface LaunchDialogLaunchMode extends LaunchDialogBase { + mode: 'launch'; members: ResolvedTeamMember[]; defaultProjectPath?: string; provisioningError: string | null; clearProvisioningError?: () => void; activeTeams?: ActiveTeamRef[]; - onClose: () => void; onLaunch: (request: TeamLaunchRequest) => Promise; } -export const LaunchTeamDialog = ({ - open, - teamName, - members, - defaultProjectPath, - provisioningError, - clearProvisioningError, - activeTeams, - onClose, - onLaunch, -}: LaunchTeamDialogProps): React.JSX.Element => { +interface LaunchDialogScheduleMode { + mode: 'schedule'; + open: boolean; + /** Team name — optional when creating from standalone schedules page */ + teamName?: string; + onClose: () => void; + /** When provided → edit mode; null/undefined → create mode */ + schedule?: Schedule | null; +} + +export type LaunchTeamDialogProps = LaunchDialogLaunchMode | LaunchDialogScheduleMode; + +// ============================================================================= +// Helpers +// ============================================================================= + +function getLocalTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'UTC'; + } +} + +// ============================================================================= +// Component +// ============================================================================= + +export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => { + const { open, onClose } = props; + const isLaunch = props.mode === 'launch'; + const isSchedule = props.mode === 'schedule'; + const schedule = isSchedule ? ((props as LaunchDialogScheduleMode).schedule ?? null) : null; + const isEditing = isSchedule && !!schedule; + + // Team name: always present for launch mode, may be absent in schedule mode (standalone page) + const propsTeamName = props.teamName ?? ''; + const [selectedTeamName, setSelectedTeamName] = useState(''); + const teamByName = useStore((s) => s.teamByName); + const teamOptions = useMemo( + () => + Object.values(teamByName) + .sort((a, b) => a.teamName.localeCompare(b.teamName)) + .map((team) => ({ + value: team.teamName, + label: team.displayName || team.teamName, + description: team.description || undefined, + meta: { color: team.color }, + })), + [teamByName] + ); + + // Effective team name: from props if provided, otherwise from local selection + const effectiveTeamName = propsTeamName || selectedTeamName; + const needsTeamSelector = isSchedule && !propsTeamName; + + // --------------------------------------------------------------------------- + // Shared form state + // --------------------------------------------------------------------------- + const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project'); const [selectedProjectPath, setSelectedProjectPath] = useState(''); const [customCwd, setCustomCwd] = useState(''); - const promptDraft = useDraftPersistence({ key: `launchTeam:${teamName}:prompt` }); - const chipDraft = useChipDraftPersistence(`launchTeam:${teamName}:chips`); + const promptDraft = useDraftPersistence({ + key: `launchTeam:${effectiveTeamName || 'standalone'}:${props.mode}:prompt`, + }); + const chipDraft = useChipDraftPersistence( + `launchTeam:${effectiveTeamName || 'standalone'}:${props.mode}:chips` + ); const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); const [localError, setLocalError] = useState(null); - const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle'); - const [prepareMessage, setPrepareMessage] = useState(null); - const [prepareWarnings, setPrepareWarnings] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedModel, setSelectedModelRaw] = useState(() => { const stored = localStorage.getItem('team:lastSelectedModel') ?? ''; return stored === '__default__' ? '' : stored; }); - const [extendedContext, setExtendedContextRaw] = useState( - () => localStorage.getItem('team:lastExtendedContext') === 'true' - ); const [skipPermissions, setSkipPermissionsRaw] = useState( () => localStorage.getItem('team:lastSkipPermissions') !== 'false' ); const [selectedEffort, setSelectedEffortRaw] = useState( () => localStorage.getItem('team:lastSelectedEffort') ?? '' ); + + // --------------------------------------------------------------------------- + // Launch-only state + // --------------------------------------------------------------------------- + + const [extendedContext, setExtendedContextRaw] = useState( + () => localStorage.getItem('team:lastExtendedContext') === 'true' + ); const [clearContext, setClearContext] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false); + const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle'); + const [prepareMessage, setPrepareMessage] = useState(null); + const [prepareWarnings, setPrepareWarnings] = useState([]); // Advanced CLI section state (with localStorage persistence) const [worktreeEnabled, setWorktreeEnabledRaw] = useState( () => - localStorage.getItem(`team:lastWorktreeEnabled:${teamName}`) === 'true' && - Boolean(localStorage.getItem(`team:lastWorktreeName:${teamName}`)) + localStorage.getItem(`team:lastWorktreeEnabled:${effectiveTeamName}`) === 'true' && + Boolean(localStorage.getItem(`team:lastWorktreeName:${effectiveTeamName}`)) ); const [worktreeName, setWorktreeNameRaw] = useState( - () => localStorage.getItem(`team:lastWorktreeName:${teamName}`) ?? '' + () => localStorage.getItem(`team:lastWorktreeName:${effectiveTeamName}`) ?? '' ); const [customArgs, setCustomArgsRaw] = useState( - () => localStorage.getItem(`team:lastCustomArgs:${teamName}`) ?? '' + () => localStorage.getItem(`team:lastCustomArgs:${effectiveTeamName}`) ?? '' ); + // --------------------------------------------------------------------------- + // Schedule-only state + // --------------------------------------------------------------------------- + + const [schedLabel, setSchedLabel] = useState(''); + const [schedExpanded, setSchedExpanded] = useState(true); + 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(''); + + // Schedule store actions + const createSchedule = useStore((s) => s.createSchedule); + const updateSchedule = useStore((s) => s.updateSchedule); + + // --------------------------------------------------------------------------- + // localStorage persistence wrappers + // --------------------------------------------------------------------------- + const setWorktreeEnabled = (value: boolean): void => { setWorktreeEnabledRaw(value); - localStorage.setItem(`team:lastWorktreeEnabled:${teamName}`, String(value)); + localStorage.setItem(`team:lastWorktreeEnabled:${effectiveTeamName}`, String(value)); if (!value) { setWorktreeNameRaw(''); - localStorage.setItem(`team:lastWorktreeName:${teamName}`, ''); + localStorage.setItem(`team:lastWorktreeName:${effectiveTeamName}`, ''); } }; const setWorktreeName = (value: string): void => { setWorktreeNameRaw(value); - localStorage.setItem(`team:lastWorktreeName:${teamName}`, value); + localStorage.setItem(`team:lastWorktreeName:${effectiveTeamName}`, value); }; const setCustomArgs = (value: string): void => { setCustomArgsRaw(value); - localStorage.setItem(`team:lastCustomArgs:${teamName}`, value); + localStorage.setItem(`team:lastCustomArgs:${effectiveTeamName}`, value); }; const setSelectedModel = (value: string): void => { @@ -141,6 +246,26 @@ export const LaunchTeamDialog = ({ localStorage.setItem('team:lastSelectedEffort', value); }; + // --------------------------------------------------------------------------- + // localStorage migration: schedule → team namespace (one-time) + // --------------------------------------------------------------------------- + + useEffect(() => { + for (const suffix of ['lastSelectedModel', 'lastSelectedEffort']) { + const schedKey = `schedule:${suffix}`; + const teamKey = `team:${suffix}`; + const schedVal = localStorage.getItem(schedKey); + if (schedVal != null && localStorage.getItem(teamKey) == null) { + localStorage.setItem(teamKey, schedVal); + } + localStorage.removeItem(schedKey); + } + }, []); + + // --------------------------------------------------------------------------- + // Form reset / populate + // --------------------------------------------------------------------------- + const resetFormState = (): void => { setLocalError(null); setIsSubmitting(false); @@ -153,20 +278,67 @@ export const LaunchTeamDialog = ({ setClearContext(false); setConflictDismissed(false); chipDraft.clearChipDraft(); + // Schedule fields + setSelectedTeamName(''); + setSchedLabel(''); + setCronExpression('0 9 * * 1-5'); + setTimezone(getLocalTimezone()); + setWarmUpMinutes(15); + setMaxTurns(50); + setMaxBudgetUsd(''); }; + // Populate form in schedule edit mode + useEffect(() => { + if (!open || !isSchedule) return; + + if (schedule) { + // Edit mode — populate from existing schedule + setSchedLabel(schedule.label ?? ''); + setCronExpression(schedule.cronExpression); + setTimezone(schedule.timezone); + setWarmUpMinutes(schedule.warmUpMinutes); + setMaxTurns(schedule.maxTurns); + setMaxBudgetUsd(schedule.maxBudgetUsd != null ? String(schedule.maxBudgetUsd) : ''); + promptDraft.setValue(schedule.launchConfig.prompt); + setCustomCwd(schedule.launchConfig.cwd); + setCwdMode('custom'); + setSelectedModelRaw(schedule.launchConfig.model ?? ''); + setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); + setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); + } else { + // Create mode — reset to defaults + setSchedLabel(''); + setCronExpression('0 9 * * 1-5'); + setTimezone(getLocalTimezone()); + setWarmUpMinutes(15); + setMaxTurns(50); + setMaxBudgetUsd(''); + promptDraft.setValue(''); + setCwdMode('project'); + setSelectedProjectPath(''); + setCustomCwd(''); + } + + setLocalError(null); + setIsSubmitting(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, isSchedule, schedule?.id]); + + // --------------------------------------------------------------------------- + // Launch-only effects + // --------------------------------------------------------------------------- + // Clear stale provisioning error when dialog opens useEffect(() => { - if (open) { - clearProvisioningError?.(); - } - }, [open, clearProvisioningError]); + if (!open || !isLaunch) return; + (props as LaunchDialogLaunchMode).clearProvisioningError?.(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, isLaunch]); - // Warm up CLI on open + // Warm up CLI on open (launch mode only) useEffect(() => { - if (!open) { - return; - } + if (!open || !isLaunch) return; if (typeof api.teams.prepareProvisioning !== 'function') { setPrepareState('failed'); @@ -185,16 +357,12 @@ export const LaunchTeamDialog = ({ void (async () => { try { const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning(); - if (cancelled) { - return; - } + if (cancelled) return; setPrepareState(prepResult.ready ? 'ready' : 'failed'); setPrepareMessage(prepResult.message); setPrepareWarnings(prepResult.warnings ?? []); } catch (error) { - if (cancelled) { - return; - } + if (cancelled) return; setPrepareState('failed'); setPrepareWarnings([]); setPrepareMessage( @@ -206,15 +374,16 @@ export const LaunchTeamDialog = ({ return () => { cancelled = true; }; - }, [open]); + }, [open, isLaunch]); + + // --------------------------------------------------------------------------- + // Shared effects: projects + // --------------------------------------------------------------------------- const repositoryGroups = useStore((s) => s.repositoryGroups); - // Fetch projects on open, merging with repositoryGroups from store useEffect(() => { - if (!open) { - return; - } + if (!open) return; setProjectsLoading(true); setProjectsError(null); @@ -223,11 +392,8 @@ export const LaunchTeamDialog = ({ void (async () => { try { const apiProjects = await api.getProjects(); - if (cancelled) { - return; - } + if (cancelled) return; - // Merge repositoryGroups (may include synthetic folders without sessions) const pathSet = new Set(apiProjects.map((p) => p.path)); const extras: Project[] = []; for (const repo of repositoryGroups) { @@ -248,15 +414,11 @@ export const LaunchTeamDialog = ({ setProjects([...apiProjects, ...extras]); } catch (error) { - if (cancelled) { - return; - } + if (cancelled) return; setProjectsError(error instanceof Error ? error.message : 'Failed to load projects'); setProjects([]); } finally { - if (!cancelled) { - setProjectsLoading(false); - } + if (!cancelled) setProjectsLoading(false); } })(); @@ -265,15 +427,13 @@ export const LaunchTeamDialog = ({ }; }, [open, repositoryGroups]); - // Pre-select defaultProjectPath when projects loaded (only while dialog is open) + // Pre-select defaultProjectPath (launch mode) or first project + const defaultProjectPath = isLaunch + ? (props as LaunchDialogLaunchMode).defaultProjectPath + : undefined; + useEffect(() => { - if (!open) return; - if (cwdMode !== 'project') { - return; - } - if (selectedProjectPath || projects.length === 0) { - return; - } + if (!open || cwdMode !== 'project' || selectedProjectPath || projects.length === 0) return; if (defaultProjectPath) { const match = projects.find((p) => p.path === defaultProjectPath); if (match) { @@ -289,20 +449,33 @@ export const LaunchTeamDialog = ({ // Pre-warm file list cache so @-mention file search is instant useFileListCacheWarmer(effectiveCwd || null); + // --------------------------------------------------------------------------- + // Launch-only: conflict detection + // --------------------------------------------------------------------------- + + const activeTeams = isLaunch ? (props as LaunchDialogLaunchMode).activeTeams : undefined; + const conflictingTeam = useMemo(() => { - if (!activeTeams?.length || !effectiveCwd) return null; + if (!isLaunch || !activeTeams?.length || !effectiveCwd) return null; const norm = normalizePath(effectiveCwd); return ( - activeTeams.find((t) => t.teamName !== teamName && normalizePath(t.projectPath) === norm) ?? - null + activeTeams.find( + (t) => t.teamName !== effectiveTeamName && normalizePath(t.projectPath) === norm + ) ?? null ); - }, [activeTeams, effectiveCwd, teamName]); + }, [isLaunch, activeTeams, effectiveCwd, effectiveTeamName]); - // Reset dismiss when conflict target changes (different path or different conflicting team) useEffect(() => { setConflictDismissed(false); }, [conflictingTeam?.teamName, effectiveCwd]); + // --------------------------------------------------------------------------- + // Mention suggestions (shared — from props in launch, from store in schedule) + // --------------------------------------------------------------------------- + + const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); + const members = isLaunch ? (props as LaunchDialogLaunchMode).members : storeMembers; + const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const mentionSuggestions = useMemo( () => @@ -315,25 +488,56 @@ export const LaunchTeamDialog = ({ [members, colorMap] ); + // --------------------------------------------------------------------------- + // Launch-only: internal args preview + // --------------------------------------------------------------------------- + const internalArgs = useMemo(() => { + if (!isLaunch) return []; const args: string[] = []; - // Infrastructure (always present, dimmed in preview) args.push('--input-format', 'stream-json', '--output-format', 'stream-json'); args.push('--verbose', '--setting-sources', 'user,project,local'); args.push('--mcp-config', '', '--disallowedTools', 'TeamDelete,TodoWrite'); - // User-visible if (skipPermissions) args.push('--dangerously-skip-permissions'); const model = computeEffectiveTeamModel(selectedModel, extendedContext); if (model) args.push('--model', model); if (selectedEffort) args.push('--effort', selectedEffort); if (!clearContext) args.push('--resume', ''); return args; - }, [skipPermissions, selectedModel, extendedContext, selectedEffort, clearContext]); + }, [isLaunch, skipPermissions, selectedModel, extendedContext, selectedEffort, clearContext]); + // --------------------------------------------------------------------------- + // Validation + // --------------------------------------------------------------------------- + + const validationErrors = useMemo(() => { + const errors: string[] = []; + if (!effectiveCwd) errors.push('Working directory is required'); + if (isSchedule) { + if (!effectiveTeamName) errors.push('Team is required'); + if (!promptDraft.value.trim()) errors.push('Prompt is required'); + if (!cronExpression.trim()) errors.push('Cron expression is required'); + } + return errors; + }, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]); + + // --------------------------------------------------------------------------- + // Error + // --------------------------------------------------------------------------- + + const provisioningError = isLaunch ? (props as LaunchDialogLaunchMode).provisioningError : null; const activeError = localError ?? provisioningError; + // --------------------------------------------------------------------------- + // Submit + // --------------------------------------------------------------------------- + const handleSubmit = (): void => { - if (!effectiveCwd) { + if (validationErrors.length > 0) { + setLocalError(validationErrors[0]); + return; + } + if (isLaunch && !effectiveCwd) { setLocalError('Select working directory (cwd)'); return; } @@ -342,47 +546,123 @@ export const LaunchTeamDialog = ({ void (async () => { try { - await onLaunch({ - teamName, - cwd: effectiveCwd, - prompt: promptDraft.value.trim() || undefined, - model: computeEffectiveTeamModel(selectedModel, extendedContext), - effort: (selectedEffort as EffortLevel) || undefined, - clearContext: clearContext || undefined, - skipPermissions, - worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, - extraCliArgs: customArgs.trim() || undefined, - }); - resetFormState(); - onClose(); - } catch { - // error is shown via provisioningError prop + if (isLaunch) { + await (props as LaunchDialogLaunchMode).onLaunch({ + teamName: effectiveTeamName, + cwd: effectiveCwd, + prompt: promptDraft.value.trim() || undefined, + model: computeEffectiveTeamModel(selectedModel, extendedContext), + effort: (selectedEffort as EffortLevel) || undefined, + clearContext: clearContext || undefined, + skipPermissions, + worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, + extraCliArgs: customArgs.trim() || undefined, + }); + resetFormState(); + onClose(); + } else { + // Schedule mode: create or update + const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined; + const launchConfig: ScheduleLaunchConfig = { + cwd: effectiveCwd, + prompt: promptDraft.value.trim(), + model: selectedModel || undefined, + effort: (selectedEffort as EffortLevel) || undefined, + skipPermissions, + }; + + if (isEditing && schedule) { + const patch: UpdateSchedulePatch = { + label: schedLabel.trim() || undefined, + cronExpression: cronExpression.trim(), + timezone, + warmUpMinutes, + maxTurns, + maxBudgetUsd: parsedBudget, + launchConfig, + }; + await updateSchedule(schedule.id, patch); + } else { + const input: CreateScheduleInput = { + teamName: effectiveTeamName, + label: schedLabel.trim() || undefined, + cronExpression: cronExpression.trim(), + timezone, + warmUpMinutes, + maxTurns, + maxBudgetUsd: parsedBudget, + launchConfig, + }; + await createSchedule(input); + } + onClose(); + } + } catch (err) { + if (isSchedule) { + setLocalError(err instanceof Error ? err.message : 'Failed to save schedule'); + } + // launch errors shown via provisioningError prop } finally { setIsSubmitting(false); } })(); }; + // --------------------------------------------------------------------------- + // Disabled state + // --------------------------------------------------------------------------- + + const isDisabled = isLaunch + ? isSubmitting || prepareState !== 'ready' + : isSubmitting || validationErrors.length > 0; + + // --------------------------------------------------------------------------- + // Dynamic labels + // --------------------------------------------------------------------------- + + const dialogTitle = isLaunch ? 'Launch Team' : isEditing ? 'Edit Schedule' : 'Create Schedule'; + + const dialogDescription = isLaunch ? ( + <> + Start team {effectiveTeamName} via local Claude + CLI. + + ) : isEditing ? ( + `Editing schedule for team "${effectiveTeamName}"` + ) : effectiveTeamName ? ( + `Schedule automatic runs for team "${effectiveTeamName}"` + ) : ( + 'Schedule automatic Claude task execution' + ); + + const submitLabel = isLaunch ? 'Launch' : isEditing ? 'Save Changes' : 'Create Schedule'; + + const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...'; + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + return ( { if (!nextOpen) { - resetFormState(); + if (isLaunch) resetFormState(); onClose(); } }} > - + - Launch Team - - Start team {teamName} via local Claude - CLI. - + {dialogTitle} + {dialogDescription} - {conflictingTeam && !conflictDismissed ? ( + {/* Launch-only: Conflict warning */} + {isLaunch && conflictingTeam && !conflictDismissed ? (
) : null} - {prepareState === 'failed' ? ( + {/* Launch-only: CLI env failed */} + {isLaunch && prepareState === 'failed' ? (
@@ -451,6 +732,122 @@ export const LaunchTeamDialog = ({ ) : null}
+ {/* ═══════════════════════════════════════════════════════════════════ + Schedule-only: Team selector (standalone mode) + ═══════════════════════════════════════════════════════════════════ */} + {needsTeamSelector ? ( +
+ + { + const colorName = option.meta?.color as string | undefined; + const colorSet = colorName + ? getTeamColorSet(colorName) + : nameColorSet(option.label); + return ( + <> + {isSelected ? ( + + ) : ( + + )} +
+
+ {isSelected ? ( + + ) : null} +

+ {option.label} +

+
+ {option.description ? ( +

+ {option.description} +

+ ) : null} +
+ + ); + }} + /> +
+ ) : null} + + {/* ═══════════════════════════════════════════════════════════════════ + Schedule-only: Schedule configuration section + ═══════════════════════════════════════════════════════════════════ */} + {isSchedule ? ( +
+ + + {schedExpanded ? ( +
+ {/* Label */} +
+ + setSchedLabel(e.target.value)} + placeholder="e.g., Daily code review, Nightly tests..." + /> +
+ + {/* Cron + Timezone + Warmup */} + +
+ ) : null} +
+ ) : null} + + {/* ═══════════════════════════════════════════════════════════════════ + Shared: Working directory + ═══════════════════════════════════════════════════════════════════ */} + {/* ═══════════════════════════════════════════════════════════════════ + Shared: Prompt (MentionableTextarea for both modes) + ═══════════════════════════════════════════════════════════════════ */}
-
+ {/* ═══════════════════════════════════════════════════════════════════ + Shared: Model + Effort + Permissions + ═══════════════════════════════════════════════════════════════════ */}
- + {/* Extended context — launch only */} + {isLaunch ? ( + + ) : null}
-
-
- setClearContext(checked === true)} - /> - -
- {clearContext && ( -
-
- -

- The team lead will start a new session without resuming previous context. All - accumulated session memory and conversation history will not be available. -

+ {/* ═══════════════════════════════════════════════════════════════════ + Launch-only: Clear context + Advanced CLI + ═══════════════════════════════════════════════════════════════════ */} + {isLaunch ? ( + <> +
+
+ setClearContext(checked === true)} + /> +
+ {clearContext && ( +
+
+ +

+ The team lead will start a new session without resuming previous context. + All accumulated session memory and conversation history will not be + available. +

+
+
+ )}
- )} -
- + + + ) : null} + + {/* ═══════════════════════════════════════════════════════════════════ + Schedule-only: Execution limits + ═══════════════════════════════════════════════════════════════════ */} + {isSchedule ? ( +
+
+ + setMaxTurns(Math.max(1, parseInt(e.target.value) || 50))} + /> +
+ +
+ + setMaxBudgetUsd(e.target.value)} + placeholder="No limit" + /> +
+
+ ) : null}
+ {/* Error display */} {activeError ? ( -

- {activeError} -

+
+ + {activeError} +
) : null} - -
- {prepareState === 'idle' || prepareState === 'loading' ? ( -
- - - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -
- ) : null} - - {prepareState === 'ready' ? ( -
-
- + + {/* Launch-only: CLI warm-up status */} + {isLaunch ? ( +
+ {prepareState === 'idle' || prepareState === 'loading' ? ( +
+ - {prepareWarnings.length > 0 - ? 'CLI environment ready (with notes)' - : 'CLI environment ready'} + {prepareMessage ?? + (prepareState === 'idle' + ? 'Warming up CLI environment...' + : 'Preparing environment...')}
- {prepareMessage ? ( -

- {prepareMessage} -

- ) : null} - {prepareWarnings.length > 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} -
- ) : null} -
- ) : null} + ) : null} - {prepareState === 'failed' ?
: null} -
+ {prepareState === 'ready' ? ( +
+
+ + + {prepareWarnings.length > 0 + ? 'CLI environment ready (with notes)' + : 'CLI environment ready'} + +
+ {prepareMessage ? ( +

+ {prepareMessage} +

+ ) : null} + {prepareWarnings.length > 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +
+ ) : null} + + {prepareState === 'failed' ?
: null} +
+ ) : null}
diff --git a/src/renderer/components/team/schedule/ScheduleDialog.tsx b/src/renderer/components/team/schedule/ScheduleDialog.tsx deleted file mode 100644 index 80dcde6d..00000000 --- a/src/renderer/components/team/schedule/ScheduleDialog.tsx +++ /dev/null @@ -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('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([]); - const [projectsLoading, setProjectsLoading] = useState(false); - const [projectsError, setProjectsError] = useState(null); - - // --- Submission state --- - const [localError, setLocalError] = useState(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 ( - { - if (!nextOpen) onClose(); - }} - > - - - - {isEditing ? 'Edit Schedule' : 'Create Schedule'} - - - {isEditing - ? `Editing schedule for team "${teamName}"` - : `Schedule automatic runs for team "${teamName}"`} - - - -
- {/* Label */} -
- - setLabel(e.target.value)} - placeholder="e.g., Daily code review, Nightly tests..." - /> -
- - {/* Cron + Timezone + Warmup */} - - - {/* Project / Working directory */} - - - {/* Prompt (required for schedule) */} -
- -