/** * 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 { scheduleStartupIdleTask } from '@renderer/utils/startupIdleTask'; import { buildTaskChangePresenceKey, buildTaskChangeRequestOptions, canDisplayTaskChangesForOptions, } from '@renderer/utils/taskChangeRequest'; import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents'; import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { createLogger } from '@shared/utils/logger'; import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { create } from 'zustand'; import { createChangeReviewSlice } from './slices/changeReviewSlice'; import { createCliInstallerSlice, getIncompleteMultimodelProviderIds, getModelOnlyFallbackProviderIds, mergeCliStatusPreservingHydratedProviders, reconcileMultimodelProviderLoading, refreshCodexProviderStatusAfterRuntimeInstall, refreshOpenCodeProviderStatusAfterRuntimeInstall, } 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, getCurrentProvisioningProgressForTeam, getLastResolvedTeamDataRefreshAt, hasActiveTeamPendingReplyWait, isTeamDataRefreshPending, selectTeamDataForName, } from './slices/teamSlice'; import { createUISlice } from './slices/uiSlice'; import { createUpdateSlice } from './slices/updateSlice'; import { decideProcessFanoutDryRun, decideProcessFanoutMode, type TeamProcessFanoutDecision, } from './teamProcessFanoutDryRun'; import { installTeamRefreshFanoutDebugBridge } from './teamRefreshFanoutDebugBridge'; import { noteTeamRefreshFanout, type TeamRefreshFanoutOperation, } from './teamRefreshFanoutDiagnostics'; import type { DetectedError } from '../types/data'; import type { AppState } from './types'; import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts'; import type { ActiveToolCall, CliInstallerProgress, CliProviderId, LeadContextUsage, OpenCodeRuntimeStatus, ScheduleChangeEvent, TeamChangeEvent, TeamProvisioningProgress, ToolActivityEventPayload, ToolApprovalEvent, ToolApprovalRequest, UpdaterStatus, } from '@shared/types'; const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false; const ENABLE_IN_PROGRESS_CHANGE_PRESENCE_BACKGROUND_POLL = 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 TASK_LOG_ACTIVITY_PULSE_MS = 3_500; const STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000; const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000; const STARTUP_PROVIDER_STATUS_MAX_DELAY_MS = 30_000; const STARTUP_GLOBAL_TASKS_MIN_DELAY_MS = 5_000; const STARTUP_GLOBAL_TASKS_MAX_DELAY_MS = 30_000; const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet = new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']); export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout'; 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([ 'task', 'config', 'inbox', 'lead-message', 'lead-context', 'lead-activity', 'member-advisory', 'process', 'member-spawn', ]); const teamChangeEventDiagnostics = new Map< string, { windowStartedAt: number; count: number; lastWarnAt: number; countsByType: Record; } >(); export function isTeamProcessLiteFanoutEnabled(): boolean { if (typeof window === 'undefined') { return true; } try { return window.localStorage.getItem(TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY) !== '0'; } catch { return true; } } 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()((...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)[] = []; cleanupFns.push(installTeamRefreshFanoutDebugBridge()); let cliStatusTimer: ReturnType | null = null; let runtimeStatusTimer: ReturnType | null = null; let deferredProviderStatusCleanup: (() => void) | null = null; let deferredGlobalTasksCleanup: (() => void) | null = null; let disposed = false; useStore.getState().subscribeProvisioningProgress(); cleanupFns.push(() => { useStore.getState().unsubscribeProvisioningProgress(); }); // Initial data fetches. Config loads first (needed for theme), then the // immediately visible data follows. Repository grouping is owned by the // Sessions sidebar tab when it first mounts; scanning it here made startup // pay for a hidden panel. 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 (async () => { await useStore .getState() .bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' }); if (deferredProviderStatusCleanup) { deferredProviderStatusCleanup(); } deferredProviderStatusCleanup = scheduleStartupIdleTask( () => { const providerIds = getIncompleteMultimodelProviderIds( useStore.getState().cliStatus ); for (const providerId of providerIds) { void useStore.getState().fetchCliProviderStatus(providerId, { silent: false }); } deferredProviderStatusCleanup = null; }, { minDelayMs: STARTUP_PROVIDER_STATUS_MIN_DELAY_MS, maxDelayMs: STARTUP_PROVIDER_STATUS_MAX_DELAY_MS, } ); })(); } else { void useStore.getState().fetchCliStatus(); } cliStatusTimer = null; }, delayMs); } runtimeStatusTimer = setTimeout(() => { if (api.openCodeRuntime) { void useStore.getState().fetchOpenCodeRuntimeStatus(); } if (api.codexRuntime) { void useStore.getState().fetchCodexRuntimeStatus(); } runtimeStatusTimer = null; }, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS); // Keep immediately visible startup data first; global task aggregation can // scan all team task files, so hydrate it after first paint/idle. await Promise.all([ useStore.getState().fetchTeams(), useStore.getState().fetchNotifications(), useStore.getState().fetchSchedules(), ]); if (disposed) { return; } deferredGlobalTasksCleanup = scheduleStartupIdleTask( () => { deferredGlobalTasksCleanup = null; void useStore.getState().fetchAllTasks(); }, { minDelayMs: STARTUP_GLOBAL_TASKS_MIN_DELAY_MS, maxDelayMs: STARTUP_GLOBAL_TASKS_MAX_DELAY_MS, } ); })(); cleanupFns.push(() => { disposed = true; if (cliStatusTimer) clearTimeout(cliStatusTimer); if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer); if (deferredProviderStatusCleanup) deferredProviderStatusCleanup(); if (deferredGlobalTasksCleanup) deferredGlobalTasksCleanup(); }); // TODO(task-change-presence): re-enable this only after the board uses a bounded // batch/priority presence pipeline. The old one-task-per-tick poll was accurate // only after enough time or after opening a task popup, while still doing periodic // summary extraction work in the background. The replacement should check visible // tasks first, dedupe in-flight requests, keep popup/full diff requests higher // priority, and never render "unknown" as "no_changes". if (ENABLE_IN_PROGRESS_CHANGE_PRESENCE_BACKGROUND_POLL) { const inProgressChangePresencePollTimer = setInterval(() => { void pollVisibleTeamInProgressChangePresence(); }, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS); cleanupFns.push(() => { clearInterval(inProgressChangePresencePollTimer); }); } const pendingSessionRefreshTimers = new Map>(); const pendingProjectRefreshTimers = new Map>(); const teamLastRelevantActivityAt = new Map(); const teamLastIdleWatchdogRefreshAt = new Map(); let teamRefreshTimers = new Map>(); let teamMessageRefreshTimers = new Map>(); let teamPresenceRefreshTimers = new Map>(); let memberAdvisorySafetyRefreshTimers = new Map>(); let memberSpawnRefreshTimers = new Map>(); let teamAgentRuntimeRefreshTimers = new Map>(); let toolActivityTimers = new Map>(); let taskLogActivityTimers = new Map>(); let processLiteStructuralReconcileTimers = new Map< string, { firstScheduledAt: number; timer: ReturnType } >(); let inProgressChangePresencePollInFlight = false; let teamMessageFallbackPollInFlight = false; const inProgressChangePresenceCursorByTeam = new Map(); let teamListRefreshTimer: ReturnType | null = null; let globalTasksRefreshTimer: ReturnType | null = null; const pendingTeamListRefreshDiagnostics = new Map>(); const pendingGlobalTasksRefreshDiagnostics = new Map>(); 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 = 2000; const PROCESS_LITE_STRUCTURAL_RECONCILE_IDLE_MS = 2_500; const PROCESS_LITE_STRUCTURAL_RECONCILE_MAX_WAIT_MS = 15_000; const buildTeamChangeFanoutReason = (eventType: string): string => `event:${eventType}`; const isProvisioningProgressActiveForProcessLite = ( progress: Pick | null ): boolean => progress != null && ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE.has(progress.state); const hasActiveProvisioningRunForTeam = (teamName: string): boolean => { return isProvisioningProgressActiveForProcessLite( getCurrentProvisioningProgressForTeam(useStore.getState(), teamName) ); }; const shouldDeferAutomaticTeamDataRefreshDuringLaunch = (teamName: string): boolean => { return isTeamProcessLiteFanoutEnabled() && hasActiveProvisioningRunForTeam(teamName); }; const addPendingGlobalRefreshDiagnostic = ( pending: Map>, teamName: string, reason: string ): void => { const reasons = pending.get(teamName) ?? new Set(); reasons.add(reason); pending.set(teamName, reasons); }; const drainPendingGlobalRefreshDiagnostics = ( pending: Map>, 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>, 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 => { 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. } }; type RuntimeRefreshReason = 'event:member-spawn' | 'event:process-lite'; interface RuntimeRefreshScheduleOptions { reason?: RuntimeRefreshReason; skipIfHiddenAtExecution?: boolean; } const buildRuntimeRefreshTimerKey = (teamName: string, reason: RuntimeRefreshReason): string => `${teamName}:${reason}`; const scheduleMemberSpawnStatusesRefresh = ( teamName: string | null | undefined, options: RuntimeRefreshScheduleOptions = {} ): void => { if (!teamName || !isTeamVisibleInAnyPane(teamName)) { return; } const reason = options.reason ?? 'event:member-spawn'; const timerKey = buildRuntimeRefreshTimerKey(teamName, reason); const existingTimer = memberSpawnRefreshTimers.get(timerKey); noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: existingTimer ? 'coalesced' : 'scheduled', reason, operation: 'fetchMemberSpawnStatuses', }); if (memberSpawnRefreshTimers.has(timerKey)) { return; } const timer = setTimeout(() => { memberSpawnRefreshTimers.delete(timerKey); if (options.skipIfHiddenAtExecution === true && !isTeamVisibleInAnyPane(teamName)) { noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: 'skipped', reason, operation: 'fetchMemberSpawnStatuses', visible: false, }); return; } noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: 'executed', reason, operation: 'fetchMemberSpawnStatuses', }); void useStore.getState().fetchMemberSpawnStatuses(teamName); }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); memberSpawnRefreshTimers.set(timerKey, timer); }; const scheduleTeamAgentRuntimeRefresh = ( teamName: string | null | undefined, options: RuntimeRefreshScheduleOptions = {} ): void => { if (!teamName || !isTeamVisibleInAnyPane(teamName)) { return; } const reason = options.reason ?? 'event:member-spawn'; const timerKey = buildRuntimeRefreshTimerKey(teamName, reason); const existingTimer = teamAgentRuntimeRefreshTimers.get(timerKey); noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: existingTimer ? 'coalesced' : 'scheduled', reason, operation: 'fetchTeamAgentRuntime', }); if (teamAgentRuntimeRefreshTimers.has(timerKey)) { return; } const timer = setTimeout(() => { teamAgentRuntimeRefreshTimers.delete(timerKey); if (options.skipIfHiddenAtExecution === true && !isTeamVisibleInAnyPane(teamName)) { noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: 'skipped', reason, operation: 'fetchTeamAgentRuntime', visible: false, }); return; } noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: 'executed', reason, operation: 'fetchTeamAgentRuntime', }); void useStore.getState().fetchTeamAgentRuntime(teamName); }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); teamAgentRuntimeRefreshTimers.set(timerKey, timer); }; const scheduleProcessLiteRuntimeRefresh = (teamName: string): void => { scheduleMemberSpawnStatusesRefresh(teamName, { reason: 'event:process-lite', skipIfHiddenAtExecution: true, }); scheduleTeamAgentRuntimeRefresh(teamName, { reason: 'event:process-lite', skipIfHiddenAtExecution: true, }); }; 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 buildTaskLogActivityTimerKey = (teamName: string, taskId: string): string => `${teamName}\u0000${taskId}`; const clearTaskLogActivityTimer = (teamName: string, taskId: string): void => { const key = buildTaskLogActivityTimerKey(teamName, taskId); const existing = taskLogActivityTimers.get(key); if (existing) { clearTimeout(existing); taskLogActivityTimers.delete(key); } }; const clearTaskLogActivityTimersForTeam = (teamName: string): void => { const prefix = `${teamName}\u0000`; for (const [key, timer] of taskLogActivityTimers.entries()) { if (!key.startsWith(prefix)) continue; clearTimeout(timer); taskLogActivityTimers.delete(key); } }; const clearTaskLogActivityStateForTeam = (teamName: string): void => { clearTaskLogActivityTimersForTeam(teamName); useStore.setState((prev) => { if (!(teamName in prev.activeTaskLogActivityByTeam)) { return {}; } const next = { ...prev.activeTaskLogActivityByTeam }; delete next[teamName]; return { activeTaskLogActivityByTeam: next }; }); }; const markTaskLogActivity = (teamName: string, taskId: string): void => { clearTaskLogActivityTimer(teamName, taskId); const isAlreadyActive = useStore.getState().activeTaskLogActivityByTeam[teamName]?.[taskId] === true; if (!isAlreadyActive) { useStore.setState((prev) => ({ activeTaskLogActivityByTeam: { ...prev.activeTaskLogActivityByTeam, [teamName]: { ...(prev.activeTaskLogActivityByTeam[teamName] ?? {}), [taskId]: true, }, }, })); } const timerKey = buildTaskLogActivityTimerKey(teamName, taskId); const timer = setTimeout(() => { taskLogActivityTimers.delete(timerKey); useStore.setState((prev) => { const teamActivity = prev.activeTaskLogActivityByTeam[teamName]; if (!teamActivity?.[taskId]) { return {}; } const nextTeamActivity = { ...teamActivity }; delete nextTeamActivity[taskId]; const nextByTeam = { ...prev.activeTaskLogActivityByTeam }; if (Object.keys(nextTeamActivity).length === 0) { delete nextByTeam[teamName]; } else { nextByTeam[teamName] = nextTeamActivity; } return { activeTaskLogActivityByTeam: nextByTeam }; }); }, TASK_LOG_ACTIVITY_PULSE_MS); taskLogActivityTimers.set(timerKey, timer); }; const clearRuntimeToolStateForTeam = ( prev: AppState, teamName: string ): Pick => { 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>, teamName: string, entry: ActiveToolCall ): Record> => { 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> | undefined, entry: ActiveToolCall ): Record> => ({ ...(teamState ?? {}), [entry.memberName]: { ...((teamState ?? {})[entry.memberName] ?? {}), [entry.toolUseId]: entry, }, }); const removeMemberToolEntry = ( teamState: Record> | undefined, memberName: string, toolUseId: string ): Record> => { 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> | undefined, memberName: string ): Record> => { if (!teamState?.[memberName]) return teamState ?? {}; const nextTeamState = { ...(teamState ?? {}) }; delete nextTeamState[memberName]; return nextTeamState; }; const removeMemberToolEntries = ( teamState: Record> | undefined, memberName: string, toolUseIds: readonly string[] ): Record> => { 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 => { 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 (shouldDeferAutomaticTeamDataRefreshDuringLaunch(teamName)) { continue; } if (!isTeamDataRefreshPending(teamName)) { void state.refreshTeamData(teamName, { withDedup: true }); } continue; } const candidateTasks = teamData.tasks.filter((task) => { if (!isDisplayableCurrentTask(task)) { 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 (shouldDeferAutomaticTeamDataRefreshDuringLaunch(teamName)) { continue; } if (!isTeamDataRefreshPending(teamName)) { void current.refreshTeamData(teamName, { withDedup: true }); } continue; } const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id); if (!isDisplayableCurrentTask(currentTask)) { 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 => { const { paneLayout } = state; const visibleTeamNames = new Set(); 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 => { const tracked = getVisibleTeamNamesInAnyPane(); for (const teamName of getActiveTeamPendingReplyWaits()) { tracked.add(teamName); } return tracked; }; const getTrackedChangePresenceTeams = (): Set => { const state = useStore.getState(); const tracked = new Set(); for (const teamName of getVisibleTeamNamesInAnyPane(state)) { if (selectTeamDataForName(state, teamName)) { tracked.add(teamName); } } return tracked; }; const getTrackedToolActivityTeams = (): Set => { return getVisibleTeamNamesInAnyPane(); }; const getTrackedTaskLogActivityTeams = (): Set => { 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 buildProcessFanoutDecision = ( event: TeamChangeEvent, isStaleRuntimeEvent: boolean ): TeamProcessFanoutDecision => { const state = useStore.getState(); const teamName = event.teamName; return decideProcessFanoutMode({ teamName, eventType: event.type, detail: event.detail, hasRunId: Boolean(event.runId), isStaleRuntimeEvent, isVisible: isTeamVisibleInAnyPane(teamName), hasVisibleTeamData: selectTeamDataForName(state, teamName) != null, hasActiveProvisioningRun: isProvisioningProgressActiveForProcessLite( getCurrentProvisioningProgressForTeam(state, teamName) ), hasCurrentRuntimeRun: state.currentRuntimeRunIdByTeam[teamName] != null, }); }; const recordProcessFanoutDecision = ( event: TeamChangeEvent, decision: TeamProcessFanoutDecision ): void => { const dryRun = decideProcessFanoutDryRun({ teamName: event.teamName, eventType: event.type, detail: event.detail, hasRunId: Boolean(event.runId), isStaleRuntimeEvent: decision.reason === 'stale-runtime-event', isVisible: decision.reason !== 'hidden-team', hasVisibleTeamData: decision.reason !== 'missing-visible-team-data', hasActiveProvisioningRun: decision.reason !== 'no-active-runtime-context', hasCurrentRuntimeRun: decision.reason !== 'no-active-runtime-context', }); const state = useStore.getState(); noteTeamRefreshFanout({ teamName: event.teamName, surface: 'team-change-listener', phase: 'skipped', reason: `dry-run:process-lite:${dryRun.reason}`, operation: dryRun.wouldUseProcessLite ? 'wouldUseProcessLite' : 'wouldKeepStructuralProcess', eventType: event.type, visible: isTeamVisibleInAnyPane(event.teamName), selected: state.selectedTeamName === event.teamName, activeTab: getFocusedVisibleTeamName() === event.teamName, }); }; const cancelProcessLiteStructuralReconcile = ( teamName: string, reason = 'event:process-lite:structural-reconcile:cancelled-by-structural' ): void => { const existing = processLiteStructuralReconcileTimers.get(teamName); if (!existing) { return; } clearTimeout(existing.timer); processLiteStructuralReconcileTimers.delete(teamName); noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: 'skipped', reason, operation: 'refreshTeamData', }); }; const shouldSuppressProcessLiteStructuralReconcileDuringLaunch = (teamName: string): boolean => { return isTeamProcessLiteFanoutEnabled() && hasActiveProvisioningRunForTeam(teamName); }; const noteProcessLiteStructuralReconcileSuppressedDuringLaunch = (teamName: string): void => { noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: 'skipped', reason: 'event:process-lite:structural-reconcile:suppressed-during-launch', operation: 'refreshTeamData', visible: isTeamVisibleInAnyPane(teamName), selected: useStore.getState().selectedTeamName === teamName, activeTab: getFocusedVisibleTeamName() === teamName, }); }; const shouldUseLaunchProcessRuntimeOnlyFanout = ( event: TeamChangeEvent, isStaleRuntimeEvent: boolean ): boolean => { return ( event.type === 'process' && event.detail === 'processes.json' && !isStaleRuntimeEvent && isTeamVisibleInAnyPane(event.teamName) && shouldSuppressProcessLiteStructuralReconcileDuringLaunch(event.teamName) ); }; const noteLaunchProcessStructuralSuppressed = ( teamName: string, operation: 'fetchTeams' | 'refreshTeamData' ): void => { noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: 'skipped', reason: 'event:process-lite:structural-suppressed-during-launch', operation, eventType: 'process', selected: useStore.getState().selectedTeamName === teamName, visible: true, activeTab: getFocusedVisibleTeamName() === teamName, }); }; const runProcessLiteStructuralReconcile = (teamName: string): void => { if (shouldSuppressProcessLiteStructuralReconcileDuringLaunch(teamName)) { noteProcessLiteStructuralReconcileSuppressedDuringLaunch(teamName); return; } const current = useStore.getState(); noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: 'executed', reason: 'event:process-lite:structural-reconcile', operation: 'fetchTeams', }); void current.fetchTeams(); if (!isTeamVisibleInAnyPane(teamName) || selectTeamDataForName(current, teamName) == null) { noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: 'skipped', reason: 'event:process-lite:structural-reconcile:hidden-or-missing-data', operation: 'refreshTeamData', visible: isTeamVisibleInAnyPane(teamName), }); return; } noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: 'executed', reason: 'event:process-lite:structural-reconcile', operation: 'refreshTeamData', selected: current.selectedTeamName === teamName, visible: true, activeTab: getFocusedVisibleTeamName() === teamName, }); void current.refreshTeamData(teamName, { withDedup: true }); }; const scheduleProcessLiteStructuralReconcile = (teamName: string): void => { const now = Date.now(); const existing = processLiteStructuralReconcileTimers.get(teamName); const firstScheduledAt = existing?.firstScheduledAt ?? now; if (existing) { clearTimeout(existing.timer); } const elapsed = now - firstScheduledAt; const remainingMaxWait = Math.max(0, PROCESS_LITE_STRUCTURAL_RECONCILE_MAX_WAIT_MS - elapsed); const delay = Math.min(PROCESS_LITE_STRUCTURAL_RECONCILE_IDLE_MS, remainingMaxWait); noteTeamRefreshFanout({ teamName, surface: 'team-change-listener', phase: existing ? 'coalesced' : 'scheduled', reason: 'event:process-lite:structural-reconcile', operation: 'refreshTeamData', }); const timer = setTimeout(() => { processLiteStructuralReconcileTimers.delete(teamName); runProcessLiteStructuralReconcile(teamName); }, delay); processLiteStructuralReconcileTimers.set(teamName, { firstScheduledAt, timer }); }; const pollTrackedTeamMessageFallback = async (): Promise => { 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 => { 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; } if (shouldDeferAutomaticTeamDataRefreshDuringLaunch(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(); 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(); 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(); }); } if (api.teams?.setTaskLogStreamTracking) { let trackedTeamNames = new Set(); const syncVisibleTeamTracking = (): void => { const nextTrackedTeamNames = getTrackedTaskLogActivityTeams(); for (const teamName of nextTrackedTeamNames) { if (!trackedTeamNames.has(teamName)) { void api.teams.setTaskLogStreamTracking(teamName, true).catch(() => undefined); } } for (const teamName of trackedTeamNames) { if (!nextTrackedTeamNames.has(teamName)) { void api.teams.setTaskLogStreamTracking(teamName, false).catch(() => undefined); clearTaskLogActivityStateForTeam(teamName); } } 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.setTaskLogStreamTracking(teamName, false).catch(() => undefined); clearTaskLogActivityStateForTeam(teamName); } 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 = { 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.activeTaskLogActivityByTeam = { ...prev.activeTaskLogActivityByTeam }; delete nextState.activeTaskLogActivityByTeam[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); clearTaskLogActivityTimersForTeam(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; } if (event.type === 'task-log-change') { if (isStaleRuntimeEvent) { return; } seedCurrentRunIdIfMissing(); const visible = isTeamVisibleInAnyPane(event.teamName); if (event.taskId && visible) { const isLogActivitySignal = isTaskLogActivityChangeEvent(event); if (isLogActivitySignal) { markTaskLogActivity(event.teamName, event.taskId); } if (event.taskSignalKind === 'log') { return; } const existingDetailTimer = teamRefreshTimers.get(event.teamName); noteTeamRefreshFanout({ teamName: event.teamName, surface: 'team-change-listener', phase: existingDetailTimer ? 'coalesced' : 'scheduled', reason: 'event:task-log-change:task-state-safety', operation: 'refreshTeamData', eventType: event.type, selected: useStore.getState().selectedTeamName === event.teamName, visible, activeTab: getFocusedVisibleTeamName() === event.teamName, }); if (!existingDetailTimer) { const timer = setTimeout(() => { teamRefreshTimers.delete(event.teamName); const current = useStore.getState(); const visibleAtExecution = isTeamVisibleInAnyPane(event.teamName); noteTeamRefreshFanout({ teamName: event.teamName, surface: 'team-change-listener', phase: visibleAtExecution ? 'executed' : 'skipped', reason: 'event:task-log-change:task-state-safety', operation: 'refreshTeamData', eventType: event.type, selected: current.selectedTeamName === event.teamName, visible: visibleAtExecution, activeTab: getFocusedVisibleTeamName() === event.teamName, }); if (!visibleAtExecution) { return; } void current.refreshTeamData(event.teamName, { withDedup: true }); }, TEAM_REFRESH_THROTTLE_MS); teamRefreshTimers.set(event.teamName, timer); } } 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'); if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { return; } const existingDetailTimer = teamRefreshTimers.get(event.teamName); if (existingDetailTimer) { return; } const eventReason = `${buildTeamChangeFanoutReason(event.type)}:structural-safety`; const timer = setTimeout(() => { teamRefreshTimers.delete(event.teamName); const current = useStore.getState(); if (!isTeamVisibleInAnyPane(event.teamName)) { return; } noteTeamRefreshFanout({ teamName: event.teamName, surface: 'team-change-listener', phase: 'executed', reason: eventReason, operation: 'refreshTeamData', eventType: event.type, selected: current.selectedTeamName === event.teamName, visible: true, activeTab: getFocusedVisibleTeamName() === event.teamName, }); void current.refreshTeamData(event.teamName, { withDedup: true }); }, TEAM_REFRESH_THROTTLE_MS); teamRefreshTimers.set(event.teamName, timer); 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 === 'member-advisory') { if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { return; } cancelProcessLiteStructuralReconcile(event.teamName); const eventReason = buildTeamChangeFanoutReason(event.type); const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName; const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName; const existingSafetyTimer = memberAdvisorySafetyRefreshTimers.get(event.teamName); if (existingSafetyTimer) { clearTimeout(existingSafetyTimer); } memberAdvisorySafetyRefreshTimers.set( event.teamName, setTimeout(() => { memberAdvisorySafetyRefreshTimers.delete(event.teamName); if (!isTeamVisibleInAnyPane(event.teamName)) { return; } const current = useStore.getState(); noteTeamRefreshFanout({ teamName: event.teamName, surface: 'team-change-listener', phase: 'executed', reason: `${eventReason}:safety`, operation: 'refreshTeamData', eventType: event.type, selected: current.selectedTeamName === event.teamName, visible: true, activeTab: getFocusedVisibleTeamName() === event.teamName, }); void current.refreshTeamData(event.teamName); }, TEAM_REFRESH_THROTTLE_MS + 250) ); 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); 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; } if (event.type === 'process') { const processDecision = buildProcessFanoutDecision(event, isStaleRuntimeEvent); recordProcessFanoutDecision(event, processDecision); if ( isTeamProcessLiteFanoutEnabled() && shouldUseLaunchProcessRuntimeOnlyFanout(event, isStaleRuntimeEvent) ) { noteLaunchProcessStructuralSuppressed(event.teamName, 'fetchTeams'); noteLaunchProcessStructuralSuppressed(event.teamName, 'refreshTeamData'); scheduleProcessLiteRuntimeRefresh(event.teamName); cancelProcessLiteStructuralReconcile( event.teamName, 'event:process-lite:structural-reconcile:cancelled-during-launch' ); noteProcessLiteStructuralReconcileSuppressedDuringLaunch(event.teamName); return; } if (processDecision.mode === 'process-lite' && isTeamProcessLiteFanoutEnabled()) { noteTeamRefreshFanout({ teamName: event.teamName, surface: 'team-change-listener', phase: 'skipped', reason: 'event:process-lite:structural-suppressed', operation: 'fetchTeams', eventType: event.type, selected: useStore.getState().selectedTeamName === event.teamName, visible: true, activeTab: getFocusedVisibleTeamName() === event.teamName, }); noteTeamRefreshFanout({ teamName: event.teamName, surface: 'team-change-listener', phase: 'skipped', reason: 'event:process-lite:structural-suppressed', operation: 'refreshTeamData', eventType: event.type, selected: useStore.getState().selectedTeamName === event.teamName, visible: true, activeTab: getFocusedVisibleTeamName() === event.teamName, }); scheduleProcessLiteRuntimeRefresh(event.teamName); scheduleProcessLiteStructuralReconcile(event.teamName); return; } if (processDecision.mode === 'process-lite') { noteTeamRefreshFanout({ teamName: event.teamName, surface: 'team-change-listener', phase: 'skipped', reason: 'event:process-lite:disabled', operation: 'wouldKeepStructuralProcess', eventType: event.type, }); } } 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. cancelProcessLiteStructuralReconcile(event.teamName); 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 memberAdvisorySafetyRefreshTimers.values()) clearTimeout(t); memberAdvisorySafetyRefreshTimers = 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(); for (const t of taskLogActivityTimers.values()) clearTimeout(t); taskLogActivityTimers = new Map(); for (const state of processLiteStructuralReconcileTimers.values()) { clearTimeout(state.timer); } processLiteStructuralReconcileTimers = 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 | 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; } }); } } if (api.openCodeRuntime?.onProgress) { const cleanup = api.openCodeRuntime.onProgress((_event: unknown, data: unknown) => { const status = data as OpenCodeRuntimeStatus; useStore.setState({ openCodeRuntimeStatus: status, openCodeRuntimeError: status.error ?? null, openCodeRuntimeStatusLoading: status.state === 'checking' || status.state === 'downloading' || status.state === 'installing', }); if (status.installed && status.state === 'ready') { void (async () => { await refreshOpenCodeProviderStatusAfterRuntimeInstall(() => useStore.getState()); })(); } }); if (typeof cleanup === 'function') { cleanupFns.push(cleanup); } } if (api.codexRuntime?.onProgress) { const cleanup = api.codexRuntime.onProgress((_event: unknown, data: unknown) => { const status = data as CodexRuntimeStatus; useStore.setState({ codexRuntimeStatus: status, codexRuntimeError: status.error ?? null, codexRuntimeStatusLoading: status.state === 'checking' || status.state === 'downloading' || status.state === 'installing', }); if (status.installed && status.state === 'ready') { void (async () => { await refreshCodexProviderStatusAfterRuntimeInstall(() => useStore.getState()); })(); } }); if (typeof cleanup === 'function') { cleanupFns.push(cleanup); } } // 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()); }; }