agent-ecosystem/src/renderer/store/slices/teamSlice.ts
iliya b369b779cc feat: implement live lead message handling and improve team data refresh logic
- Added a new method to create and push live lead messages during team provisioning, enhancing real-time communication.
- Updated the event handling to ensure lead-message events only refresh team details, preventing unnecessary updates to team/task lists.
- Improved error handling in team data refresh to preserve existing data during provisioning and transient errors.
- Enhanced tests to cover new lead-message functionality and ensure proper behavior during team provisioning scenarios.
2026-03-09 00:13:12 +02:00

1277 lines
44 KiB
TypeScript

import { api } from '@renderer/api';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
const logger = createLogger('teamSlice');
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
const TEAM_FETCH_TIMEOUT_MS = 30_000;
function nowIso(): string {
return new Date().toISOString();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'disconnected', 'cancelled']);
function isPendingProvisioningRunId(runId: string): boolean {
return runId.startsWith('pending:');
}
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new Error(`Timeout after ${ms}ms: ${label}`));
}, ms);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
async function pollProvisioningStatus(
getState: () => TeamSlice,
runId: string,
opts?: { maxAttempts?: number; initialDelayMs?: number }
): Promise<void> {
const maxAttempts = opts?.maxAttempts ?? 12;
let delayMs = opts?.initialDelayMs ?? 150;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const state = getState();
const current = state.provisioningRuns[runId];
if (current && TERMINAL_PROVISIONING_STATES.has(current.state)) {
return;
}
try {
const progress = await state.getProvisioningStatus(runId);
if (TERMINAL_PROVISIONING_STATES.has(progress.state)) {
return;
}
} catch {
// best-effort polling; don't fail launch because status fetch is flaky
}
await sleep(delayMs);
delayMs = Math.min(1500, Math.round(delayMs * 1.5));
}
}
import type { AppState } from '../types';
import type { AppConfig } from '@renderer/types/data';
import type {
AddMemberRequest,
CommentAttachmentPayload,
CreateTaskRequest,
GlobalTask,
KanbanColumnId,
LeadActivityState,
LeadContextUsage,
SendMessageRequest,
SendMessageResult,
TaskComment,
TeamCreateRequest,
TeamData,
TeamLaunchRequest,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
TeamTaskStatus,
ToolApprovalRequest,
ToolApprovalSettings,
UpdateKanbanPatch,
} from '@shared/types';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import type { StateCreator } from 'zustand';
// --- Clarification notification tracking ---
// Native OS notifications for new inbox messages are handled in main process
// (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only
// handles clarification-specific logic (e.g., marking tasks as needing user input).
const notifiedClarificationTaskKeys = new Set<string>();
const notifiedStatusChangeKeys = new Set<string>();
let isFirstFetchAllTasks = true;
function detectClarificationNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
for (const task of newTasks) {
const key = `${task.teamName}:${task.id}`;
if (task.needsClarification === 'user') {
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
if (oldTask?.needsClarification !== 'user' && !notifiedClarificationTaskKeys.has(key)) {
notifiedClarificationTaskKeys.add(key);
// Always store in-app; suppress OS toast when per-type toggle is off
fireClarificationNotification(task, !notifyEnabled);
}
} else {
notifiedClarificationTaskKeys.delete(key);
}
}
}
function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void {
// Delegate to main process for native OS notification (cross-platform, no permission needed)
const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined;
const body =
latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`;
void api.teams
?.showMessageNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: latestComment?.author || 'team-lead',
to: 'user',
summary: `Clarification needed — Task ${formatTaskDisplayLabel(task)}`,
body,
teamEventType: 'task_clarification',
dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`,
suppressToast,
})
.catch(() => undefined);
}
function detectStatusChangeNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
config: AppConfig | null,
teamByName: Record<string, TeamSummary>
): void {
const statusChangeEnabled =
!!config?.notifications?.notifyOnStatusChange && !!config.notifications.enabled;
const statuses = config?.notifications?.statusChangeStatuses ?? ['in_progress', 'completed'];
if (statuses.length === 0) return;
const onlySolo = config?.notifications?.statusChangeOnlySolo ?? true;
for (const task of newTasks) {
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
if (!oldTask) continue;
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
const taskKanbanColumn = getTaskKanbanColumn(task);
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask);
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
const statusChanged = oldTask.status !== task.status;
if (!statusChanged && !becameApproved) continue;
if (onlySolo) {
const team = teamByName[task.teamName];
if (team && team.memberCount > 0) continue;
}
// Resolve the effective status for notification matching
const effectiveStatus = becameApproved ? 'approved' : task.status;
if (!statuses.includes(effectiveStatus)) continue;
const key = `${task.teamName}:${task.id}:${effectiveStatus}`;
if (notifiedStatusChangeKeys.has(key)) continue;
notifiedStatusChangeKeys.add(key);
const fromLabel = becameApproved ? 'Completed' : oldTask.status;
fireStatusChangeNotification(
task,
fromLabel,
becameApproved ? 'approved' : undefined,
!statusChangeEnabled
);
}
}
function fireStatusChangeNotification(
task: GlobalTask,
fromStatus: string,
overrideToStatus?: string,
suppressToast?: boolean
): void {
const statusLabels: Record<string, string> = {
pending: 'Pending',
in_progress: 'In Progress',
completed: 'Completed',
deleted: 'Deleted',
approved: 'Approved',
};
const from = statusLabels[fromStatus] ?? fromStatus;
const toStatus = overrideToStatus ?? task.status;
const to = statusLabels[toStatus] ?? toStatus;
void api.teams
?.showMessageNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system',
to: 'user',
summary: `Task ${formatTaskDisplayLabel(task)}: ${from}${to}`,
body: task.subject,
teamEventType: 'task_status_change',
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`,
suppressToast,
})
.catch(() => undefined);
}
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 GlobalTaskDetailState {
teamName: string;
taskId: string;
}
export interface TeamSlice {
teams: TeamSummary[];
/** O(1) lookup to avoid array scans in render-hot paths */
teamByName: Record<string, TeamSummary>;
/** O(1) lookup: sessionId -> owning team (lead + history) */
teamBySessionId: Record<string, TeamSummary>;
/** Centralized git branch cache: normalizedPath → branch name | null */
branchByPath: Record<string, string | null>;
teamsLoading: boolean;
teamsError: string | null;
globalTasks: GlobalTask[];
globalTasksLoading: boolean;
globalTasksInitialized: boolean;
globalTasksError: string | null;
globalTaskDetail: GlobalTaskDetailState | null;
openGlobalTaskDetail: (teamName: string, taskId: string) => void;
closeGlobalTaskDetail: () => void;
/** Set by GlobalTaskDetailDialog to signal TeamDetailView to open ChangeReviewDialog */
pendingReviewRequest: { taskId: string; filePath?: string } | null;
setPendingReviewRequest: (req: { taskId: string; filePath?: string } | null) => void;
selectedTeamName: string | null;
selectedTeamData: TeamData | null;
selectedTeamLoading: boolean;
selectedTeamError: string | null;
sendingMessage: boolean;
sendMessageError: string | null;
lastSendMessageResult: SendMessageResult | null;
reviewActionError: string | null;
provisioningRuns: Record<string, TeamProvisioningProgress>;
/**
* Per-team lower bound for provisioning progress timestamps.
* Used to ignore late progress events from a previous run after stop→launch.
*/
provisioningStartedAtFloorByTeam: Record<string, string>;
leadActivityByTeam: Record<string, LeadActivityState>;
leadContextByTeam: Record<string, LeadContextUsage>;
activeProvisioningRunId: string | null;
provisioningError: string | null;
clearProvisioningError: () => void;
kanbanFilterQuery: string | null;
provisioningProgressUnsubscribe: (() => void) | null;
fetchBranches: (paths: string[]) => Promise<void>;
fetchTeams: () => Promise<void>;
fetchAllTasks: () => Promise<void>;
openTeamsTab: () => void;
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void;
clearKanbanFilter: () => void;
selectTeam: (teamName: string, opts?: { skipProjectAutoSelect?: boolean }) => Promise<void>;
refreshTeamData: (teamName: string) => Promise<void>;
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
updateKanbanColumnOrder: (
teamName: string,
columnId: KanbanColumnId,
orderedTaskIds: string[]
) => Promise<void>;
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
updateTaskFields: (
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
) => Promise<void>;
addingComment: boolean;
addCommentError: string | null;
addTaskComment: (
teamName: string,
taskId: string,
text: string,
attachments?: CommentAttachmentPayload[]
) => Promise<TaskComment>;
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
removeMember: (teamName: string, memberName: string) => Promise<void>;
updateMemberRole: (
teamName: string,
memberName: string,
role: string | undefined
) => Promise<void>;
addTaskRelationship: (
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
) => Promise<void>;
removeTaskRelationship: (
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
) => Promise<void>;
setTaskNeedsClarification: (
teamName: string,
taskId: string,
value: 'lead' | 'user' | null
) => Promise<void>;
saveTaskAttachment: (
teamName: string,
taskId: string,
file: { name: string; type: string; base64: string }
) => Promise<void>;
deleteTaskAttachment: (
teamName: string,
taskId: string,
attachmentId: string,
mimeType: string
) => Promise<void>;
getTaskAttachmentData: (
teamName: string,
taskId: string,
attachmentId: string,
mimeType: string
) => Promise<string | null>;
deletedTasks: TeamTask[];
deletedTasksLoading: boolean;
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
restoreTask: (teamName: string, taskId: string) => Promise<void>;
fetchDeletedTasks: (teamName: string) => Promise<void>;
deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>;
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
createTeam: (request: TeamCreateRequest) => Promise<string>;
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
cancelProvisioning: (runId: string) => Promise<void>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
onProvisioningProgress: (progress: TeamProvisioningProgress) => void;
subscribeProvisioningProgress: () => void;
unsubscribeProvisioningProgress: () => void;
pendingApprovals: ToolApprovalRequest[];
toolApprovalSettings: ToolApprovalSettings;
updateToolApprovalSettings: (patch: Partial<ToolApprovalSettings>) => Promise<void>;
respondToToolApproval: (
teamName: string,
runId: string,
requestId: string,
allow: boolean,
message?: string
) => Promise<void>;
}
function loadToolApprovalSettings(): ToolApprovalSettings {
try {
const raw = localStorage.getItem('team:toolApprovalSettings');
if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS;
const parsed = JSON.parse(raw) as Record<string, unknown>;
const d = DEFAULT_TOOL_APPROVAL_SETTINGS;
return {
autoAllowFileEdits:
typeof parsed.autoAllowFileEdits === 'boolean'
? parsed.autoAllowFileEdits
: d.autoAllowFileEdits,
autoAllowSafeBash:
typeof parsed.autoAllowSafeBash === 'boolean'
? parsed.autoAllowSafeBash
: d.autoAllowSafeBash,
timeoutAction:
typeof parsed.timeoutAction === 'string' &&
['allow', 'deny', 'wait'].includes(parsed.timeoutAction)
? (parsed.timeoutAction as ToolApprovalSettings['timeoutAction'])
: d.timeoutAction,
timeoutSeconds:
typeof parsed.timeoutSeconds === 'number' &&
Number.isFinite(parsed.timeoutSeconds) &&
parsed.timeoutSeconds >= 5 &&
parsed.timeoutSeconds <= 300
? parsed.timeoutSeconds
: d.timeoutSeconds,
};
} catch {
return DEFAULT_TOOL_APPROVAL_SETTINGS;
}
}
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({
teams: [],
teamByName: {},
teamBySessionId: {},
branchByPath: {},
teamsLoading: false,
teamsError: null,
globalTasks: [],
globalTasksLoading: false,
globalTasksInitialized: false,
globalTasksError: null,
selectedTeamName: null,
selectedTeamData: null,
selectedTeamLoading: false,
selectedTeamError: null,
sendingMessage: false,
sendMessageError: null,
lastSendMessageResult: null,
reviewActionError: null,
provisioningRuns: {},
provisioningStartedAtFloorByTeam: {},
leadActivityByTeam: {},
leadContextByTeam: {},
activeProvisioningRunId: null,
provisioningError: null,
clearProvisioningError: () => set({ provisioningError: null }),
kanbanFilterQuery: null,
globalTaskDetail: null,
pendingReviewRequest: null,
setPendingReviewRequest: (req) => set({ pendingReviewRequest: req }),
openGlobalTaskDetail: (teamName: string, taskId: string) => {
set({ globalTaskDetail: { teamName, taskId } });
},
closeGlobalTaskDetail: () => set({ globalTaskDetail: null }),
addingComment: false,
addCommentError: null,
provisioningProgressUnsubscribe: null,
deletedTasks: [],
deletedTasksLoading: false,
pendingApprovals: [],
toolApprovalSettings: loadToolApprovalSettings(),
fetchBranches: async (paths: string[]) => {
const results: Record<string, string | null> = {};
for (const p of paths) {
try {
const branch = await api.teams.getProjectBranch(p);
results[normalizePath(p)] = branch;
} catch {
results[normalizePath(p)] = null;
}
}
if (Object.keys(results).length > 0) {
set((state) => ({ branchByPath: { ...state.branchByPath, ...results } }));
}
},
fetchTeams: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain).
// Only effective during initial load (when teamsLoading is set to true below).
// Refreshes are already serialized by the throttle timer in onTeamChange.
if (get().teamsLoading) return;
// Only show loading spinner on initial load — avoids flickering when refreshing
const isInitialLoad = get().teams.length === 0;
if (isInitialLoad) {
set({ teamsLoading: true, teamsError: null });
}
try {
const teams = await withTimeout(
unwrapIpc('team:list', () => api.teams.list()),
TEAM_FETCH_TIMEOUT_MS,
'fetchTeams'
);
const teamByName: Record<string, TeamSummary> = {};
const teamBySessionId: Record<string, TeamSummary> = {};
for (const team of teams) {
teamByName[team.teamName] = team;
if (team.leadSessionId) {
teamBySessionId[team.leadSessionId] = team;
}
if (Array.isArray(team.sessionHistory)) {
for (const sid of team.sessionHistory) {
if (typeof sid === 'string' && sid) {
teamBySessionId[sid] = team;
}
}
}
}
set({ teams, teamByName, teamBySessionId, teamsLoading: false, teamsError: null });
} catch (error) {
// On refresh failure, keep existing teams visible
set({
teamsLoading: false,
teamsError: isInitialLoad
? error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: 'Failed to fetch teams'
: null,
});
}
},
fetchAllTasks: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain)
if (get().globalTasksLoading) return;
// Show skeleton only on the very first fetch — not on subsequent refreshes
// even when the task list is empty (avoids flickering skeleton on every watcher event).
const isInitialLoad = !get().globalTasksInitialized;
if (isInitialLoad) {
set({ globalTasksLoading: true, globalTasksError: null });
}
const oldTasks = get().globalTasks;
const wasFirst = isFirstFetchAllTasks;
isFirstFetchAllTasks = false;
try {
const tasks = await withTimeout(
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
TEAM_FETCH_TIMEOUT_MS,
'fetchAllTasks'
);
if (!wasFirst) {
const notifyOnClarifications =
get().appConfig?.notifications?.notifyOnClarifications ?? true;
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
} else {
// Initial load — seed the Sets to prevent false notifications on next update
for (const task of tasks) {
if (task.needsClarification === 'user') {
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
}
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
if (getTaskKanbanColumn(task) === 'approved') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
}
}
}
set({
globalTasks: tasks,
globalTasksLoading: false,
globalTasksInitialized: true,
globalTasksError: null,
});
} catch (error) {
set({
globalTasksLoading: false,
globalTasksInitialized: true,
globalTasksError: isInitialLoad
? error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: 'Failed to fetch tasks'
: null,
});
}
},
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 stateForProject = get();
const normalizedPath = normalizePath(projectPath);
const matchingProject = stateForProject.projects.find(
(p) => normalizePath(p.path) === normalizedPath
);
if (matchingProject && stateForProject.selectedProjectId !== matchingProject.id) {
stateForProject.selectProject(matchingProject.id);
}
}
const state = get();
// Use display name from teams list or selected team data if available
const teamSummary = state.teamByName[teamName];
const selectedTeamDisplayName =
state.selectedTeamName === teamName ? state.selectedTeamData?.config.name : undefined;
const displayName = teamSummary?.displayName || selectedTeamDisplayName || teamName;
const allTabs = state.getAllPaneTabs();
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
if (existing) {
state.setActiveTab(existing.id);
// Sync label in case display name changed
if (existing.label !== displayName) {
state.updateTabLabel(existing.id, displayName);
}
} else {
state.openTab({
type: 'team',
label: displayName,
teamName,
});
}
},
clearKanbanFilter: () => {
set({ kanbanFilterQuery: null });
},
selectTeam: async (teamName: string, opts) => {
// Guard: prevent duplicate in-flight fetches for the same team.
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
if (get().selectedTeamLoading && get().selectedTeamName === teamName) {
return;
}
// Clear stale data immediately to prevent flash of previous team's content
const prev = get().selectedTeamName;
set({
selectedTeamName: teamName,
selectedTeamData: prev !== teamName ? null : get().selectedTeamData,
selectedTeamLoading: true,
selectedTeamError: null,
reviewActionError: null,
});
try {
const data = await withTimeout(
unwrapIpc('team:getData', () => api.teams.getData(teamName)),
TEAM_GET_DATA_TIMEOUT_MS,
`team:getData(${teamName})`
);
// Stale check: user may have switched to another team during the async call
if (get().selectedTeamName !== teamName) {
return;
}
// Eagerly patch teamByName with color/displayName from detailed data
// so that tab color renders immediately without waiting for fetchTeams()
const prevByName = get().teamByName;
const existingEntry = prevByName[teamName];
const configColor = data.config.color;
if (configColor && (!existingEntry || existingEntry?.color !== configColor)) {
const patched: TeamSummary = existingEntry
? { ...existingEntry, color: configColor, displayName: data.config.name || teamName }
: {
teamName,
displayName: data.config.name || teamName,
description: data.config.description ?? '',
color: configColor,
memberCount: data.members.length,
taskCount: 0,
lastActivity: null,
};
set({ teamByName: { ...prevByName, [teamName]: patched } });
}
set({
selectedTeamName: teamName,
selectedTeamData: data,
selectedTeamLoading: false,
selectedTeamError: null,
});
// Sync tab label with the team's display name from config
const displayName = data.config.name || teamName;
const allTabs = get().getAllPaneTabs();
const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
if (teamTab && teamTab.label !== displayName) {
get().updateTabLabel(teamTab.id, displayName);
}
if (opts?.skipProjectAutoSelect) {
return;
}
// 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 = normalizePath(projectPath);
// 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) {
set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
void get().fetchSessionsInitial(matchingWorktree.id);
}
break;
}
}
}
}
} catch (error) {
// If provisioning is in progress for this team, stay in loading state;
// file watcher / progress callback will refresh once config is written.
const isProvisioning = Object.values(get().provisioningRuns).some(
(run) =>
run.teamName === teamName &&
!['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state)
);
const msg = error instanceof Error ? error.message : String(error);
// IPC can report provisioning state explicitly.
if (msg === 'TEAM_PROVISIONING' || (msg.includes('TEAM_PROVISIONING') && isProvisioning)) {
set({
selectedTeamLoading: true,
selectedTeamData: null,
selectedTeamError: null,
});
return;
}
const message =
error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: 'Failed to fetch team data';
set({
selectedTeamLoading: false,
selectedTeamData: null,
selectedTeamError: message,
});
}
},
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 withTimeout(
unwrapIpc('team:getData', () => api.teams.getData(teamName)),
TEAM_GET_DATA_TIMEOUT_MS,
`refreshTeamData(${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;
}
const msg =
error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: 'Failed to refresh team data';
// During provisioning, team:getData may not be readable yet.
// Preserve existing data instead of showing a fatal error.
if (msg === 'TEAM_PROVISIONING' || msg.includes('TEAM_PROVISIONING')) {
logger.debug(`refreshTeamData(${teamName}) skipped: team is still provisioning`);
set({ selectedTeamError: null });
return;
}
logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`);
// Non-destructive: if we already have data, keep it visible.
// Only set error when there's nothing to show.
if (get().selectedTeamData) {
logger.debug(`refreshTeamData(${teamName}) preserving existing data after transient error`);
set({ selectedTeamError: null });
return;
}
set({ selectedTeamError: msg });
}
},
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;
}
},
updateKanbanColumnOrder: async (
teamName: string,
columnId: KanbanColumnId,
orderedTaskIds: string[]
) => {
await unwrapIpc('team:updateKanbanColumnOrder', () =>
api.teams.updateKanbanColumnOrder(teamName, columnId, orderedTaskIds)
);
await get().refreshTeamData(teamName);
},
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;
},
startTask: async (teamName: string, taskId: string) => {
const result = await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId));
await get().refreshTeamData(teamName);
return result;
},
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
await unwrapIpc('team:updateTaskStatus', () =>
api.teams.updateTaskStatus(teamName, taskId, status)
);
await get().refreshTeamData(teamName);
},
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
await unwrapIpc('team:updateTaskOwner', () =>
api.teams.updateTaskOwner(teamName, taskId, owner)
);
await get().refreshTeamData(teamName);
},
updateTaskFields: async (
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
) => {
await unwrapIpc('team:updateTaskFields', () =>
api.teams.updateTaskFields(teamName, taskId, fields)
);
await get().refreshTeamData(teamName);
},
addTaskRelationship: async (teamName, taskId, targetId, type) => {
await unwrapIpc('team:addTaskRelationship', () =>
api.teams.addTaskRelationship(teamName, taskId, targetId, type)
);
await get().refreshTeamData(teamName);
},
removeTaskRelationship: async (teamName, taskId, targetId, type) => {
await unwrapIpc('team:removeTaskRelationship', () =>
api.teams.removeTaskRelationship(teamName, taskId, targetId, type)
);
await get().refreshTeamData(teamName);
},
setTaskNeedsClarification: async (teamName, taskId, value) => {
await unwrapIpc('team:setTaskClarification', () =>
api.teams.setTaskClarification(teamName, taskId, value)
);
await get().refreshTeamData(teamName);
await get().fetchAllTasks();
},
saveTaskAttachment: async (teamName, taskId, file) => {
const id = crypto.randomUUID();
await unwrapIpc('team:saveTaskAttachment', () =>
api.teams.saveTaskAttachment(teamName, taskId, id, file.name, file.type, file.base64)
);
await get().refreshTeamData(teamName);
},
deleteTaskAttachment: async (teamName, taskId, attachmentId, mimeType) => {
await unwrapIpc('team:deleteTaskAttachment', () =>
api.teams.deleteTaskAttachment(teamName, taskId, attachmentId, mimeType)
);
await get().refreshTeamData(teamName);
},
getTaskAttachmentData: async (teamName, taskId, attachmentId, mimeType) => {
return unwrapIpc('team:getTaskAttachment', () =>
api.teams.getTaskAttachment(teamName, taskId, attachmentId, mimeType)
);
},
addTaskComment: async (teamName, taskId, text, attachments) => {
set({ addingComment: true, addCommentError: null });
try {
const comment = await unwrapIpc('team:addTaskComment', () =>
api.teams.addTaskComment(teamName, taskId, text, attachments)
);
set({ addingComment: false });
await get().refreshTeamData(teamName);
return comment;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to add comment';
set({ addingComment: false, addCommentError: msg });
throw error;
}
},
addMember: async (teamName: string, request: AddMemberRequest) => {
await unwrapIpc('team:addMember', () => api.teams.addMember(teamName, request));
await get().refreshTeamData(teamName);
},
removeMember: async (teamName: string, memberName: string) => {
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
await get().refreshTeamData(teamName);
},
updateMemberRole: async (teamName: string, memberName: string, role: string | undefined) => {
await unwrapIpc('team:updateMemberRole', () =>
api.teams.updateMemberRole(teamName, memberName, role)
);
await get().refreshTeamData(teamName);
},
softDeleteTask: async (teamName: string, taskId: string) => {
await unwrapIpc('team:softDeleteTask', () => api.teams.softDeleteTask(teamName, taskId));
await get().refreshTeamData(teamName);
await get().fetchDeletedTasks(teamName);
},
restoreTask: async (teamName: string, taskId: string) => {
await unwrapIpc('team:restoreTask', () => api.teams.restoreTask(teamName, taskId));
await get().refreshTeamData(teamName);
await get().fetchDeletedTasks(teamName);
},
fetchDeletedTasks: async (teamName: string) => {
set({ deletedTasksLoading: true });
try {
const tasks = await unwrapIpc('team:getDeletedTasks', () =>
api.teams.getDeletedTasks(teamName)
);
set({ deletedTasks: tasks, deletedTasksLoading: false });
} catch (error) {
logger.error('Failed to fetch deleted tasks:', error);
set({ deletedTasks: [], deletedTasksLoading: false });
}
},
deleteTeam: async (teamName: string) => {
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
await get().fetchTeams();
await get().fetchAllTasks();
},
restoreTeam: async (teamName: string) => {
await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName));
await get().fetchTeams();
await get().fetchAllTasks();
},
permanentlyDeleteTeam: async (teamName: string) => {
await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName));
const state = get();
if (state.selectedTeamName === teamName) {
set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null });
}
await get().fetchTeams();
await get().fetchAllTasks();
},
createTeam: async (request: TeamCreateRequest) => {
// Ensure provisioning progress subscription is active (defensive).
get().subscribeProvisioningProgress();
// Establish a per-team floor so late events from a previous run can't override UI.
const floor = nowIso();
set((state) => ({
provisioningStartedAtFloorByTeam: {
...state.provisioningStartedAtFloorByTeam,
[request.teamName]: floor,
},
}));
// Clear stale provisioning runs for this team so the banner starts fresh
set((state) => {
const cleaned = { ...state.provisioningRuns };
for (const [runId, run] of Object.entries(cleaned)) {
if (run.teamName === request.teamName) {
delete cleaned[runId];
}
}
return { provisioningError: null, provisioningRuns: cleaned };
});
// Optimistic progress entry: ensures banner shows even if IPC progress is delayed/missed.
const pendingRunId = `pending:${request.teamName}:${Date.now()}`;
set((state) => ({
provisioningRuns: {
...state.provisioningRuns,
[pendingRunId]: {
runId: pendingRunId,
teamName: request.teamName,
state: 'spawning',
message: 'Starting Claude CLI process...',
startedAt: floor,
updatedAt: floor,
},
},
activeProvisioningRunId: pendingRunId,
}));
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,
});
try {
await get().getProvisioningStatus(response.runId);
} catch {
// ignore — polling below will retry
}
void pollProvisioningStatus(get, 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) => {
// Ensure provisioning progress subscription is active (defensive).
get().subscribeProvisioningProgress();
// Establish a per-team floor so late events from a previous run can't override UI.
const floor = nowIso();
set((state) => ({
provisioningStartedAtFloorByTeam: {
...state.provisioningStartedAtFloorByTeam,
[request.teamName]: floor,
},
}));
// Clear stale provisioning runs for this team so the banner starts fresh
set((state) => {
const cleaned = { ...state.provisioningRuns };
for (const [runId, run] of Object.entries(cleaned)) {
if (run.teamName === request.teamName) {
delete cleaned[runId];
}
}
return { provisioningError: null, provisioningRuns: cleaned };
});
// Optimistic progress entry: ensures banner shows even if IPC progress is delayed/missed.
const pendingRunId = `pending:${request.teamName}:${Date.now()}`;
set((state) => ({
provisioningRuns: {
...state.provisioningRuns,
[pendingRunId]: {
runId: pendingRunId,
teamName: request.teamName,
state: 'spawning',
message: 'Starting Claude CLI process...',
startedAt: floor,
updatedAt: floor,
},
},
activeProvisioningRunId: pendingRunId,
}));
try {
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
set({
activeProvisioningRunId: response.runId,
provisioningError: null,
});
try {
await get().getProvisioningStatus(response.runId);
} catch {
// ignore — polling below will retry
}
void pollProvisioningStatus(get, 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);
return progress;
},
cancelProvisioning: async (runId: string) => {
await unwrapIpc('team:cancelProvisioning', () => api.teams.cancelProvisioning(runId));
},
onProvisioningProgress: (progress: TeamProvisioningProgress) => {
const floor = get().provisioningStartedAtFloorByTeam[progress.teamName];
if (floor && progress.startedAt < floor) {
// Ignore late progress from a previous run (common after stop→launch).
return;
}
set((state) => {
const nextRuns: Record<string, TeamProvisioningProgress> = {
...state.provisioningRuns,
[progress.runId]: progress,
};
// When real progress arrives, drop any pending placeholder runs for this team.
if (!isPendingProvisioningRunId(progress.runId)) {
for (const [runId, run] of Object.entries(nextRuns)) {
if (isPendingProvisioningRunId(runId) && run.teamName === progress.teamName) {
delete nextRuns[runId];
}
}
}
return {
provisioningRuns: nextRuns,
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 });
},
updateToolApprovalSettings: async (patch) => {
const current = get().toolApprovalSettings;
const merged = { ...current, ...patch };
set({ toolApprovalSettings: merged });
localStorage.setItem('team:toolApprovalSettings', JSON.stringify(merged));
try {
await api.teams.updateToolApprovalSettings(merged);
} catch (err) {
logger.warn('Failed to sync tool approval settings to main:', err);
}
},
respondToToolApproval: async (teamName, runId, requestId, allow, message) => {
try {
await api.teams.respondToToolApproval(teamName, runId, requestId, allow, message);
// Remove ONLY after successful IPC, by runId+requestId pair
set((s) => ({
pendingApprovals: s.pendingApprovals.filter(
(a) => !(a.runId === runId && a.requestId === requestId)
),
}));
} catch {
// IPC failed — approval stays in UI, user can retry
// Do NOT modify pendingApprovals — nothing was removed, nothing to rollback
}
},
unsubscribeProvisioningProgress: () => {
const unsubscribe = get().provisioningProgressUnsubscribe;
if (unsubscribe) {
unsubscribe();
set({ provisioningProgressUnsubscribe: null });
}
},
});