agent-ecosystem/src/renderer/store/index.ts

1819 lines
66 KiB
TypeScript

/**
* Store index - combines all slices and exports the unified store.
*/
import { api } from '@renderer/api';
import { syncRendererTelemetry } from '@renderer/sentry';
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest';
import { createLogger } from '@shared/utils/logger';
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
import { create } from 'zustand';
import { createChangeReviewSlice } from './slices/changeReviewSlice';
import {
createCliInstallerSlice,
getModelOnlyFallbackProviderIds,
mergeCliStatusPreservingHydratedProviders,
reconcileMultimodelProviderLoading,
} from './slices/cliInstallerSlice';
import { createConfigSlice } from './slices/configSlice';
import { createConnectionSlice } from './slices/connectionSlice';
import { createContextSlice } from './slices/contextSlice';
import { createConversationSlice } from './slices/conversationSlice';
import { createEditorSlice } from './slices/editorSlice';
import { createExtensionsSlice } from './slices/extensionsSlice';
import { createNotificationSlice } from './slices/notificationSlice';
import { createPaneSlice } from './slices/paneSlice';
import { createProjectSlice } from './slices/projectSlice';
import { createRepositorySlice } from './slices/repositorySlice';
import { createScheduleSlice } from './slices/scheduleSlice';
import { createSessionDetailSlice } from './slices/sessionDetailSlice';
import { createSessionSlice } from './slices/sessionSlice';
import { createSubagentSlice } from './slices/subagentSlice';
import { createTabSlice } from './slices/tabSlice';
import { createTabUISlice } from './slices/tabUISlice';
import {
createTeamSlice,
getActiveTeamPendingReplyWaits,
getLastResolvedTeamDataRefreshAt,
hasActiveTeamPendingReplyWait,
isTeamDataRefreshPending,
selectTeamDataForName,
} from './slices/teamSlice';
import {
noteTeamRefreshFanout,
type TeamRefreshFanoutOperation,
} from './teamRefreshFanoutDiagnostics';
import { createUISlice } from './slices/uiSlice';
import { createUpdateSlice } from './slices/updateSlice';
import type { DetectedError } from '../types/data';
import type { AppState } from './types';
import type {
ActiveToolCall,
CliInstallerProgress,
CliProviderId,
LeadContextUsage,
ScheduleChangeEvent,
TeamChangeEvent,
ToolActivityEventPayload,
ToolApprovalEvent,
ToolApprovalRequest,
UpdaterStatus,
} from '@shared/types';
const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false;
const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
const FINISHED_TOOL_DISPLAY_MS = 1_500;
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
const TEAM_CHANGE_EVENT_BURST_WINDOW_MS = 4_000;
const TEAM_CHANGE_EVENT_BURST_WARN_COUNT = 8;
const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
const CURRENT_APP_VERSION =
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
const logger = createLogger('Store:index');
const RELEVANT_TEAM_CHANGE_EVENT_TYPES = new Set<TeamChangeEvent['type']>([
'task',
'config',
'inbox',
'lead-message',
'lead-context',
'lead-activity',
'process',
'member-spawn',
]);
const teamChangeEventDiagnostics = new Map<
string,
{
windowStartedAt: number;
count: number;
lastWarnAt: number;
countsByType: Record<string, number>;
}
>();
function noteTeamChangeEventBurst(teamName: string, eventType: string, visible: boolean): void {
if (!visible) return;
const now = Date.now();
const diagnostic = teamChangeEventDiagnostics.get(teamName) ?? {
windowStartedAt: now,
count: 0,
lastWarnAt: 0,
countsByType: {},
};
if (now - diagnostic.windowStartedAt > TEAM_CHANGE_EVENT_BURST_WINDOW_MS) {
diagnostic.windowStartedAt = now;
diagnostic.count = 0;
diagnostic.countsByType = {};
}
diagnostic.count += 1;
diagnostic.countsByType[eventType] = (diagnostic.countsByType[eventType] ?? 0) + 1;
if (
diagnostic.count >= TEAM_CHANGE_EVENT_BURST_WARN_COUNT &&
now - diagnostic.lastWarnAt >= TEAM_CHANGE_EVENT_WARN_THROTTLE_MS
) {
diagnostic.lastWarnAt = now;
// Disabled - this warning is too noisy during normal inbox bursts on active teams.
}
teamChangeEventDiagnostics.set(teamName, diagnostic);
}
// =============================================================================
// Store Creation
// =============================================================================
export const useStore = create<AppState>()((...args) => ({
...createProjectSlice(...args),
...createRepositorySlice(...args),
...createSessionSlice(...args),
...createSessionDetailSlice(...args),
...createSubagentSlice(...args),
...createTeamSlice(...args),
...createConversationSlice(...args),
...createTabSlice(...args),
...createTabUISlice(...args),
...createPaneSlice(...args),
...createUISlice(...args),
...createNotificationSlice(...args),
...createConfigSlice(...args),
...createConnectionSlice(...args),
...createContextSlice(...args),
...createUpdateSlice(...args),
...createChangeReviewSlice(...args),
...createCliInstallerSlice(...args),
...createEditorSlice(...args),
...createScheduleSlice(...args),
...createExtensionsSlice(...args),
}));
// =============================================================================
// Re-exports
// =============================================================================
// =============================================================================
// Store Initialization - Subscribe to IPC Events
// =============================================================================
/**
* Initialize notification event listeners and fetch initial notification count.
* Call this once when the app starts (e.g., in App.tsx useEffect).
*/
export function initializeNotificationListeners(): () => void {
void cleanupCommentReadState();
const cleanupFns: (() => void)[] = [];
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
useStore.getState().subscribeProvisioningProgress();
cleanupFns.push(() => {
useStore.getState().unsubscribeProvisioningProgress();
});
// Initial data fetches. Config loads first (needed for theme), then the rest
// run in parallel (no data dependencies between them). UV_THREADPOOL_SIZE=16
// prevents thread pool saturation even with concurrent I/O on Windows.
// Components also fire these from useEffect — loading guards in each action
// prevent duplicate IPC calls (whichever caller starts first wins).
void (async () => {
// Config: fast (in-memory read) — needed for theme before first paint.
await useStore.getState().fetchConfig();
// Sync Sentry renderer telemetry gate from loaded config
const loadedConfig = useStore.getState().appConfig;
syncRendererTelemetry(loadedConfig?.general?.telemetryEnabled ?? true);
if (api.cliInstaller) {
// Resolve the configured CLI flavor after config has loaded to avoid
// bootstrapping multimodel placeholder state in Claude-only mode.
type NavigatorWithUserAgentData = Navigator & { userAgentData?: { platform?: string } };
const nav: NavigatorWithUserAgentData | null =
typeof navigator !== 'undefined' ? (navigator as NavigatorWithUserAgentData) : null;
// Prefer UA-CH when available; fall back to deprecated-but-still-supported navigator.platform.
const platform: string =
nav?.userAgentData?.platform ?? nav?.platform ?? nav?.userAgent ?? '';
const isWindows = platform.toLowerCase().includes('win');
const delayMs = isWindows ? 3000 : 0;
cliStatusTimer = setTimeout(() => {
const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
if (multimodelEnabled) {
void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true });
} else {
void useStore.getState().fetchCliStatus();
}
cliStatusTimer = null;
}, delayMs);
}
// Remaining fetches have no data dependency on each other — run in parallel
// to avoid blocking teams/notifications behind a slow repository scan.
await Promise.all([
useStore.getState().fetchRepositoryGroups(),
useStore.getState().fetchAllTasks(),
useStore.getState().fetchTeams(),
useStore.getState().fetchNotifications(),
useStore.getState().fetchSchedules(),
]);
})();
cleanupFns.push(() => {
if (cliStatusTimer) clearTimeout(cliStatusTimer);
});
// This lightweight renderer-side poll keeps visible in-progress task badges fresh.
// It is intentionally independent from the backend log-source tracking feature flag below.
const inProgressChangePresencePollTimer = setInterval(() => {
void pollVisibleTeamInProgressChangePresence();
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);
cleanupFns.push(() => {
clearInterval(inProgressChangePresencePollTimer);
});
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const teamLastRelevantActivityAt = new Map<string, number>();
const teamLastIdleWatchdogRefreshAt = new Map<string, number>();
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
let inProgressChangePresencePollInFlight = false;
let teamMessageFallbackPollInFlight = false;
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let globalTasksRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const pendingTeamListRefreshDiagnostics = new Map<string, Set<string>>();
const pendingGlobalTasksRefreshDiagnostics = new Map<string, Set<string>>();
const SESSION_REFRESH_DEBOUNCE_MS = 150;
const PROJECT_REFRESH_DEBOUNCE_MS = 300;
const TEAM_REFRESH_THROTTLE_MS = 800;
const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400;
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
const buildTeamChangeFanoutReason = (eventType: string): string => `event:${eventType}`;
const addPendingGlobalRefreshDiagnostic = (
pending: Map<string, Set<string>>,
teamName: string,
reason: string
): void => {
const reasons = pending.get(teamName) ?? new Set<string>();
reasons.add(reason);
pending.set(teamName, reasons);
};
const drainPendingGlobalRefreshDiagnostics = (
pending: Map<string, Set<string>>,
operation: TeamRefreshFanoutOperation
): void => {
const entries = Array.from(pending.entries());
pending.clear();
for (const [teamName, reasons] of entries) {
for (const reason of reasons) {
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: 'executed',
reason,
operation,
});
}
}
};
const noteGlobalRefreshScheduled = (
pending: Map<string, Set<string>>,
teamName: string | null | undefined,
reason: string,
operation: TeamRefreshFanoutOperation,
coalesced: boolean
): void => {
if (!teamName) {
return;
}
addPendingGlobalRefreshDiagnostic(pending, teamName, reason);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: coalesced ? 'coalesced' : 'scheduled',
reason,
operation,
});
};
const refreshTrackedTeamMessages = async (teamName: string): Promise<void> => {
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
return;
}
const current = useStore.getState();
try {
const headResult = await current.refreshTeamMessagesHead(teamName);
const latest = useStore.getState();
const meta = latest.memberActivityMetaByTeam[teamName];
if (headResult.feedChanged || meta?.feedRevision !== headResult.feedRevision) {
await latest.refreshMemberActivityMeta(teamName);
}
} catch {
// Best-effort refresh for message-driven events and fallback polling only.
}
};
const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => {
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return;
}
const existingTimer = memberSpawnRefreshTimers.get(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: existingTimer ? 'coalesced' : 'scheduled',
reason: 'event:member-spawn',
operation: 'fetchMemberSpawnStatuses',
});
if (memberSpawnRefreshTimers.has(teamName)) {
return;
}
const timer = setTimeout(() => {
memberSpawnRefreshTimers.delete(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: 'event:member-spawn',
operation: 'fetchMemberSpawnStatuses',
});
void useStore.getState().fetchMemberSpawnStatuses(teamName);
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
memberSpawnRefreshTimers.set(teamName, timer);
};
const scheduleTeamAgentRuntimeRefresh = (teamName: string | null | undefined): void => {
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return;
}
const existingTimer = teamAgentRuntimeRefreshTimers.get(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: existingTimer ? 'coalesced' : 'scheduled',
reason: 'event:member-spawn',
operation: 'fetchTeamAgentRuntime',
});
if (teamAgentRuntimeRefreshTimers.has(teamName)) {
return;
}
const timer = setTimeout(() => {
teamAgentRuntimeRefreshTimers.delete(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: 'event:member-spawn',
operation: 'fetchTeamAgentRuntime',
});
void useStore.getState().fetchTeamAgentRuntime(teamName);
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
teamAgentRuntimeRefreshTimers.set(teamName, timer);
};
const scheduleTrackedTeamMessageRefresh = (
teamName: string | null | undefined,
reason: 'event:inbox' | 'event:lead-message'
): void => {
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
return;
}
const existingTimer = teamMessageRefreshTimers.get(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: existingTimer ? 'coalesced' : 'scheduled',
reason,
operation: 'fetchTeamMessageHead',
});
if (teamMessageRefreshTimers.has(teamName)) {
return;
}
const timer = setTimeout(() => {
teamMessageRefreshTimers.delete(teamName);
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: 'executed',
reason,
operation: 'fetchTeamMessageHead',
});
void refreshTrackedTeamMessages(teamName);
}, TEAM_REFRESH_THROTTLE_MS);
teamMessageRefreshTimers.set(teamName, timer);
};
const buildToolActivityTimerKey = (
teamName: string,
memberName: string,
toolUseId: string,
kind: 'fade'
): string => `${teamName}:${memberName}:${toolUseId}:${kind}`;
const clearToolActivityTimer = (
teamName: string,
memberName: string,
toolUseId: string,
kind: 'fade'
): void => {
const key = buildToolActivityTimerKey(teamName, memberName, toolUseId, kind);
const existing = toolActivityTimers.get(key);
if (existing) {
clearTimeout(existing);
toolActivityTimers.delete(key);
}
};
const scheduleToolActivityTimer = (
teamName: string,
memberName: string,
toolUseId: string,
kind: 'fade',
delayMs: number,
cb: () => void
): void => {
clearToolActivityTimer(teamName, memberName, toolUseId, kind);
const key = buildToolActivityTimerKey(teamName, memberName, toolUseId, kind);
const timer = setTimeout(() => {
toolActivityTimers.delete(key);
cb();
}, delayMs);
toolActivityTimers.set(key, timer);
};
const clearToolActivityTimersForTeam = (teamName: string): void => {
for (const [key, timer] of toolActivityTimers.entries()) {
if (!key.startsWith(`${teamName}:`)) continue;
clearTimeout(timer);
toolActivityTimers.delete(key);
}
};
const clearRuntimeToolStateForTeam = (
prev: AppState,
teamName: string
): Pick<AppState, 'activeToolsByTeam' | 'finishedVisibleByTeam' | 'toolHistoryByTeam'> => {
const nextActive = { ...prev.activeToolsByTeam };
const nextFinished = { ...prev.finishedVisibleByTeam };
const nextHistory = { ...prev.toolHistoryByTeam };
delete nextActive[teamName];
delete nextFinished[teamName];
delete nextHistory[teamName];
return {
activeToolsByTeam: nextActive,
finishedVisibleByTeam: nextFinished,
toolHistoryByTeam: nextHistory,
};
};
const pushToolHistoryEntry = (
history: Record<string, Record<string, ActiveToolCall[]>>,
teamName: string,
entry: ActiveToolCall
): Record<string, Record<string, ActiveToolCall[]>> => {
const teamHistory = { ...(history[teamName] ?? {}) };
const existing = teamHistory[entry.memberName] ?? [];
teamHistory[entry.memberName] = [
entry,
...existing.filter((t) => t.toolUseId !== entry.toolUseId),
].slice(0, MAX_TOOL_HISTORY_PER_MEMBER);
return { ...history, [teamName]: teamHistory };
};
const upsertMemberToolEntry = (
teamState: Record<string, Record<string, ActiveToolCall>> | undefined,
entry: ActiveToolCall
): Record<string, Record<string, ActiveToolCall>> => ({
...(teamState ?? {}),
[entry.memberName]: {
...((teamState ?? {})[entry.memberName] ?? {}),
[entry.toolUseId]: entry,
},
});
const removeMemberToolEntry = (
teamState: Record<string, Record<string, ActiveToolCall>> | undefined,
memberName: string,
toolUseId: string
): Record<string, Record<string, ActiveToolCall>> => {
if (!teamState?.[memberName]?.[toolUseId]) return teamState ?? {};
const nextTeamState = { ...(teamState ?? {}) };
const nextMemberState = { ...(nextTeamState[memberName] ?? {}) };
delete nextMemberState[toolUseId];
if (Object.keys(nextMemberState).length === 0) {
delete nextTeamState[memberName];
} else {
nextTeamState[memberName] = nextMemberState;
}
return nextTeamState;
};
const removeMemberToolGroup = (
teamState: Record<string, Record<string, ActiveToolCall>> | undefined,
memberName: string
): Record<string, Record<string, ActiveToolCall>> => {
if (!teamState?.[memberName]) return teamState ?? {};
const nextTeamState = { ...(teamState ?? {}) };
delete nextTeamState[memberName];
return nextTeamState;
};
const removeMemberToolEntries = (
teamState: Record<string, Record<string, ActiveToolCall>> | undefined,
memberName: string,
toolUseIds: readonly string[]
): Record<string, Record<string, ActiveToolCall>> => {
if (!teamState?.[memberName] || toolUseIds.length === 0) return teamState ?? {};
let nextTeamState = teamState ?? {};
let changed = false;
for (const toolUseId of toolUseIds) {
if (!nextTeamState[memberName]?.[toolUseId]) continue;
nextTeamState = removeMemberToolEntry(nextTeamState, memberName, toolUseId);
changed = true;
}
return changed ? nextTeamState : (teamState ?? {});
};
const getBaseProjectId = (projectId: string | null | undefined): string | null => {
if (!projectId) return null;
const separatorIndex = projectId.indexOf('::');
return separatorIndex >= 0 ? projectId.slice(0, separatorIndex) : projectId;
};
const pollVisibleTeamInProgressChangePresence = async (): Promise<void> => {
if (inProgressChangePresencePollInFlight) {
return;
}
const state = useStore.getState();
const visibleTeamNames = Array.from(getVisibleTeamNamesInAnyPane(state));
if (visibleTeamNames.length === 0) {
return;
}
// Cleanup cursors for teams that no longer exist (prevent unbounded growth)
if (inProgressChangePresenceCursorByTeam.size > 50) {
const teamNames = new Set(useStore.getState().teams.map((t) => t.teamName));
for (const key of inProgressChangePresenceCursorByTeam.keys()) {
if (!teamNames.has(key)) {
inProgressChangePresenceCursorByTeam.delete(key);
}
}
}
inProgressChangePresencePollInFlight = true;
try {
for (const teamName of visibleTeamNames) {
const teamData = selectTeamDataForName(state, teamName);
if (teamData?.teamName !== teamName) {
if (!isTeamDataRefreshPending(teamName)) {
void state.refreshTeamData(teamName, { withDedup: true });
}
continue;
}
const candidateTasks = teamData.tasks.filter((task) => {
if (task.status !== 'in_progress') {
return false;
}
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
});
if (candidateTasks.length === 0) {
inProgressChangePresenceCursorByTeam.delete(teamName);
continue;
}
const cursor = inProgressChangePresenceCursorByTeam.get(teamName) ?? 0;
const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown');
const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks;
const nextTask = sourceTasks[cursor % sourceTasks.length];
inProgressChangePresenceCursorByTeam.set(teamName, (cursor + 1) % sourceTasks.length);
const current = useStore.getState();
if (!isTeamVisibleInAnyPane(teamName)) {
continue;
}
const currentTeamData = selectTeamDataForName(current, teamName);
if (currentTeamData?.teamName !== teamName) {
if (!isTeamDataRefreshPending(teamName)) {
void current.refreshTeamData(teamName, { withDedup: true });
}
continue;
}
const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id);
if (currentTask?.status !== 'in_progress') {
continue;
}
const requestOptions = buildTaskChangeRequestOptions(currentTask);
const cacheKey = buildTaskChangePresenceKey(teamName, currentTask.id, requestOptions);
current.invalidateTaskChangePresence([cacheKey]);
await current.checkTaskHasChanges(teamName, currentTask.id, requestOptions);
}
} catch {
// Best-effort polling for in-progress tasks only.
} finally {
inProgressChangePresencePollInFlight = false;
}
};
const scheduleSessionRefresh = (projectId: string, sessionId: string): void => {
const key = `${projectId}/${sessionId}`;
// Throttle (not trailing debounce): keep at most one pending refresh per session.
// Debounce can delay updates indefinitely while the file is continuously appended.
if (pendingSessionRefreshTimers.has(key)) {
return;
}
const timer = setTimeout(() => {
pendingSessionRefreshTimers.delete(key);
const state = useStore.getState();
void state.refreshSessionInPlace(projectId, sessionId);
}, SESSION_REFRESH_DEBOUNCE_MS);
pendingSessionRefreshTimers.set(key, timer);
};
const scheduleProjectRefresh = (projectId: string): void => {
const existingTimer = pendingProjectRefreshTimers.get(projectId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
pendingProjectRefreshTimers.delete(projectId);
const state = useStore.getState();
void state.refreshSessionsInPlace(projectId);
}, PROJECT_REFRESH_DEBOUNCE_MS);
pendingProjectRefreshTimers.set(projectId, timer);
};
// Listen for new notifications from main process
if (api.notifications?.onNew) {
const cleanup = api.notifications.onNew((_event: unknown, error: unknown) => {
// Cast the error to DetectedError type
const notification = error as DetectedError;
if (notification?.id) {
// Keep list in sync immediately; unread count is synced via notification:updated/fetch.
useStore.setState((state) => {
if (state.notifications.some((n) => n.id === notification.id)) {
return {};
}
return { notifications: [notification, ...state.notifications].slice(0, 200) };
});
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Listen for notification updates from main process
if (api.notifications?.onUpdated) {
const cleanup = api.notifications.onUpdated(
(_event: unknown, payload: { total: number; unreadCount: number }) => {
const unreadCount =
typeof payload.unreadCount === 'number' && Number.isFinite(payload.unreadCount)
? Math.max(0, Math.floor(payload.unreadCount))
: 0;
useStore.setState({ unreadCount });
}
);
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Navigate to error when user clicks a native OS notification
if (api.notifications?.onClicked) {
const cleanup = api.notifications.onClicked((_event: unknown, data: unknown) => {
const error = data as DetectedError;
if (error?.id && error?.sessionId && error?.projectId) {
useStore.getState().navigateToError(error);
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// fetchNotifications() is called in the parallel init chain above.
/**
* Check if a session is visible in any pane (not just the focused pane's active tab).
* This ensures file change and task-list listeners refresh sessions shown in any split pane.
*/
const isSessionVisibleInAnyPane = (sessionId: string): boolean => {
const { paneLayout } = useStore.getState();
return paneLayout.panes.some(
(pane) =>
pane.activeTabId != null &&
pane.tabs.some(
(tab) =>
tab.id === pane.activeTabId && tab.type === 'session' && tab.sessionId === sessionId
)
);
};
const getVisibleTeamNamesInAnyPane = (state = useStore.getState()): Set<string> => {
const { paneLayout } = state;
const visibleTeamNames = new Set<string>();
for (const pane of paneLayout.panes) {
if (!pane.activeTabId) continue;
const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId);
if (
(activeTab?.type === 'team' || activeTab?.type === 'graph') &&
activeTab.teamName != null
) {
visibleTeamNames.add(activeTab.teamName);
}
}
return visibleTeamNames;
};
const isTeamVisibleInAnyPane = (teamName: string): boolean => {
return getVisibleTeamNamesInAnyPane().has(teamName);
};
const shouldRefreshTeamMessages = (teamName: string): boolean => {
return isTeamVisibleInAnyPane(teamName) || hasActiveTeamPendingReplyWait(teamName);
};
const getTrackedTeamMessageRefreshTeams = (): Set<string> => {
const tracked = getVisibleTeamNamesInAnyPane();
for (const teamName of getActiveTeamPendingReplyWaits()) {
tracked.add(teamName);
}
return tracked;
};
const getTrackedChangePresenceTeams = (): Set<string> => {
const state = useStore.getState();
const tracked = new Set<string>();
for (const teamName of getVisibleTeamNamesInAnyPane(state)) {
if (selectTeamDataForName(state, teamName)) {
tracked.add(teamName);
}
}
return tracked;
};
const getTrackedToolActivityTeams = (): Set<string> => {
return getVisibleTeamNamesInAnyPane();
};
const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => {
teamLastRelevantActivityAt.set(teamName, timestamp);
};
const getFocusedVisibleTeamName = (): string | null => {
const state = useStore.getState();
const focusedPane = state.paneLayout.panes.find(
(pane) => pane.id === state.paneLayout.focusedPaneId
);
if (!focusedPane?.activeTabId) {
return null;
}
const activeTab = focusedPane.tabs.find((tab) => tab.id === focusedPane.activeTabId);
if ((activeTab?.type !== 'team' && activeTab?.type !== 'graph') || !activeTab.teamName) {
return null;
}
if (!selectTeamDataForName(state, activeTab.teamName)) {
return null;
}
return activeTab.teamName;
};
const pollTrackedTeamMessageFallback = async (): Promise<void> => {
if (teamMessageFallbackPollInFlight) {
return;
}
const teamNames = getTrackedTeamMessageRefreshTeams();
if (teamNames.size === 0) {
return;
}
teamMessageFallbackPollInFlight = true;
try {
await Promise.allSettled(
Array.from(teamNames, (teamName) => {
noteTeamRefreshFanout({
teamName,
surface: 'pending-reply-fallback',
phase: 'executed',
reason: 'pending-reply:fallback-poll',
operation: 'fetchTeamMessageHead',
});
return refreshTrackedTeamMessages(teamName);
})
);
} finally {
teamMessageFallbackPollInFlight = false;
}
};
const pollFocusedVisibleTeamIdleWatchdog = async (): Promise<void> => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}
const current = useStore.getState();
const teamName = getFocusedVisibleTeamName();
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return;
}
if (current.selectedTeamName === teamName && current.selectedTeamLoading) {
return;
}
if (isTeamDataRefreshPending(teamName)) {
return;
}
const lastRelevantActivityAt = teamLastRelevantActivityAt.get(teamName) ?? 0;
const lastResolvedRefreshAt = getLastResolvedTeamDataRefreshAt(teamName) ?? 0;
const idleBaselineAt = Math.max(lastRelevantActivityAt, lastResolvedRefreshAt);
if (idleBaselineAt === 0) {
return;
}
const now = Date.now();
if (now - idleBaselineAt < TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS) {
return;
}
const lastWatchdogRefreshAt = teamLastIdleWatchdogRefreshAt.get(teamName) ?? 0;
if (lastWatchdogRefreshAt >= idleBaselineAt) {
return;
}
logger.warn(`[perf] idle-watchdog refresh team=${teamName} idleMs=${now - idleBaselineAt}`);
try {
await current.refreshTeamData(teamName, { withDedup: true });
} finally {
teamLastIdleWatchdogRefreshAt.set(
teamName,
Math.max(getLastResolvedTeamDataRefreshAt(teamName) ?? 0, idleBaselineAt, Date.now())
);
}
};
if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) {
let trackedTeamNames = new Set<string>();
const syncVisibleTeamTracking = (): void => {
const nextTrackedTeamNames = getTrackedChangePresenceTeams();
for (const teamName of nextTrackedTeamNames) {
if (!trackedTeamNames.has(teamName)) {
void api.teams.setChangePresenceTracking(teamName, true).catch(() => undefined);
}
}
for (const teamName of trackedTeamNames) {
if (!nextTrackedTeamNames.has(teamName)) {
void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined);
}
}
trackedTeamNames = nextTrackedTeamNames;
};
syncVisibleTeamTracking();
const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => {
if (
state.paneLayout === prevState.paneLayout &&
state.selectedTeamName === prevState.selectedTeamName &&
state.selectedTeamData === prevState.selectedTeamData &&
state.teamDataCacheByName === prevState.teamDataCacheByName
) {
return;
}
syncVisibleTeamTracking();
});
cleanupFns.push(() => {
unsubscribeVisibleTeamTracking();
for (const teamName of trackedTeamNames) {
void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined);
}
trackedTeamNames.clear();
});
}
if (api.teams?.setToolActivityTracking) {
let trackedTeamNames = new Set<string>();
const syncVisibleTeamTracking = (): void => {
const nextTrackedTeamNames = getTrackedToolActivityTeams();
for (const teamName of nextTrackedTeamNames) {
if (!trackedTeamNames.has(teamName)) {
void api.teams.setToolActivityTracking(teamName, true).catch(() => undefined);
}
}
for (const teamName of trackedTeamNames) {
if (!nextTrackedTeamNames.has(teamName)) {
void api.teams.setToolActivityTracking(teamName, false).catch(() => undefined);
}
}
trackedTeamNames = nextTrackedTeamNames;
};
syncVisibleTeamTracking();
const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => {
if (state.paneLayout === prevState.paneLayout) {
return;
}
syncVisibleTeamTracking();
});
cleanupFns.push(() => {
unsubscribeVisibleTeamTracking();
for (const teamName of trackedTeamNames) {
void api.teams.setToolActivityTracking(teamName, false).catch(() => undefined);
}
trackedTeamNames.clear();
});
}
// Listen for task-list file changes to refresh currently viewed session metadata
if (api.onTodoChange) {
const cleanup = api.onTodoChange((event) => {
if (!event.sessionId || event.type === 'unlink') {
return;
}
const state = useStore.getState();
const isViewingSession =
state.selectedSessionId === event.sessionId || isSessionVisibleInAnyPane(event.sessionId);
if (isViewingSession) {
// Find the project ID from any pane's tab that shows this session
const allTabs = state.getAllPaneTabs();
const sessionTab = allTabs.find(
(t) => t.type === 'session' && t.sessionId === event.sessionId
);
if (sessionTab?.projectId) {
scheduleSessionRefresh(sessionTab.projectId, event.sessionId);
}
}
// Refresh project sessions list if applicable
const activeTab = state.getActiveTab();
const activeProjectId =
activeTab?.type === 'session' && typeof activeTab.projectId === 'string'
? activeTab.projectId
: null;
if (activeProjectId && activeProjectId === state.selectedProjectId) {
scheduleProjectRefresh(activeProjectId);
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Listen for file changes to auto-refresh current session and detect new sessions
if (api.onFileChange) {
const cleanup = api.onFileChange((event) => {
// Skip unlink events
if (event.type === 'unlink') {
return;
}
const state = useStore.getState();
const selectedProjectId = state.selectedProjectId;
const selectedProjectBaseId = getBaseProjectId(selectedProjectId);
const eventProjectBaseId = getBaseProjectId(event.projectId);
const matchesSelectedProject =
!!selectedProjectId &&
(eventProjectBaseId == null || selectedProjectBaseId === eventProjectBaseId);
const isTopLevelSessionEvent = !event.isSubagent;
const isUnknownSessionInSidebar =
event.sessionId == null ||
!state.sessions.some((session) => session.id === event.sessionId);
const shouldRefreshForPotentialNewSession =
isTopLevelSessionEvent &&
matchesSelectedProject &&
isUnknownSessionInSidebar &&
(event.type === 'add' || (state.connectionMode === 'local' && event.type === 'change'));
// Refresh sidebar session list only when a truly new top-level session appears.
// Local fs.watch can report "change" before/without "add" for newly created files.
if (shouldRefreshForPotentialNewSession) {
if (matchesSelectedProject && selectedProjectId) {
scheduleProjectRefresh(selectedProjectId);
}
}
// Keep opened session view in sync on content changes.
// Some local writers emit rename/add for in-place updates, so include "add".
if ((event.type === 'change' || event.type === 'add') && selectedProjectId) {
const activeSessionId = state.selectedSessionId;
const eventSessionId = event.sessionId;
const isViewingEventSession =
!!eventSessionId &&
(activeSessionId === eventSessionId || isSessionVisibleInAnyPane(eventSessionId));
const shouldFallbackRefreshActiveSession =
matchesSelectedProject && !eventSessionId && !!activeSessionId;
const sessionIdToRefresh =
(isViewingEventSession ? eventSessionId : null) ??
(shouldFallbackRefreshActiveSession ? activeSessionId : null);
if (sessionIdToRefresh) {
const allTabs = state.getAllPaneTabs();
const visibleSessionTab = allTabs.find(
(tab) => tab.type === 'session' && tab.sessionId === sessionIdToRefresh
);
const refreshProjectId = visibleSessionTab?.projectId ?? selectedProjectId;
// Use refreshSessionInPlace to avoid flickering and preserve UI state
scheduleSessionRefresh(refreshProjectId, sessionIdToRefresh);
}
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
const teamIdleWatchdogTimer = setInterval(() => {
void pollFocusedVisibleTeamIdleWatchdog();
}, TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS);
cleanupFns.push(() => {
clearInterval(teamIdleWatchdogTimer);
});
const teamMessageFallbackPollTimer = setInterval(() => {
void pollTrackedTeamMessageFallback();
}, TEAM_MESSAGE_FALLBACK_POLL_MS);
cleanupFns.push(() => {
clearInterval(teamMessageFallbackPollTimer);
});
if (api.teams?.onTeamChange) {
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
const messageRefreshRelevant =
Boolean(event.teamName) && shouldRefreshTeamMessages(event.teamName);
noteTeamChangeEventBurst(event.teamName, event.type, messageRefreshRelevant);
const isIgnoredRuntimeRun = (() => {
if (!event.runId) return false;
const state = useStore.getState();
return (
state.ignoredProvisioningRunIds[event.runId] === event.teamName ||
state.ignoredRuntimeRunIds[event.runId] === event.teamName
);
})();
if (isIgnoredRuntimeRun) {
return;
}
const isStaleRuntimeEvent = (() => {
if (!event.runId) return false;
const currentRunId = useStore.getState().currentRuntimeRunIdByTeam[event.teamName];
return currentRunId != null && currentRunId !== event.runId;
})();
const seedCurrentRunIdIfMissing = (): void => {
if (!event.runId) return;
const currentRunId = useStore.getState().currentRuntimeRunIdByTeam[event.teamName];
if (currentRunId == null) {
useStore.setState((prev) => ({
currentRuntimeRunIdByTeam: {
...prev.currentRuntimeRunIdByTeam,
[event.teamName]: event.runId ?? null,
},
ignoredRuntimeRunIds: Object.fromEntries(
Object.entries(prev.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== event.teamName
)
),
}));
}
};
if (RELEVANT_TEAM_CHANGE_EVENT_TYPES.has(event.type) && !isStaleRuntimeEvent) {
noteRelevantTeamActivity(event.teamName);
}
// Immediate in-memory update for lead activity — no filesystem refresh needed
if (event.type === 'lead-activity' && event.detail) {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
const nextActivity = event.detail as 'active' | 'idle' | 'offline';
useStore.setState((prev) => {
const nextState: Partial<typeof prev> = {
leadActivityByTeam: {
...prev.leadActivityByTeam,
[event.teamName]: nextActivity,
},
};
const baseTeamData =
prev.teamDataCacheByName[event.teamName] ??
(prev.selectedTeamName === event.teamName ? prev.selectedTeamData : null);
const nextTeamData =
baseTeamData && baseTeamData.isAlive !== (nextActivity !== 'offline')
? {
...baseTeamData,
isAlive: nextActivity !== 'offline',
}
: baseTeamData;
if (nextTeamData) {
nextState.teamDataCacheByName = {
...prev.teamDataCacheByName,
[event.teamName]: nextTeamData,
};
}
if (prev.selectedTeamName === event.teamName && nextTeamData) {
nextState.selectedTeamData = nextTeamData;
}
// Clear context data when lead goes offline
if (nextActivity === 'offline') {
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
delete nextState.leadContextByTeam[event.teamName];
Object.assign(nextState, clearRuntimeToolStateForTeam(prev, event.teamName));
nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam };
delete nextState.currentRuntimeRunIdByTeam[event.teamName];
nextState.ignoredRuntimeRunIds = event.runId
? {
...prev.ignoredRuntimeRunIds,
[event.runId]: event.teamName,
}
: prev.ignoredRuntimeRunIds;
clearToolActivityTimersForTeam(event.teamName);
}
return nextState as typeof prev;
});
return;
}
// Immediate in-memory update for lead context usage — no filesystem refresh needed
if (event.type === 'lead-context' && event.detail) {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
try {
const ctx = JSON.parse(event.detail) as LeadContextUsage;
useStore.setState((prev) => ({
...prev,
leadContextByTeam: { ...prev.leadContextByTeam, [event.teamName]: ctx },
}));
} catch {
/* ignore malformed detail */
}
return;
}
if (event.type === 'tool-activity' && event.detail) {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
try {
const payload = JSON.parse(event.detail) as ToolActivityEventPayload;
if (payload.action === 'start' && payload.activity) {
const activity: ActiveToolCall = {
memberName: payload.activity.memberName,
toolUseId: payload.activity.toolUseId,
toolName: payload.activity.toolName,
preview: payload.activity.preview,
startedAt: payload.activity.startedAt,
source: payload.activity.source,
state: 'running',
};
useStore.setState((prev) => ({
activeToolsByTeam: {
...prev.activeToolsByTeam,
[event.teamName]: upsertMemberToolEntry(
prev.activeToolsByTeam[event.teamName],
activity
),
},
}));
} else if (payload.action === 'finish' && payload.memberName && payload.toolUseId) {
const memberName = payload.memberName;
const toolUseId = payload.toolUseId;
useStore.setState((prev) => {
const current = prev.activeToolsByTeam[event.teamName]?.[memberName]?.[toolUseId];
if (!current) {
return {};
}
const completed: ActiveToolCall = {
...current,
state: payload.isError ? 'error' : 'complete',
finishedAt: payload.finishedAt ?? new Date().toISOString(),
resultPreview: payload.resultPreview,
};
scheduleToolActivityTimer(
event.teamName,
memberName,
toolUseId,
'fade',
FINISHED_TOOL_DISPLAY_MS,
() => {
useStore.setState((state) => {
const nextCurrent =
state.finishedVisibleByTeam[event.teamName]?.[memberName]?.[toolUseId];
if (!nextCurrent) {
return {};
}
return {
finishedVisibleByTeam: {
...state.finishedVisibleByTeam,
[event.teamName]: removeMemberToolEntry(
state.finishedVisibleByTeam[event.teamName],
memberName,
toolUseId
),
},
};
});
}
);
return {
activeToolsByTeam: {
...prev.activeToolsByTeam,
[event.teamName]: removeMemberToolEntry(
prev.activeToolsByTeam[event.teamName],
memberName,
toolUseId
),
},
finishedVisibleByTeam: {
...prev.finishedVisibleByTeam,
[event.teamName]: upsertMemberToolEntry(
prev.finishedVisibleByTeam[event.teamName],
completed
),
},
toolHistoryByTeam: pushToolHistoryEntry(
prev.toolHistoryByTeam,
event.teamName,
completed
),
};
});
} else if (payload.action === 'reset') {
if (payload.memberName) {
const memberName = payload.memberName;
const toolUseIds =
Array.isArray(payload.toolUseIds) && payload.toolUseIds.length > 0
? payload.toolUseIds
: null;
useStore.setState((prev) => {
if (!prev.activeToolsByTeam[event.teamName]?.[memberName]) {
return {};
}
return {
activeToolsByTeam: {
...prev.activeToolsByTeam,
[event.teamName]: toolUseIds
? removeMemberToolEntries(
prev.activeToolsByTeam[event.teamName],
memberName,
toolUseIds
)
: removeMemberToolGroup(prev.activeToolsByTeam[event.teamName], memberName),
},
};
});
} else {
useStore.setState((prev) => ({
activeToolsByTeam: { ...prev.activeToolsByTeam, [event.teamName]: {} },
}));
}
}
} catch {
/* ignore malformed detail */
}
return;
}
// Member spawn status change: fetch updated spawn statuses for the team.
if (event.type === 'member-spawn') {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
scheduleMemberSpawnStatusesRefresh(event.teamName);
scheduleTeamAgentRuntimeRefresh(event.teamName);
return;
}
if (event.type === 'inbox') {
scheduleTrackedTeamMessageRefresh(event.teamName, 'event:inbox');
return;
}
// Live lead-message events refresh only the tracked message feed surface
// (visible team or local pending-reply wait), not the structural snapshot.
if (event.type === 'lead-message') {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
scheduleTrackedTeamMessageRefresh(event.teamName, 'event:lead-message');
return;
}
if (event.type === 'log-source-change') {
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}
const existingTimer = teamPresenceRefreshTimers.get(event.teamName);
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: existingTimer ? 'coalesced' : 'scheduled',
reason: 'event:log-source-change',
operation: 'refreshTaskChangePresence',
});
if (existingTimer) {
return;
}
const timer = setTimeout(() => {
teamPresenceRefreshTimers.delete(event.teamName);
const current = useStore.getState();
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: 'event:log-source-change',
operation: 'refreshTaskChangePresence',
});
void current.refreshTeamChangePresence(event.teamName);
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
teamPresenceRefreshTimers.set(event.teamName, timer);
return;
}
const eventReason = buildTeamChangeFanoutReason(event.type);
// Throttled refresh of summary list (keeps TeamListView current without flooding).
noteGlobalRefreshScheduled(
pendingTeamListRefreshDiagnostics,
event.teamName,
eventReason,
'fetchTeams',
teamListRefreshTimer != null
);
if (!teamListRefreshTimer) {
teamListRefreshTimer = setTimeout(() => {
teamListRefreshTimer = null;
drainPendingGlobalRefreshDiagnostics(pendingTeamListRefreshDiagnostics, 'fetchTeams');
void useStore.getState().fetchTeams();
}, TEAM_LIST_REFRESH_THROTTLE_MS);
}
const shouldRefreshGlobalTasks = event.type === 'task' || event.type === 'config';
// Throttled refresh of global tasks list for sidebar.
if (shouldRefreshGlobalTasks) {
noteGlobalRefreshScheduled(
pendingGlobalTasksRefreshDiagnostics,
event.teamName,
eventReason,
'fetchAllTasks',
globalTasksRefreshTimer != null
);
if (!globalTasksRefreshTimer) {
globalTasksRefreshTimer = setTimeout(() => {
globalTasksRefreshTimer = null;
drainPendingGlobalRefreshDiagnostics(
pendingGlobalTasksRefreshDiagnostics,
'fetchAllTasks'
);
void useStore.getState().fetchAllTasks();
}, GLOBAL_TASKS_REFRESH_THROTTLE_MS);
}
}
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}
// Per-team throttle (not debounce): keep at most one pending detail refresh per team.
// Debounce would delay indefinitely while inbox messages keep arriving.
const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName;
const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName;
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: existingDetailTimer ? 'coalesced' : 'scheduled',
reason: eventReason,
operation: 'refreshTeamData',
eventType: event.type,
selected: selectedForRefresh,
visible: true,
activeTab: activeTabForRefresh,
});
if (existingDetailTimer) {
return;
}
const timer = setTimeout(() => {
teamRefreshTimers.delete(event.teamName);
const current = useStore.getState();
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: eventReason,
operation: 'refreshTeamData',
eventType: event.type,
selected: current.selectedTeamName === event.teamName,
visible: isTeamVisibleInAnyPane(event.teamName),
activeTab: getFocusedVisibleTeamName() === event.teamName,
});
void current.refreshTeamData(event.teamName, { withDedup: true });
}, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer);
});
if (typeof cleanup === 'function') {
cleanupFns.push(() => {
cleanup();
for (const t of teamRefreshTimers.values()) clearTimeout(t);
teamRefreshTimers = new Map();
for (const t of teamMessageRefreshTimers.values()) clearTimeout(t);
teamMessageRefreshTimers = new Map();
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
teamPresenceRefreshTimers = new Map();
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
memberSpawnRefreshTimers = new Map();
for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t);
teamAgentRuntimeRefreshTimers = new Map();
for (const t of toolActivityTimers.values()) clearTimeout(t);
toolActivityTimers = new Map();
teamLastRelevantActivityAt.clear();
teamLastIdleWatchdogRefreshAt.clear();
if (teamListRefreshTimer) {
clearTimeout(teamListRefreshTimer);
teamListRefreshTimer = null;
}
pendingTeamListRefreshDiagnostics.clear();
if (globalTasksRefreshTimer) {
clearTimeout(globalTasksRefreshTimer);
globalTasksRefreshTimer = null;
}
pendingGlobalTasksRefreshDiagnostics.clear();
});
}
}
if (api.teams?.onProjectBranchChange) {
const cleanup = api.teams.onProjectBranchChange((_event: unknown, event) => {
if (!event?.projectPath) return;
const normalizedPath = normalizePath(event.projectPath);
if (!normalizedPath) return;
useStore.setState((prev) => {
const current = prev.branchByPath[normalizedPath];
if (current === event.branch) {
return {};
}
return {
branchByPath: {
...prev.branchByPath,
[normalizedPath]: event.branch,
},
};
});
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Tool approval events from CLI control_request protocol
if (api.teams?.onToolApprovalEvent) {
const cleanup = api.teams.onToolApprovalEvent((_event: unknown, data: unknown) => {
const event = data as ToolApprovalEvent;
if ('autoResolved' in event && event.autoResolved) {
// Timeout or auto-allow resolved in main — remove from UI and record result
const allowed = event.reason !== 'timeout_deny';
useStore.setState((s) => {
const next = new Map(s.resolvedApprovals);
next.set(event.requestId, allowed);
return {
pendingApprovals: s.pendingApprovals.filter(
(a) => !(a.runId === event.runId && a.requestId === event.requestId)
),
resolvedApprovals: next,
};
});
} else if ('dismissed' in event && event.dismissed) {
const dismiss = event;
useStore.setState((s) => ({
pendingApprovals: s.pendingApprovals.filter(
(a) => !(a.teamName === dismiss.teamName && a.runId === dismiss.runId)
),
}));
} else {
const request = event as ToolApprovalRequest;
useStore.setState((s) => ({
pendingApprovals: [...s.pendingApprovals, request],
}));
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
// Sync saved tool approval settings to main process on startup
const savedSettings = useStore.getState().toolApprovalSettings;
const activeTeam = useStore.getState().selectedTeamName ?? '__global__';
api.teams.updateToolApprovalSettings?.(activeTeam, savedSettings).catch(() => {
// Silently ignore — settings will use defaults until next update
});
}
// Listen for editor file change events (chokidar watcher → renderer)
if (api.editor?.onEditorChange) {
const cleanup = api.editor.onEditorChange((event) => {
const state = useStore.getState();
if (state.editorProjectPath) {
state.handleExternalFileChange(event);
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Listen for schedule change events from main process
if (api.schedules?.onScheduleChange) {
const cleanup = api.schedules.onScheduleChange((_event: unknown, data: unknown) => {
const event = data as ScheduleChangeEvent;
if (event?.scheduleId) {
void useStore.getState().applyScheduleChange(event.scheduleId);
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// fetchCliStatus() is deferred 5s after app start (heavy on Windows).
// Listen for CLI installer progress events from main process
let cliCompletedRevertTimer: ReturnType<typeof setTimeout> | null = null;
if (api.cliInstaller?.onProgress) {
const cleanup = api.cliInstaller.onProgress((_event: unknown, data: unknown) => {
const progress = data as CliInstallerProgress;
// Clear any pending auto-revert timer on new events
if (progress.type !== 'completed' && cliCompletedRevertTimer) {
clearTimeout(cliCompletedRevertTimer);
cliCompletedRevertTimer = null;
}
const detail = progress.detail ?? null;
switch (progress.type) {
case 'checking':
useStore.setState({ cliInstallerState: 'checking', cliInstallerDetail: detail });
break;
case 'downloading':
useStore.setState({
cliInstallerState: 'downloading',
cliDownloadProgress: progress.percent ?? 0,
cliDownloadTransferred: progress.transferred ?? 0,
cliDownloadTotal: progress.total ?? 0,
cliInstallerDetail: detail,
});
break;
case 'verifying':
useStore.setState({ cliInstallerState: 'verifying', cliInstallerDetail: detail });
break;
case 'installing': {
// Accumulate log lines and raw chunks for xterm.js rendering
const prevLogs = useStore.getState().cliInstallerLogs;
const prevRaw = useStore.getState().cliInstallerRawChunks;
const newLogs = detail ? [...prevLogs, detail].slice(-50) : prevLogs;
const newRaw = progress.rawChunk ? [...prevRaw, progress.rawChunk].slice(-200) : prevRaw;
useStore.setState({
cliInstallerState: 'installing',
cliInstallerDetail: detail,
cliInstallerLogs: newLogs,
cliInstallerRawChunks: newRaw,
});
break;
}
case 'completed':
{
const multimodelEnabled =
useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
void refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus: useStore.getState().bootstrapCliStatus,
fetchCliStatus: useStore.getState().fetchCliStatus,
});
}
useStore.setState({
cliInstallerState: 'completed',
cliCompletedVersion: progress.version ?? null,
cliInstallerDetail: null,
});
// Re-fetch status after install and auto-revert to idle after 3s
cliCompletedRevertTimer = setTimeout(() => {
cliCompletedRevertTimer = null;
// Only revert if still in 'completed' state (not overwritten by a new install)
if (useStore.getState().cliInstallerState === 'completed') {
useStore.setState({ cliInstallerState: 'idle' });
}
}, 3000);
break;
case 'error':
useStore.setState({
cliInstallerState: 'error',
cliInstallerError: progress.error ?? 'Unknown error',
});
break;
case 'status':
if (progress.status) {
let modelOnlyFallbackProviderIds: CliProviderId[] = [];
useStore.setState((state) => {
const nextStatus = mergeCliStatusPreservingHydratedProviders(
state.cliStatus,
progress.status!
);
modelOnlyFallbackProviderIds = getModelOnlyFallbackProviderIds(nextStatus);
return {
cliStatus: nextStatus,
cliProviderStatusLoading: reconcileMultimodelProviderLoading(
nextStatus,
state.cliProviderStatusLoading
),
};
});
for (const providerId of modelOnlyFallbackProviderIds) {
void useStore.getState().fetchCliProviderStatus(providerId, { silent: false });
}
}
break;
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(() => {
cleanup();
if (cliCompletedRevertTimer) {
clearTimeout(cliCompletedRevertTimer);
cliCompletedRevertTimer = null;
}
});
}
}
// Listen for updater status events from main process
if (api.updater?.onStatus) {
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {
const s = status as UpdaterStatus;
switch (s.type) {
case 'checking': {
// Don't downgrade status if we already know about an available/downloaded update
// or if a download is in progress (prevents UI flash during periodic re-checks)
const current = useStore.getState().updateStatus;
if (current !== 'available' && current !== 'downloaded' && current !== 'downloading') {
useStore.setState({ updateStatus: 'checking' });
}
break;
}
case 'available': {
// Don't downgrade from downloading/downloaded — the update is already
// in progress or ready to install (prevents periodic re-check from
// resetting the state after download completes)
const currentStatus = useStore.getState().updateStatus;
if (currentStatus === 'downloading' || currentStatus === 'downloaded') {
break;
}
const nextVersion = s.version ? normalizeVersion(s.version) : null;
if (!nextVersion || !isVersionOlder(CURRENT_APP_VERSION, nextVersion)) {
break;
}
const dismissed = useStore.getState().dismissedUpdateVersion;
useStore.setState({
updateStatus: 'available',
availableVersion: nextVersion,
releaseNotes: s.releaseNotes ?? null,
showUpdateDialog: nextVersion !== dismissed,
});
break;
}
case 'not-available': {
// Don't reset status if update is already downloading or downloaded
const notAvailCurrent = useStore.getState().updateStatus;
if (notAvailCurrent !== 'downloading' && notAvailCurrent !== 'downloaded') {
useStore.setState({ updateStatus: 'not-available' });
}
break;
}
case 'downloading':
useStore.setState({
updateStatus: 'downloading',
downloadProgress: s.progress?.percent ?? 0,
});
break;
case 'downloaded':
if (s.version && !isVersionOlder(CURRENT_APP_VERSION, normalizeVersion(s.version))) {
break;
}
useStore.setState({
updateStatus: 'downloaded',
downloadProgress: 100,
availableVersion: s.version
? normalizeVersion(s.version)
: useStore.getState().availableVersion,
});
break;
case 'error': {
// Don't lose downloaded state due to a transient check error —
// the update is already on disk and ready to install
const errCurrent = useStore.getState().updateStatus;
if (errCurrent === 'downloaded') {
break;
}
useStore.setState({
updateStatus: 'error',
updateError: s.error ?? 'Unknown error',
});
break;
}
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Listen for SSH connection status changes from main process
// NOTE: Only syncs connection status here. Data fetching is handled by
// connectionSlice.connectSsh/disconnectSsh and contextSlice.switchContext.
if (api.ssh?.onStatus) {
const cleanup = api.ssh.onStatus((_event: unknown, status: unknown) => {
const s = status as { state: string; host: string | null; error: string | null };
useStore
.getState()
.setConnectionStatus(
s.state as 'disconnected' | 'connecting' | 'connected' | 'error',
s.host,
s.error
);
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Listen for context changes from main process (e.g., SSH disconnect)
if (api.context?.onChanged) {
const cleanup = api.context.onChanged((_event: unknown, data: unknown) => {
const { id } = data as { id: string; type: string };
const currentContextId = useStore.getState().activeContextId;
if (id !== currentContextId) {
// Main process switched context externally (e.g., SSH disconnect)
// Trigger renderer-side context switch to sync state
void useStore.getState().switchContext(id);
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Return cleanup function
return () => {
for (const timer of pendingSessionRefreshTimers.values()) {
clearTimeout(timer);
}
pendingSessionRefreshTimers.clear();
for (const timer of pendingProjectRefreshTimers.values()) {
clearTimeout(timer);
}
pendingProjectRefreshTimers.clear();
cleanupFns.forEach((fn) => fn());
};
}