import { api } from '@renderer/api'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import type { AppState } from '../types'; import type { CreateTaskRequest, GlobalTask, SendMessageRequest, SendMessageResult, TeamCreateRequest, TeamData, TeamLaunchRequest, TeamProvisioningProgress, TeamSummary, TeamTask, TeamTaskStatus, UpdateKanbanPatch, } from '@shared/types'; import type { StateCreator } from 'zustand'; function mapSendMessageError(error: unknown): string { const message = error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; if (message.includes('Failed to verify inbox write')) { return 'Message was written but not verified (race). Please try again.'; } return message || 'Failed to send message'; } function mapReviewError(error: unknown): string { const message = error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; if (message.includes('Task status update verification failed')) { return 'Failed to update task status (possible agent conflict).'; } return message || 'Failed to perform review action'; } export interface TeamSlice { teams: TeamSummary[]; teamsLoading: boolean; teamsError: string | null; globalTasks: GlobalTask[]; globalTasksLoading: boolean; globalTasksError: string | null; selectedTeamName: string | null; selectedTeamData: TeamData | null; selectedTeamLoading: boolean; selectedTeamError: string | null; sendingMessage: boolean; sendMessageError: string | null; lastSendMessageResult: SendMessageResult | null; reviewActionError: string | null; provisioningRuns: Record; activeProvisioningRunId: string | null; provisioningError: string | null; kanbanFilterQuery: string | null; provisioningProgressUnsubscribe: (() => void) | null; fetchTeams: () => Promise; fetchAllTasks: () => Promise; openTeamsTab: () => void; openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void; clearKanbanFilter: () => void; selectTeam: (teamName: string) => Promise; refreshTeamData: (teamName: string) => Promise; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise; deleteTeam: (teamName: string) => Promise; createTeam: (request: TeamCreateRequest) => Promise; launchTeam: (request: TeamLaunchRequest) => Promise; cancelProvisioning: (runId: string) => Promise; getProvisioningStatus: (runId: string) => Promise; onProvisioningProgress: (progress: TeamProvisioningProgress) => void; subscribeProvisioningProgress: () => void; unsubscribeProvisioningProgress: () => void; } export const createTeamSlice: StateCreator = (set, get) => ({ teams: [], teamsLoading: false, teamsError: null, globalTasks: [], globalTasksLoading: false, globalTasksError: null, selectedTeamName: null, selectedTeamData: null, selectedTeamLoading: false, selectedTeamError: null, sendingMessage: false, sendMessageError: null, lastSendMessageResult: null, reviewActionError: null, provisioningRuns: {}, activeProvisioningRunId: null, provisioningError: null, kanbanFilterQuery: null, provisioningProgressUnsubscribe: null, fetchTeams: async () => { set({ teamsLoading: true, teamsError: null }); try { const teams = await unwrapIpc('team:list', () => api.teams.list()); set({ teams, teamsLoading: false, teamsError: null }); } catch (error) { set({ teamsLoading: false, teamsError: error instanceof IpcError ? error.message : error instanceof Error ? error.message : 'Failed to fetch teams', }); } }, fetchAllTasks: async () => { set({ globalTasksLoading: true, globalTasksError: null }); try { const tasks = await unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()); set({ globalTasks: tasks, globalTasksLoading: false, globalTasksError: null }); } catch (error) { set({ globalTasksLoading: false, globalTasksError: error instanceof IpcError ? error.message : error instanceof Error ? error.message : 'Failed to fetch tasks', }); } }, openTeamsTab: () => { const state = get(); const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId); const teamsTab = focusedPane?.tabs.find((tab) => tab.type === 'teams'); if (teamsTab) { state.setActiveTab(teamsTab.id); return; } state.openTab({ type: 'teams', label: 'Teams', }); }, openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => { if (!teamName.trim()) { return; } // If projectPath is provided, immediately select the matching project in the sidebar. // This avoids a race condition where config.json hasn't been updated with projectPath yet. if (projectPath) { const state = get(); const normalizePath = (p: string): string => (p.endsWith('/') ? p.slice(0, -1) : p); const normalizedPath = normalizePath(projectPath); const matchingProject = state.projects.find((p) => normalizePath(p.path) === normalizedPath); if (matchingProject && state.selectedProjectId !== matchingProject.id) { state.selectProject(matchingProject.id); } } const state = get(); const allTabs = state.getAllPaneTabs(); const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName); if (existing) { state.setActiveTab(existing.id); } else { state.openTab({ type: 'team', label: teamName, teamName, }); } if (taskId) { set({ kanbanFilterQuery: `#${taskId}` }); } }, clearKanbanFilter: () => { set({ kanbanFilterQuery: null }); }, selectTeam: async (teamName: string) => { set({ selectedTeamName: teamName, selectedTeamLoading: true, selectedTeamError: null, reviewActionError: null, }); try { const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName)); // Stale check: user may have switched to another team during the async call if (get().selectedTeamName !== teamName) { return; } set({ selectedTeamName: teamName, selectedTeamData: data, selectedTeamLoading: false, selectedTeamError: null, }); // Auto-select the project associated with this team's cwd/projectPath. // Must search both flat projects and grouped repositoryGroups/worktrees // because the default viewMode is 'grouped' and flat projects may be empty. const projectPath = data.config.projectPath; if (projectPath) { const state = get(); const normalizedTeamPath = projectPath.endsWith('/') ? projectPath.slice(0, -1) : projectPath; const normalizePath = (p: string): string => (p.endsWith('/') ? p.slice(0, -1) : p); // 1. Try flat projects list const matchingProject = state.projects.find( (p) => normalizePath(p.path) === normalizedTeamPath ); if (matchingProject && state.selectedProjectId !== matchingProject.id) { state.selectProject(matchingProject.id); } else if (!matchingProject) { // 2. Try grouped view: search worktrees across all repository groups for (const repo of state.repositoryGroups) { const matchingWorktree = repo.worktrees.find( (wt) => normalizePath(wt.path) === normalizedTeamPath ); if (matchingWorktree) { if (state.selectedWorktreeId !== matchingWorktree.id) { state.selectRepository(repo.id); state.selectWorktree(matchingWorktree.id); } break; } } } } } catch (error) { // If provisioning is in progress for this team, stay in loading state // instead of showing an error — the file watcher / progress callback will // trigger a refresh once config.json is written. const isProvisioning = Object.values(get().provisioningRuns).some( (run) => run.teamName === teamName && !['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state) ); if (isProvisioning) { set({ selectedTeamLoading: true, selectedTeamData: null, selectedTeamError: null, }); return; } set({ selectedTeamLoading: false, selectedTeamData: null, selectedTeamError: error instanceof IpcError ? error.message : error instanceof Error ? error.message : 'Failed to fetch team data', }); } }, refreshTeamData: async (teamName: string) => { const state = get(); if (state.selectedTeamName !== teamName) { return; } // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). try { const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName)); // Re-check after async: the user might have navigated away. if (get().selectedTeamName !== teamName) { return; } set({ selectedTeamData: data, selectedTeamError: null, }); } catch (error) { if (get().selectedTeamName !== teamName) { return; } set({ selectedTeamError: error instanceof IpcError ? error.message : error instanceof Error ? error.message : 'Failed to refresh team data', }); } }, updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => { try { set({ reviewActionError: null }); await unwrapIpc('team:updateKanban', () => api.teams.updateKanban(teamName, taskId, patch)); await get().refreshTeamData(teamName); } catch (error) { set({ reviewActionError: mapReviewError(error), }); throw error; } }, sendTeamMessage: async (teamName: string, request: SendMessageRequest) => { set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null }); try { const result = await unwrapIpc('team:sendMessage', () => api.teams.sendMessage(teamName, request) ); set({ sendingMessage: false, sendMessageError: null, lastSendMessageResult: result, }); await get().refreshTeamData(teamName); } catch (error) { set({ sendingMessage: false, lastSendMessageResult: null, sendMessageError: mapSendMessageError(error), }); } }, requestReview: async (teamName: string, taskId: string) => { try { set({ reviewActionError: null }); await unwrapIpc('team:requestReview', () => api.teams.requestReview(teamName, taskId)); await get().refreshTeamData(teamName); } catch (error) { set({ reviewActionError: mapReviewError(error), }); throw error; } }, createTeamTask: async (teamName: string, request: CreateTaskRequest) => { const task = await unwrapIpc('team:createTask', () => api.teams.createTask(teamName, request)); await get().refreshTeamData(teamName); return task; }, updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => { await unwrapIpc('team:updateTaskStatus', () => api.teams.updateTaskStatus(teamName, taskId, status) ); await get().refreshTeamData(teamName); }, deleteTeam: async (teamName: string) => { await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName)); const state = get(); if (state.selectedTeamName === teamName) { set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null }); } await get().fetchTeams(); }, createTeam: async (request: TeamCreateRequest) => { set({ provisioningError: null }); try { if (typeof api.teams.createTeam !== 'function') { throw new Error( 'Current preload version does not support team:create. Restart the dev app.' ); } const response = await unwrapIpc('team:create', () => api.teams.createTeam(request)); set({ activeProvisioningRunId: response.runId, provisioningError: null, }); await get().getProvisioningStatus(response.runId); return response.runId; } catch (error) { set({ provisioningError: error instanceof IpcError ? error.message : error instanceof Error ? error.message : 'Failed to create team', }); throw error; } }, launchTeam: async (request: TeamLaunchRequest) => { set({ provisioningError: null }); try { const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request)); set({ activeProvisioningRunId: response.runId, provisioningError: null, }); await get().getProvisioningStatus(response.runId); return response.runId; } catch (error) { set({ provisioningError: error instanceof IpcError ? error.message : error instanceof Error ? error.message : 'Failed to launch team', }); throw error; } }, getProvisioningStatus: async (runId: string) => { const progress = await unwrapIpc('team:provisioningStatus', () => api.teams.getProvisioningStatus(runId) ); get().onProvisioningProgress(progress); }, cancelProvisioning: async (runId: string) => { await unwrapIpc('team:cancelProvisioning', () => api.teams.cancelProvisioning(runId)); }, onProvisioningProgress: (progress: TeamProvisioningProgress) => { set((state) => ({ provisioningRuns: { ...state.provisioningRuns, [progress.runId]: progress, }, activeProvisioningRunId: progress.runId, provisioningError: progress.state === 'failed' ? (progress.error ?? null) : null, })); if (progress.state === 'ready' || progress.state === 'disconnected') { void get().fetchTeams(); // If the user already opened the team tab, reload team data now that // config.json is guaranteed to exist. if (get().selectedTeamName === progress.teamName) { void get().selectTeam(progress.teamName); } } }, subscribeProvisioningProgress: () => { const existing = get().provisioningProgressUnsubscribe; if (existing) { return; } if (!api.teams?.onProvisioningProgress) { return; } const unsubscribe = api.teams.onProvisioningProgress((_event, progress) => { get().onProvisioningProgress(progress); }); set({ provisioningProgressUnsubscribe: unsubscribe }); }, unsubscribeProvisioningProgress: () => { const unsubscribe = get().provisioningProgressUnsubscribe; if (unsubscribe) { unsubscribe(); set({ provisioningProgressUnsubscribe: null }); } }, });