diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index ada20d67..de7e0a6b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -97,7 +97,6 @@ import { import { wrapAgentBlock } from '@shared/constants/agentBlocks'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits'; -import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { extractFlagsFromHelp, extractUserFlags, @@ -111,7 +110,6 @@ import { getErrorMessage } from '@shared/utils/errorHandling'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend'; -import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { buildStandaloneSlashCommandMeta, parseStandaloneSlashCommand, @@ -133,7 +131,6 @@ import { import { getAutoResumeService, initializeAutoResumeService, - planRateLimitAutoResume, } from '../services/team/AutoResumeService'; import { cloneLaunchIoGovernorPayload, @@ -156,6 +153,7 @@ import { import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService'; +import { teamMessageNotificationScanner } from './teams/teamMessageNotificationScanner'; import { validateFromField, validateMemberName, @@ -301,14 +299,6 @@ function validateTeamGetDataOptions( }; } -/** - * In-memory set of rate-limit message keys already processed. - * Independent of NotificationManager storage — survives notification deletion/pruning. - * Without this, deleted rate-limit notifications would re-appear on next getData() scan. - */ -const seenRateLimitKeys = new Set(); -const SEEN_RATE_LIMIT_KEYS_MAX = 500; - async function withTimeoutValue( promise: Promise, timeoutMs: number, @@ -442,178 +432,6 @@ function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string | ); } -/** - * In-memory set of API error message keys already processed. - * Independent of NotificationManager storage — survives notification deletion/pruning. - */ -const seenApiErrorKeys = new Set(); -const SEEN_API_ERROR_KEYS_MAX = 500; - -function formatNotificationClockTime(date: Date): string { - return new Intl.DateTimeFormat(undefined, { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date); -} - -function buildRateLimitNotificationBody(plan: ReturnType): string { - if (plan.kind === 'scheduled') { - return `Auto-resume scheduled at ${formatNotificationClockTime(new Date(plan.fireAtMs))}`; - } - return 'Manual restart needed'; -} - -/** - * Check messages for rate limit indicators and fire notifications for new ones. - * Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion) - * and NotificationManager dedupeKey (to prevent storage duplicates). - */ -function checkRateLimitMessages( - messages: readonly { - messageId?: string; - from: string; - text: string; - timestamp: string; - to?: string; - source?: string; - leadSessionId?: string; - }[], - teamName: string, - teamDisplayName: string, - projectPath?: string, - teamIsAlive = true, - currentLeadSessionId: string | null = null -): void { - const observedAt = new Date(); - const autoResumeEnabled = - ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit; - - for (const msg of messages) { - if (msg.from === 'user') continue; - if (!isRateLimitMessage(msg.text)) continue; - - const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; - const dedupeKey = `rate-limit:${teamName}:${rawKey}`; - const isLeadAutoResumeCandidate = - !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); - const autoResumeSessionMatches = - msg.source !== 'lead_session' || - (Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId); - const autoResumePlan = planRateLimitAutoResume({ - enabled: autoResumeEnabled, - canAutoResume: teamIsAlive && isLeadAutoResumeCandidate && autoResumeSessionMatches, - messageText: msg.text, - observedAt, - messageTimestamp: new Date(msg.timestamp), - }); - - // In-memory guard: prevents resurrection after user deletes the notification. - if (!seenRateLimitKeys.has(dedupeKey)) { - seenRateLimitKeys.add(dedupeKey); - - // Evict oldest entries to prevent unbounded growth - if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) { - const first = seenRateLimitKeys.values().next().value; - if (first) seenRateLimitKeys.delete(first); - } - - void NotificationManager.getInstance() - .addTeamNotification({ - teamEventType: 'rate_limit', - teamName, - teamDisplayName, - from: msg.from, - summary: 'Rate limit', - body: buildRateLimitNotificationBody(autoResumePlan), - dedupeKey, - target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' }, - projectPath, - }) - .catch(() => undefined); - } - - // Only schedule auto-resume while a live team run currently exists. - // Persisted history for an offline/stopped team may still contain the old - // rate-limit message, but arming a new timer from that stale history would - // resurrect the nudge into a later manual restart. - if (autoResumePlan.kind === 'scheduled') { - // Only let persisted lead_session history rebuild auto-resume when it - // clearly belongs to the currently running lead session. Otherwise an old - // rate-limit from a previous manual run can resurrect into a newer restart. - // Pass the original message timestamp so relative reset windows survive restarts - // and old history does not rebuild a fresh auto-resume timer from "now". - getAutoResumeService().handleRateLimitMessage( - teamName, - msg.text, - observedAt, - new Date(msg.timestamp) - ); - } - } -} - -/** - * Check messages for API errors (e.g. "API Error: 429 ...") and fire OS notifications. - * Mirrors the rate-limit approach: in-memory dedup + NotificationManager dedupeKey. - * Skips rate-limit messages (they have their own notification path). - */ -function checkApiErrorMessages( - messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], - teamName: string, - teamDisplayName: string, - projectPath?: string -): void { - for (const msg of messages) { - if (msg.from === 'user') continue; - if (!isApiErrorMessage(msg.text)) continue; - // Don't double-notify if it's also a rate limit message - if (isRateLimitMessage(msg.text)) continue; - - const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; - const dedupeKey = `api-error:${teamName}:${rawKey}`; - - if (seenApiErrorKeys.has(dedupeKey)) continue; - seenApiErrorKeys.add(dedupeKey); - - if (seenApiErrorKeys.size > SEEN_API_ERROR_KEYS_MAX) { - const first = seenApiErrorKeys.values().next().value; - if (first) seenApiErrorKeys.delete(first); - } - - // Extract status code for summary - const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text); - const statusCode = statusMatch?.[1] ?? '???'; - - void NotificationManager.getInstance() - .addTeamNotification({ - teamEventType: 'api_error', - teamName, - teamDisplayName, - from: msg.from, - summary: `API Error ${statusCode}`, - body: 'Manual restart needed', - dedupeKey, - target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' }, - projectPath, - }) - .catch(() => undefined); - } -} - -function scanTeamMessageNotifications( - messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], - teamName: string, - teamDisplayName: string, - projectPath?: string -): void { - if (messages.length === 0) { - return; - } - checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath); - checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath); -} - let teamDataService: TeamDataService | null = null; let teamProvisioningService: TeamProvisioningService | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; @@ -1145,17 +963,24 @@ async function handleGetData( if (live.length === 0) { if (durableMessages.length > 0) { - checkRateLimitMessages( - durableMessages, - tn, - displayName, + teamMessageNotificationScanner.checkRateLimitMessages(durableMessages, { + teamName: tn, + teamDisplayName: displayName, projectPath, - isAlive, - currentLeadSessionId - ); - checkApiErrorMessages(durableMessages, tn, displayName, projectPath); + teamIsAlive: isAlive, + currentLeadSessionId, + }); + teamMessageNotificationScanner.checkApiErrorMessages(durableMessages, { + teamName: tn, + teamDisplayName: displayName, + projectPath, + }); } else { - scanTeamMessageNotifications(live, tn, displayName, projectPath); + teamMessageNotificationScanner.scan(live, { + teamName: tn, + teamDisplayName: displayName, + projectPath, + }); } return { success: true, data: { ...data, isAlive } }; } @@ -1177,8 +1002,18 @@ async function handleGetData( } } - checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId); - checkApiErrorMessages(merged, tn, displayName, projectPath); + teamMessageNotificationScanner.checkRateLimitMessages(merged, { + teamName: tn, + teamDisplayName: displayName, + projectPath, + teamIsAlive: isAlive, + currentLeadSessionId, + }); + teamMessageNotificationScanner.checkApiErrorMessages(merged, { + teamName: tn, + teamDisplayName: displayName, + projectPath, + }); return { success: true, data: { ...data, isAlive } }; } @@ -2844,12 +2679,11 @@ async function handleGetMessagesPage( .catch(() => ({ displayName: teamName })); void notificationContextPromise .then((notificationContext) => { - scanTeamMessageNotifications( - messagesPage.messages, + teamMessageNotificationScanner.scan(messagesPage.messages, { teamName, - notificationContext.displayName, - notificationContext.projectPath - ); + teamDisplayName: notificationContext.displayName, + projectPath: notificationContext.projectPath, + }); }) .catch((error: unknown) => { logger.debug( diff --git a/src/main/ipc/teams/teamMessageNotificationScanner.ts b/src/main/ipc/teams/teamMessageNotificationScanner.ts new file mode 100644 index 00000000..e974c0fe --- /dev/null +++ b/src/main/ipc/teams/teamMessageNotificationScanner.ts @@ -0,0 +1,240 @@ +import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; +import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; +import { + getAutoResumeService, + planRateLimitAutoResume, + type RateLimitAutoResumePlan, +} from '@main/services/team/AutoResumeService'; +import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; +import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; + +import type { TeamNotificationPayload } from '@main/utils/teamNotificationBuilder'; + +export interface TeamNotificationMessage { + messageId?: string; + from: string; + text: string; + timestamp: string; + to?: string; + source?: string; + leadSessionId?: string; +} + +interface TeamNotificationSink { + addTeamNotification(payload: TeamNotificationPayload): Promise; +} + +interface AutoResumeSink { + handleRateLimitMessage( + teamName: string, + messageText: string, + observedAt: Date, + messageTimestamp: Date + ): void; +} + +interface ConfigReader { + getConfig(): { + notifications: { + autoResumeOnRateLimit: boolean; + }; + }; +} + +export interface TeamMessageNotificationScannerDeps { + configReader?: ConfigReader; + notificationSink?: TeamNotificationSink; + autoResumeSink?: AutoResumeSink; + planAutoResume?: typeof planRateLimitAutoResume; + isRateLimit?: (text: string) => boolean; + isApiError?: (text: string) => boolean; + now?: () => Date; + formatClockTime?: (date: Date) => string; +} + +export interface TeamMessageNotificationContext { + teamName: string; + teamDisplayName: string; + projectPath?: string; + teamIsAlive?: boolean; + currentLeadSessionId?: string | null; +} + +const SEEN_RATE_LIMIT_KEYS_MAX = 500; +const SEEN_API_ERROR_KEYS_MAX = 500; + +function formatNotificationClockTime(date: Date): string { + return new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); +} + +function buildRateLimitNotificationBody( + plan: RateLimitAutoResumePlan, + formatClockTime: (date: Date) => string +): string { + if (plan.kind === 'scheduled') { + return `Auto-resume scheduled at ${formatClockTime(new Date(plan.fireAtMs))}`; + } + return 'Manual restart needed'; +} + +function evictOldestIfNeeded(keys: Set, maxSize: number): void { + if (keys.size <= maxSize) { + return; + } + + const first = keys.values().next().value; + if (first) { + keys.delete(first); + } +} + +function createDefaultNotificationSink(): TeamNotificationSink { + return { + addTeamNotification: (payload) => NotificationManager.getInstance().addTeamNotification(payload), + }; +} + +export class TeamMessageNotificationScanner { + readonly #seenRateLimitKeys = new Set(); + readonly #seenApiErrorKeys = new Set(); + readonly #configReader: ConfigReader; + readonly #notificationSink: TeamNotificationSink; + readonly #planAutoResume: typeof planRateLimitAutoResume; + readonly #isRateLimit: (text: string) => boolean; + readonly #isApiError: (text: string) => boolean; + readonly #now: () => Date; + readonly #formatClockTime: (date: Date) => string; + readonly #autoResumeSink: AutoResumeSink | null; + + constructor(deps: TeamMessageNotificationScannerDeps = {}) { + this.#configReader = deps.configReader ?? ConfigManager.getInstance(); + this.#notificationSink = deps.notificationSink ?? createDefaultNotificationSink(); + this.#planAutoResume = deps.planAutoResume ?? planRateLimitAutoResume; + this.#isRateLimit = deps.isRateLimit ?? isRateLimitMessage; + this.#isApiError = deps.isApiError ?? isApiErrorMessage; + this.#now = deps.now ?? (() => new Date()); + this.#formatClockTime = deps.formatClockTime ?? formatNotificationClockTime; + this.#autoResumeSink = deps.autoResumeSink ?? null; + } + + checkRateLimitMessages( + messages: readonly TeamNotificationMessage[], + context: TeamMessageNotificationContext + ): void { + const observedAt = this.#now(); + const autoResumeEnabled = this.#configReader.getConfig().notifications.autoResumeOnRateLimit; + + for (const msg of messages) { + if (msg.from === 'user') continue; + if (!this.#isRateLimit(msg.text)) continue; + + const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; + const dedupeKey = `rate-limit:${context.teamName}:${rawKey}`; + const isLeadAutoResumeCandidate = + !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); + const currentLeadSessionId = context.currentLeadSessionId ?? null; + const autoResumeSessionMatches = + msg.source !== 'lead_session' || + (Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId); + const autoResumePlan = this.#planAutoResume({ + enabled: autoResumeEnabled, + canAutoResume: + (context.teamIsAlive ?? true) && + isLeadAutoResumeCandidate && + autoResumeSessionMatches, + messageText: msg.text, + observedAt, + messageTimestamp: new Date(msg.timestamp), + }); + + if (!this.#seenRateLimitKeys.has(dedupeKey)) { + this.#seenRateLimitKeys.add(dedupeKey); + evictOldestIfNeeded(this.#seenRateLimitKeys, SEEN_RATE_LIMIT_KEYS_MAX); + + void this.#notificationSink + .addTeamNotification({ + teamEventType: 'rate_limit', + teamName: context.teamName, + teamDisplayName: context.teamDisplayName, + from: msg.from, + summary: 'Rate limit', + body: buildRateLimitNotificationBody(autoResumePlan, this.#formatClockTime), + dedupeKey, + target: { + kind: 'member', + teamName: context.teamName, + memberName: msg.from, + focus: 'logs', + }, + projectPath: context.projectPath, + }) + .catch(() => undefined); + } + + if (autoResumePlan.kind === 'scheduled') { + const autoResumeSink = this.#autoResumeSink ?? getAutoResumeService(); + autoResumeSink.handleRateLimitMessage( + context.teamName, + msg.text, + observedAt, + new Date(msg.timestamp) + ); + } + } + } + + checkApiErrorMessages( + messages: readonly TeamNotificationMessage[], + context: TeamMessageNotificationContext + ): void { + for (const msg of messages) { + if (msg.from === 'user') continue; + if (!this.#isApiError(msg.text)) continue; + if (this.#isRateLimit(msg.text)) continue; + + const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; + const dedupeKey = `api-error:${context.teamName}:${rawKey}`; + + if (this.#seenApiErrorKeys.has(dedupeKey)) continue; + this.#seenApiErrorKeys.add(dedupeKey); + evictOldestIfNeeded(this.#seenApiErrorKeys, SEEN_API_ERROR_KEYS_MAX); + + const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text); + const statusCode = statusMatch?.[1] ?? '???'; + + void this.#notificationSink + .addTeamNotification({ + teamEventType: 'api_error', + teamName: context.teamName, + teamDisplayName: context.teamDisplayName, + from: msg.from, + summary: `API Error ${statusCode}`, + body: 'Manual restart needed', + dedupeKey, + target: { + kind: 'member', + teamName: context.teamName, + memberName: msg.from, + focus: 'logs', + }, + projectPath: context.projectPath, + }) + .catch(() => undefined); + } + } + + scan(messages: readonly TeamNotificationMessage[], context: TeamMessageNotificationContext): void { + if (messages.length === 0) { + return; + } + + this.checkRateLimitMessages(messages, context); + this.checkApiErrorMessages(messages, context); + } +} + +export const teamMessageNotificationScanner = new TeamMessageNotificationScanner(); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 12537c80..4e5ced93 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -11,28 +11,134 @@ import { canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; -import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; -import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; -import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; -import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; import { getTeamTaskWorkflowColumn, isTeamTaskFinalForCompletionNotification, isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; +import { areTeamAgentRuntimeSnapshotsEqual } from '../team/teamAgentRuntimeSnapshotEquality'; +import { + clearAllLastResolvedTeamDataRefreshes, + clearLastResolvedTeamDataRefreshAt, + hasLastResolvedTeamDataRefreshAt, + recordLastResolvedTeamDataRefresh, +} from '../team/teamDataRefreshTimestamps'; +import { + getFullTeamDataRequestKey, + getTeamDataRequestKey, + getTeamDataRequestLabel, + getThinTeamDataRequestKey, + isTeamDataRequestKeyForTeam, + normalizeTeamGetDataOptions, +} from '../team/teamDataRequestKeys'; +import { selectTeamDataForName } from '../team/teamDataSelectors'; +import { + mapReviewError, + mapSendMessageError, + shouldInvalidateCachedTeamDataForError, +} from '../team/teamErrorPolicies'; +import { + areTeamGraphSlotAssignmentsEqual, + DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS, + GRAPH_STABLE_SLOT_LAYOUT_VERSION, + migrateStableSlotAssignmentsForMembers, + normalizeTeamGraphGridOwnerOrder, + pruneTeamGraphSlotAssignmentsForVisibleOwners, + seedStableSlotAssignmentsForMembers, + type TeamGraphConfigMemberSeedInput, + type TeamGraphLayoutSessionState, + type TeamGraphMemberSeedInput, + type TeamGraphSlotAssignments, +} from '../team/teamGraphLayout'; +import { + areTeamLaunchParamsEqual, + buildLaunchParamsFromRuntimeRequest, + type TeamLaunchParams, +} from '../team/teamLaunchParams'; +import { + captureTeamLocalStateEpoch, + clearAllTeamLocalStateEpochs, + hasTeamLocalStateEpoch, + invalidateTeamLocalStateEpoch, + isTeamLocalStateEpochCurrent, +} from '../team/teamLocalStateEpoch'; +import { + isMemberActivityMetaStale, + structurallyShareMemberActivityFacts, +} from '../team/teamMemberActivityMeta'; +import { areMemberSpawnSnapshotsSemanticallyEqual } from '../team/teamMemberSpawnSnapshotEquality'; +import { + clearAllMemberSpawnStatusesIpcBackoffs, + clearMemberSpawnStatusesIpcBackoff, + hasMemberSpawnStatusesIpcBackoff, + isMemberSpawnStatusesIpcBackoffActive, + recordMemberSpawnStatusesIpcRetryBackoff, +} from '../team/teamMemberSpawnStatusBackoff'; +import { + clearAllMemberSpawnUiEqualLastWarns, + clearMemberSpawnUiEqualLastWarn, + hasMemberSpawnUiEqualLastWarn, + shouldLogMemberSpawnUiEqualSuppressed, +} from '../team/teamMemberSpawnUiEqualWarningThrottle'; +import { + areInboxMessageArraysEquivalent, + clearTeamMessageSelectorCaches, + clearTeamMessageSelectorCachesForTeam, + extractRetainedCanonicalOlderTail, + getCanonicalHeadSlice, + getTeamMessagesCacheEntry, + getTeamMessageSelectorCacheSnapshotForTeam, + pruneOptimisticMessages, + upsertOptimisticTeamMessage, +} from '../team/teamMessagesCache'; +import { + loadPersistedMessagesPanelMode, + savePersistedMessagesPanelMode, +} from '../team/teamMessagesPanelModePersistence'; +import { + clearAllPendingReplyRefreshWaits, + clearPendingReplyRefreshWaits, + setPendingReplyRefreshEnabled, +} from '../team/teamPendingReplyWaits'; +import { + isActiveProvisioningState, + isTerminalProvisioningState, + shouldIgnoreProvisioningProgressRegression, +} from '../team/teamProvisioningStateRules'; +import { + clearAllTeamRefreshBurstDiagnostics, + clearTeamRefreshBurstDiagnostics, + hasTeamRefreshBurstDiagnostics, + noteTeamRefreshBurst, +} from '../team/teamRefreshBurstDiagnostics'; +import { + clearResolvedMemberSelectorCaches, + clearResolvedMemberSelectorCachesForTeam, + getResolvedMemberSelectorCacheSnapshotForTeam, + shouldPreserveSelectedTeamSnapshot, +} from '../team/teamResolvedMembers'; +import { + buildTeamScopedProgressTombstones, + collectTeamScopedStateRemovals, + collectTeamScopedVisibleLoadingResets, +} from '../team/teamScopedStateCleanup'; +import { structurallyShareTeamSnapshot } from '../team/teamSnapshotStructuralSharing'; +import { parseToolApprovalSettings } from '../team/teamToolApprovalSettings'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; +import type { + RefreshTeamMessagesHeadResult, + TeamMessagesCacheEntry, +} from '../team/teamMessagesCache'; import type { AppState } from '../types'; import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; import type { AppConfig } from '@renderer/types/data'; @@ -44,32 +150,24 @@ import type { AddTaskCommentRequest, CreateTaskRequest, CrossTeamSendRequest, - EffortLevel, GlobalTask, InboxMessage, KanbanColumnId, LeadActivityState, LeadContextUsage, - MemberActivityMetaEntry, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, NotificationTarget, - PersistedTeamLaunchSummary, - ResolvedTeamMember, RetryFailedOpenCodeSecondaryLanesResult, SendMessageRequest, SendMessageResult, TaskChangePresenceState, TaskComment, - TeamAgentRuntimeEntry, - TeamAgentRuntimeResourceSample, TeamAgentRuntimeSnapshot, TeamCreateRequest, TeamGetDataOptions, TeamLaunchRequest, TeamMemberActivityMeta, - TeamMemberSnapshot, - TeamProviderId, TeamProvisioningProgress, TeamSummary, TeamTask, @@ -81,8 +179,36 @@ import type { } from '@shared/types'; import type { StateCreator } from 'zustand'; -const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; -const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; +export { getLastResolvedTeamDataRefreshAt } from '../team/teamDataRefreshTimestamps'; +export { + selectTeamDataForName, + selectTeamIsAliveForName, + selectTeamMemberSnapshotsForName, + selectTeamTasksForName, +} from '../team/teamDataSelectors'; +export { + getDefaultTeamGraphSlotAssignmentsForMembers, + isTeamGraphSlotPersistenceDisabled, +} from '../team/teamGraphLayout'; +export type { TeamLaunchParams } from '../team/teamLaunchParams'; +export type { + RefreshTeamMessagesHeadResult, + TeamMessagesCacheEntry, +} from '../team/teamMessagesCache'; +export { selectMemberMessagesForTeamMember, selectTeamMessages } from '../team/teamMessagesCache'; +export { + loadPersistedMessagesPanelMode, + savePersistedMessagesPanelMode, +} from '../team/teamMessagesPanelModePersistence'; +export { + getActiveTeamPendingReplyWaits, + hasActiveTeamPendingReplyWait, +} from '../team/teamPendingReplyWaits'; +export { + selectResolvedMemberForTeamName, + selectResolvedMembersForTeamName, +} from '../team/teamResolvedMembers'; + const logger = createLogger('teamSlice'); const TEAM_GET_DATA_TIMEOUT_MS = 30_000; @@ -113,53 +239,12 @@ const pendingFreshTeamMessagesHeadRefreshes = new Set(); const inFlightTeamMemberActivityMetaRequests = new Map>(); const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); const pendingTeamPendingReplyRefreshTimers = new Map>(); -const activeTeamPendingReplyWaitSourceIdsByTeam = new Map>(); -const lastResolvedTeamDataRefreshAtByTeam = new Map(); -const teamLocalStateEpochByTeam = new Map(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; -const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); -const teamRefreshBurstDiagnostics = new Map< - string, - { windowStartedAt: number; count: number; lastWarnAt: number } ->(); -const memberSpawnUiEqualLastWarnAtByTeam = new Map(); interface RefreshTeamDataOptions { withDedup?: boolean; } -type TeamDataSnapshotMode = 'full' | 'thin'; - -function normalizeTeamGetDataOptions(options?: TeamGetDataOptions): TeamGetDataOptions | undefined { - return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined; -} - -function shouldIncludeMemberBranches(options?: TeamGetDataOptions): boolean { - return normalizeTeamGetDataOptions(options)?.includeMemberBranches !== false; -} - -function getTeamDataSnapshotMode(options?: TeamGetDataOptions): TeamDataSnapshotMode { - return shouldIncludeMemberBranches(options) ? 'full' : 'thin'; -} - -function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string { - const normalizedOptions = normalizeTeamGetDataOptions(options); - return `${teamName}\u0000mode:${getTeamDataSnapshotMode(normalizedOptions)}`; -} - -function getTeamDataRequestLabel(teamName: string, options?: TeamGetDataOptions): string { - const normalizedOptions = normalizeTeamGetDataOptions(options); - return `team:getData(${teamName},mode=${getTeamDataSnapshotMode(normalizedOptions)})`; -} - -function getFullTeamDataRequestKey(teamName: string): string { - return getTeamDataRequestKey(teamName); -} - -function getThinTeamDataRequestKey(teamName: string): string { - return getTeamDataRequestKey(teamName, { includeMemberBranches: false }); -} - function hasFullTeamDataRequestForTeam(teamName: string): boolean { return inFlightTeamDataRequests.has(getFullTeamDataRequestKey(teamName)); } @@ -169,25 +254,13 @@ function hasThinTeamDataRequestForTeam(teamName: string): boolean { } function clearTeamDataRequestsForTeam(teamName: string): void { - const prefix = `${teamName}\u0000`; for (const key of inFlightTeamDataRequests.keys()) { - if (key.startsWith(prefix)) { + if (isTeamDataRequestKeyForTeam(key, teamName)) { inFlightTeamDataRequests.delete(key); } } } -type TeamGraphSlotAssignments = Record; -type TeamGraphMemberSeedInput = Pick; -type TeamGraphConfigMemberSeedInput = Pick< - NonNullable[number], - 'name' | 'agentId' | 'removedAt' ->; -interface TeamGraphLayoutSessionState { - mode: 'default' | 'manual'; - signature: string | null; -} - export function isTeamDataRefreshPending(teamName: string): boolean { return ( hasFullTeamDataRequestForTeam(teamName) || @@ -197,22 +270,6 @@ export function isTeamDataRefreshPending(teamName: string): boolean { ); } -export function getLastResolvedTeamDataRefreshAt(teamName: string): number | undefined { - return lastResolvedTeamDataRefreshAtByTeam.get(teamName); -} - -export function hasActiveTeamPendingReplyWait(teamName: string): boolean { - return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0; -} - -export function getActiveTeamPendingReplyWaits(): Set { - return new Set( - Array.from(activeTeamPendingReplyWaitSourceIdsByTeam.entries()) - .filter(([, sourceIds]) => sourceIds.size > 0) - .map(([teamName]) => teamName) - ); -} - export function __resetTeamSliceModuleStateForTests(): void { inFlightTeamDataRequests.clear(); inFlightRefreshTeamDataCalls.clear(); @@ -232,33 +289,19 @@ export function __resetTeamSliceModuleStateForTests(): void { clearTimeout(timer); } pendingTeamPendingReplyRefreshTimers.clear(); - activeTeamPendingReplyWaitSourceIdsByTeam.clear(); - lastResolvedTeamDataRefreshAtByTeam.clear(); - teamLocalStateEpochByTeam.clear(); - memberSpawnStatusesIpcBackoffUntilByTeam.clear(); - teamRefreshBurstDiagnostics.clear(); - memberSpawnUiEqualLastWarnAtByTeam.clear(); - resolvedMembersSelectorCache.clear(); - resolvedMemberSelectorCache.clear(); - mergedMessagesSelectorCache.clear(); - memberMessagesSelectorCache.clear(); + clearAllPendingReplyRefreshWaits(); + clearAllLastResolvedTeamDataRefreshes(); + clearAllTeamLocalStateEpochs(); + clearAllMemberSpawnStatusesIpcBackoffs(); + clearAllTeamRefreshBurstDiagnostics(); + clearAllMemberSpawnUiEqualLastWarns(); + clearResolvedMemberSelectorCaches(); + clearTeamMessageSelectorCaches(); } function clearTeamScopedSelectorCaches(teamName: string): void { - resolvedMembersSelectorCache.delete(teamName); - mergedMessagesSelectorCache.delete(teamName); - - const teamScopedPrefix = `${teamName}:`; - for (const key of resolvedMemberSelectorCache.keys()) { - if (key.startsWith(teamScopedPrefix)) { - resolvedMemberSelectorCache.delete(key); - } - } - for (const key of memberMessagesSelectorCache.keys()) { - if (key.startsWith(teamScopedPrefix)) { - memberMessagesSelectorCache.delete(key); - } - } + clearResolvedMemberSelectorCachesForTeam(teamName); + clearTeamMessageSelectorCachesForTeam(teamName); } function clearTeamScopedTransientState(teamName: string): void { @@ -273,185 +316,13 @@ function clearTeamScopedTransientState(teamName: string): void { pendingFreshTeamMessagesHeadRefreshes.delete(teamName); inFlightTeamMemberActivityMetaRequests.delete(teamName); pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName); - lastResolvedTeamDataRefreshAtByTeam.delete(teamName); - memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); - teamRefreshBurstDiagnostics.delete(teamName); - memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); + clearLastResolvedTeamDataRefreshAt(teamName); + clearMemberSpawnStatusesIpcBackoff(teamName); + clearTeamRefreshBurstDiagnostics(teamName); + clearMemberSpawnUiEqualLastWarn(teamName); clearTeamScopedSelectorCaches(teamName); } -function collectTeamScopedVisibleLoadingResets( - state: Pick< - TeamSlice, - 'teamMessagesByName' | 'selectedTeamName' | 'selectedTeamLoading' | 'selectedTeamError' - >, - teamName: string -): Partial { - const nextTeamMessagesEntry = state.teamMessagesByName[teamName]; - const nextTeamMessagesByName = - nextTeamMessagesEntry && - (nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder) - ? { - ...state.teamMessagesByName, - [teamName]: { - ...nextTeamMessagesEntry, - loadingHead: false, - loadingOlder: false, - }, - } - : null; - - const shouldResetSelectedSurface = - state.selectedTeamName === teamName && - (state.selectedTeamLoading || state.selectedTeamError != null); - - return { - ...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}), - ...(shouldResetSelectedSurface - ? { - selectedTeamLoading: false, - selectedTeamError: null, - } - : {}), - }; -} - -function omitTeamKey(record: Record, teamName: string): Record | null { - if (!(teamName in record)) { - return null; - } - const next = { ...record }; - delete next[teamName]; - return next; -} - -function collectTeamScopedStateRemovals( - state: Pick< - TeamSlice, - | 'provisioningRuns' - | 'teamDataCacheByName' - | 'teamAgentRuntimeByTeam' - | 'teamMessagesByName' - | 'memberActivityMetaByTeam' - | 'provisioningSnapshotByTeam' - | 'currentProvisioningRunIdByTeam' - | 'currentRuntimeRunIdByTeam' - | 'provisioningStartedAtFloorByTeam' - | 'leadActivityByTeam' - | 'leadContextByTeam' - | 'activeTaskLogActivityByTeam' - | 'activeToolsByTeam' - | 'finishedVisibleByTeam' - | 'toolHistoryByTeam' - | 'memberSpawnStatusesByTeam' - | 'memberSpawnSnapshotsByTeam' - | 'provisioningErrorByTeam' - >, - teamName: string -): Partial { - const nextProvisioningRuns = Object.fromEntries( - Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName) - ) as Record; - const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName); - const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName); - const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName); - const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName); - const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName); - const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName); - const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName); - const nextProvisioningStartedAtFloor = omitTeamKey( - state.provisioningStartedAtFloorByTeam, - teamName - ); - const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName); - const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName); - const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName); - const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName); - const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName); - const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName); - const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName); - const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName); - const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName); - - return { - ...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length - ? { provisioningRuns: nextProvisioningRuns } - : {}), - ...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}), - ...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}), - ...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}), - ...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}), - ...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}), - ...(nextCurrentProvisioningRunId - ? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId } - : {}), - ...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}), - ...(nextProvisioningStartedAtFloor - ? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor } - : {}), - ...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}), - ...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}), - ...(nextActiveTaskLogActivity - ? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity } - : {}), - ...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}), - ...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}), - ...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}), - ...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}), - ...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}), - ...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}), - }; -} - -function buildTeamScopedProgressTombstones( - state: Pick< - TeamSlice, - | 'currentProvisioningRunIdByTeam' - | 'currentRuntimeRunIdByTeam' - | 'ignoredProvisioningRunIds' - | 'ignoredRuntimeRunIds' - | 'provisioningStartedAtFloorByTeam' - >, - teamName: string, - floor: string -): Pick< - TeamSlice, - 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' -> { - const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds }; - const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds }; - - const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName]; - const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName]; - if (currentProvisioningRunId) { - nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName; - } - if (currentRuntimeRunId) { - nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName; - } - - return { - ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds, - ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, - provisioningStartedAtFloorByTeam: { - ...state.provisioningStartedAtFloorByTeam, - [teamName]: floor, - }, - }; -} - -function captureTeamLocalStateEpoch(teamName: string): number { - return teamLocalStateEpochByTeam.get(teamName) ?? 0; -} - -function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean { - return captureTeamLocalStateEpoch(teamName) === epoch; -} - -function invalidateTeamLocalStateEpoch(teamName: string): void { - teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1); -} - function beginInFlightTeamDataRefresh(teamName: string): symbol { const token = Symbol(teamName); const existing = inFlightRefreshTeamDataCalls.get(teamName); @@ -648,26 +519,17 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasTeamRefreshBurstDiagnostics: boolean; hasMemberSpawnUiEqualLastWarn: boolean; } { - const teamScopedPrefix = `${teamName}:`; - let resolvedMemberSelectorCount = 0; - let memberMessagesSelectorCount = 0; - - for (const key of resolvedMemberSelectorCache.keys()) { - if (key.startsWith(teamScopedPrefix)) { - resolvedMemberSelectorCount += 1; - } - } - for (const key of memberMessagesSelectorCache.keys()) { - if (key.startsWith(teamScopedPrefix)) { - memberMessagesSelectorCount += 1; - } - } + const messageSelectorCache = getTeamMessageSelectorCacheSnapshotForTeam(teamName); + const resolvedMemberSelectorCacheSnapshot = + getResolvedMemberSelectorCacheSnapshotForTeam(teamName); return { - hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName), - resolvedMemberSelectorCount, - hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName), - memberMessagesSelectorCount, + hasResolvedMembersSelector: + resolvedMemberSelectorCacheSnapshot.hasResolvedMembersSelector, + resolvedMemberSelectorCount: + resolvedMemberSelectorCacheSnapshot.resolvedMemberSelectorCount, + hasMergedMessagesSelector: messageSelectorCache.hasMergedMessagesSelector, + memberMessagesSelectorCount: messageSelectorCache.memberMessagesSelectorCount, hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName), hasQueuedFullTeamDataRefreshAfterThin: queuedFullTeamDataRefreshesAfterThin.has(teamName), hasPostPaintTeamEnrichmentTimer: postPaintTeamEnrichmentTimers.has(teamName), @@ -675,11 +537,11 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasPendingFreshMessagesHeadRefresh: pendingFreshTeamMessagesHeadRefreshes.has(teamName), hasPendingFreshMemberActivityMetaRefresh: pendingFreshTeamMemberActivityMetaRefreshes.has(teamName), - hasLastResolvedTeamDataRefresh: lastResolvedTeamDataRefreshAtByTeam.has(teamName), - hasCurrentLocalStateEpoch: teamLocalStateEpochByTeam.has(teamName), - hasMemberSpawnStatusesIpcBackoff: memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName), - hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), - hasMemberSpawnUiEqualLastWarn: memberSpawnUiEqualLastWarnAtByTeam.has(teamName), + hasLastResolvedTeamDataRefresh: hasLastResolvedTeamDataRefreshAt(teamName), + hasCurrentLocalStateEpoch: hasTeamLocalStateEpoch(teamName), + hasMemberSpawnStatusesIpcBackoff: hasMemberSpawnStatusesIpcBackoff(teamName), + hasTeamRefreshBurstDiagnostics: hasTeamRefreshBurstDiagnostics(teamName), + hasMemberSpawnUiEqualLastWarn: hasMemberSpawnUiEqualLastWarn(teamName), }; } @@ -691,93 +553,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function isPlainObject(value: unknown): value is Record { - if (value == null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function structurallySharePlainValue(previous: T, next: T): T { - if (Object.is(previous, next)) { - return previous; - } - - if (Array.isArray(previous) && Array.isArray(next)) { - let changed = previous.length !== next.length; - const result = next.map((nextItem, index) => { - const sharedItem = structurallySharePlainValue(previous[index], nextItem); - if (!Object.is(sharedItem, previous[index])) { - changed = true; - } - return sharedItem; - }); - return changed ? (result as T) : previous; - } - - if (isPlainObject(previous) && isPlainObject(next)) { - const previousRecord = previous as Record; - const nextRecord = next as Record; - const previousKeys = Object.keys(previousRecord); - const nextKeys = Object.keys(nextRecord); - let changed = previousKeys.length !== nextKeys.length; - const result: Record = {}; - - for (const key of nextKeys) { - if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) { - changed = true; - } - const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]); - if (!Object.is(sharedValue, previousRecord[key])) { - changed = true; - } - result[key] = sharedValue; - } - - return changed ? (result as T) : previous; - } - - return next; -} - -function structurallyShareTeamSnapshot( - previous: TeamViewSnapshot | null | undefined, - next: TeamViewSnapshot -): TeamViewSnapshot { - if (!previous) { - return next; - } - return structurallySharePlainValue(previous, next); -} - -const ACTIVE_PROVISIONING_STATES = new Set([ - 'validating', - 'spawning', - 'configuring', - 'assembling', - 'finalizing', - 'verifying', -]); -const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'disconnected', 'cancelled']); - -function shouldIgnoreProvisioningProgressRegression( - currentState: TeamProvisioningProgress['state'], - nextState: TeamProvisioningProgress['state'] -): boolean { - if (currentState === 'ready') { - return nextState !== 'ready' && nextState !== 'disconnected'; - } - if ( - currentState === 'failed' || - currentState === 'cancelled' || - currentState === 'disconnected' - ) { - return nextState !== currentState; - } - return false; -} - function isPendingProvisioningRunId(runId: string): boolean { return runId.startsWith('pending:'); } @@ -844,391 +619,23 @@ function fetchTeamDataFresh( ); } -function noteTeamRefreshBurst(teamName: string): number { - const now = Date.now(); - const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? { - windowStartedAt: now, - count: 0, - lastWarnAt: 0, - }; - - if (now - diagnostic.windowStartedAt > TEAM_REFRESH_BURST_WINDOW_MS) { - diagnostic.windowStartedAt = now; - diagnostic.count = 0; - } - - diagnostic.count += 1; - - teamRefreshBurstDiagnostics.set(teamName, diagnostic); - return diagnostic.count; -} - -function areLaunchSummaryCountsEqual( - left: PersistedTeamLaunchSummary | undefined, - right: PersistedTeamLaunchSummary | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return left === right; - return ( - left.confirmedCount === right.confirmedCount && - left.pendingCount === right.pendingCount && - left.failedCount === right.failedCount && - left.skippedCount === right.skippedCount && - left.runtimeAlivePendingCount === right.runtimeAlivePendingCount && - left.shellOnlyPendingCount === right.shellOnlyPendingCount && - left.runtimeProcessPendingCount === right.runtimeProcessPendingCount && - left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount && - left.noRuntimePendingCount === right.noRuntimePendingCount && - left.permissionPendingCount === right.permissionPendingCount - ); -} - -function areExpectedMembersEqual( - left: readonly string[] | undefined, - right: readonly string[] | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return left === right; - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - if (left[index] !== right[index]) { - return false; - } - } - return true; -} - -function areMemberSpawnStatusEntriesEqual( - left: MemberSpawnStatusEntry | undefined, - right: MemberSpawnStatusEntry | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return left === right; - const leftPendingPermissionIds = [...(left.pendingPermissionRequestIds ?? [])].sort(); - const rightPendingPermissionIds = [...(right.pendingPermissionRequestIds ?? [])].sort(); - // Renderer equality intentionally ignores raw timing fields that do not change - // visible member status. This suppresses heartbeat-only churn in TeamDetailView. - return ( - left.status === right.status && - left.launchState === right.launchState && - left.error === right.error && - left.hardFailureReason === right.hardFailureReason && - left.skippedForLaunch === right.skippedForLaunch && - left.skipReason === right.skipReason && - left.skippedAt === right.skippedAt && - left.livenessSource === right.livenessSource && - left.runtimeAlive === right.runtimeAlive && - left.runtimeModel === right.runtimeModel && - left.livenessKind === right.livenessKind && - left.runtimeDiagnostic === right.runtimeDiagnostic && - left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && - left.bootstrapConfirmed === right.bootstrapConfirmed && - left.hardFailure === right.hardFailure && - leftPendingPermissionIds.length === rightPendingPermissionIds.length && - leftPendingPermissionIds.every((value, index) => value === rightPendingPermissionIds[index]) - ); -} - -function areMemberSpawnStatusesEqual( - left: Record, - right: Record -): boolean { - if (left === right) return true; - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) return false; - for (const key of leftKeys) { - if (!(key in right)) { - return false; - } - if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) { - return false; - } - } - return true; -} - -function areMemberSpawnSnapshotsSemanticallyEqual( - left: MemberSpawnStatusesSnapshot | undefined, - right: MemberSpawnStatusesSnapshot -): boolean { - if (!left) return false; - return ( - left.runId === right.runId && - left.teamLaunchState === right.teamLaunchState && - left.launchPhase === right.launchPhase && - left.source === right.source && - areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) && - areLaunchSummaryCountsEqual(left.summary, right.summary) && - areMemberSpawnStatusesEqual(left.statuses, right.statuses) - ); -} - function maybeLogMemberSpawnUiEqualSuppressed( teamName: string, runId: string | null | undefined ): void { - const now = Date.now(); - const lastWarnAt = memberSpawnUiEqualLastWarnAtByTeam.get(teamName) ?? 0; - if (now - lastWarnAt < MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS) { + if ( + !shouldLogMemberSpawnUiEqualSuppressed( + teamName, + MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS + ) + ) { return; } - memberSpawnUiEqualLastWarnAtByTeam.set(teamName, now); logger.debug( `[perf] member-spawn snapshot suppressed team=${teamName} runId=${runId ?? 'none'} reason=member-spawn-ui-equal` ); } -function isTeamAgentRuntimeResourceSampleLike( - value: unknown -): value is TeamAgentRuntimeResourceSample { - return Boolean(value) && typeof value === 'object'; -} - -function areTeamAgentRuntimeResourceSamplesEqual(left: unknown, right: unknown): boolean { - if (left === right) return true; - if (!isTeamAgentRuntimeResourceSampleLike(left) || !isTeamAgentRuntimeResourceSampleLike(right)) { - return false; - } - return ( - left.timestamp === right.timestamp && - left.cpuPercent === right.cpuPercent && - left.rssBytes === right.rssBytes && - left.primaryCpuPercent === right.primaryCpuPercent && - left.primaryRssBytes === right.primaryRssBytes && - left.childCpuPercent === right.childCpuPercent && - left.childRssBytes === right.childRssBytes && - left.processCount === right.processCount && - left.runtimeLoadScope === right.runtimeLoadScope && - left.runtimeLoadTruncated === right.runtimeLoadTruncated && - left.pidSource === right.pidSource && - left.pid === right.pid && - left.runtimePid === right.runtimePid - ); -} - -function areTeamAgentRuntimeEntriesEqual( - left: TeamAgentRuntimeEntry | undefined, - right: TeamAgentRuntimeEntry | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return left === right; - const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : []; - const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : []; - const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : []; - const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : []; - return ( - left.memberName === right.memberName && - left.alive === right.alive && - left.restartable === right.restartable && - left.backendType === right.backendType && - left.providerId === right.providerId && - left.providerBackendId === right.providerBackendId && - left.laneId === right.laneId && - left.laneKind === right.laneKind && - left.pid === right.pid && - left.runtimeModel === right.runtimeModel && - left.rssBytes === right.rssBytes && - left.cpuPercent === right.cpuPercent && - left.primaryCpuPercent === right.primaryCpuPercent && - left.primaryRssBytes === right.primaryRssBytes && - left.childCpuPercent === right.childCpuPercent && - left.childRssBytes === right.childRssBytes && - left.processCount === right.processCount && - left.runtimeLoadScope === right.runtimeLoadScope && - left.runtimeLoadTruncated === right.runtimeLoadTruncated && - left.livenessKind === right.livenessKind && - left.pidSource === right.pidSource && - left.processCommand === right.processCommand && - left.paneId === right.paneId && - left.panePid === right.panePid && - left.paneCurrentCommand === right.paneCurrentCommand && - left.runtimePid === right.runtimePid && - left.runtimeSessionId === right.runtimeSessionId && - left.runtimeDiagnostic === right.runtimeDiagnostic && - left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && - left.runtimeLastSeenAt === right.runtimeLastSeenAt && - left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed && - leftDiagnostics.length === rightDiagnostics.length && - leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) && - leftResourceHistory.length === rightResourceHistory.length && - leftResourceHistory.every((value, index) => - areTeamAgentRuntimeResourceSamplesEqual(value, rightResourceHistory[index]) - ) - ); -} - -function areTeamAgentRuntimeSnapshotsEqual( - left: TeamAgentRuntimeSnapshot | undefined, - right: TeamAgentRuntimeSnapshot -): boolean { - if (!left) return false; - if (left.teamName !== right.teamName || left.runId !== right.runId) { - return false; - } - const leftKeys = Object.keys(left.members); - const rightKeys = Object.keys(right.members); - if (leftKeys.length !== rightKeys.length) { - return false; - } - for (const key of leftKeys) { - if (!(key in right.members)) { - return false; - } - if (!areTeamAgentRuntimeEntriesEqual(left.members[key], right.members[key])) { - return false; - } - } - return true; -} - -function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number { - const aTime = Date.parse(a.timestamp); - const bTime = Date.parse(b.timestamp); - const aValid = Number.isFinite(aTime); - const bValid = Number.isFinite(bTime); - if (aValid && bValid && aTime !== bTime) { - return aTime - bTime; - } - if (aValid !== bValid) { - return aValid ? -1 : 1; - } - const aId = typeof a.messageId === 'string' ? a.messageId : ''; - const bId = typeof b.messageId === 'string' ? b.messageId : ''; - return aId.localeCompare(bId); -} - -export interface TeamMessagesCacheEntry { - canonicalMessages: InboxMessage[]; - optimisticMessages: InboxMessage[]; - feedRevision: string | null; - nextCursor: string | null; - hasMore: boolean; - lastFetchedAt: number | null; - loadingHead: boolean; - loadingOlder: boolean; - headHydrated: boolean; -} - -export interface RefreshTeamMessagesHeadResult { - feedChanged: boolean; - headChanged: boolean; - feedRevision: string | null; -} - -const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = { - canonicalMessages: [], - optimisticMessages: [], - feedRevision: null, - nextCursor: null, - hasMore: false, - lastFetchedAt: null, - loadingHead: false, - loadingOlder: false, - headHydrated: false, -}; - -function createEmptyTeamMessagesCacheEntry(): TeamMessagesCacheEntry { - return { - canonicalMessages: [], - optimisticMessages: [], - feedRevision: null, - nextCursor: null, - hasMore: false, - lastFetchedAt: null, - loadingHead: false, - loadingOlder: false, - headHydrated: false, - }; -} - -function getTeamMessagesCacheEntry( - state: Pick, - teamName: string -): TeamMessagesCacheEntry { - return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY; -} - -function upsertOptimisticTeamMessage( - entry: TeamMessagesCacheEntry, - message: InboxMessage -): TeamMessagesCacheEntry { - const nextOptimistic = [...entry.optimisticMessages]; - const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; - if (messageId.length > 0) { - const existingIndex = nextOptimistic.findIndex( - (candidate) => - typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId - ); - if (existingIndex >= 0) { - nextOptimistic[existingIndex] = { - ...nextOptimistic[existingIndex], - ...message, - }; - } else { - nextOptimistic.push(message); - } - } else { - nextOptimistic.push(message); - } - nextOptimistic.sort(compareInboxMessagesByTimestamp); - return { - ...entry, - optimisticMessages: nextOptimistic, - }; -} - -function areInboxMessageArraysEquivalent( - left: readonly InboxMessage[], - right: readonly InboxMessage[] -): boolean { - if (left === right) return true; - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - const leftItem = left[index]; - const rightItem = right[index]; - if ( - leftItem.messageId !== rightItem.messageId || - leftItem.timestamp !== rightItem.timestamp || - leftItem.from !== rightItem.from || - leftItem.to !== rightItem.to || - leftItem.text !== rightItem.text || - leftItem.summary !== rightItem.summary || - leftItem.read !== rightItem.read || - leftItem.actionMode !== rightItem.actionMode || - leftItem.commentId !== rightItem.commentId || - leftItem.relayOfMessageId !== rightItem.relayOfMessageId || - leftItem.source !== rightItem.source || - leftItem.leadSessionId !== rightItem.leadSessionId || - leftItem.messageKind !== rightItem.messageKind || - JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null) - ) { - return false; - } - } - return true; -} - -function pruneOptimisticMessages( - optimistic: readonly InboxMessage[], - canonical: readonly InboxMessage[] -): InboxMessage[] { - if (optimistic.length === 0) { - return []; - } - - const canonicalIds = new Set( - canonical - .map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : '')) - .filter((messageId) => messageId.length > 0) - ); - - return optimistic.filter((message) => { - const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; - return !messageId || !canonicalIds.has(messageId); - }); -} - function clearPendingReplyRefreshTimer(teamName: string): void { const existingTimer = pendingTeamPendingReplyRefreshTimers.get(teamName); if (existingTimer == null) { @@ -1238,78 +645,6 @@ function clearPendingReplyRefreshTimer(teamName: string): void { pendingTeamPendingReplyRefreshTimers.delete(teamName); } -function clearPendingReplyRefreshWaits(teamName: string): void { - activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); -} - -function setPendingReplyRefreshEnabled( - teamName: string, - sourceId: string, - enabled: boolean -): boolean { - if (enabled) { - const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set(); - existing.add(sourceId); - activeTeamPendingReplyWaitSourceIdsByTeam.set(teamName, existing); - return true; - } - - const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName); - if (!existing) { - return false; - } - existing.delete(sourceId); - if (existing.size === 0) { - activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); - return false; - } - return true; -} - -function getCanonicalHeadSlice( - canonicalMessages: readonly InboxMessage[], - headLength: number -): readonly InboxMessage[] { - if (headLength <= 0) { - return []; - } - return canonicalMessages.slice(0, headLength); -} - -function extractRetainedCanonicalOlderTail( - canonicalMessages: readonly InboxMessage[], - freshHeadMessages: readonly InboxMessage[] -): InboxMessage[] | null { - if (canonicalMessages.length === 0) { - return []; - } - if (freshHeadMessages.length === 0) { - return null; - } - - const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message))); - let hasMessagesOutsideFreshHead = false; - for (const message of canonicalMessages) { - if (!freshHeadKeys.has(toMessageKey(message))) { - hasMessagesOutsideFreshHead = true; - break; - } - } - if (!hasMessagesOutsideFreshHead) { - return []; - } - - const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]); - const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey); - if (anchorIndex < 0) { - return null; - } - - return canonicalMessages - .slice(anchorIndex + 1) - .filter((message) => !freshHeadKeys.has(toMessageKey(message))); -} - async function refreshTaskChangePresenceForUpdatedTask( getState: () => AppState, teamName: string, @@ -1357,12 +692,12 @@ async function pollProvisioningStatus( for (let attempt = 1; attempt <= maxAttempts; attempt++) { const state = getState(); const current = state.provisioningRuns[runId]; - if (current && TERMINAL_PROVISIONING_STATES.has(current.state)) { + if (current && isTerminalProvisioningState(current.state)) { return; } try { const progress = await state.getProvisioningStatus(runId); - if (TERMINAL_PROVISIONING_STATES.has(progress.state)) { + if (isTerminalProvisioningState(progress.state)) { return; } } catch (error) { @@ -1879,24 +1214,6 @@ function preserveKnownTaskChangePresence( return changed ? mergedTasks : nextTasks; } -function mapSendMessageError(error: unknown): string { - const message = - error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; - if (message.includes('Failed to verify inbox write')) { - return 'Message was written but not verified (race). Please try again.'; - } - return message || 'Failed to send message'; -} - -function mapReviewError(error: unknown): string { - const message = - error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; - if (message.includes('Task status update verification failed')) { - return 'Failed to update task status (possible agent conflict).'; - } - return message || 'Failed to perform review action'; -} - export interface GlobalTaskDetailState { teamName: string; taskId: string; @@ -1916,861 +1233,6 @@ export interface PendingTeamSectionFocusState { section: TeamSectionTarget; } -/** Per-team launch parameters shown in the header badge. */ -export interface TeamLaunchParams { - providerId?: TeamProviderId; - providerBackendId?: string; - model?: string; // 'opus' | 'sonnet' | 'haiku' - effort?: EffortLevel; - fastMode?: 'inherit' | 'on' | 'off'; - limitContext?: boolean; -} - -const resolvedMembersSelectorCache = new Map< - string, - { - snapshotRef: TeamViewSnapshot['members']; - configMembersRef: TeamViewSnapshot['config']['members'] | undefined; - summaryRef: TeamSummary | undefined; - tasksRef: TeamViewSnapshot['tasks'] | undefined; - metaMembersRef: TeamMemberActivityMeta['members'] | undefined; - result: ResolvedTeamMember[]; - } ->(); -const resolvedMemberSelectorCache = new Map< - string, - { - snapshotMemberRef: TeamMemberSnapshot | undefined; - metaEntryRef: MemberActivityMetaEntry | undefined; - result: ResolvedTeamMember | null; - } ->(); -const mergedMessagesSelectorCache = new Map< - string, - { - canonicalRef: InboxMessage[]; - optimisticRef: InboxMessage[]; - result: InboxMessage[]; - } ->(); -const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = []; -const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = []; -const memberMessagesSelectorCache = new Map< - string, - { - messagesRef: InboxMessage[]; - result: InboxMessage[]; - } ->(); - -function resolveMemberStatus( - snapshot: TeamMemberSnapshot, - activity: MemberActivityMetaEntry | undefined -): ResolvedTeamMember['status'] { - if (activity?.latestAuthoredMessageSignalsTermination) { - return 'terminated'; - } - - if (!activity?.lastAuthoredMessageAt) { - return snapshot.currentTaskId ? 'active' : 'idle'; - } - - const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt); - if (Number.isNaN(ageMs)) { - return 'unknown'; - } - if (ageMs < 5 * 60 * 1000) { - return 'active'; - } - return 'idle'; -} - -function buildResolvedMembers( - snapshots: readonly TeamMemberSnapshot[], - meta: TeamMemberActivityMeta | undefined -): ResolvedTeamMember[] { - return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name])); -} - -function isDisplayableFallbackCurrentTask(task: TeamViewSnapshot['tasks'][number]): boolean { - return ( - task.status === 'in_progress' && - getTeamTaskWorkflowColumn(task) !== 'review' && - !isTeamTaskFinalForCompletionNotification(task) - ); -} - -function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMemberSnapshot[] { - const configMembers = snapshot.config.members ?? []; - const hasConfiguredTeammate = configMembers.some((member) => { - const name = member.name?.trim(); - return Boolean(name) && !member.removedAt && !isLeadMember(member); - }); - if (!hasConfiguredTeammate) { - return []; - } - - const seenNames = new Set(); - const fallbackMembers: TeamMemberSnapshot[] = []; - for (const member of configMembers) { - const name = member.name?.trim(); - if (!name) continue; - const key = name.toLowerCase(); - if (seenNames.has(key)) continue; - seenNames.add(key); - - const ownedTasks = snapshot.tasks.filter((task) => task.owner === name); - const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask); - fallbackMembers.push({ - name, - agentId: member.agentId, - currentTaskId: currentTask?.id ?? null, - taskCount: ownedTasks.length, - color: member.color ?? getMemberColorByName(name), - agentType: member.agentType, - role: member.role, - workflow: member.workflow, - isolation: member.isolation, - providerId: member.providerId, - providerBackendId: member.providerBackendId, - model: member.model, - effort: member.effort, - mcpPolicy: member.mcpPolicy, - selectedFastMode: member.fastMode, - cwd: member.cwd, - removedAt: member.removedAt, - }); - } - - return fallbackMembers; -} - -function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] { - if (!snapshot) { - return []; - } - const names = new Set(); - for (const member of snapshot.members) { - const name = member.name.trim(); - const key = name.toLowerCase(); - if (!name || key === 'user' || member.removedAt || isLeadMember(member)) { - continue; - } - names.add(key); - } - return Array.from(names).sort((left, right) => left.localeCompare(right)); -} - -function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { - return getActiveRawTeammateNameKeys(snapshot).length > 0; -} - -function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { - return Boolean(snapshot?.members.some((member) => member.removedAt)); -} - -function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { - return Boolean( - snapshot?.config.members?.some((member) => { - const name = member.name?.trim(); - return Boolean(name) && !member.removedAt && !isLeadMember(member); - }) - ); -} - -interface SummaryFallbackMemberSource { - name: string; - agentId?: string; - role?: string; - color?: string; - mcpPolicy?: TeamMemberSnapshot['mcpPolicy']; -} - -function normalizeSummaryTeammateName( - name: string | undefined | null, - leadName?: string -): string | null { - const trimmed = name?.trim(); - const normalizedName = trimmed?.toLowerCase(); - const normalizedLeadName = leadName?.trim().toLowerCase(); - if ( - !trimmed || - normalizedName === 'user' || - isLeadMember({ name: trimmed }) || - (normalizedLeadName && normalizedName === normalizedLeadName) - ) { - return null; - } - return trimmed; -} - -function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { - const seenNames = new Set(); - const sources: SummaryFallbackMemberSource[] = []; - for (const member of summary.members ?? []) { - const name = normalizeSummaryTeammateName(member.name, summary.leadName); - if (!name) { - continue; - } - const key = name.toLowerCase(); - if (seenNames.has(key)) { - continue; - } - seenNames.add(key); - sources.push({ - name, - agentId: member.agentId, - role: member.role, - color: member.color, - mcpPolicy: member.mcpPolicy, - }); - } - return sources; -} - -function shouldUseSummaryLaunchTeammateSources(summary: TeamSummary): boolean { - return ( - summary.partialLaunchFailure === true || - summary.teamLaunchState === 'partial_failure' || - summary.teamLaunchState === 'partial_pending' || - summary.teamLaunchState === 'partial_skipped' - ); -} - -function getSummaryLaunchTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { - if (!shouldUseSummaryLaunchTeammateSources(summary)) { - return []; - } - - const seenNames = new Set(); - const sources: SummaryFallbackMemberSource[] = []; - for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) { - const name = normalizeSummaryTeammateName(rawName, summary.leadName); - if (!name) { - continue; - } - const key = name.toLowerCase(); - if (seenNames.has(key)) { - continue; - } - seenNames.add(key); - sources.push({ name }); - } - return sources; -} - -function getSummaryLaunchTeammateNameKeys(summary: TeamSummary): string[] { - return getSummaryLaunchTeammateSources(summary) - .map((member) => member.name.toLowerCase()) - .sort((left, right) => left.localeCompare(right)); -} - -function getSummaryTeammateNameKeys(summary: TeamSummary): string[] { - const rosterNames = getSummaryRosterTeammateSources(summary) - .map((member) => member.name.toLowerCase()) - .sort((left, right) => left.localeCompare(right)); - if (rosterNames.length > 0) { - return rosterNames; - } - - const launchNames = getSummaryLaunchTeammateNameKeys(summary); - const expectedCount = summary.expectedMemberCount ?? summary.memberCount; - if (expectedCount > 0 && launchNames.length === expectedCount) { - return launchNames; - } - return []; -} - -function getSummaryFallbackTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { - return getSummaryRosterTeammateSources(summary); -} - -function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((name, index) => name === right[index]); -} - -function summaryConfirmsActiveTeammateRoster( - current: TeamViewSnapshot, - summary: TeamSummary -): boolean { - if ((summary.expectedMemberCount ?? summary.memberCount) <= 0) { - return false; - } - - const currentNames = getActiveRawTeammateNameKeys(current); - const summaryNames = getSummaryTeammateNameKeys(summary); - if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) { - return false; - } - - return areNameKeyListsEqual(summaryNames, currentNames); -} - -function buildSummaryFallbackMemberSnapshots( - snapshot: TeamViewSnapshot, - summary: TeamSummary | undefined -): TeamMemberSnapshot[] { - if (!summary) { - return []; - } - const summaryMembers = getSummaryFallbackTeammateSources(summary); - if (summaryMembers.length === 0) { - return []; - } - - const seenNames = new Set(); - const buildSnapshot = ( - name: string, - source?: Omit, - lead = false - ): TeamMemberSnapshot | null => { - const trimmed = name.trim(); - if (!trimmed) return null; - const key = trimmed.toLowerCase(); - if (seenNames.has(key)) return null; - seenNames.add(key); - - const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed); - const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask); - return { - name: trimmed, - agentId: source?.agentId, - currentTaskId: currentTask?.id ?? null, - taskCount: ownedTasks.length, - color: source?.color ?? getMemberColorByName(trimmed), - agentType: lead ? 'team-lead' : undefined, - role: source?.role ?? (lead ? 'Team Lead' : undefined), - mcpPolicy: source?.mcpPolicy, - }; - }; - - const teammates = summaryMembers.flatMap((member) => { - const item = buildSnapshot(member.name, member); - return item ? [item] : []; - }); - if (teammates.length === 0) { - return []; - } - - const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member)); - if (existingLead) { - return [existingLead, ...teammates]; - } - - const configuredLead = snapshot.config.members?.find( - (member) => !member.removedAt && isLeadMember(member) - ); - const leadName = configuredLead?.name?.trim() || summary.leadName?.trim(); - const lead = leadName - ? buildSnapshot( - leadName, - { - agentId: configuredLead?.agentId, - role: configuredLead?.role, - color: configuredLead?.color ?? summary.leadColor, - }, - true - ) - : null; - - return lead ? [lead, ...teammates] : teammates; -} - -function getResolvableMemberSnapshots( - snapshot: TeamViewSnapshot, - summary?: TeamSummary -): readonly TeamMemberSnapshot[] { - if ( - snapshot.members.length > 0 && - (hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot)) - ) { - return snapshot.members; - } - - const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot); - if (configFallbackMembers.length > 0) { - return configFallbackMembers; - } - - const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary); - if (summaryFallbackMembers.length > 0) { - return summaryFallbackMembers; - } - - return snapshot.members; -} - -function shouldPreserveSelectedTeamSnapshot( - current: TeamViewSnapshot | null, - baseline: TeamViewSnapshot | null | undefined, - incoming: TeamViewSnapshot, - summary: TeamSummary | undefined -): boolean { - if (!current || !hasActiveRawTeammateRoster(current)) { - return false; - } - if ( - hasActiveRawTeammateRoster(incoming) || - hasRemovedRawMemberRoster(incoming) || - hasConfigTeammateRoster(incoming) - ) { - return false; - } - const currentNames = getActiveRawTeammateNameKeys(current); - if ( - current !== baseline && - !areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline)) - ) { - return true; - } - if (summary) { - return summaryConfirmsActiveTeammateRoster(current, summary); - } - - return false; -} - -function buildResolvedMember( - snapshot: TeamMemberSnapshot, - activity: MemberActivityMetaEntry | undefined -): ResolvedTeamMember { - return { - ...snapshot, - status: resolveMemberStatus(snapshot, activity), - messageCount: activity?.messageCountExact ?? 0, - lastActiveAt: activity?.lastAuthoredMessageAt ?? null, - }; -} - -function areMemberActivityMetaEntriesEqual( - left: MemberActivityMetaEntry | undefined, - right: MemberActivityMetaEntry -): boolean { - if (!left) { - return false; - } - return ( - left.memberName === right.memberName && - left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && - left.messageCountExact === right.messageCountExact && - left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination - ); -} - -function structurallyShareMemberActivityFacts( - previous: Record | undefined, - next: Record -): Record { - if (!previous) { - return next; - } - - const nextKeys = Object.keys(next); - const previousKeys = Object.keys(previous); - let changed = nextKeys.length !== previousKeys.length; - const shared: Record = {}; - - for (const key of nextKeys) { - const nextEntry = next[key]; - const previousEntry = previous[key]; - if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) { - changed = true; - shared[key] = nextEntry; - continue; - } - shared[key] = previousEntry; - } - - return changed ? shared : previous; -} - -type TeamDataSelectorState = Pick< - TeamSlice, - 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' ->; - -export function selectTeamDataForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): TeamViewSnapshot | null { - if (!teamName) { - return null; - } - if (state.selectedTeamName === teamName && state.selectedTeamData) { - return state.selectedTeamData; - } - return ( - state.teamDataCacheByName[teamName] ?? - (state.selectedTeamName === teamName ? state.selectedTeamData : null) - ); -} - -type ResolvedMemberSelectorState = Pick< - TeamSlice, - 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' -> & - Partial>; - -function migrateStableSlotAssignmentsForMembers( - assignments: TeamGraphSlotAssignments | undefined, - members: readonly TeamGraphMemberSeedInput[] -): { assignments: TeamGraphSlotAssignments; changed: boolean } { - const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) }; - let changed = false; - - for (const member of members) { - const fallbackKey = member.name.trim(); - const stableOwnerId = getStableTeamOwnerId(member); - const fallbackAssignment = nextAssignments[fallbackKey]; - const stableAssignment = nextAssignments[stableOwnerId]; - - if (stableOwnerId !== fallbackKey && fallbackAssignment && !stableAssignment) { - nextAssignments[stableOwnerId] = fallbackAssignment; - delete nextAssignments[fallbackKey]; - changed = true; - continue; - } - - if (stableOwnerId !== fallbackKey && fallbackAssignment && stableAssignment) { - delete nextAssignments[fallbackKey]; - changed = true; - } - } - - return { assignments: nextAssignments, changed }; -} - -export function selectResolvedMembersForTeamName( - state: ResolvedMemberSelectorState, - teamName: string | null | undefined -): ResolvedTeamMember[] { - const snapshot = selectTeamDataForName(state, teamName); - if (!snapshot || !teamName) { - return []; - } - - const meta = state.memberActivityMetaByTeam[teamName]; - const metaMembers = meta?.members; - const shouldUseMemberFallback = - snapshot.members.length === 0 || - (!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot)); - const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined; - const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined; - const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined; - const cached = resolvedMembersSelectorCache.get(teamName); - if ( - cached?.snapshotRef === snapshot.members && - cached.configMembersRef === configMembersRef && - cached.summaryRef === summaryRef && - cached.tasksRef === tasksRef && - cached.metaMembersRef === metaMembers - ) { - return cached.result; - } - - const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta); - resolvedMembersSelectorCache.set(teamName, { - snapshotRef: snapshot.members, - configMembersRef, - summaryRef, - tasksRef, - metaMembersRef: metaMembers, - result, - }); - return result; -} - -export function selectResolvedMemberForTeamName( - state: ResolvedMemberSelectorState, - teamName: string | null | undefined, - memberName: string | null | undefined -): ResolvedTeamMember | null { - const snapshot = selectTeamDataForName(state, teamName); - if (!snapshot || !teamName || !memberName) { - return null; - } - - const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find( - (member) => member.name === memberName - ); - if (!snapshotMember) { - return null; - } - - const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName]; - const cacheKey = `${teamName}:${memberName}`; - const cached = resolvedMemberSelectorCache.get(cacheKey); - if (cached?.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) { - return cached.result; - } - - const result = buildResolvedMember(snapshotMember, metaEntry); - resolvedMemberSelectorCache.set(cacheKey, { - snapshotMemberRef: snapshotMember, - metaEntryRef: metaEntry, - result, - }); - return result; -} - -export function selectTeamMemberSnapshotsForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): TeamViewSnapshot['members'] { - return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS; -} - -export function selectTeamTasksForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): TeamViewSnapshot['tasks'] { - return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS; -} - -export function selectTeamIsAliveForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): boolean | undefined { - return selectTeamDataForName(state, teamName)?.isAlive; -} - -export function selectTeamMessages( - state: Pick, - teamName: string | null | undefined -): InboxMessage[] { - if (!teamName) { - return []; - } - - const entry = getTeamMessagesCacheEntry(state, teamName); - const cached = mergedMessagesSelectorCache.get(teamName); - if ( - cached?.canonicalRef === entry.canonicalMessages && - cached.optimisticRef === entry.optimisticMessages - ) { - return cached.result; - } - - const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages); - mergedMessagesSelectorCache.set(teamName, { - canonicalRef: entry.canonicalMessages, - optimisticRef: entry.optimisticMessages, - result, - }); - return result; -} - -export function selectMemberMessagesForTeamMember( - state: Pick, - teamName: string | null | undefined, - memberName: string | null | undefined -): InboxMessage[] { - if (!teamName || !memberName) { - return []; - } - - const messages = selectTeamMessages(state, teamName); - const cacheKey = `${teamName}:${memberName}`; - const cached = memberMessagesSelectorCache.get(cacheKey); - if (cached?.messagesRef === messages) { - return cached.result; - } - - const result = messages.filter( - (message) => message.from === memberName || message.to === memberName - ); - memberMessagesSelectorCache.set(cacheKey, { - messagesRef: messages, - result, - }); - return result; -} - -function isMemberActivityMetaStale( - state: Pick, - teamName: string -): boolean { - const meta = state.memberActivityMetaByTeam[teamName]; - const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; - if (!meta) { - return true; - } - if (!feedRevision) { - return false; - } - return meta.feedRevision !== feedRevision; -} - -function seedStableSlotAssignmentsForMembers( - assignments: TeamGraphSlotAssignments, - members: readonly TeamGraphMemberSeedInput[], - configMembers: readonly TeamGraphConfigMemberSeedInput[] = [] -): { assignments: TeamGraphSlotAssignments; changed: boolean } { - const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers); - if ( - defaultSeed.orderedVisibleOwnerIds.length === 0 || - Object.keys(defaultSeed.assignments).length === 0 - ) { - return { assignments, changed: false }; - } - - const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds; - const hasAnyVisibleAssignments = visibleStableOwnerIds.some( - (stableOwnerId) => assignments[stableOwnerId] != null - ); - if (hasAnyVisibleAssignments) { - return { assignments, changed: false }; - } - - const nextAssignments: TeamGraphSlotAssignments = { ...assignments }; - visibleStableOwnerIds.forEach((stableOwnerId) => { - nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!; - }); - - return { assignments: nextAssignments, changed: true }; -} - -function areTeamGraphSlotAssignmentsEqual( - left: TeamGraphSlotAssignments | undefined, - right: TeamGraphSlotAssignments | undefined -): boolean { - const leftEntries = Object.entries(left ?? {}); - const rightEntries = Object.entries(right ?? {}); - if (leftEntries.length !== rightEntries.length) { - return false; - } - - for (const [stableOwnerId, leftAssignment] of leftEntries) { - const rightAssignment = right?.[stableOwnerId]; - if ( - rightAssignment?.ringIndex !== leftAssignment.ringIndex || - rightAssignment.sectorIndex !== leftAssignment.sectorIndex - ) { - return false; - } - } - - return true; -} - -function normalizeTeamGraphSlotAssignmentsForVisibleOwners( - assignments: TeamGraphSlotAssignments | undefined, - visibleOwnerIds: readonly string[] -): TeamGraphSlotAssignments { - if (visibleOwnerIds.length === 0 || !assignments) { - return {}; - } - - const normalizedAssignments: TeamGraphSlotAssignments = {}; - for (const stableOwnerId of visibleOwnerIds) { - const assignment = assignments[stableOwnerId]; - if (!assignment) { - continue; - } - normalizedAssignments[stableOwnerId] = assignment; - } - return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds); -} - -function normalizeLegacySixRowOrbitAssignments( - assignments: TeamGraphSlotAssignments, - visibleOwnerIds: readonly string[] -): TeamGraphSlotAssignments { - if (visibleOwnerIds.length !== 6) { - return assignments; - } - - const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => { - const assignment = assignments[stableOwnerId]; - return assignment ? [assignment] : []; - }); - const hasLegacyTwoRowBottomMarker = visibleAssignments.some( - (assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2 - ); - let changed = false; - const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments }; - - for (const stableOwnerId of visibleOwnerIds) { - const assignment = normalizedAssignments[stableOwnerId]; - if (!assignment) { - continue; - } - - if ( - hasLegacyTwoRowBottomMarker && - assignment.ringIndex === 1 && - assignment.sectorIndex >= 0 && - assignment.sectorIndex < 3 - ) { - normalizedAssignments[stableOwnerId] = { - ringIndex: 2, - sectorIndex: assignment.sectorIndex, - }; - changed = true; - continue; - } - - if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) { - normalizedAssignments[stableOwnerId] = { - ringIndex: 2, - sectorIndex: assignment.sectorIndex - 3, - }; - changed = true; - } - } - - return changed ? normalizedAssignments : assignments; -} - -function pruneTeamGraphSlotAssignmentsForVisibleOwners( - assignments: TeamGraphSlotAssignments | undefined, - visibleOwnerIds: readonly string[] -): TeamGraphSlotAssignments | undefined { - const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners( - assignments, - visibleOwnerIds - ); - return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined; -} - -function normalizeTeamGraphGridOwnerOrder( - order: readonly string[] | undefined, - visibleOwnerIds: readonly string[] -): string[] { - const visibleOwnerIdSet = new Set(visibleOwnerIds); - const normalizedOrder: string[] = []; - const seenOwnerIds = new Set(); - - for (const stableOwnerId of order ?? []) { - if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) { - continue; - } - normalizedOrder.push(stableOwnerId); - seenOwnerIds.add(stableOwnerId); - } - - for (const stableOwnerId of visibleOwnerIds) { - if (seenOwnerIds.has(stableOwnerId)) { - continue; - } - normalizedOrder.push(stableOwnerId); - seenOwnerIds.add(stableOwnerId); - } - - return normalizedOrder; -} - -export function getDefaultTeamGraphSlotAssignmentsForMembers( - members: readonly TeamGraphMemberSeedInput[], - configMembers: readonly TeamGraphConfigMemberSeedInput[] = [] -): TeamGraphSlotAssignments { - return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments; -} - -export function isTeamGraphSlotPersistenceDisabled(): boolean { - return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS; -} - function isVisibleInActiveTeamSurface( state: Pick, teamName: string | null | undefined @@ -2789,15 +1251,6 @@ function isVisibleInActiveTeamSurface( }); } -function shouldInvalidateCachedTeamDataForError(teamName: string, message: string): boolean { - return ( - message === 'TEAM_DRAFT' || - message.includes('TEAM_DRAFT') || - message === `Team not found: ${teamName}` || - message === 'Team config not found' - ); -} - export interface TeamSlice { teams: TeamSummary[]; /** O(1) lookup to avoid array scans in render-hot paths */ @@ -3075,33 +1528,6 @@ export interface TeamSlice { // --- Per-team launch params persistence --- const LAUNCH_PARAMS_PREFIX = 'team:launchParams:'; -const MESSAGES_PANEL_MODE_STORAGE_KEY = 'team:messagesPanelMode'; -const DEFAULT_MESSAGES_PANEL_MODE: TeamMessagesPanelMode = 'sidebar'; -const VALID_MESSAGES_PANEL_MODES: ReadonlySet = new Set([ - 'sidebar', - 'inline', - 'bottom-sheet', - 'floating-composer', -]); - -export function loadPersistedMessagesPanelMode(): TeamMessagesPanelMode { - try { - const persisted = localStorage.getItem(MESSAGES_PANEL_MODE_STORAGE_KEY); - return VALID_MESSAGES_PANEL_MODES.has(persisted as TeamMessagesPanelMode) - ? (persisted as TeamMessagesPanelMode) - : DEFAULT_MESSAGES_PANEL_MODE; - } catch { - return DEFAULT_MESSAGES_PANEL_MODE; - } -} - -export function savePersistedMessagesPanelMode(mode: TeamMessagesPanelMode): void { - try { - localStorage.setItem(MESSAGES_PANEL_MODE_STORAGE_KEY, mode); - } catch { - // ignore - best-effort UI preference persistence - } -} export function getCurrentProvisioningProgressForTeam( state: Pick, @@ -3116,7 +1542,7 @@ export function isTeamProvisioningActive( teamName: string ): boolean { const current = getCurrentProvisioningProgressForTeam(state, teamName); - return current != null && ACTIVE_PROVISIONING_STATES.has(current.state); + return current != null && isActiveProvisioningState(current.state); } function loadAllLaunchParams(): Record { @@ -3146,112 +1572,8 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void { } } -/** - * Extract the base model name from the raw model string sent to CLI. - * E.g. 'opus[1m]' → 'opus', 'sonnet' → 'sonnet', undefined → undefined. - */ -function extractBaseModel(raw?: string, providerId?: TeamProviderId): string | undefined { - return extractProviderScopedBaseModel(raw, providerId); -} - -function buildLaunchParamsFromRuntimeRequest( - request: Pick< - TeamCreateRequest, - 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' - >, - fallback?: TeamLaunchParams -): TeamLaunchParams { - const providerId = request.providerId ?? fallback?.providerId ?? 'anthropic'; - const providerChanged = - request.providerId != null && - fallback?.providerId != null && - request.providerId !== fallback.providerId; - const hasModel = Object.hasOwn(request, 'model'); - const baseModel = - hasModel && typeof request.model === 'string' - ? extractBaseModel(request.model, providerId) - : undefined; - const rawProviderBackendId = Object.hasOwn(request, 'providerBackendId') - ? request.providerBackendId - : providerChanged - ? undefined - : fallback?.providerBackendId; - return { - providerId, - providerBackendId: migrateProviderBackendId(providerId, rawProviderBackendId), - model: hasModel - ? baseModel || 'default' - : (providerChanged ? undefined : fallback?.model) || 'default', - effort: Object.hasOwn(request, 'effort') - ? request.effort - : providerChanged - ? undefined - : fallback?.effort, - fastMode: Object.hasOwn(request, 'fastMode') - ? request.fastMode - : providerChanged - ? undefined - : fallback?.fastMode, - limitContext: - typeof request.limitContext === 'boolean' - ? request.limitContext - : providerChanged - ? false - : (fallback?.limitContext ?? false), - }; -} - -function areTeamLaunchParamsEqual( - left: TeamLaunchParams | undefined, - right: TeamLaunchParams | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return false; - return ( - left.providerId === right.providerId && - left.providerBackendId === right.providerBackendId && - left.model === right.model && - left.effort === right.effort && - left.fastMode === right.fastMode && - left.limitContext === right.limitContext - ); -} - const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:'; -function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings { - if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS; - try { - const parsed = JSON.parse(raw) as Record; - const d = DEFAULT_TOOL_APPROVAL_SETTINGS; - return { - autoAllowAll: typeof parsed.autoAllowAll === 'boolean' ? parsed.autoAllowAll : d.autoAllowAll, - autoAllowFileEdits: - typeof parsed.autoAllowFileEdits === 'boolean' - ? parsed.autoAllowFileEdits - : d.autoAllowFileEdits, - autoAllowSafeBash: - typeof parsed.autoAllowSafeBash === 'boolean' - ? parsed.autoAllowSafeBash - : d.autoAllowSafeBash, - timeoutAction: - typeof parsed.timeoutAction === 'string' && - ['allow', 'deny', 'wait'].includes(parsed.timeoutAction) - ? (parsed.timeoutAction as ToolApprovalSettings['timeoutAction']) - : d.timeoutAction, - timeoutSeconds: - typeof parsed.timeoutSeconds === 'number' && - Number.isFinite(parsed.timeoutSeconds) && - parsed.timeoutSeconds >= 5 && - parsed.timeoutSeconds <= 300 - ? parsed.timeoutSeconds - : d.timeoutSeconds, - }; - } catch { - return DEFAULT_TOOL_APPROVAL_SETTINGS; - } -} - function loadToolApprovalSettingsForTeam(teamName: string): ToolApprovalSettings { return parseToolApprovalSettings(localStorage.getItem(TOOL_APPROVAL_PREFIX + teamName)); } @@ -3335,13 +1657,12 @@ export const createTeamSlice: StateCreator = (set, launchParamsByTeam: loadAllLaunchParams(), fetchMemberSpawnStatuses: async (teamName: string) => { if (!api.teams?.getMemberSpawnStatuses) return; - const backoffUntil = memberSpawnStatusesIpcBackoffUntilByTeam.get(teamName) ?? 0; - if (backoffUntil > Date.now()) { + if (isMemberSpawnStatusesIpcBackoffActive(teamName)) { return; } try { const snapshot = await api.teams.getMemberSpawnStatuses(teamName); - memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); + clearMemberSpawnStatusesIpcBackoff(teamName); set((prev) => { if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) { return {}; @@ -3405,9 +1726,9 @@ export const createTeamSlice: StateCreator = (set, } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes("No handler registered for 'team:memberSpawnStatuses'")) { - memberSpawnStatusesIpcBackoffUntilByTeam.set( + recordMemberSpawnStatusesIpcRetryBackoff( teamName, - Date.now() + MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS + MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS ); } // ignore — spawn statuses are best-effort @@ -4364,7 +2685,7 @@ export const createTeamSlice: StateCreator = (set, selectedTeamError: null, }; }); - lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); + recordLastResolvedTeamDataRefresh(teamName); try { const invalidationState = previousData @@ -4539,7 +2860,7 @@ export const createTeamSlice: StateCreator = (set, const refreshToken = beginInFlightTeamDataRefresh(teamName); // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). - noteTeamRefreshBurst(teamName); + noteTeamRefreshBurst(teamName, TEAM_REFRESH_BURST_WINDOW_MS); if (reusedInFlightRequest) { pendingFreshTeamDataRefreshes.add(teamName); } @@ -4588,7 +2909,7 @@ export const createTeamSlice: StateCreator = (set, ...selectedState, }; }); - lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); + recordLastResolvedTeamDataRefresh(teamName); const invalidationState = previousData ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) : { cacheKeys: [], taskIds: [] }; @@ -6114,7 +4435,7 @@ export const createTeamSlice: StateCreator = (set, } } - if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) { + if (isCanonicalRun && isTerminalProvisioningState(progress.state)) { set((prev) => { const next = { ...prev.memberSpawnStatusesByTeam }; const nextSnapshots = { ...prev.memberSpawnSnapshotsByTeam }; diff --git a/src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts b/src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts new file mode 100644 index 00000000..8f776e83 --- /dev/null +++ b/src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts @@ -0,0 +1,108 @@ +import type { + TeamAgentRuntimeEntry, + TeamAgentRuntimeResourceSample, + TeamAgentRuntimeSnapshot, +} from '@shared/types'; + +function isTeamAgentRuntimeResourceSampleLike( + value: unknown +): value is TeamAgentRuntimeResourceSample { + return Boolean(value) && typeof value === 'object'; +} + +export function areTeamAgentRuntimeResourceSamplesEqual(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (!isTeamAgentRuntimeResourceSampleLike(left) || !isTeamAgentRuntimeResourceSampleLike(right)) { + return false; + } + return ( + left.timestamp === right.timestamp && + left.cpuPercent === right.cpuPercent && + left.rssBytes === right.rssBytes && + left.primaryCpuPercent === right.primaryCpuPercent && + left.primaryRssBytes === right.primaryRssBytes && + left.childCpuPercent === right.childCpuPercent && + left.childRssBytes === right.childRssBytes && + left.processCount === right.processCount && + left.runtimeLoadScope === right.runtimeLoadScope && + left.runtimeLoadTruncated === right.runtimeLoadTruncated && + left.pidSource === right.pidSource && + left.pid === right.pid && + left.runtimePid === right.runtimePid + ); +} + +export function areTeamAgentRuntimeEntriesEqual( + left: TeamAgentRuntimeEntry | undefined, + right: TeamAgentRuntimeEntry | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : []; + const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : []; + const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : []; + const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : []; + return ( + left.memberName === right.memberName && + left.alive === right.alive && + left.restartable === right.restartable && + left.backendType === right.backendType && + left.providerId === right.providerId && + left.providerBackendId === right.providerBackendId && + left.laneId === right.laneId && + left.laneKind === right.laneKind && + left.pid === right.pid && + left.runtimeModel === right.runtimeModel && + left.rssBytes === right.rssBytes && + left.cpuPercent === right.cpuPercent && + left.primaryCpuPercent === right.primaryCpuPercent && + left.primaryRssBytes === right.primaryRssBytes && + left.childCpuPercent === right.childCpuPercent && + left.childRssBytes === right.childRssBytes && + left.processCount === right.processCount && + left.runtimeLoadScope === right.runtimeLoadScope && + left.runtimeLoadTruncated === right.runtimeLoadTruncated && + left.livenessKind === right.livenessKind && + left.pidSource === right.pidSource && + left.processCommand === right.processCommand && + left.paneId === right.paneId && + left.panePid === right.panePid && + left.paneCurrentCommand === right.paneCurrentCommand && + left.runtimePid === right.runtimePid && + left.runtimeSessionId === right.runtimeSessionId && + left.runtimeDiagnostic === right.runtimeDiagnostic && + left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && + left.runtimeLastSeenAt === right.runtimeLastSeenAt && + left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed && + leftDiagnostics.length === rightDiagnostics.length && + leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) && + leftResourceHistory.length === rightResourceHistory.length && + leftResourceHistory.every((value, index) => + areTeamAgentRuntimeResourceSamplesEqual(value, rightResourceHistory[index]) + ) + ); +} + +export function areTeamAgentRuntimeSnapshotsEqual( + left: TeamAgentRuntimeSnapshot | undefined, + right: TeamAgentRuntimeSnapshot +): boolean { + if (!left) return false; + if (left.teamName !== right.teamName || left.runId !== right.runId) { + return false; + } + const leftKeys = Object.keys(left.members); + const rightKeys = Object.keys(right.members); + if (leftKeys.length !== rightKeys.length) { + return false; + } + for (const key of leftKeys) { + if (!(key in right.members)) { + return false; + } + if (!areTeamAgentRuntimeEntriesEqual(left.members[key], right.members[key])) { + return false; + } + } + return true; +} diff --git a/src/renderer/store/team/teamDataRefreshTimestamps.ts b/src/renderer/store/team/teamDataRefreshTimestamps.ts new file mode 100644 index 00000000..9330d545 --- /dev/null +++ b/src/renderer/store/team/teamDataRefreshTimestamps.ts @@ -0,0 +1,21 @@ +const lastResolvedTeamDataRefreshAtByTeam = new Map(); + +export function getLastResolvedTeamDataRefreshAt(teamName: string): number | undefined { + return lastResolvedTeamDataRefreshAtByTeam.get(teamName); +} + +export function recordLastResolvedTeamDataRefresh(teamName: string, resolvedAt = Date.now()): void { + lastResolvedTeamDataRefreshAtByTeam.set(teamName, resolvedAt); +} + +export function hasLastResolvedTeamDataRefreshAt(teamName: string): boolean { + return lastResolvedTeamDataRefreshAtByTeam.has(teamName); +} + +export function clearLastResolvedTeamDataRefreshAt(teamName: string): void { + lastResolvedTeamDataRefreshAtByTeam.delete(teamName); +} + +export function clearAllLastResolvedTeamDataRefreshes(): void { + lastResolvedTeamDataRefreshAtByTeam.clear(); +} diff --git a/src/renderer/store/team/teamDataRequestKeys.ts b/src/renderer/store/team/teamDataRequestKeys.ts new file mode 100644 index 00000000..32e87773 --- /dev/null +++ b/src/renderer/store/team/teamDataRequestKeys.ts @@ -0,0 +1,39 @@ +import type { TeamGetDataOptions } from '@shared/types'; + +export type TeamDataSnapshotMode = 'full' | 'thin'; + +export function normalizeTeamGetDataOptions( + options?: TeamGetDataOptions +): TeamGetDataOptions | undefined { + return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined; +} + +export function shouldIncludeMemberBranches(options?: TeamGetDataOptions): boolean { + return normalizeTeamGetDataOptions(options)?.includeMemberBranches !== false; +} + +export function getTeamDataSnapshotMode(options?: TeamGetDataOptions): TeamDataSnapshotMode { + return shouldIncludeMemberBranches(options) ? 'full' : 'thin'; +} + +export function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string { + const normalizedOptions = normalizeTeamGetDataOptions(options); + return `${teamName}\u0000mode:${getTeamDataSnapshotMode(normalizedOptions)}`; +} + +export function getTeamDataRequestLabel(teamName: string, options?: TeamGetDataOptions): string { + const normalizedOptions = normalizeTeamGetDataOptions(options); + return `team:getData(${teamName},mode=${getTeamDataSnapshotMode(normalizedOptions)})`; +} + +export function getFullTeamDataRequestKey(teamName: string): string { + return getTeamDataRequestKey(teamName); +} + +export function getThinTeamDataRequestKey(teamName: string): string { + return getTeamDataRequestKey(teamName, { includeMemberBranches: false }); +} + +export function isTeamDataRequestKeyForTeam(requestKey: string, teamName: string): boolean { + return requestKey.startsWith(`${teamName}\u0000`); +} diff --git a/src/renderer/store/team/teamDataSelectors.ts b/src/renderer/store/team/teamDataSelectors.ts new file mode 100644 index 00000000..aaa23433 --- /dev/null +++ b/src/renderer/store/team/teamDataSelectors.ts @@ -0,0 +1,47 @@ +import type { TeamViewSnapshot } from '@shared/types'; + +export interface TeamDataSelectorState { + teamDataCacheByName: Record; + selectedTeamName: string | null; + selectedTeamData: TeamViewSnapshot | null; +} + +const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamViewSnapshot['members'] = []; +const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = []; + +export function selectTeamDataForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): TeamViewSnapshot | null { + if (!teamName) { + return null; + } + if (state.selectedTeamName === teamName && state.selectedTeamData) { + return state.selectedTeamData; + } + return ( + state.teamDataCacheByName[teamName] ?? + (state.selectedTeamName === teamName ? state.selectedTeamData : null) + ); +} + +export function selectTeamMemberSnapshotsForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): TeamViewSnapshot['members'] { + return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS; +} + +export function selectTeamTasksForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): TeamViewSnapshot['tasks'] { + return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS; +} + +export function selectTeamIsAliveForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): boolean | undefined { + return selectTeamDataForName(state, teamName)?.isAlive; +} diff --git a/src/renderer/store/team/teamErrorPolicies.ts b/src/renderer/store/team/teamErrorPolicies.ts new file mode 100644 index 00000000..51695967 --- /dev/null +++ b/src/renderer/store/team/teamErrorPolicies.ts @@ -0,0 +1,33 @@ +import { IpcError } from '@renderer/utils/unwrapIpc'; + +function getErrorMessage(error: unknown): string { + return error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; +} + +export function mapSendMessageError(error: unknown): string { + const message = getErrorMessage(error); + if (message.includes('Failed to verify inbox write')) { + return 'Message was written but not verified (race). Please try again.'; + } + return message || 'Failed to send message'; +} + +export function mapReviewError(error: unknown): string { + const message = getErrorMessage(error); + if (message.includes('Task status update verification failed')) { + return 'Failed to update task status (possible agent conflict).'; + } + return message || 'Failed to perform review action'; +} + +export function shouldInvalidateCachedTeamDataForError( + teamName: string, + message: string +): boolean { + return ( + message === 'TEAM_DRAFT' || + message.includes('TEAM_DRAFT') || + message === `Team not found: ${teamName}` || + message === 'Team config not found' + ); +} diff --git a/src/renderer/store/team/teamGraphLayout.ts b/src/renderer/store/team/teamGraphLayout.ts new file mode 100644 index 00000000..6f7329e8 --- /dev/null +++ b/src/renderer/store/team/teamGraphLayout.ts @@ -0,0 +1,219 @@ +import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; +import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; + +import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; +import type { TeamMemberSnapshot, TeamViewSnapshot } from '@shared/types'; + +export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; +export const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; + +export type TeamGraphSlotAssignments = Record; +export type TeamGraphMemberSeedInput = Pick; +export type TeamGraphConfigMemberSeedInput = Pick< + NonNullable[number], + 'name' | 'agentId' | 'removedAt' +>; + +export interface TeamGraphLayoutSessionState { + mode: 'default' | 'manual'; + signature: string | null; +} + +export function migrateStableSlotAssignmentsForMembers( + assignments: TeamGraphSlotAssignments | undefined, + members: readonly TeamGraphMemberSeedInput[] +): { assignments: TeamGraphSlotAssignments; changed: boolean } { + const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) }; + let changed = false; + + for (const member of members) { + const fallbackKey = member.name.trim(); + const stableOwnerId = getStableTeamOwnerId(member); + const fallbackAssignment = nextAssignments[fallbackKey]; + const stableAssignment = nextAssignments[stableOwnerId]; + + if (stableOwnerId !== fallbackKey && fallbackAssignment && !stableAssignment) { + nextAssignments[stableOwnerId] = fallbackAssignment; + delete nextAssignments[fallbackKey]; + changed = true; + continue; + } + + if (stableOwnerId !== fallbackKey && fallbackAssignment && stableAssignment) { + delete nextAssignments[fallbackKey]; + changed = true; + } + } + + return { assignments: nextAssignments, changed }; +} + +export function seedStableSlotAssignmentsForMembers( + assignments: TeamGraphSlotAssignments, + members: readonly TeamGraphMemberSeedInput[], + configMembers: readonly TeamGraphConfigMemberSeedInput[] = [] +): { assignments: TeamGraphSlotAssignments; changed: boolean } { + const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers); + if ( + defaultSeed.orderedVisibleOwnerIds.length === 0 || + Object.keys(defaultSeed.assignments).length === 0 + ) { + return { assignments, changed: false }; + } + + const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds; + const hasAnyVisibleAssignments = visibleStableOwnerIds.some( + (stableOwnerId) => assignments[stableOwnerId] != null + ); + if (hasAnyVisibleAssignments) { + return { assignments, changed: false }; + } + + const nextAssignments: TeamGraphSlotAssignments = { ...assignments }; + visibleStableOwnerIds.forEach((stableOwnerId) => { + nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!; + }); + + return { assignments: nextAssignments, changed: true }; +} + +export function areTeamGraphSlotAssignmentsEqual( + left: TeamGraphSlotAssignments | undefined, + right: TeamGraphSlotAssignments | undefined +): boolean { + const leftEntries = Object.entries(left ?? {}); + const rightEntries = Object.entries(right ?? {}); + if (leftEntries.length !== rightEntries.length) { + return false; + } + + for (const [stableOwnerId, leftAssignment] of leftEntries) { + const rightAssignment = right?.[stableOwnerId]; + if ( + rightAssignment?.ringIndex !== leftAssignment.ringIndex || + rightAssignment.sectorIndex !== leftAssignment.sectorIndex + ) { + return false; + } + } + + return true; +} + +export function normalizeTeamGraphSlotAssignmentsForVisibleOwners( + assignments: TeamGraphSlotAssignments | undefined, + visibleOwnerIds: readonly string[] +): TeamGraphSlotAssignments { + if (visibleOwnerIds.length === 0 || !assignments) { + return {}; + } + + const normalizedAssignments: TeamGraphSlotAssignments = {}; + for (const stableOwnerId of visibleOwnerIds) { + const assignment = assignments[stableOwnerId]; + if (!assignment) { + continue; + } + normalizedAssignments[stableOwnerId] = assignment; + } + return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds); +} + +export function normalizeLegacySixRowOrbitAssignments( + assignments: TeamGraphSlotAssignments, + visibleOwnerIds: readonly string[] +): TeamGraphSlotAssignments { + if (visibleOwnerIds.length !== 6) { + return assignments; + } + + const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => { + const assignment = assignments[stableOwnerId]; + return assignment ? [assignment] : []; + }); + const hasLegacyTwoRowBottomMarker = visibleAssignments.some( + (assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2 + ); + let changed = false; + const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments }; + + for (const stableOwnerId of visibleOwnerIds) { + const assignment = normalizedAssignments[stableOwnerId]; + if (!assignment) { + continue; + } + + if ( + hasLegacyTwoRowBottomMarker && + assignment.ringIndex === 1 && + assignment.sectorIndex >= 0 && + assignment.sectorIndex < 3 + ) { + normalizedAssignments[stableOwnerId] = { + ringIndex: 2, + sectorIndex: assignment.sectorIndex, + }; + changed = true; + continue; + } + + if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) { + normalizedAssignments[stableOwnerId] = { + ringIndex: 2, + sectorIndex: assignment.sectorIndex - 3, + }; + changed = true; + } + } + + return changed ? normalizedAssignments : assignments; +} + +export function pruneTeamGraphSlotAssignmentsForVisibleOwners( + assignments: TeamGraphSlotAssignments | undefined, + visibleOwnerIds: readonly string[] +): TeamGraphSlotAssignments | undefined { + const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners( + assignments, + visibleOwnerIds + ); + return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined; +} + +export function normalizeTeamGraphGridOwnerOrder( + order: readonly string[] | undefined, + visibleOwnerIds: readonly string[] +): string[] { + const visibleOwnerIdSet = new Set(visibleOwnerIds); + const normalizedOrder: string[] = []; + const seenOwnerIds = new Set(); + + for (const stableOwnerId of order ?? []) { + if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) { + continue; + } + normalizedOrder.push(stableOwnerId); + seenOwnerIds.add(stableOwnerId); + } + + for (const stableOwnerId of visibleOwnerIds) { + if (seenOwnerIds.has(stableOwnerId)) { + continue; + } + normalizedOrder.push(stableOwnerId); + seenOwnerIds.add(stableOwnerId); + } + + return normalizedOrder; +} + +export function getDefaultTeamGraphSlotAssignmentsForMembers( + members: readonly TeamGraphMemberSeedInput[], + configMembers: readonly TeamGraphConfigMemberSeedInput[] = [] +): TeamGraphSlotAssignments { + return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments; +} + +export function isTeamGraphSlotPersistenceDisabled(): boolean { + return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS; +} diff --git a/src/renderer/store/team/teamLaunchParams.ts b/src/renderer/store/team/teamLaunchParams.ts new file mode 100644 index 00000000..02ab0f2b --- /dev/null +++ b/src/renderer/store/team/teamLaunchParams.ts @@ -0,0 +1,89 @@ +import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; + +import type { + EffortLevel, + TeamCreateRequest, + TeamFastMode, + TeamProviderId, +} from '@shared/types'; + +/** Per-team launch parameters shown in the header badge. */ +export interface TeamLaunchParams { + providerId?: TeamProviderId; + providerBackendId?: string; + model?: string; + effort?: EffortLevel; + fastMode?: TeamFastMode; + limitContext?: boolean; +} + +export function extractBaseModel( + raw?: string, + providerId?: TeamProviderId +): string | undefined { + return extractProviderScopedBaseModel(raw, providerId); +} + +export function buildLaunchParamsFromRuntimeRequest( + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' + >, + fallback?: TeamLaunchParams +): TeamLaunchParams { + const providerId = request.providerId ?? fallback?.providerId ?? 'anthropic'; + const providerChanged = + request.providerId != null && + fallback?.providerId != null && + request.providerId !== fallback.providerId; + const hasModel = Object.hasOwn(request, 'model'); + const baseModel = + hasModel && typeof request.model === 'string' + ? extractBaseModel(request.model, providerId) + : undefined; + const rawProviderBackendId = Object.hasOwn(request, 'providerBackendId') + ? request.providerBackendId + : providerChanged + ? undefined + : fallback?.providerBackendId; + return { + providerId, + providerBackendId: migrateProviderBackendId(providerId, rawProviderBackendId), + model: hasModel + ? baseModel || 'default' + : (providerChanged ? undefined : fallback?.model) || 'default', + effort: Object.hasOwn(request, 'effort') + ? request.effort + : providerChanged + ? undefined + : fallback?.effort, + fastMode: Object.hasOwn(request, 'fastMode') + ? request.fastMode + : providerChanged + ? undefined + : fallback?.fastMode, + limitContext: + typeof request.limitContext === 'boolean' + ? request.limitContext + : providerChanged + ? false + : (fallback?.limitContext ?? false), + }; +} + +export function areTeamLaunchParamsEqual( + left: TeamLaunchParams | undefined, + right: TeamLaunchParams | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return false; + return ( + left.providerId === right.providerId && + left.providerBackendId === right.providerBackendId && + left.model === right.model && + left.effort === right.effort && + left.fastMode === right.fastMode && + left.limitContext === right.limitContext + ); +} diff --git a/src/renderer/store/team/teamLocalStateEpoch.ts b/src/renderer/store/team/teamLocalStateEpoch.ts new file mode 100644 index 00000000..4568f082 --- /dev/null +++ b/src/renderer/store/team/teamLocalStateEpoch.ts @@ -0,0 +1,25 @@ +const teamLocalStateEpochByTeam = new Map(); + +export function captureTeamLocalStateEpoch(teamName: string): number { + return teamLocalStateEpochByTeam.get(teamName) ?? 0; +} + +export function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean { + return captureTeamLocalStateEpoch(teamName) === epoch; +} + +export function invalidateTeamLocalStateEpoch(teamName: string): void { + teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1); +} + +export function hasTeamLocalStateEpoch(teamName: string): boolean { + return teamLocalStateEpochByTeam.has(teamName); +} + +export function clearTeamLocalStateEpoch(teamName: string): void { + teamLocalStateEpochByTeam.delete(teamName); +} + +export function clearAllTeamLocalStateEpochs(): void { + teamLocalStateEpochByTeam.clear(); +} diff --git a/src/renderer/store/team/teamMemberActivityMeta.ts b/src/renderer/store/team/teamMemberActivityMeta.ts new file mode 100644 index 00000000..da1b9b3a --- /dev/null +++ b/src/renderer/store/team/teamMemberActivityMeta.ts @@ -0,0 +1,64 @@ +import { getTeamMessagesCacheEntry, type TeamMessagesCacheState } from './teamMessagesCache'; + +import type { MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types'; + +export interface TeamMemberActivityMetaState extends TeamMessagesCacheState { + memberActivityMetaByTeam: Record; +} + +export function areMemberActivityMetaEntriesEqual( + left: MemberActivityMetaEntry | undefined, + right: MemberActivityMetaEntry +): boolean { + if (!left) { + return false; + } + return ( + left.memberName === right.memberName && + left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && + left.messageCountExact === right.messageCountExact && + left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination + ); +} + +export function structurallyShareMemberActivityFacts( + previous: Record | undefined, + next: Record +): Record { + if (!previous) { + return next; + } + + const nextKeys = Object.keys(next); + const previousKeys = Object.keys(previous); + let changed = nextKeys.length !== previousKeys.length; + const shared: Record = {}; + + for (const key of nextKeys) { + const nextEntry = next[key]; + const previousEntry = previous[key]; + if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) { + changed = true; + shared[key] = nextEntry; + continue; + } + shared[key] = previousEntry; + } + + return changed ? shared : previous; +} + +export function isMemberActivityMetaStale( + state: TeamMemberActivityMetaState, + teamName: string +): boolean { + const meta = state.memberActivityMetaByTeam[teamName]; + const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; + if (!meta) { + return true; + } + if (!feedRevision) { + return false; + } + return meta.feedRevision !== feedRevision; +} diff --git a/src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts b/src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts new file mode 100644 index 00000000..3f5990e2 --- /dev/null +++ b/src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts @@ -0,0 +1,106 @@ +import type { + MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + PersistedTeamLaunchSummary, +} from '@shared/types'; + +export function areLaunchSummaryCountsEqual( + left: PersistedTeamLaunchSummary | undefined, + right: PersistedTeamLaunchSummary | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + return ( + left.confirmedCount === right.confirmedCount && + left.pendingCount === right.pendingCount && + left.failedCount === right.failedCount && + left.skippedCount === right.skippedCount && + left.runtimeAlivePendingCount === right.runtimeAlivePendingCount && + left.shellOnlyPendingCount === right.shellOnlyPendingCount && + left.runtimeProcessPendingCount === right.runtimeProcessPendingCount && + left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount && + left.noRuntimePendingCount === right.noRuntimePendingCount && + left.permissionPendingCount === right.permissionPendingCount + ); +} + +export function areExpectedMembersEqual( + left: readonly string[] | undefined, + right: readonly string[] | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +export function areMemberSpawnStatusEntriesEqual( + left: MemberSpawnStatusEntry | undefined, + right: MemberSpawnStatusEntry | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + const leftPendingPermissionIds = [...(left.pendingPermissionRequestIds ?? [])].sort(); + const rightPendingPermissionIds = [...(right.pendingPermissionRequestIds ?? [])].sort(); + // Renderer equality intentionally ignores raw timing fields that do not change + // visible member status. This suppresses heartbeat-only churn in TeamDetailView. + return ( + left.status === right.status && + left.launchState === right.launchState && + left.error === right.error && + left.hardFailureReason === right.hardFailureReason && + left.skippedForLaunch === right.skippedForLaunch && + left.skipReason === right.skipReason && + left.skippedAt === right.skippedAt && + left.livenessSource === right.livenessSource && + left.runtimeAlive === right.runtimeAlive && + left.runtimeModel === right.runtimeModel && + left.livenessKind === right.livenessKind && + left.runtimeDiagnostic === right.runtimeDiagnostic && + left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && + left.bootstrapConfirmed === right.bootstrapConfirmed && + left.hardFailure === right.hardFailure && + leftPendingPermissionIds.length === rightPendingPermissionIds.length && + leftPendingPermissionIds.every((value, index) => value === rightPendingPermissionIds[index]) + ); +} + +export function areMemberSpawnStatusesEqual( + left: Record, + right: Record +): boolean { + if (left === right) return true; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + for (const key of leftKeys) { + if (!(key in right)) { + return false; + } + if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) { + return false; + } + } + return true; +} + +export function areMemberSpawnSnapshotsSemanticallyEqual( + left: MemberSpawnStatusesSnapshot | undefined, + right: MemberSpawnStatusesSnapshot +): boolean { + if (!left) return false; + return ( + left.runId === right.runId && + left.teamLaunchState === right.teamLaunchState && + left.launchPhase === right.launchPhase && + left.source === right.source && + areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) && + areLaunchSummaryCountsEqual(left.summary, right.summary) && + areMemberSpawnStatusesEqual(left.statuses, right.statuses) + ); +} diff --git a/src/renderer/store/team/teamMemberSpawnStatusBackoff.ts b/src/renderer/store/team/teamMemberSpawnStatusBackoff.ts new file mode 100644 index 00000000..bcb6fc71 --- /dev/null +++ b/src/renderer/store/team/teamMemberSpawnStatusBackoff.ts @@ -0,0 +1,39 @@ +const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); + +export function getMemberSpawnStatusesIpcBackoffUntil(teamName: string): number { + return memberSpawnStatusesIpcBackoffUntilByTeam.get(teamName) ?? 0; +} + +export function hasMemberSpawnStatusesIpcBackoff(teamName: string): boolean { + return memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName); +} + +export function isMemberSpawnStatusesIpcBackoffActive( + teamName: string, + now = Date.now() +): boolean { + return getMemberSpawnStatusesIpcBackoffUntil(teamName) > now; +} + +export function recordMemberSpawnStatusesIpcBackoffUntil( + teamName: string, + backoffUntil: number +): void { + memberSpawnStatusesIpcBackoffUntilByTeam.set(teamName, backoffUntil); +} + +export function recordMemberSpawnStatusesIpcRetryBackoff( + teamName: string, + retryBackoffMs: number, + now = Date.now() +): void { + recordMemberSpawnStatusesIpcBackoffUntil(teamName, now + retryBackoffMs); +} + +export function clearMemberSpawnStatusesIpcBackoff(teamName: string): void { + memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); +} + +export function clearAllMemberSpawnStatusesIpcBackoffs(): void { + memberSpawnStatusesIpcBackoffUntilByTeam.clear(); +} diff --git a/src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle.ts b/src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle.ts new file mode 100644 index 00000000..65e9a92a --- /dev/null +++ b/src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle.ts @@ -0,0 +1,30 @@ +const memberSpawnUiEqualLastWarnAtByTeam = new Map(); + +export function getMemberSpawnUiEqualLastWarnAt(teamName: string): number | undefined { + return memberSpawnUiEqualLastWarnAtByTeam.get(teamName); +} + +export function hasMemberSpawnUiEqualLastWarn(teamName: string): boolean { + return memberSpawnUiEqualLastWarnAtByTeam.has(teamName); +} + +export function shouldLogMemberSpawnUiEqualSuppressed( + teamName: string, + throttleMs: number, + now = Date.now() +): boolean { + const lastWarnAt = memberSpawnUiEqualLastWarnAtByTeam.get(teamName) ?? 0; + if (now - lastWarnAt < throttleMs) { + return false; + } + memberSpawnUiEqualLastWarnAtByTeam.set(teamName, now); + return true; +} + +export function clearMemberSpawnUiEqualLastWarn(teamName: string): void { + memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); +} + +export function clearAllMemberSpawnUiEqualLastWarns(): void { + memberSpawnUiEqualLastWarnAtByTeam.clear(); +} diff --git a/src/renderer/store/team/teamMessagesCache.ts b/src/renderer/store/team/teamMessagesCache.ts new file mode 100644 index 00000000..b5900d82 --- /dev/null +++ b/src/renderer/store/team/teamMessagesCache.ts @@ -0,0 +1,291 @@ +import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; + +import type { InboxMessage } from '@shared/types'; + +export interface TeamMessagesCacheEntry { + canonicalMessages: InboxMessage[]; + optimisticMessages: InboxMessage[]; + feedRevision: string | null; + nextCursor: string | null; + hasMore: boolean; + lastFetchedAt: number | null; + loadingHead: boolean; + loadingOlder: boolean; + headHydrated: boolean; +} + +export interface RefreshTeamMessagesHeadResult { + feedChanged: boolean; + headChanged: boolean; + feedRevision: string | null; +} + +export interface TeamMessagesCacheState { + teamMessagesByName: Record; +} + +export interface TeamMessageSelectorCacheSnapshot { + hasMergedMessagesSelector: boolean; + memberMessagesSelectorCount: number; +} + +export const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: false, +}; + +const mergedMessagesSelectorCache = new Map< + string, + { + canonicalRef: readonly InboxMessage[]; + optimisticRef: readonly InboxMessage[]; + result: InboxMessage[]; + } +>(); +const memberMessagesSelectorCache = new Map< + string, + { + messagesRef: readonly InboxMessage[]; + result: InboxMessage[]; + } +>(); + +export function clearTeamMessageSelectorCaches(): void { + mergedMessagesSelectorCache.clear(); + memberMessagesSelectorCache.clear(); +} + +export function clearTeamMessageSelectorCachesForTeam(teamName: string): void { + mergedMessagesSelectorCache.delete(teamName); + + const teamScopedPrefix = `${teamName}:`; + for (const key of memberMessagesSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + memberMessagesSelectorCache.delete(key); + } + } +} + +export function getTeamMessageSelectorCacheSnapshotForTeam( + teamName: string +): TeamMessageSelectorCacheSnapshot { + const teamScopedPrefix = `${teamName}:`; + let memberMessagesSelectorCount = 0; + for (const key of memberMessagesSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + memberMessagesSelectorCount += 1; + } + } + + return { + hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName), + memberMessagesSelectorCount, + }; +} + +export function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number { + const aTime = Date.parse(a.timestamp); + const bTime = Date.parse(b.timestamp); + const aValid = Number.isFinite(aTime); + const bValid = Number.isFinite(bTime); + if (aValid && bValid && aTime !== bTime) { + return aTime - bTime; + } + if (aValid !== bValid) { + return aValid ? -1 : 1; + } + const aId = typeof a.messageId === 'string' ? a.messageId : ''; + const bId = typeof b.messageId === 'string' ? b.messageId : ''; + return aId.localeCompare(bId); +} + +export function getTeamMessagesCacheEntry( + state: TeamMessagesCacheState, + teamName: string +): TeamMessagesCacheEntry { + return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY; +} + +export function upsertOptimisticTeamMessage( + entry: TeamMessagesCacheEntry, + message: InboxMessage +): TeamMessagesCacheEntry { + const nextOptimistic = [...entry.optimisticMessages]; + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId.length > 0) { + const existingIndex = nextOptimistic.findIndex( + (candidate) => + typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId + ); + if (existingIndex >= 0) { + nextOptimistic[existingIndex] = { + ...nextOptimistic[existingIndex], + ...message, + }; + } else { + nextOptimistic.push(message); + } + } else { + nextOptimistic.push(message); + } + nextOptimistic.sort(compareInboxMessagesByTimestamp); + return { + ...entry, + optimisticMessages: nextOptimistic, + }; +} + +export function areInboxMessageArraysEquivalent( + left: readonly InboxMessage[], + right: readonly InboxMessage[] +): boolean { + if (left === right) return true; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftItem = left[index]; + const rightItem = right[index]; + if ( + leftItem.messageId !== rightItem.messageId || + leftItem.timestamp !== rightItem.timestamp || + leftItem.from !== rightItem.from || + leftItem.to !== rightItem.to || + leftItem.text !== rightItem.text || + leftItem.summary !== rightItem.summary || + leftItem.read !== rightItem.read || + leftItem.actionMode !== rightItem.actionMode || + leftItem.commentId !== rightItem.commentId || + leftItem.relayOfMessageId !== rightItem.relayOfMessageId || + leftItem.source !== rightItem.source || + leftItem.leadSessionId !== rightItem.leadSessionId || + leftItem.messageKind !== rightItem.messageKind || + JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null) + ) { + return false; + } + } + return true; +} + +export function pruneOptimisticMessages( + optimistic: readonly InboxMessage[], + canonical: readonly InboxMessage[] +): InboxMessage[] { + if (optimistic.length === 0) { + return []; + } + + const canonicalIds = new Set( + canonical + .map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : '')) + .filter((messageId) => messageId.length > 0) + ); + + return optimistic.filter((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + return !messageId || !canonicalIds.has(messageId); + }); +} + +export function getCanonicalHeadSlice( + canonicalMessages: readonly InboxMessage[], + headLength: number +): readonly InboxMessage[] { + if (headLength <= 0) { + return []; + } + return canonicalMessages.slice(0, headLength); +} + +export function extractRetainedCanonicalOlderTail( + canonicalMessages: readonly InboxMessage[], + freshHeadMessages: readonly InboxMessage[] +): InboxMessage[] | null { + if (canonicalMessages.length === 0) { + return []; + } + if (freshHeadMessages.length === 0) { + return null; + } + + const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message))); + let hasMessagesOutsideFreshHead = false; + for (const message of canonicalMessages) { + if (!freshHeadKeys.has(toMessageKey(message))) { + hasMessagesOutsideFreshHead = true; + break; + } + } + if (!hasMessagesOutsideFreshHead) { + return []; + } + + const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]); + const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey); + if (anchorIndex < 0) { + return null; + } + + return canonicalMessages + .slice(anchorIndex + 1) + .filter((message) => !freshHeadKeys.has(toMessageKey(message))); +} + +export function selectTeamMessages( + state: TeamMessagesCacheState, + teamName: string | null | undefined +): InboxMessage[] { + if (!teamName) { + return []; + } + + const entry = getTeamMessagesCacheEntry(state, teamName); + const cached = mergedMessagesSelectorCache.get(teamName); + if ( + cached?.canonicalRef === entry.canonicalMessages && + cached.optimisticRef === entry.optimisticMessages + ) { + return cached.result; + } + + const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages); + mergedMessagesSelectorCache.set(teamName, { + canonicalRef: entry.canonicalMessages, + optimisticRef: entry.optimisticMessages, + result, + }); + return result; +} + +export function selectMemberMessagesForTeamMember( + state: TeamMessagesCacheState, + teamName: string | null | undefined, + memberName: string | null | undefined +): InboxMessage[] { + if (!teamName || !memberName) { + return []; + } + + const messages = selectTeamMessages(state, teamName); + const cacheKey = `${teamName}:${memberName}`; + const cached = memberMessagesSelectorCache.get(cacheKey); + if (cached?.messagesRef === messages) { + return cached.result; + } + + const result = messages.filter( + (message) => message.from === memberName || message.to === memberName + ); + memberMessagesSelectorCache.set(cacheKey, { + messagesRef: messages, + result, + }); + return result; +} diff --git a/src/renderer/store/team/teamMessagesPanelModePersistence.ts b/src/renderer/store/team/teamMessagesPanelModePersistence.ts new file mode 100644 index 00000000..4a53086b --- /dev/null +++ b/src/renderer/store/team/teamMessagesPanelModePersistence.ts @@ -0,0 +1,29 @@ +import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; + +const MESSAGES_PANEL_MODE_STORAGE_KEY = 'team:messagesPanelMode'; +const DEFAULT_MESSAGES_PANEL_MODE: TeamMessagesPanelMode = 'sidebar'; +const VALID_MESSAGES_PANEL_MODES: ReadonlySet = new Set([ + 'sidebar', + 'inline', + 'bottom-sheet', + 'floating-composer', +]); + +export function loadPersistedMessagesPanelMode(): TeamMessagesPanelMode { + try { + const persisted = localStorage.getItem(MESSAGES_PANEL_MODE_STORAGE_KEY); + return VALID_MESSAGES_PANEL_MODES.has(persisted as TeamMessagesPanelMode) + ? (persisted as TeamMessagesPanelMode) + : DEFAULT_MESSAGES_PANEL_MODE; + } catch { + return DEFAULT_MESSAGES_PANEL_MODE; + } +} + +export function savePersistedMessagesPanelMode(mode: TeamMessagesPanelMode): void { + try { + localStorage.setItem(MESSAGES_PANEL_MODE_STORAGE_KEY, mode); + } catch { + // ignore - best-effort UI preference persistence + } +} diff --git a/src/renderer/store/team/teamPendingReplyWaits.ts b/src/renderer/store/team/teamPendingReplyWaits.ts new file mode 100644 index 00000000..e4c11b65 --- /dev/null +++ b/src/renderer/store/team/teamPendingReplyWaits.ts @@ -0,0 +1,45 @@ +const activeTeamPendingReplyWaitSourceIdsByTeam = new Map>(); + +export function hasActiveTeamPendingReplyWait(teamName: string): boolean { + return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0; +} + +export function getActiveTeamPendingReplyWaits(): Set { + return new Set( + Array.from(activeTeamPendingReplyWaitSourceIdsByTeam.entries()) + .filter(([, sourceIds]) => sourceIds.size > 0) + .map(([teamName]) => teamName) + ); +} + +export function clearAllPendingReplyRefreshWaits(): void { + activeTeamPendingReplyWaitSourceIdsByTeam.clear(); +} + +export function clearPendingReplyRefreshWaits(teamName: string): void { + activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); +} + +export function setPendingReplyRefreshEnabled( + teamName: string, + sourceId: string, + enabled: boolean +): boolean { + if (enabled) { + const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set(); + existing.add(sourceId); + activeTeamPendingReplyWaitSourceIdsByTeam.set(teamName, existing); + return true; + } + + const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName); + if (!existing) { + return false; + } + existing.delete(sourceId); + if (existing.size === 0) { + activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); + return false; + } + return true; +} diff --git a/src/renderer/store/team/teamProvisioningStateRules.ts b/src/renderer/store/team/teamProvisioningStateRules.ts new file mode 100644 index 00000000..3c574141 --- /dev/null +++ b/src/renderer/store/team/teamProvisioningStateRules.ts @@ -0,0 +1,44 @@ +import type { TeamProvisioningProgress } from '@shared/types'; + +type TeamProvisioningProgressState = TeamProvisioningProgress['state']; + +const ACTIVE_PROVISIONING_STATES: ReadonlySet = new Set([ + 'validating', + 'spawning', + 'configuring', + 'assembling', + 'finalizing', + 'verifying', +]); + +const TERMINAL_PROVISIONING_STATES: ReadonlySet = new Set([ + 'ready', + 'failed', + 'disconnected', + 'cancelled', +]); + +export function isActiveProvisioningState(state: TeamProvisioningProgressState): boolean { + return ACTIVE_PROVISIONING_STATES.has(state); +} + +export function isTerminalProvisioningState(state: TeamProvisioningProgressState): boolean { + return TERMINAL_PROVISIONING_STATES.has(state); +} + +export function shouldIgnoreProvisioningProgressRegression( + currentState: TeamProvisioningProgressState, + nextState: TeamProvisioningProgressState +): boolean { + if (currentState === 'ready') { + return nextState !== 'ready' && nextState !== 'disconnected'; + } + if ( + currentState === 'failed' || + currentState === 'cancelled' || + currentState === 'disconnected' + ) { + return nextState !== currentState; + } + return false; +} diff --git a/src/renderer/store/team/teamRefreshBurstDiagnostics.ts b/src/renderer/store/team/teamRefreshBurstDiagnostics.ts new file mode 100644 index 00000000..27205052 --- /dev/null +++ b/src/renderer/store/team/teamRefreshBurstDiagnostics.ts @@ -0,0 +1,48 @@ +interface TeamRefreshBurstDiagnostic { + windowStartedAt: number; + count: number; + lastWarnAt: number; +} + +const teamRefreshBurstDiagnostics = new Map(); + +export function hasTeamRefreshBurstDiagnostics(teamName: string): boolean { + return teamRefreshBurstDiagnostics.has(teamName); +} + +export function getTeamRefreshBurstDiagnosticForTests( + teamName: string +): TeamRefreshBurstDiagnostic | undefined { + const diagnostic = teamRefreshBurstDiagnostics.get(teamName); + return diagnostic ? { ...diagnostic } : undefined; +} + +export function noteTeamRefreshBurst( + teamName: string, + windowMs: number, + now = Date.now() +): number { + const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? { + windowStartedAt: now, + count: 0, + lastWarnAt: 0, + }; + + if (now - diagnostic.windowStartedAt > windowMs) { + diagnostic.windowStartedAt = now; + diagnostic.count = 0; + } + + diagnostic.count += 1; + + teamRefreshBurstDiagnostics.set(teamName, diagnostic); + return diagnostic.count; +} + +export function clearTeamRefreshBurstDiagnostics(teamName: string): void { + teamRefreshBurstDiagnostics.delete(teamName); +} + +export function clearAllTeamRefreshBurstDiagnostics(): void { + teamRefreshBurstDiagnostics.clear(); +} diff --git a/src/renderer/store/team/teamResolvedMembers.ts b/src/renderer/store/team/teamResolvedMembers.ts new file mode 100644 index 00000000..1f5b093f --- /dev/null +++ b/src/renderer/store/team/teamResolvedMembers.ts @@ -0,0 +1,533 @@ +import { getMemberColorByName } from '@shared/constants/memberColors'; +import { isLeadMember } from '@shared/utils/leadDetection'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskFinalForCompletionNotification, +} from '@shared/utils/teamTaskState'; + +import { selectTeamDataForName, type TeamDataSelectorState } from './teamDataSelectors'; + +import type { + MemberActivityMetaEntry, + ResolvedTeamMember, + TeamMemberActivityMeta, + TeamMemberSnapshot, + TeamSummary, + TeamViewSnapshot, +} from '@shared/types'; + +export interface ResolvedMemberSelectorState extends TeamDataSelectorState { + memberActivityMetaByTeam: Record; + teamByName?: Record; +} + +export interface ResolvedMemberSelectorCacheSnapshot { + hasResolvedMembersSelector: boolean; + resolvedMemberSelectorCount: number; +} + +const resolvedMembersSelectorCache = new Map< + string, + { + snapshotRef: TeamViewSnapshot['members']; + configMembersRef: TeamViewSnapshot['config']['members'] | undefined; + summaryRef: TeamSummary | undefined; + tasksRef: TeamViewSnapshot['tasks'] | undefined; + metaMembersRef: TeamMemberActivityMeta['members'] | undefined; + result: ResolvedTeamMember[]; + } +>(); +const resolvedMemberSelectorCache = new Map< + string, + { + snapshotMemberRef: TeamMemberSnapshot | undefined; + metaEntryRef: MemberActivityMetaEntry | undefined; + result: ResolvedTeamMember | null; + } +>(); + +export function clearResolvedMemberSelectorCaches(): void { + resolvedMembersSelectorCache.clear(); + resolvedMemberSelectorCache.clear(); +} + +export function clearResolvedMemberSelectorCachesForTeam(teamName: string): void { + resolvedMembersSelectorCache.delete(teamName); + + const teamScopedPrefix = `${teamName}:`; + for (const key of resolvedMemberSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + resolvedMemberSelectorCache.delete(key); + } + } +} + +export function getResolvedMemberSelectorCacheSnapshotForTeam( + teamName: string +): ResolvedMemberSelectorCacheSnapshot { + const teamScopedPrefix = `${teamName}:`; + let resolvedMemberSelectorCount = 0; + + for (const key of resolvedMemberSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + resolvedMemberSelectorCount += 1; + } + } + + return { + hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName), + resolvedMemberSelectorCount, + }; +} + +function resolveMemberStatus( + snapshot: TeamMemberSnapshot, + activity: MemberActivityMetaEntry | undefined +): ResolvedTeamMember['status'] { + if (activity?.latestAuthoredMessageSignalsTermination) { + return 'terminated'; + } + + if (!activity?.lastAuthoredMessageAt) { + return snapshot.currentTaskId ? 'active' : 'idle'; + } + + const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt); + if (Number.isNaN(ageMs)) { + return 'unknown'; + } + if (ageMs < 5 * 60 * 1000) { + return 'active'; + } + return 'idle'; +} + +function buildResolvedMembers( + snapshots: readonly TeamMemberSnapshot[], + meta: TeamMemberActivityMeta | undefined +): ResolvedTeamMember[] { + return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name])); +} + +function isDisplayableFallbackCurrentTask(task: TeamViewSnapshot['tasks'][number]): boolean { + return ( + task.status === 'in_progress' && + getTeamTaskWorkflowColumn(task) !== 'review' && + !isTeamTaskFinalForCompletionNotification(task) + ); +} + +function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMemberSnapshot[] { + const configMembers = snapshot.config.members ?? []; + const hasConfiguredTeammate = configMembers.some((member) => { + const name = member.name?.trim(); + return Boolean(name) && !member.removedAt && !isLeadMember(member); + }); + if (!hasConfiguredTeammate) { + return []; + } + + const seenNames = new Set(); + const fallbackMembers: TeamMemberSnapshot[] = []; + for (const member of configMembers) { + const name = member.name?.trim(); + if (!name) continue; + const key = name.toLowerCase(); + if (seenNames.has(key)) continue; + seenNames.add(key); + + const ownedTasks = snapshot.tasks.filter((task) => task.owner === name); + const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask); + fallbackMembers.push({ + name, + agentId: member.agentId, + currentTaskId: currentTask?.id ?? null, + taskCount: ownedTasks.length, + color: member.color ?? getMemberColorByName(name), + agentType: member.agentType, + role: member.role, + workflow: member.workflow, + isolation: member.isolation, + providerId: member.providerId, + providerBackendId: member.providerBackendId, + model: member.model, + effort: member.effort, + mcpPolicy: member.mcpPolicy, + selectedFastMode: member.fastMode, + cwd: member.cwd, + removedAt: member.removedAt, + }); + } + + return fallbackMembers; +} + +function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] { + if (!snapshot) { + return []; + } + const names = new Set(); + for (const member of snapshot.members) { + const name = member.name.trim(); + const key = name.toLowerCase(); + if (!name || key === 'user' || member.removedAt || isLeadMember(member)) { + continue; + } + names.add(key); + } + return Array.from(names).sort((left, right) => left.localeCompare(right)); +} + +function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { + return getActiveRawTeammateNameKeys(snapshot).length > 0; +} + +function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { + return Boolean(snapshot?.members.some((member) => member.removedAt)); +} + +function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { + return Boolean( + snapshot?.config.members?.some((member) => { + const name = member.name?.trim(); + return Boolean(name) && !member.removedAt && !isLeadMember(member); + }) + ); +} + +interface SummaryFallbackMemberSource { + name: string; + agentId?: string; + role?: string; + color?: string; + mcpPolicy?: TeamMemberSnapshot['mcpPolicy']; +} + +function normalizeSummaryTeammateName( + name: string | undefined | null, + leadName?: string +): string | null { + const trimmed = name?.trim(); + const normalizedName = trimmed?.toLowerCase(); + const normalizedLeadName = leadName?.trim().toLowerCase(); + if ( + !trimmed || + normalizedName === 'user' || + isLeadMember({ name: trimmed }) || + (normalizedLeadName && normalizedName === normalizedLeadName) + ) { + return null; + } + return trimmed; +} + +function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { + const seenNames = new Set(); + const sources: SummaryFallbackMemberSource[] = []; + for (const member of summary.members ?? []) { + const name = normalizeSummaryTeammateName(member.name, summary.leadName); + if (!name) { + continue; + } + const key = name.toLowerCase(); + if (seenNames.has(key)) { + continue; + } + seenNames.add(key); + sources.push({ + name, + agentId: member.agentId, + role: member.role, + color: member.color, + mcpPolicy: member.mcpPolicy, + }); + } + return sources; +} + +function shouldUseSummaryLaunchTeammateSources(summary: TeamSummary): boolean { + return ( + summary.partialLaunchFailure === true || + summary.teamLaunchState === 'partial_failure' || + summary.teamLaunchState === 'partial_pending' || + summary.teamLaunchState === 'partial_skipped' + ); +} + +function getSummaryLaunchTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { + if (!shouldUseSummaryLaunchTeammateSources(summary)) { + return []; + } + + const seenNames = new Set(); + const sources: SummaryFallbackMemberSource[] = []; + for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) { + const name = normalizeSummaryTeammateName(rawName, summary.leadName); + if (!name) { + continue; + } + const key = name.toLowerCase(); + if (seenNames.has(key)) { + continue; + } + seenNames.add(key); + sources.push({ name }); + } + return sources; +} + +function getSummaryLaunchTeammateNameKeys(summary: TeamSummary): string[] { + return getSummaryLaunchTeammateSources(summary) + .map((member) => member.name.toLowerCase()) + .sort((left, right) => left.localeCompare(right)); +} + +function getSummaryTeammateNameKeys(summary: TeamSummary): string[] { + const rosterNames = getSummaryRosterTeammateSources(summary) + .map((member) => member.name.toLowerCase()) + .sort((left, right) => left.localeCompare(right)); + if (rosterNames.length > 0) { + return rosterNames; + } + + const launchNames = getSummaryLaunchTeammateNameKeys(summary); + const expectedCount = summary.expectedMemberCount ?? summary.memberCount; + if (expectedCount > 0 && launchNames.length === expectedCount) { + return launchNames; + } + return []; +} + +function getSummaryFallbackTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { + return getSummaryRosterTeammateSources(summary); +} + +function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((name, index) => name === right[index]); +} + +function summaryConfirmsActiveTeammateRoster( + current: TeamViewSnapshot, + summary: TeamSummary +): boolean { + if ((summary.expectedMemberCount ?? summary.memberCount) <= 0) { + return false; + } + + const currentNames = getActiveRawTeammateNameKeys(current); + const summaryNames = getSummaryTeammateNameKeys(summary); + if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) { + return false; + } + + return areNameKeyListsEqual(summaryNames, currentNames); +} + +function buildSummaryFallbackMemberSnapshots( + snapshot: TeamViewSnapshot, + summary: TeamSummary | undefined +): TeamMemberSnapshot[] { + if (!summary) { + return []; + } + const summaryMembers = getSummaryFallbackTeammateSources(summary); + if (summaryMembers.length === 0) { + return []; + } + + const seenNames = new Set(); + const buildSnapshot = ( + name: string, + source?: Omit, + lead = false + ): TeamMemberSnapshot | null => { + const trimmed = name.trim(); + if (!trimmed) return null; + const key = trimmed.toLowerCase(); + if (seenNames.has(key)) return null; + seenNames.add(key); + + const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed); + const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask); + return { + name: trimmed, + agentId: source?.agentId, + currentTaskId: currentTask?.id ?? null, + taskCount: ownedTasks.length, + color: source?.color ?? getMemberColorByName(trimmed), + agentType: lead ? 'team-lead' : undefined, + role: source?.role ?? (lead ? 'Team Lead' : undefined), + mcpPolicy: source?.mcpPolicy, + }; + }; + + const teammates = summaryMembers.flatMap((member) => { + const item = buildSnapshot(member.name, member); + return item ? [item] : []; + }); + if (teammates.length === 0) { + return []; + } + + const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member)); + if (existingLead) { + return [existingLead, ...teammates]; + } + + const configuredLead = snapshot.config.members?.find( + (member) => !member.removedAt && isLeadMember(member) + ); + const leadName = configuredLead?.name?.trim() || summary.leadName?.trim(); + const lead = leadName + ? buildSnapshot( + leadName, + { + agentId: configuredLead?.agentId, + role: configuredLead?.role, + color: configuredLead?.color ?? summary.leadColor, + }, + true + ) + : null; + + return lead ? [lead, ...teammates] : teammates; +} + +function getResolvableMemberSnapshots( + snapshot: TeamViewSnapshot, + summary?: TeamSummary +): readonly TeamMemberSnapshot[] { + if ( + snapshot.members.length > 0 && + (hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot)) + ) { + return snapshot.members; + } + + const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot); + if (configFallbackMembers.length > 0) { + return configFallbackMembers; + } + + const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary); + if (summaryFallbackMembers.length > 0) { + return summaryFallbackMembers; + } + + return snapshot.members; +} + +export function shouldPreserveSelectedTeamSnapshot( + current: TeamViewSnapshot | null, + baseline: TeamViewSnapshot | null | undefined, + incoming: TeamViewSnapshot, + summary: TeamSummary | undefined +): boolean { + if (!current || !hasActiveRawTeammateRoster(current)) { + return false; + } + if ( + hasActiveRawTeammateRoster(incoming) || + hasRemovedRawMemberRoster(incoming) || + hasConfigTeammateRoster(incoming) + ) { + return false; + } + const currentNames = getActiveRawTeammateNameKeys(current); + if ( + current !== baseline && + !areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline)) + ) { + return true; + } + if (summary) { + return summaryConfirmsActiveTeammateRoster(current, summary); + } + + return false; +} + +function buildResolvedMember( + snapshot: TeamMemberSnapshot, + activity: MemberActivityMetaEntry | undefined +): ResolvedTeamMember { + return { + ...snapshot, + status: resolveMemberStatus(snapshot, activity), + messageCount: activity?.messageCountExact ?? 0, + lastActiveAt: activity?.lastAuthoredMessageAt ?? null, + }; +} + +export function selectResolvedMembersForTeamName( + state: ResolvedMemberSelectorState, + teamName: string | null | undefined +): ResolvedTeamMember[] { + const snapshot = selectTeamDataForName(state, teamName); + if (!snapshot || !teamName) { + return []; + } + + const meta = state.memberActivityMetaByTeam[teamName]; + const metaMembers = meta?.members; + const shouldUseMemberFallback = + snapshot.members.length === 0 || + (!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot)); + const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined; + const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined; + const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined; + const cached = resolvedMembersSelectorCache.get(teamName); + if ( + cached?.snapshotRef === snapshot.members && + cached.configMembersRef === configMembersRef && + cached.summaryRef === summaryRef && + cached.tasksRef === tasksRef && + cached.metaMembersRef === metaMembers + ) { + return cached.result; + } + + const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta); + resolvedMembersSelectorCache.set(teamName, { + snapshotRef: snapshot.members, + configMembersRef, + summaryRef, + tasksRef, + metaMembersRef: metaMembers, + result, + }); + return result; +} + +export function selectResolvedMemberForTeamName( + state: ResolvedMemberSelectorState, + teamName: string | null | undefined, + memberName: string | null | undefined +): ResolvedTeamMember | null { + const snapshot = selectTeamDataForName(state, teamName); + if (!snapshot || !teamName || !memberName) { + return null; + } + + const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find( + (member) => member.name === memberName + ); + if (!snapshotMember) { + return null; + } + + const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName]; + const cacheKey = `${teamName}:${memberName}`; + const cached = resolvedMemberSelectorCache.get(cacheKey); + if (cached?.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) { + return cached.result; + } + + const result = buildResolvedMember(snapshotMember, metaEntry); + resolvedMemberSelectorCache.set(cacheKey, { + snapshotMemberRef: snapshotMember, + metaEntryRef: metaEntry, + result, + }); + return result; +} diff --git a/src/renderer/store/team/teamScopedStateCleanup.ts b/src/renderer/store/team/teamScopedStateCleanup.ts new file mode 100644 index 00000000..a91e89ef --- /dev/null +++ b/src/renderer/store/team/teamScopedStateCleanup.ts @@ -0,0 +1,190 @@ +interface TeamMessagesLoadingEntry { + loadingHead: boolean; + loadingOlder: boolean; +} + +interface TeamScopedVisibleLoadingResetState< + TTeamMessagesEntry extends TeamMessagesLoadingEntry, +> { + teamMessagesByName: Record; + selectedTeamName: string | null; + selectedTeamLoading: boolean; + selectedTeamError: string | null; +} + +interface TeamScopedProvisioningRun { + teamName: string; +} + +type TeamScopedRecord = Record; + +interface TeamScopedStateRemovalState< + TProvisioningRun extends TeamScopedProvisioningRun = TeamScopedProvisioningRun, +> { + provisioningRuns: Record; + teamDataCacheByName: TeamScopedRecord; + teamAgentRuntimeByTeam: TeamScopedRecord; + teamMessagesByName: TeamScopedRecord; + memberActivityMetaByTeam: TeamScopedRecord; + provisioningSnapshotByTeam: TeamScopedRecord; + currentProvisioningRunIdByTeam: TeamScopedRecord; + currentRuntimeRunIdByTeam: TeamScopedRecord; + provisioningStartedAtFloorByTeam: TeamScopedRecord; + leadActivityByTeam: TeamScopedRecord; + leadContextByTeam: TeamScopedRecord; + activeTaskLogActivityByTeam: TeamScopedRecord; + activeToolsByTeam: TeamScopedRecord; + finishedVisibleByTeam: TeamScopedRecord; + toolHistoryByTeam: TeamScopedRecord; + memberSpawnStatusesByTeam: TeamScopedRecord; + memberSpawnSnapshotsByTeam: TeamScopedRecord; + provisioningErrorByTeam: TeamScopedRecord; +} + +type TeamScopedStateRemovalKey = keyof TeamScopedStateRemovalState; + +interface TeamScopedProgressTombstoneState { + currentProvisioningRunIdByTeam: Record; + currentRuntimeRunIdByTeam: Record; + ignoredProvisioningRunIds: Record; + ignoredRuntimeRunIds: Record; + provisioningStartedAtFloorByTeam: Record; +} + +export function collectTeamScopedVisibleLoadingResets< + TTeamMessagesEntry extends TeamMessagesLoadingEntry, +>( + state: TeamScopedVisibleLoadingResetState, + teamName: string +): Partial> { + const nextTeamMessagesEntry = state.teamMessagesByName[teamName]; + const nextTeamMessagesByName = + nextTeamMessagesEntry && + (nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder) + ? { + ...state.teamMessagesByName, + [teamName]: { + ...nextTeamMessagesEntry, + loadingHead: false, + loadingOlder: false, + } as TTeamMessagesEntry, + } + : null; + + const shouldResetSelectedSurface = + state.selectedTeamName === teamName && + (state.selectedTeamLoading || state.selectedTeamError != null); + + return { + ...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}), + ...(shouldResetSelectedSurface + ? { + selectedTeamLoading: false, + selectedTeamError: null, + } + : {}), + }; +} + +function omitTeamKey>( + record: TRecord, + teamName: string +): TRecord | null { + if (!(teamName in record)) { + return null; + } + const next = { ...record }; + delete next[teamName]; + return next; +} + +export function collectTeamScopedStateRemovals( + state: TState, + teamName: string +): Partial> { + const nextProvisioningRuns = Object.fromEntries( + Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName) + ) as TState['provisioningRuns']; + const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName); + const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName); + const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName); + const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName); + const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName); + const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName); + const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName); + const nextProvisioningStartedAtFloor = omitTeamKey( + state.provisioningStartedAtFloorByTeam, + teamName + ); + const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName); + const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName); + const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName); + const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName); + const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName); + const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName); + const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName); + const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName); + const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName); + + return { + ...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length + ? { provisioningRuns: nextProvisioningRuns } + : {}), + ...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}), + ...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}), + ...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}), + ...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}), + ...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}), + ...(nextCurrentProvisioningRunId + ? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId } + : {}), + ...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}), + ...(nextProvisioningStartedAtFloor + ? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor } + : {}), + ...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}), + ...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}), + ...(nextActiveTaskLogActivity + ? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity } + : {}), + ...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}), + ...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}), + ...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}), + ...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}), + ...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}), + ...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}), + }; +} + +export function buildTeamScopedProgressTombstones( + state: TState, + teamName: string, + floor: string +): Pick< + TState, + 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' +> { + const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds }; + const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds }; + + const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName]; + const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName]; + if (currentProvisioningRunId) { + nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName; + } + if (currentRuntimeRunId) { + nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName; + } + + return { + ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds, + ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, + provisioningStartedAtFloorByTeam: { + ...state.provisioningStartedAtFloorByTeam, + [teamName]: floor, + }, + } as Pick< + TState, + 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' + >; +} diff --git a/src/renderer/store/team/teamSnapshotStructuralSharing.ts b/src/renderer/store/team/teamSnapshotStructuralSharing.ts new file mode 100644 index 00000000..f90500e3 --- /dev/null +++ b/src/renderer/store/team/teamSnapshotStructuralSharing.ts @@ -0,0 +1,61 @@ +import type { TeamViewSnapshot } from '@shared/types'; + +function isPlainObject(value: unknown): value is Record { + if (value == null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +export function structurallySharePlainValue(previous: T, next: T): T { + if (Object.is(previous, next)) { + return previous; + } + + if (Array.isArray(previous) && Array.isArray(next)) { + let changed = previous.length !== next.length; + const result = next.map((nextItem, index) => { + const sharedItem = structurallySharePlainValue(previous[index], nextItem); + if (!Object.is(sharedItem, previous[index])) { + changed = true; + } + return sharedItem; + }); + return changed ? (result as T) : previous; + } + + if (isPlainObject(previous) && isPlainObject(next)) { + const previousRecord = previous as Record; + const nextRecord = next as Record; + const previousKeys = Object.keys(previousRecord); + const nextKeys = Object.keys(nextRecord); + let changed = previousKeys.length !== nextKeys.length; + const result: Record = {}; + + for (const key of nextKeys) { + if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) { + changed = true; + } + const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]); + if (!Object.is(sharedValue, previousRecord[key])) { + changed = true; + } + result[key] = sharedValue; + } + + return changed ? (result as T) : previous; + } + + return next; +} + +export function structurallyShareTeamSnapshot( + previous: TeamViewSnapshot | null | undefined, + next: TeamViewSnapshot +): TeamViewSnapshot { + if (!previous) { + return next; + } + return structurallySharePlainValue(previous, next); +} diff --git a/src/renderer/store/team/teamToolApprovalSettings.ts b/src/renderer/store/team/teamToolApprovalSettings.ts new file mode 100644 index 00000000..8edadb0c --- /dev/null +++ b/src/renderer/store/team/teamToolApprovalSettings.ts @@ -0,0 +1,42 @@ +import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; + +import type { ToolApprovalSettings } from '@shared/types'; + +const VALID_TIMEOUT_ACTIONS: ReadonlySet = new Set([ + 'allow', + 'deny', + 'wait', +]); + +export function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings { + if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS; + try { + const parsed = JSON.parse(raw) as Record; + const d = DEFAULT_TOOL_APPROVAL_SETTINGS; + return { + autoAllowAll: typeof parsed.autoAllowAll === 'boolean' ? parsed.autoAllowAll : d.autoAllowAll, + autoAllowFileEdits: + typeof parsed.autoAllowFileEdits === 'boolean' + ? parsed.autoAllowFileEdits + : d.autoAllowFileEdits, + autoAllowSafeBash: + typeof parsed.autoAllowSafeBash === 'boolean' + ? parsed.autoAllowSafeBash + : d.autoAllowSafeBash, + timeoutAction: + typeof parsed.timeoutAction === 'string' && + VALID_TIMEOUT_ACTIONS.has(parsed.timeoutAction as ToolApprovalSettings['timeoutAction']) + ? (parsed.timeoutAction as ToolApprovalSettings['timeoutAction']) + : d.timeoutAction, + timeoutSeconds: + typeof parsed.timeoutSeconds === 'number' && + Number.isFinite(parsed.timeoutSeconds) && + parsed.timeoutSeconds >= 5 && + parsed.timeoutSeconds <= 300 + ? parsed.timeoutSeconds + : d.timeoutSeconds, + }; + } catch { + return DEFAULT_TOOL_APPROVAL_SETTINGS; + } +} diff --git a/test/main/ipc/teams/teamMessageNotificationScanner.test.ts b/test/main/ipc/teams/teamMessageNotificationScanner.test.ts new file mode 100644 index 00000000..cc8339c9 --- /dev/null +++ b/test/main/ipc/teams/teamMessageNotificationScanner.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + TeamMessageNotificationScanner, + type TeamNotificationMessage, +} from '../../../../src/main/ipc/teams/teamMessageNotificationScanner'; + +import type { RateLimitAutoResumePlan } from '../../../../src/main/services/team/AutoResumeService'; +import type { TeamNotificationPayload } from '../../../../src/main/utils/teamNotificationBuilder'; + +function createMessage(overrides: Partial = {}): TeamNotificationMessage { + return { + from: 'team-lead', + text: "You've hit your limit. Resets in 5 minutes.", + timestamp: '2026-04-17T12:00:00.000Z', + messageId: 'msg-1', + source: 'lead_session', + leadSessionId: 'sess-live', + ...overrides, + }; +} + +describe('TeamMessageNotificationScanner', () => { + const notificationSink = { + addTeamNotification: vi.fn<() => Promise>(), + }; + const autoResumeSink = { + handleRateLimitMessage: vi.fn(), + }; + let autoResumeEnabled = true; + + beforeEach(() => { + notificationSink.addTeamNotification.mockReset(); + notificationSink.addTeamNotification.mockResolvedValue(null); + autoResumeSink.handleRateLimitMessage.mockReset(); + autoResumeEnabled = true; + }); + + function createScanner(options?: { + isRateLimit?: (text: string) => boolean; + isApiError?: (text: string) => boolean; + planAutoResume?: (input: { + enabled: boolean; + canAutoResume: boolean; + messageText: string; + observedAt: Date; + messageTimestamp?: Date; + }) => RateLimitAutoResumePlan; + }): TeamMessageNotificationScanner { + return new TeamMessageNotificationScanner({ + configReader: { + getConfig: () => ({ notifications: { autoResumeOnRateLimit: autoResumeEnabled } }), + }, + notificationSink, + autoResumeSink, + now: () => new Date('2026-04-17T12:02:00.000Z'), + formatClockTime: () => '12:05', + isRateLimit: options?.isRateLimit ?? ((text) => text.includes('limit')), + isApiError: options?.isApiError ?? ((text) => text.startsWith('API Error:')), + planAutoResume: + options?.planAutoResume ?? + ((input) => + input.enabled && input.canAutoResume + ? { + kind: 'scheduled', + resetTime: new Date('2026-04-17T12:05:00.000Z'), + delayMs: 180_000, + fireAtMs: Date.parse('2026-04-17T12:05:30.000Z'), + rawDelayMs: 180_000, + } + : { kind: 'manual', reason: 'disabled' }), + }); + } + + it('notifies and schedules auto-resume for a live lead rate-limit message', () => { + const scanner = createScanner(); + + scanner.checkRateLimitMessages([createMessage()], { + teamName: 'my-team', + teamDisplayName: 'My Team', + projectPath: '/tmp/project', + teamIsAlive: true, + currentLeadSessionId: 'sess-live', + }); + + expect(notificationSink.addTeamNotification).toHaveBeenCalledWith( + expect.objectContaining({ + teamEventType: 'rate_limit', + teamName: 'my-team', + teamDisplayName: 'My Team', + from: 'team-lead', + summary: 'Rate limit', + body: 'Auto-resume scheduled at 12:05', + dedupeKey: 'rate-limit:my-team:msg-1', + target: { kind: 'member', teamName: 'my-team', memberName: 'team-lead', focus: 'logs' }, + projectPath: '/tmp/project', + }) + ); + expect(autoResumeSink.handleRateLimitMessage).toHaveBeenCalledWith( + 'my-team', + "You've hit your limit. Resets in 5 minutes.", + new Date('2026-04-17T12:02:00.000Z'), + new Date('2026-04-17T12:00:00.000Z') + ); + }); + + it('dedupes notification storage but still re-evaluates auto-resume later', () => { + const scanner = createScanner(); + const context = { + teamName: 'my-team', + teamDisplayName: 'My Team', + teamIsAlive: true, + currentLeadSessionId: 'sess-live', + }; + + autoResumeEnabled = false; + scanner.checkRateLimitMessages([createMessage()], context); + expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1); + expect(autoResumeSink.handleRateLimitMessage).not.toHaveBeenCalled(); + + autoResumeEnabled = true; + scanner.checkRateLimitMessages([createMessage()], context); + + expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1); + expect(autoResumeSink.handleRateLimitMessage).toHaveBeenCalledTimes(1); + }); + + it('does not schedule auto-resume from an older lead session', () => { + const scanner = createScanner(); + + scanner.checkRateLimitMessages( + [createMessage({ leadSessionId: 'sess-old', messageId: 'old-session' })], + { + teamName: 'my-team', + teamDisplayName: 'My Team', + teamIsAlive: true, + currentLeadSessionId: 'sess-live', + } + ); + + expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1); + expect(autoResumeSink.handleRateLimitMessage).not.toHaveBeenCalled(); + }); + + it('sends API-error notifications while leaving rate limits to the rate-limit path', () => { + const scanner = createScanner({ + isRateLimit: (text) => text.includes('429'), + isApiError: (text) => text.startsWith('API Error:'), + }); + + scanner.checkApiErrorMessages( + [ + createMessage({ text: 'API Error: 429 rate limited', messageId: 'rate-limit-api' }), + createMessage({ text: 'API Error: 500 server failed', messageId: 'api-500' }), + ], + { + teamName: 'my-team', + teamDisplayName: 'My Team', + } + ); + + expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1); + expect(notificationSink.addTeamNotification).toHaveBeenCalledWith( + expect.objectContaining({ + teamEventType: 'api_error', + summary: 'API Error 500', + dedupeKey: 'api-error:my-team:api-500', + }) + ); + }); +}); diff --git a/test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts b/test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts new file mode 100644 index 00000000..c940ce5c --- /dev/null +++ b/test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; + +import { + areTeamAgentRuntimeEntriesEqual, + areTeamAgentRuntimeResourceSamplesEqual, + areTeamAgentRuntimeSnapshotsEqual, +} from '../../../src/renderer/store/team/teamAgentRuntimeSnapshotEquality'; + +import type { + TeamAgentRuntimeEntry, + TeamAgentRuntimeResourceSample, + TeamAgentRuntimeSnapshot, +} from '../../../src/shared/types'; + +function createResourceSample( + overrides: Partial = {} +): TeamAgentRuntimeResourceSample { + return { + timestamp: '2026-05-22T10:00:00.000Z', + cpuPercent: 4, + rssBytes: 1024, + primaryCpuPercent: 3, + primaryRssBytes: 768, + childCpuPercent: 1, + childRssBytes: 256, + processCount: 2, + runtimeLoadScope: 'process-tree', + runtimeLoadTruncated: false, + pidSource: 'agent_process_table', + pid: 111, + runtimePid: 222, + ...overrides, + }; +} + +function createRuntimeEntry(overrides: Partial = {}): TeamAgentRuntimeEntry { + return { + memberName: 'alice', + alive: true, + restartable: true, + backendType: 'process', + providerId: 'codex', + providerBackendId: 'codex-native', + laneId: 'lane-1', + laneKind: 'primary', + pid: 111, + runtimeModel: 'gpt-5.3-codex', + cwd: '/tmp/old', + rssBytes: 1024, + cpuPercent: 4, + primaryCpuPercent: 3, + primaryRssBytes: 768, + childCpuPercent: 1, + childRssBytes: 256, + processCount: 2, + runtimeLoadScope: 'process-tree', + runtimeLoadTruncated: false, + resourceHistory: [createResourceSample()], + livenessKind: 'confirmed_bootstrap', + pidSource: 'agent_process_table', + processCommand: 'codex', + paneId: '%1', + panePid: 333, + paneCurrentCommand: 'node', + runtimePid: 222, + runtimeSessionId: 'runtime-session-1', + runtimeLeaseExpiresAt: '2026-05-22T10:10:00.000Z', + runtimeLastSeenAt: '2026-05-22T10:00:00.000Z', + historicalBootstrapConfirmed: true, + runtimeDiagnostic: 'Ready', + runtimeDiagnosticSeverity: 'info', + diagnostics: ['healthy'], + updatedAt: '2026-05-22T10:00:00.000Z', + ...overrides, + }; +} + +function createRuntimeSnapshot( + overrides: Partial = {} +): TeamAgentRuntimeSnapshot { + return { + teamName: 'my-team', + updatedAt: '2026-05-22T10:00:00.000Z', + runId: 'run-1', + providerBackendId: 'codex-native', + fastMode: 'inherit', + members: { + alice: createRuntimeEntry(), + }, + ...overrides, + }; +} + +describe('teamAgentRuntimeSnapshotEquality', () => { + it('compares runtime resource samples by visible process metrics', () => { + expect( + areTeamAgentRuntimeResourceSamplesEqual(createResourceSample(), createResourceSample()) + ).toBe(true); + expect( + areTeamAgentRuntimeResourceSamplesEqual( + createResourceSample(), + createResourceSample({ cpuPercent: 5 }) + ) + ).toBe(false); + expect(areTeamAgentRuntimeResourceSamplesEqual(null, createResourceSample())).toBe(false); + }); + + it('ignores runtime entry fields that do not currently affect equality', () => { + const left = createRuntimeEntry({ + cwd: '/tmp/old', + runtimeLeaseExpiresAt: '2026-05-22T10:10:00.000Z', + updatedAt: '2026-05-22T10:00:00.000Z', + }); + const right = createRuntimeEntry({ + cwd: '/tmp/new', + runtimeLeaseExpiresAt: '2026-05-22T10:20:00.000Z', + updatedAt: '2026-05-22T10:05:00.000Z', + }); + + expect(areTeamAgentRuntimeEntriesEqual(left, right)).toBe(true); + }); + + it('detects visible runtime entry field changes', () => { + expect( + areTeamAgentRuntimeEntriesEqual( + createRuntimeEntry(), + createRuntimeEntry({ runtimeDiagnosticSeverity: 'warning' }) + ) + ).toBe(false); + expect( + areTeamAgentRuntimeEntriesEqual( + createRuntimeEntry(), + createRuntimeEntry({ resourceHistory: [createResourceSample({ rssBytes: 2048 })] }) + ) + ).toBe(false); + }); + + it('compares diagnostics and resource history arrays in stable order', () => { + expect( + areTeamAgentRuntimeEntriesEqual( + createRuntimeEntry({ diagnostics: ['a', 'b'] }), + createRuntimeEntry({ diagnostics: ['b', 'a'] }) + ) + ).toBe(false); + expect( + areTeamAgentRuntimeEntriesEqual( + createRuntimeEntry({ + resourceHistory: [ + createResourceSample({ timestamp: '2026-05-22T10:00:00.000Z' }), + createResourceSample({ timestamp: '2026-05-22T10:01:00.000Z' }), + ], + }), + createRuntimeEntry({ + resourceHistory: [ + createResourceSample({ timestamp: '2026-05-22T10:01:00.000Z' }), + createResourceSample({ timestamp: '2026-05-22T10:00:00.000Z' }), + ], + }) + ) + ).toBe(false); + }); + + it('compares runtime snapshots by team, run id, and semantic member entries', () => { + expect(areTeamAgentRuntimeSnapshotsEqual(createRuntimeSnapshot(), createRuntimeSnapshot())).toBe( + true + ); + expect( + areTeamAgentRuntimeSnapshotsEqual( + createRuntimeSnapshot(), + createRuntimeSnapshot({ runId: 'run-2' }) + ) + ).toBe(false); + expect( + areTeamAgentRuntimeSnapshotsEqual( + createRuntimeSnapshot(), + createRuntimeSnapshot({ + members: { + alice: createRuntimeEntry(), + bob: createRuntimeEntry({ memberName: 'bob' }), + }, + }) + ) + ).toBe(false); + }); + + it('ignores snapshot metadata fields that do not currently affect equality', () => { + const left = createRuntimeSnapshot({ + updatedAt: '2026-05-22T10:00:00.000Z', + providerBackendId: 'codex-native', + fastMode: 'inherit', + }); + const right = createRuntimeSnapshot({ + updatedAt: '2026-05-22T10:05:00.000Z', + providerBackendId: 'api', + fastMode: 'on', + }); + + expect(areTeamAgentRuntimeSnapshotsEqual(left, right)).toBe(true); + }); + + it('returns false when there is no previous runtime snapshot', () => { + expect(areTeamAgentRuntimeSnapshotsEqual(undefined, createRuntimeSnapshot())).toBe(false); + }); +}); diff --git a/test/renderer/store/teamDataRefreshTimestamps.test.ts b/test/renderer/store/teamDataRefreshTimestamps.test.ts new file mode 100644 index 00000000..5c8bf9de --- /dev/null +++ b/test/renderer/store/teamDataRefreshTimestamps.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAllLastResolvedTeamDataRefreshes, + clearLastResolvedTeamDataRefreshAt, + getLastResolvedTeamDataRefreshAt, + hasLastResolvedTeamDataRefreshAt, + recordLastResolvedTeamDataRefresh, +} from '../../../src/renderer/store/team/teamDataRefreshTimestamps'; + +afterEach(() => { + vi.useRealTimers(); + clearAllLastResolvedTeamDataRefreshes(); +}); + +describe('teamDataRefreshTimestamps', () => { + it('returns undefined for teams without a recorded refresh', () => { + expect(getLastResolvedTeamDataRefreshAt('my-team')).toBeUndefined(); + expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(false); + }); + + it('records explicit refresh timestamps by team', () => { + recordLastResolvedTeamDataRefresh('my-team', 100); + recordLastResolvedTeamDataRefresh('other-team', 200); + + expect(getLastResolvedTeamDataRefreshAt('my-team')).toBe(100); + expect(getLastResolvedTeamDataRefreshAt('other-team')).toBe(200); + expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(true); + }); + + it('uses Date.now by default to preserve current call-site behavior', () => { + vi.setSystemTime(new Date('2026-05-22T06:30:00.000Z')); + + recordLastResolvedTeamDataRefresh('my-team'); + + expect(getLastResolvedTeamDataRefreshAt('my-team')).toBe( + new Date('2026-05-22T06:30:00.000Z').getTime() + ); + }); + + it('clears one team timestamp without touching other teams', () => { + recordLastResolvedTeamDataRefresh('my-team', 100); + recordLastResolvedTeamDataRefresh('other-team', 200); + + clearLastResolvedTeamDataRefreshAt('my-team'); + + expect(getLastResolvedTeamDataRefreshAt('my-team')).toBeUndefined(); + expect(getLastResolvedTeamDataRefreshAt('other-team')).toBe(200); + }); + + it('clears all recorded timestamps', () => { + recordLastResolvedTeamDataRefresh('my-team', 100); + recordLastResolvedTeamDataRefresh('other-team', 200); + + clearAllLastResolvedTeamDataRefreshes(); + + expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(false); + expect(hasLastResolvedTeamDataRefreshAt('other-team')).toBe(false); + }); +}); diff --git a/test/renderer/store/teamDataRequestKeys.test.ts b/test/renderer/store/teamDataRequestKeys.test.ts new file mode 100644 index 00000000..9409c541 --- /dev/null +++ b/test/renderer/store/teamDataRequestKeys.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + getFullTeamDataRequestKey, + getTeamDataRequestKey, + getTeamDataRequestLabel, + getTeamDataSnapshotMode, + getThinTeamDataRequestKey, + isTeamDataRequestKeyForTeam, + normalizeTeamGetDataOptions, + shouldIncludeMemberBranches, +} from '../../../src/renderer/store/team/teamDataRequestKeys'; + +describe('teamDataRequestKeys', () => { + it('normalizes only the thin snapshot option and treats all other inputs as full snapshots', () => { + expect(normalizeTeamGetDataOptions()).toBeUndefined(); + expect(normalizeTeamGetDataOptions({})).toBeUndefined(); + expect(normalizeTeamGetDataOptions({ includeMemberBranches: true })).toBeUndefined(); + expect(normalizeTeamGetDataOptions({ includeMemberBranches: false })).toEqual({ + includeMemberBranches: false, + }); + + expect(shouldIncludeMemberBranches()).toBe(true); + expect(shouldIncludeMemberBranches({ includeMemberBranches: true })).toBe(true); + expect(shouldIncludeMemberBranches({ includeMemberBranches: false })).toBe(false); + }); + + it('maps normalized request options to stable full and thin snapshot modes', () => { + expect(getTeamDataSnapshotMode()).toBe('full'); + expect(getTeamDataSnapshotMode({ includeMemberBranches: true })).toBe('full'); + expect(getTeamDataSnapshotMode({ includeMemberBranches: false })).toBe('thin'); + }); + + it('builds request keys that preserve the existing null-separated team/mode contract', () => { + expect(getTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:full'); + expect(getTeamDataRequestKey('my-team', { includeMemberBranches: true })).toBe( + 'my-team\u0000mode:full' + ); + expect(getTeamDataRequestKey('my-team', { includeMemberBranches: false })).toBe( + 'my-team\u0000mode:thin' + ); + expect(getFullTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:full'); + expect(getThinTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:thin'); + }); + + it('builds timeout/debug labels from the same normalized mode policy', () => { + expect(getTeamDataRequestLabel('my-team')).toBe('team:getData(my-team,mode=full)'); + expect(getTeamDataRequestLabel('my-team', { includeMemberBranches: false })).toBe( + 'team:getData(my-team,mode=thin)' + ); + }); + + it('matches request keys only for the exact team prefix boundary', () => { + expect(isTeamDataRequestKeyForTeam('my-team\u0000mode:full', 'my-team')).toBe(true); + expect(isTeamDataRequestKeyForTeam('my-team-extra\u0000mode:full', 'my-team')).toBe(false); + expect(isTeamDataRequestKeyForTeam('my-team', 'my-team')).toBe(false); + }); +}); diff --git a/test/renderer/store/teamDataSelectors.test.ts b/test/renderer/store/teamDataSelectors.test.ts new file mode 100644 index 00000000..049081cf --- /dev/null +++ b/test/renderer/store/teamDataSelectors.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + selectTeamDataForName, + selectTeamIsAliveForName, + selectTeamMemberSnapshotsForName, + selectTeamTasksForName, + type TeamDataSelectorState, +} from '../../../src/renderer/store/team/teamDataSelectors'; + +import type { TeamViewSnapshot } from '../../../src/shared/types'; + +function createSnapshot(overrides: Partial = {}): TeamViewSnapshot { + return { + teamName: 'my-team', + config: { name: 'My Team' }, + members: [], + tasks: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + ...overrides, + }; +} + +function createState(overrides: Partial = {}): TeamDataSelectorState { + return { + teamDataCacheByName: {}, + selectedTeamName: null, + selectedTeamData: null, + ...overrides, + }; +} + +describe('teamDataSelectors', () => { + it('returns null when no team name is selected or cached', () => { + const state = createState(); + + expect(selectTeamDataForName(state, null)).toBeNull(); + expect(selectTeamDataForName(state, undefined)).toBeNull(); + expect(selectTeamDataForName(state, 'missing-team')).toBeNull(); + }); + + it('prefers selected team data over cached data for the active team', () => { + const cachedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: false }); + const selectedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: true }); + const state = createState({ + selectedTeamName: 'my-team', + selectedTeamData: selectedSnapshot, + teamDataCacheByName: { + 'my-team': cachedSnapshot, + }, + }); + + expect(selectTeamDataForName(state, 'my-team')).toBe(selectedSnapshot); + }); + + it('falls back to cached team data outside the selected snapshot', () => { + const cachedSnapshot = createSnapshot({ teamName: 'cached-team', isAlive: true }); + const state = createState({ + selectedTeamName: 'other-team', + selectedTeamData: createSnapshot({ teamName: 'other-team' }), + teamDataCacheByName: { + 'cached-team': cachedSnapshot, + }, + }); + + expect(selectTeamDataForName(state, 'cached-team')).toBe(cachedSnapshot); + }); + + it('returns stable empty arrays and scalar fields from team snapshots', () => { + const task = { id: 'task-1', subject: 'Build', status: 'pending' as const }; + const member = { name: 'alice', role: 'developer', currentTaskId: null, taskCount: 0 }; + const state = createState({ + teamDataCacheByName: { + 'my-team': createSnapshot({ + members: [member], + tasks: [task], + isAlive: true, + }), + }, + }); + + expect(selectTeamMemberSnapshotsForName(state, 'my-team')).toEqual([member]); + expect(selectTeamTasksForName(state, 'my-team')).toEqual([task]); + expect(selectTeamIsAliveForName(state, 'my-team')).toBe(true); + + expect(selectTeamMemberSnapshotsForName(state, 'missing-team')).toBe( + selectTeamMemberSnapshotsForName(state, 'missing-team') + ); + expect(selectTeamTasksForName(state, 'missing-team')).toBe( + selectTeamTasksForName(state, 'missing-team') + ); + expect(selectTeamIsAliveForName(state, 'missing-team')).toBeUndefined(); + }); +}); diff --git a/test/renderer/store/teamErrorPolicies.test.ts b/test/renderer/store/teamErrorPolicies.test.ts new file mode 100644 index 00000000..df8c670f --- /dev/null +++ b/test/renderer/store/teamErrorPolicies.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + mapReviewError, + mapSendMessageError, + shouldInvalidateCachedTeamDataForError, +} from '../../../src/renderer/store/team/teamErrorPolicies'; +import { IpcError } from '../../../src/renderer/utils/unwrapIpc'; + +describe('teamErrorPolicies', () => { + it('maps send-message verification races to the user-facing retry copy', () => { + expect(mapSendMessageError(new Error('Failed to verify inbox write for message-1'))).toBe( + 'Message was written but not verified (race). Please try again.' + ); + expect( + mapSendMessageError( + new IpcError('team:sendMessage', 'Failed to verify inbox write after timeout') + ) + ).toBe('Message was written but not verified (race). Please try again.'); + }); + + it('maps send-message errors to original messages or fallback copy', () => { + expect(mapSendMessageError(new Error('Transport failed'))).toBe('Transport failed'); + expect(mapSendMessageError('plain failure')).toBe('Failed to send message'); + expect(mapSendMessageError(null)).toBe('Failed to send message'); + }); + + it('maps review verification conflicts to the user-facing conflict copy', () => { + expect(mapReviewError(new Error('Task status update verification failed for task-1'))).toBe( + 'Failed to update task status (possible agent conflict).' + ); + expect( + mapReviewError( + new IpcError('team:updateKanban', 'Task status update verification failed after retry') + ) + ).toBe('Failed to update task status (possible agent conflict).'); + }); + + it('maps review errors to original messages or fallback copy', () => { + expect(mapReviewError(new Error('Review failed'))).toBe('Review failed'); + expect(mapReviewError({ message: 'ignored non-error shape' })).toBe( + 'Failed to perform review action' + ); + expect(mapReviewError(undefined)).toBe('Failed to perform review action'); + }); + + it('invalidates cached team data for draft and missing-team errors', () => { + expect(shouldInvalidateCachedTeamDataForError('my-team', 'TEAM_DRAFT')).toBe(true); + expect( + shouldInvalidateCachedTeamDataForError('my-team', 'Cannot read team: TEAM_DRAFT') + ).toBe(true); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team not found: my-team')).toBe(true); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team config not found')).toBe(true); + }); + + it('does not invalidate cached team data for unrelated or other-team errors', () => { + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Network timeout')).toBe(false); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team not found: other-team')).toBe( + false + ); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team config missing')).toBe(false); + }); +}); diff --git a/test/renderer/store/teamGraphLayout.test.ts b/test/renderer/store/teamGraphLayout.test.ts new file mode 100644 index 00000000..515bdd50 --- /dev/null +++ b/test/renderer/store/teamGraphLayout.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { + areTeamGraphSlotAssignmentsEqual, + getDefaultTeamGraphSlotAssignmentsForMembers, + isTeamGraphSlotPersistenceDisabled, + migrateStableSlotAssignmentsForMembers, + normalizeLegacySixRowOrbitAssignments, + normalizeTeamGraphGridOwnerOrder, + normalizeTeamGraphSlotAssignmentsForVisibleOwners, + pruneTeamGraphSlotAssignmentsForVisibleOwners, + seedStableSlotAssignmentsForMembers, +} from '../../../src/renderer/store/team/teamGraphLayout'; + +describe('teamGraphLayout', () => { + it('migrates legacy name-keyed assignments to stable owner ids', () => { + const migrated = migrateStableSlotAssignmentsForMembers( + { + alice: { ringIndex: 0, sectorIndex: 1 }, + }, + [{ name: 'alice', agentId: 'agent-a' }] + ); + + expect(migrated.changed).toBe(true); + expect(migrated.assignments).toEqual({ + 'agent-a': { ringIndex: 0, sectorIndex: 1 }, + }); + }); + + it('drops stale name-keyed assignments when stable assignments already exist', () => { + const migrated = migrateStableSlotAssignmentsForMembers( + { + alice: { ringIndex: 0, sectorIndex: 1 }, + 'agent-a': { ringIndex: 0, sectorIndex: 2 }, + }, + [{ name: 'alice', agentId: 'agent-a' }] + ); + + expect(migrated.changed).toBe(true); + expect(migrated.assignments).toEqual({ + 'agent-a': { ringIndex: 0, sectorIndex: 2 }, + }); + }); + + it('seeds default assignments only when no visible owner has a persisted assignment', () => { + const seeded = seedStableSlotAssignmentsForMembers( + { unrelated: { ringIndex: 4, sectorIndex: 0 } }, + [ + { name: 'alice', agentId: 'agent-a' }, + { name: 'bob', agentId: 'agent-b' }, + ] + ); + + expect(seeded.changed).toBe(true); + expect(Object.keys(seeded.assignments)).toEqual(['unrelated', 'agent-a', 'agent-b']); + expect(seeded.assignments['agent-a']).toEqual({ ringIndex: 0, sectorIndex: 0 }); + expect(seeded.assignments['agent-b']).toEqual({ ringIndex: 0, sectorIndex: 1 }); + + const preserved = seedStableSlotAssignmentsForMembers(seeded.assignments, [ + { name: 'alice', agentId: 'agent-a' }, + { name: 'bob', agentId: 'agent-b' }, + ]); + expect(preserved.changed).toBe(false); + expect(preserved.assignments).toBe(seeded.assignments); + }); + + it('normalizes six-owner legacy two-row orbit assignments', () => { + const ownerIds = ['a', 'b', 'c', 'd', 'e', 'f']; + const normalized = normalizeLegacySixRowOrbitAssignments( + { + a: { ringIndex: 0, sectorIndex: 0 }, + b: { ringIndex: 0, sectorIndex: 4 }, + c: { ringIndex: 1, sectorIndex: 2 }, + d: { ringIndex: 1, sectorIndex: 0 }, + }, + ownerIds + ); + + expect(normalized).toEqual({ + a: { ringIndex: 0, sectorIndex: 0 }, + b: { ringIndex: 2, sectorIndex: 1 }, + c: { ringIndex: 2, sectorIndex: 2 }, + d: { ringIndex: 2, sectorIndex: 0 }, + }); + }); + + it('normalizes and prunes assignments to visible owners', () => { + const normalized = normalizeTeamGraphSlotAssignmentsForVisibleOwners( + { + a: { ringIndex: 0, sectorIndex: 0 }, + hidden: { ringIndex: 4, sectorIndex: 4 }, + }, + ['a'] + ); + + expect(normalized).toEqual({ a: { ringIndex: 0, sectorIndex: 0 } }); + expect(pruneTeamGraphSlotAssignmentsForVisibleOwners({ hidden: { ringIndex: 4, sectorIndex: 4 } }, ['a'])) + .toBeUndefined(); + }); + + it('normalizes grid owner order by filtering stale and duplicate ids then appending missing ids', () => { + expect(normalizeTeamGraphGridOwnerOrder(['b', 'stale', 'b'], ['a', 'b', 'c'])).toEqual([ + 'b', + 'a', + 'c', + ]); + }); + + it('compares assignments by owner id and slot coordinates', () => { + expect( + areTeamGraphSlotAssignmentsEqual( + { a: { ringIndex: 0, sectorIndex: 0 } }, + { a: { ringIndex: 0, sectorIndex: 0 } } + ) + ).toBe(true); + expect( + areTeamGraphSlotAssignmentsEqual( + { a: { ringIndex: 0, sectorIndex: 0 } }, + { a: { ringIndex: 0, sectorIndex: 1 } } + ) + ).toBe(false); + }); + + it('exposes default assignment and persistence guardrail helpers', () => { + expect( + getDefaultTeamGraphSlotAssignmentsForMembers([ + { name: 'team-lead', agentId: 'lead-agent' }, + { name: 'alice', agentId: 'agent-a' }, + ]) + ).toEqual({ 'agent-a': { ringIndex: 0, sectorIndex: 0 } }); + expect(isTeamGraphSlotPersistenceDisabled()).toBe(true); + }); +}); diff --git a/test/renderer/store/teamLaunchParams.test.ts b/test/renderer/store/teamLaunchParams.test.ts new file mode 100644 index 00000000..fc898ffc --- /dev/null +++ b/test/renderer/store/teamLaunchParams.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; + +import { + areTeamLaunchParamsEqual, + buildLaunchParamsFromRuntimeRequest, + extractBaseModel, +} from '../../../src/renderer/store/team/teamLaunchParams'; + +import type { TeamLaunchParams } from '../../../src/renderer/store/team/teamLaunchParams'; + +const codexFallback: TeamLaunchParams = { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + effort: 'medium', + fastMode: 'on', + limitContext: true, +}; + +describe('teamLaunchParams', () => { + it('extracts provider-scoped base models', () => { + expect(extractBaseModel(' opus[1m] ', 'anthropic')).toBe('opus'); + expect(extractBaseModel('sonnet', 'anthropic')).toBe('sonnet'); + expect(extractBaseModel('gpt-5.5[1m]', 'codex')).toBe('gpt-5.5[1m]'); + expect(extractBaseModel(' ', 'anthropic')).toBeUndefined(); + expect(extractBaseModel(undefined, 'anthropic')).toBeUndefined(); + }); + + it('builds default anthropic launch params without fallback', () => { + expect(buildLaunchParamsFromRuntimeRequest({})).toEqual({ + providerId: 'anthropic', + providerBackendId: undefined, + model: 'default', + effort: undefined, + fastMode: undefined, + limitContext: false, + }); + }); + + it('preserves fallback values for metadata-only requests on the same provider', () => { + expect(buildLaunchParamsFromRuntimeRequest({}, codexFallback)).toEqual(codexFallback); + }); + + it('resets provider-scoped values when the provider changes without explicit fields', () => { + expect( + buildLaunchParamsFromRuntimeRequest( + { + providerId: 'anthropic', + }, + codexFallback + ) + ).toEqual({ + providerId: 'anthropic', + providerBackendId: undefined, + model: 'default', + effort: undefined, + fastMode: undefined, + limitContext: false, + }); + }); + + it('uses explicit model, effort, fast mode, and limitContext when present', () => { + expect( + buildLaunchParamsFromRuntimeRequest( + { + providerId: 'anthropic', + model: 'haiku[1m]', + effort: 'low', + fastMode: 'off', + limitContext: false, + }, + codexFallback + ) + ).toEqual({ + providerId: 'anthropic', + providerBackendId: undefined, + model: 'haiku', + effort: 'low', + fastMode: 'off', + limitContext: false, + }); + }); + + it('treats an explicit undefined model as Default for the active provider', () => { + expect( + buildLaunchParamsFromRuntimeRequest( + { + providerId: 'codex', + providerBackendId: 'codex-native', + model: undefined, + effort: 'low', + }, + codexFallback + ) + ).toEqual({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'default', + effort: 'low', + fastMode: 'on', + limitContext: true, + }); + }); + + it('migrates legacy provider backend ids for codex requests', () => { + expect( + buildLaunchParamsFromRuntimeRequest({ + providerId: 'codex', + providerBackendId: 'api', + }) + ).toEqual({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'default', + effort: undefined, + fastMode: undefined, + limitContext: false, + }); + }); + + it('compares launch params by all persisted fields', () => { + expect(areTeamLaunchParamsEqual(codexFallback, { ...codexFallback })).toBe(true); + expect( + areTeamLaunchParamsEqual(codexFallback, { + ...codexFallback, + fastMode: 'off', + }) + ).toBe(false); + expect(areTeamLaunchParamsEqual(undefined, undefined)).toBe(true); + expect(areTeamLaunchParamsEqual(undefined, codexFallback)).toBe(false); + }); +}); diff --git a/test/renderer/store/teamLocalStateEpoch.test.ts b/test/renderer/store/teamLocalStateEpoch.test.ts new file mode 100644 index 00000000..3ff503ca --- /dev/null +++ b/test/renderer/store/teamLocalStateEpoch.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + captureTeamLocalStateEpoch, + clearAllTeamLocalStateEpochs, + clearTeamLocalStateEpoch, + hasTeamLocalStateEpoch, + invalidateTeamLocalStateEpoch, + isTeamLocalStateEpochCurrent, +} from '../../../src/renderer/store/team/teamLocalStateEpoch'; + +afterEach(() => { + clearAllTeamLocalStateEpochs(); +}); + +describe('teamLocalStateEpoch', () => { + it('starts missing teams at epoch zero without materializing an entry', () => { + expect(captureTeamLocalStateEpoch('my-team')).toBe(0); + expect(isTeamLocalStateEpochCurrent('my-team', 0)).toBe(true); + expect(hasTeamLocalStateEpoch('my-team')).toBe(false); + }); + + it('increments epochs independently per team', () => { + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('other-team'); + + expect(captureTeamLocalStateEpoch('my-team')).toBe(2); + expect(captureTeamLocalStateEpoch('other-team')).toBe(1); + expect(isTeamLocalStateEpochCurrent('my-team', 1)).toBe(false); + expect(isTeamLocalStateEpochCurrent('my-team', 2)).toBe(true); + }); + + it('clears one team epoch without touching other teams', () => { + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('other-team'); + + clearTeamLocalStateEpoch('my-team'); + + expect(captureTeamLocalStateEpoch('my-team')).toBe(0); + expect(hasTeamLocalStateEpoch('my-team')).toBe(false); + expect(captureTeamLocalStateEpoch('other-team')).toBe(1); + expect(hasTeamLocalStateEpoch('other-team')).toBe(true); + }); + + it('clears all materialized epochs', () => { + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('other-team'); + + clearAllTeamLocalStateEpochs(); + + expect(hasTeamLocalStateEpoch('my-team')).toBe(false); + expect(hasTeamLocalStateEpoch('other-team')).toBe(false); + expect(captureTeamLocalStateEpoch('my-team')).toBe(0); + expect(captureTeamLocalStateEpoch('other-team')).toBe(0); + }); +}); diff --git a/test/renderer/store/teamMemberActivityMeta.test.ts b/test/renderer/store/teamMemberActivityMeta.test.ts new file mode 100644 index 00000000..32ca24b6 --- /dev/null +++ b/test/renderer/store/teamMemberActivityMeta.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; + +import { + areMemberActivityMetaEntriesEqual, + isMemberActivityMetaStale, + structurallyShareMemberActivityFacts, +} from '../../../src/renderer/store/team/teamMemberActivityMeta'; + +import type { TeamMessagesCacheEntry } from '../../../src/renderer/store/team/teamMessagesCache'; +import type { + MemberActivityMetaEntry, + TeamMemberActivityMeta, +} from '../../../src/shared/types'; + +function createEntry(overrides: Partial = {}): MemberActivityMetaEntry { + return { + memberName: 'alice', + lastAuthoredMessageAt: '2026-05-22T10:00:00.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + ...overrides, + }; +} + +function createMeta(overrides: Partial = {}): TeamMemberActivityMeta { + return { + teamName: 'my-team', + computedAt: '2026-05-22T10:00:00.000Z', + members: { + alice: createEntry(), + }, + feedRevision: 'feed-1', + ...overrides, + }; +} + +function createMessagesEntry( + overrides: Partial = {} +): TeamMessagesCacheEntry { + return { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'feed-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + ...overrides, + }; +} + +describe('teamMemberActivityMeta', () => { + it('compares member activity entries by visible facts', () => { + expect(areMemberActivityMetaEntriesEqual(createEntry(), createEntry())).toBe(true); + expect( + areMemberActivityMetaEntriesEqual(createEntry(), createEntry({ messageCountExact: 4 })) + ).toBe(false); + expect( + areMemberActivityMetaEntriesEqual( + createEntry(), + createEntry({ latestAuthoredMessageSignalsTermination: true }) + ) + ).toBe(false); + expect(areMemberActivityMetaEntriesEqual(undefined, createEntry())).toBe(false); + }); + + it('returns next activity facts when there is no previous record', () => { + const next = { + alice: createEntry(), + }; + + expect(structurallyShareMemberActivityFacts(undefined, next)).toBe(next); + }); + + it('preserves the previous record when all entries are semantically equal', () => { + const previous = { + alice: createEntry(), + bob: createEntry({ memberName: 'bob', messageCountExact: 1 }), + }; + const next = { + alice: createEntry(), + bob: createEntry({ memberName: 'bob', messageCountExact: 1 }), + }; + + expect(structurallyShareMemberActivityFacts(previous, next)).toBe(previous); + }); + + it('shares unchanged entries and replaces changed entries', () => { + const previousAlice = createEntry(); + const previousBob = createEntry({ memberName: 'bob', messageCountExact: 1 }); + const nextBob = createEntry({ memberName: 'bob', messageCountExact: 2 }); + const previous = { + alice: previousAlice, + bob: previousBob, + }; + + const shared = structurallyShareMemberActivityFacts( + previous, + { + alice: createEntry(), + bob: nextBob, + } + ); + + expect(shared).not.toBe(previous); + expect(shared.alice).toBe(previousAlice); + expect(shared.bob).toBe(nextBob); + }); + + it('returns a new record when activity keys are added or removed', () => { + const previous = { + alice: createEntry(), + bob: createEntry({ memberName: 'bob' }), + }; + + const removed = structurallyShareMemberActivityFacts(previous, { + alice: createEntry(), + }); + + expect(removed).not.toBe(previous); + expect(removed).toEqual({ + alice: previous.alice, + }); + expect(removed.alice).toBe(previous.alice); + + const singlePrevious = { + alice: createEntry(), + }; + const added = structurallyShareMemberActivityFacts(singlePrevious, { + alice: createEntry(), + bob: createEntry({ memberName: 'bob' }), + }); + + expect(added).not.toBe(singlePrevious); + expect(added.alice).toBe(singlePrevious.alice); + expect(added.bob).toEqual(createEntry({ memberName: 'bob' })); + }); + + it('treats missing member activity meta as stale', () => { + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: {}, + teamMessagesByName: {}, + }, + 'my-team' + ) + ).toBe(true); + }); + + it('does not require refresh when the message feed has no revision yet', () => { + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: { + 'my-team': createMeta({ feedRevision: 'old-feed' }), + }, + teamMessagesByName: { + 'my-team': createMessagesEntry({ feedRevision: null }), + }, + }, + 'my-team' + ) + ).toBe(false); + }); + + it('compares member activity meta feedRevision against the messages feed revision', () => { + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: { + 'my-team': createMeta({ feedRevision: 'feed-1' }), + }, + teamMessagesByName: { + 'my-team': createMessagesEntry({ feedRevision: 'feed-1' }), + }, + }, + 'my-team' + ) + ).toBe(false); + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: { + 'my-team': createMeta({ feedRevision: 'feed-1' }), + }, + teamMessagesByName: { + 'my-team': createMessagesEntry({ feedRevision: 'feed-2' }), + }, + }, + 'my-team' + ) + ).toBe(true); + }); +}); diff --git a/test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts b/test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts new file mode 100644 index 00000000..c372ef09 --- /dev/null +++ b/test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; + +import { + areExpectedMembersEqual, + areLaunchSummaryCountsEqual, + areMemberSpawnSnapshotsSemanticallyEqual, + areMemberSpawnStatusEntriesEqual, + areMemberSpawnStatusesEqual, +} from '../../../src/renderer/store/team/teamMemberSpawnSnapshotEquality'; + +import type { + MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + PersistedTeamLaunchSummary, +} from '../../../src/shared/types'; + +function createSummary( + overrides: Partial = {} +): PersistedTeamLaunchSummary { + return { + confirmedCount: 1, + pendingCount: 0, + failedCount: 0, + skippedCount: 0, + runtimeAlivePendingCount: 0, + shellOnlyPendingCount: 0, + runtimeProcessPendingCount: 0, + runtimeCandidatePendingCount: 0, + noRuntimePendingCount: 0, + permissionPendingCount: 0, + ...overrides, + }; +} + +function createStatusEntry( + overrides: Partial = {} +): MemberSpawnStatusEntry { + return { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-05-22T10:00:00.000Z', + livenessSource: 'heartbeat', + runtimeAlive: true, + runtimeModel: 'gpt-5.3-codex', + livenessKind: 'confirmed_bootstrap', + runtimeDiagnostic: 'Ready', + runtimeDiagnosticSeverity: 'info', + bootstrapConfirmed: true, + hardFailure: false, + pendingPermissionRequestIds: ['perm-a', 'perm-b'], + ...overrides, + }; +} + +function createSnapshot( + overrides: Partial = {} +): MemberSpawnStatusesSnapshot { + return { + statuses: { + alice: createStatusEntry(), + }, + runId: 'run-1', + teamLaunchState: 'clean_success', + launchPhase: 'active', + expectedMembers: ['alice'], + updatedAt: '2026-05-22T10:00:00.000Z', + summary: createSummary(), + source: 'live', + ...overrides, + }; +} + +describe('teamMemberSpawnSnapshotEquality', () => { + it('compares launch summaries by visible counts', () => { + expect(areLaunchSummaryCountsEqual(createSummary(), createSummary())).toBe(true); + expect( + areLaunchSummaryCountsEqual(createSummary(), createSummary({ permissionPendingCount: 1 })) + ).toBe(false); + expect(areLaunchSummaryCountsEqual(undefined, undefined)).toBe(true); + expect(areLaunchSummaryCountsEqual(undefined, createSummary())).toBe(false); + }); + + it('compares expected members in stable order', () => { + expect(areExpectedMembersEqual(['alice', 'bob'], ['alice', 'bob'])).toBe(true); + expect(areExpectedMembersEqual(['alice', 'bob'], ['bob', 'alice'])).toBe(false); + expect(areExpectedMembersEqual(undefined, undefined)).toBe(true); + expect(areExpectedMembersEqual(undefined, [])).toBe(false); + }); + + it('ignores non-visible status churn and unordered pending permission ids', () => { + const left = createStatusEntry({ + pendingPermissionRequestIds: ['perm-b', 'perm-a'], + updatedAt: '2026-05-22T10:00:00.000Z', + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-05-22T10:00:01.000Z', + lastHeartbeatAt: '2026-05-22T10:00:02.000Z', + livenessLastCheckedAt: '2026-05-22T10:00:03.000Z', + bootstrapStalled: true, + }); + const right = createStatusEntry({ + pendingPermissionRequestIds: ['perm-a', 'perm-b'], + updatedAt: '2026-05-22T10:05:00.000Z', + agentToolAccepted: false, + firstSpawnAcceptedAt: '2026-05-22T10:05:01.000Z', + lastHeartbeatAt: '2026-05-22T10:05:02.000Z', + livenessLastCheckedAt: '2026-05-22T10:05:03.000Z', + bootstrapStalled: false, + }); + + expect(areMemberSpawnStatusEntriesEqual(left, right)).toBe(true); + }); + + it('detects visible status entry changes', () => { + expect( + areMemberSpawnStatusEntriesEqual( + createStatusEntry(), + createStatusEntry({ runtimeDiagnosticSeverity: 'warning' }) + ) + ).toBe(false); + expect( + areMemberSpawnStatusEntriesEqual( + createStatusEntry(), + createStatusEntry({ pendingPermissionRequestIds: ['perm-a'] }) + ) + ).toBe(false); + }); + + it('compares per-member status maps by keys and semantic entries', () => { + expect( + areMemberSpawnStatusesEqual( + { + alice: createStatusEntry(), + bob: createStatusEntry({ runtimeModel: 'gpt-5.4' }), + }, + { + bob: createStatusEntry({ runtimeModel: 'gpt-5.4' }), + alice: createStatusEntry(), + } + ) + ).toBe(true); + expect( + areMemberSpawnStatusesEqual( + { + alice: createStatusEntry(), + }, + { + alice: createStatusEntry(), + bob: createStatusEntry(), + } + ) + ).toBe(false); + }); + + it('compares snapshots by semantic launch fields and ignores snapshot updatedAt churn', () => { + const left = createSnapshot({ + updatedAt: '2026-05-22T10:00:00.000Z', + }); + const right = createSnapshot({ + updatedAt: '2026-05-22T10:05:00.000Z', + statuses: { + alice: createStatusEntry({ + pendingPermissionRequestIds: ['perm-b', 'perm-a'], + updatedAt: '2026-05-22T10:05:00.000Z', + }), + }, + }); + + expect(areMemberSpawnSnapshotsSemanticallyEqual(left, right)).toBe(true); + }); + + it('detects semantic snapshot changes', () => { + expect( + areMemberSpawnSnapshotsSemanticallyEqual( + createSnapshot(), + createSnapshot({ runId: 'run-2' }) + ) + ).toBe(false); + expect( + areMemberSpawnSnapshotsSemanticallyEqual( + createSnapshot(), + createSnapshot({ expectedMembers: ['alice', 'bob'] }) + ) + ).toBe(false); + expect(areMemberSpawnSnapshotsSemanticallyEqual(undefined, createSnapshot())).toBe(false); + }); +}); diff --git a/test/renderer/store/teamMemberSpawnStatusBackoff.test.ts b/test/renderer/store/teamMemberSpawnStatusBackoff.test.ts new file mode 100644 index 00000000..70ffc747 --- /dev/null +++ b/test/renderer/store/teamMemberSpawnStatusBackoff.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAllMemberSpawnStatusesIpcBackoffs, + clearMemberSpawnStatusesIpcBackoff, + getMemberSpawnStatusesIpcBackoffUntil, + hasMemberSpawnStatusesIpcBackoff, + isMemberSpawnStatusesIpcBackoffActive, + recordMemberSpawnStatusesIpcBackoffUntil, + recordMemberSpawnStatusesIpcRetryBackoff, +} from '../../../src/renderer/store/team/teamMemberSpawnStatusBackoff'; + +afterEach(() => { + vi.useRealTimers(); + clearAllMemberSpawnStatusesIpcBackoffs(); +}); + +describe('teamMemberSpawnStatusBackoff', () => { + it('defaults to no backoff for unknown teams', () => { + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(0); + expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false); + expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 100)).toBe(false); + }); + + it('tracks active backoff deadlines by team', () => { + recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150); + recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250); + + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(150); + expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 149)).toBe(true); + expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 150)).toBe(false); + expect(isMemberSpawnStatusesIpcBackoffActive('other-team', 249)).toBe(true); + }); + + it('records retry backoff from Date.now by default', () => { + vi.setSystemTime(new Date('2026-05-22T07:00:00.000Z')); + + recordMemberSpawnStatusesIpcRetryBackoff('my-team', 5_000); + + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe( + new Date('2026-05-22T07:00:05.000Z').getTime() + ); + }); + + it('records retry backoff from an explicit clock for deterministic callers', () => { + recordMemberSpawnStatusesIpcRetryBackoff('my-team', 5_000, 100); + + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(5_100); + }); + + it('clears one team backoff without touching others', () => { + recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150); + recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250); + + clearMemberSpawnStatusesIpcBackoff('my-team'); + + expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false); + expect(getMemberSpawnStatusesIpcBackoffUntil('other-team')).toBe(250); + }); + + it('clears all recorded backoffs', () => { + recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150); + recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250); + + clearAllMemberSpawnStatusesIpcBackoffs(); + + expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false); + expect(hasMemberSpawnStatusesIpcBackoff('other-team')).toBe(false); + }); +}); diff --git a/test/renderer/store/teamMemberSpawnUiEqualWarningThrottle.test.ts b/test/renderer/store/teamMemberSpawnUiEqualWarningThrottle.test.ts new file mode 100644 index 00000000..7025a836 --- /dev/null +++ b/test/renderer/store/teamMemberSpawnUiEqualWarningThrottle.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAllMemberSpawnUiEqualLastWarns, + clearMemberSpawnUiEqualLastWarn, + getMemberSpawnUiEqualLastWarnAt, + hasMemberSpawnUiEqualLastWarn, + shouldLogMemberSpawnUiEqualSuppressed, +} from '../../../src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle'; + +afterEach(() => { + vi.useRealTimers(); + clearAllMemberSpawnUiEqualLastWarns(); +}); + +describe('teamMemberSpawnUiEqualWarningThrottle', () => { + it('preserves the existing zero fallback boundary for unknown teams', () => { + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 1_999)).toBe(false); + expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false); + + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 2_000)).toBe(true); + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(2_000); + }); + + it('throttles repeated warnings until the boundary is reached', () => { + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000)).toBe(true); + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 11_999)).toBe(false); + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(10_000); + + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 12_000)).toBe(true); + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(12_000); + }); + + it('tracks teams independently', () => { + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000)).toBe(true); + expect(shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500)).toBe(true); + + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(10_000); + expect(getMemberSpawnUiEqualLastWarnAt('other-team')).toBe(10_500); + }); + + it('uses Date.now by default for production callers', () => { + vi.setSystemTime(new Date('2026-05-22T07:30:00.000Z')); + + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000)).toBe(true); + + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe( + new Date('2026-05-22T07:30:00.000Z').getTime() + ); + }); + + it('clears one team without touching other teams', () => { + shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000); + shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500); + + clearMemberSpawnUiEqualLastWarn('my-team'); + + expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false); + expect(getMemberSpawnUiEqualLastWarnAt('other-team')).toBe(10_500); + }); + + it('clears all tracked warnings', () => { + shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000); + shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500); + + clearAllMemberSpawnUiEqualLastWarns(); + + expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false); + expect(hasMemberSpawnUiEqualLastWarn('other-team')).toBe(false); + }); +}); diff --git a/test/renderer/store/teamMessagesCache.test.ts b/test/renderer/store/teamMessagesCache.test.ts new file mode 100644 index 00000000..74f11d82 --- /dev/null +++ b/test/renderer/store/teamMessagesCache.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + areInboxMessageArraysEquivalent, + clearTeamMessageSelectorCaches, + clearTeamMessageSelectorCachesForTeam, + EMPTY_TEAM_MESSAGES_CACHE_ENTRY, + extractRetainedCanonicalOlderTail, + getCanonicalHeadSlice, + getTeamMessagesCacheEntry, + getTeamMessageSelectorCacheSnapshotForTeam, + pruneOptimisticMessages, + selectMemberMessagesForTeamMember, + selectTeamMessages, + type TeamMessagesCacheEntry, + type TeamMessagesCacheState, + upsertOptimisticTeamMessage, +} from '../../../src/renderer/store/team/teamMessagesCache'; + +import type { InboxMessage } from '../../../src/shared/types'; + +afterEach(() => { + clearTeamMessageSelectorCaches(); +}); + +function createMessage(overrides: Partial & { messageId: string }): InboxMessage { + return { + from: 'lead', + to: 'alice', + text: overrides.messageId, + timestamp: '2026-03-12T10:00:00.000Z', + read: false, + ...overrides, + }; +} + +function createEntry(overrides: Partial = {}): TeamMessagesCacheEntry { + return { + ...EMPTY_TEAM_MESSAGES_CACHE_ENTRY, + ...overrides, + }; +} + +describe('teamMessagesCache', () => { + it('returns the immutable empty entry when a team has no cached messages', () => { + const state: TeamMessagesCacheState = { teamMessagesByName: {} }; + + expect(getTeamMessagesCacheEntry(state, 'missing-team')).toBe(EMPTY_TEAM_MESSAGES_CACHE_ENTRY); + }); + + it('upserts optimistic messages by durable id and keeps deterministic timestamp order', () => { + const first = upsertOptimisticTeamMessage( + createEntry(), + createMessage({ + messageId: 'msg-new', + timestamp: '2026-03-12T10:00:03.000Z', + text: 'draft', + }) + ); + const second = upsertOptimisticTeamMessage( + first, + createMessage({ + messageId: 'msg-old', + timestamp: '2026-03-12T10:00:01.000Z', + }) + ); + const replaced = upsertOptimisticTeamMessage( + second, + createMessage({ + messageId: 'msg-new', + timestamp: '2026-03-12T10:00:03.000Z', + text: 'sent', + }) + ); + + expect(replaced.optimisticMessages.map((message) => message.messageId)).toEqual([ + 'msg-old', + 'msg-new', + ]); + expect(replaced.optimisticMessages[1].text).toBe('sent'); + }); + + it('compares semantic message arrays and prunes optimistic rows confirmed by canonical data', () => { + const canonical = [ + createMessage({ messageId: 'msg-1', text: 'confirmed' }), + createMessage({ messageId: 'msg-2' }), + ]; + const equivalentCanonical = [ + createMessage({ messageId: 'msg-1', text: 'confirmed' }), + createMessage({ messageId: 'msg-2' }), + ]; + const optimistic = [ + createMessage({ messageId: 'msg-1', text: 'draft that arrived' }), + createMessage({ messageId: 'msg-local', text: 'still local' }), + ]; + + expect(areInboxMessageArraysEquivalent(canonical, equivalentCanonical)).toBe(true); + expect( + areInboxMessageArraysEquivalent(canonical, [ + createMessage({ messageId: 'msg-1', text: 'changed' }), + createMessage({ messageId: 'msg-2' }), + ]) + ).toBe(false); + expect(pruneOptimisticMessages(optimistic, canonical).map((message) => message.messageId)).toEqual( + ['msg-local'] + ); + }); + + it('retains already-loaded older tail only when the fresh head anchors into canonical data', () => { + const canonical = [ + createMessage({ messageId: 'msg-4', timestamp: '2026-03-12T10:00:04.000Z' }), + createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }), + createMessage({ messageId: 'msg-2', timestamp: '2026-03-12T10:00:02.000Z' }), + createMessage({ messageId: 'msg-1', timestamp: '2026-03-12T10:00:01.000Z' }), + ]; + const freshHead = [ + createMessage({ messageId: 'msg-5', timestamp: '2026-03-12T10:00:05.000Z' }), + createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }), + ]; + + expect(getCanonicalHeadSlice(canonical, 2).map((message) => message.messageId)).toEqual([ + 'msg-4', + 'msg-3', + ]); + expect( + extractRetainedCanonicalOlderTail(canonical, freshHead)?.map((message) => message.messageId) + ).toEqual(['msg-2', 'msg-1']); + expect( + extractRetainedCanonicalOlderTail(canonical, [createMessage({ messageId: 'disjoint' })]) + ).toBeNull(); + }); + + it('memoizes merged and member-scoped selectors and clears team-scoped caches', () => { + const state: TeamMessagesCacheState = { + teamMessagesByName: { + 'my-team': createEntry({ + canonicalMessages: [ + createMessage({ + messageId: 'msg-1', + to: 'alice', + timestamp: '2026-03-12T10:00:01.000Z', + }), + createMessage({ + messageId: 'msg-2', + to: 'bob', + timestamp: '2026-03-12T10:00:02.000Z', + }), + ], + optimisticMessages: [ + createMessage({ + messageId: 'msg-3', + from: 'alice', + to: 'lead', + timestamp: '2026-03-12T10:00:03.000Z', + }), + ], + }), + }, + }; + + const firstTeamMessages = selectTeamMessages(state, 'my-team'); + const secondTeamMessages = selectTeamMessages(state, 'my-team'); + const firstAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice'); + const secondAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice'); + + expect(firstTeamMessages).toBe(secondTeamMessages); + expect(firstAliceMessages).toBe(secondAliceMessages); + expect(firstTeamMessages.map((message) => message.messageId)).toEqual([ + 'msg-3', + 'msg-2', + 'msg-1', + ]); + expect(firstAliceMessages.map((message) => message.messageId)).toEqual(['msg-3', 'msg-1']); + expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({ + hasMergedMessagesSelector: true, + memberMessagesSelectorCount: 1, + }); + + clearTeamMessageSelectorCachesForTeam('my-team'); + + expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({ + hasMergedMessagesSelector: false, + memberMessagesSelectorCount: 0, + }); + }); +}); diff --git a/test/renderer/store/teamMessagesPanelModePersistence.test.ts b/test/renderer/store/teamMessagesPanelModePersistence.test.ts new file mode 100644 index 00000000..baea02fb --- /dev/null +++ b/test/renderer/store/teamMessagesPanelModePersistence.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + loadPersistedMessagesPanelMode, + savePersistedMessagesPanelMode, +} from '../../../src/renderer/store/team/teamMessagesPanelModePersistence'; + +import type { TeamMessagesPanelMode } from '../../../src/renderer/types/teamMessagesPanelMode'; + +const STORAGE_KEY = 'team:messagesPanelMode'; + +describe('teamMessagesPanelModePersistence', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('defaults to sidebar when no value was persisted', () => { + expect(loadPersistedMessagesPanelMode()).toBe('sidebar'); + }); + + it('loads each supported persisted mode', () => { + const modes: TeamMessagesPanelMode[] = [ + 'sidebar', + 'inline', + 'bottom-sheet', + 'floating-composer', + ]; + + for (const mode of modes) { + window.localStorage.setItem(STORAGE_KEY, mode); + + expect(loadPersistedMessagesPanelMode()).toBe(mode); + } + }); + + it('falls back to sidebar for invalid persisted values', () => { + window.localStorage.setItem(STORAGE_KEY, 'bad-mode'); + + expect(loadPersistedMessagesPanelMode()).toBe('sidebar'); + }); + + it('persists the selected mode', () => { + savePersistedMessagesPanelMode('inline'); + + expect(window.localStorage.getItem(STORAGE_KEY)).toBe('inline'); + }); + + it('falls back to sidebar when localStorage read fails', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + + expect(loadPersistedMessagesPanelMode()).toBe('sidebar'); + }); + + it('ignores localStorage write failures', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked'); + }); + + expect(() => savePersistedMessagesPanelMode('bottom-sheet')).not.toThrow(); + }); +}); diff --git a/test/renderer/store/teamPendingReplyWaits.test.ts b/test/renderer/store/teamPendingReplyWaits.test.ts new file mode 100644 index 00000000..45a7b1aa --- /dev/null +++ b/test/renderer/store/teamPendingReplyWaits.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + clearAllPendingReplyRefreshWaits, + clearPendingReplyRefreshWaits, + getActiveTeamPendingReplyWaits, + hasActiveTeamPendingReplyWait, + setPendingReplyRefreshEnabled, +} from '../../../src/renderer/store/team/teamPendingReplyWaits'; + +afterEach(() => { + clearAllPendingReplyRefreshWaits(); +}); + +describe('teamPendingReplyWaits', () => { + it('tracks active teams with at least one enabled source', () => { + expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', true)).toBe(true); + expect(setPendingReplyRefreshEnabled('other-team', 'tab-b', true)).toBe(true); + + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true); + expect(hasActiveTeamPendingReplyWait('other-team')).toBe(true); + expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team', 'other-team'])); + }); + + it('keeps a team active until the last source is disabled', () => { + setPendingReplyRefreshEnabled('my-team', 'tab-a', true); + setPendingReplyRefreshEnabled('my-team', 'tab-b', true); + + expect(setPendingReplyRefreshEnabled('my-team', 'tab-b', false)).toBe(true); + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true); + expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team'])); + + expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', false)).toBe(false); + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false); + expect(getActiveTeamPendingReplyWaits().size).toBe(0); + }); + + it('is idempotent for repeated enables from the same source', () => { + setPendingReplyRefreshEnabled('my-team', 'tab-a', true); + setPendingReplyRefreshEnabled('my-team', 'tab-a', true); + + expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', false)).toBe(false); + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false); + }); + + it('returns false when disabling a source that has no active wait', () => { + expect(setPendingReplyRefreshEnabled('missing-team', 'tab-a', false)).toBe(false); + expect(getActiveTeamPendingReplyWaits().size).toBe(0); + }); + + it('clears waits by team or globally', () => { + setPendingReplyRefreshEnabled('my-team', 'tab-a', true); + setPendingReplyRefreshEnabled('other-team', 'tab-b', true); + + clearPendingReplyRefreshWaits('my-team'); + + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false); + expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['other-team'])); + + clearAllPendingReplyRefreshWaits(); + + expect(hasActiveTeamPendingReplyWait('other-team')).toBe(false); + expect(getActiveTeamPendingReplyWaits().size).toBe(0); + }); +}); diff --git a/test/renderer/store/teamProvisioningStateRules.test.ts b/test/renderer/store/teamProvisioningStateRules.test.ts new file mode 100644 index 00000000..cc2e4dc8 --- /dev/null +++ b/test/renderer/store/teamProvisioningStateRules.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + isActiveProvisioningState, + isTerminalProvisioningState, + shouldIgnoreProvisioningProgressRegression, +} from '../../../src/renderer/store/team/teamProvisioningStateRules'; + +import type { TeamProvisioningProgress } from '../../../src/shared/types'; + +type ProgressState = TeamProvisioningProgress['state']; + +const activeStates: ProgressState[] = [ + 'validating', + 'spawning', + 'configuring', + 'assembling', + 'finalizing', + 'verifying', +]; + +const terminalStates: ProgressState[] = ['ready', 'failed', 'disconnected', 'cancelled']; + +describe('teamProvisioningStateRules', () => { + it('classifies active provisioning states', () => { + for (const state of activeStates) { + expect(isActiveProvisioningState(state), state).toBe(true); + expect(isTerminalProvisioningState(state), state).toBe(false); + } + }); + + it('classifies terminal provisioning states', () => { + for (const state of terminalStates) { + expect(isTerminalProvisioningState(state), state).toBe(true); + expect(isActiveProvisioningState(state), state).toBe(false); + } + }); + + it('allows active state progressions and regressions to be processed', () => { + expect(shouldIgnoreProvisioningProgressRegression('spawning', 'validating')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('validating', 'spawning')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('verifying', 'ready')).toBe(false); + }); + + it('prevents ready from regressing except to disconnected', () => { + expect(shouldIgnoreProvisioningProgressRegression('ready', 'validating')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'failed')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'cancelled')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'ready')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'disconnected')).toBe(false); + }); + + it('locks failed, cancelled, and disconnected to their current terminal state', () => { + expect(shouldIgnoreProvisioningProgressRegression('failed', 'failed')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('failed', 'ready')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('cancelled', 'cancelled')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('cancelled', 'spawning')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('disconnected', 'disconnected')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('disconnected', 'ready')).toBe(true); + }); +}); diff --git a/test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts b/test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts new file mode 100644 index 00000000..5cc3f529 --- /dev/null +++ b/test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + clearAllTeamRefreshBurstDiagnostics, + clearTeamRefreshBurstDiagnostics, + getTeamRefreshBurstDiagnosticForTests, + hasTeamRefreshBurstDiagnostics, + noteTeamRefreshBurst, +} from '../../../src/renderer/store/team/teamRefreshBurstDiagnostics'; + +afterEach(() => { + clearAllTeamRefreshBurstDiagnostics(); +}); + +describe('teamRefreshBurstDiagnostics store', () => { + it('creates a window on the first refresh note', () => { + expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1); + + expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({ + windowStartedAt: 10_000, + count: 1, + lastWarnAt: 0, + }); + expect(hasTeamRefreshBurstDiagnostics('my-team')).toBe(true); + }); + + it('increments inside the active burst window', () => { + expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1); + expect(noteTeamRefreshBurst('my-team', 4_000, 13_999)).toBe(2); + expect(noteTeamRefreshBurst('my-team', 4_000, 14_000)).toBe(3); + + expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({ + windowStartedAt: 10_000, + count: 3, + lastWarnAt: 0, + }); + }); + + it('resets only after now is strictly beyond the burst window', () => { + expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1); + expect(noteTeamRefreshBurst('my-team', 4_000, 14_001)).toBe(1); + + expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({ + windowStartedAt: 14_001, + count: 1, + lastWarnAt: 0, + }); + }); + + it('tracks each team independently', () => { + noteTeamRefreshBurst('my-team', 4_000, 10_000); + noteTeamRefreshBurst('my-team', 4_000, 10_500); + noteTeamRefreshBurst('other-team', 4_000, 11_000); + + expect(getTeamRefreshBurstDiagnosticForTests('my-team')?.count).toBe(2); + expect(getTeamRefreshBurstDiagnosticForTests('other-team')?.count).toBe(1); + }); + + it('clears one team or all diagnostics', () => { + noteTeamRefreshBurst('my-team', 4_000, 10_000); + noteTeamRefreshBurst('other-team', 4_000, 11_000); + + clearTeamRefreshBurstDiagnostics('my-team'); + + expect(hasTeamRefreshBurstDiagnostics('my-team')).toBe(false); + expect(hasTeamRefreshBurstDiagnostics('other-team')).toBe(true); + + clearAllTeamRefreshBurstDiagnostics(); + + expect(hasTeamRefreshBurstDiagnostics('other-team')).toBe(false); + }); + + it('returns defensive diagnostic snapshots for tests', () => { + noteTeamRefreshBurst('my-team', 4_000, 10_000); + + const snapshot = getTeamRefreshBurstDiagnosticForTests('my-team'); + if (snapshot) { + snapshot.count = 99; + } + + expect(getTeamRefreshBurstDiagnosticForTests('my-team')?.count).toBe(1); + }); +}); diff --git a/test/renderer/store/teamResolvedMembers.test.ts b/test/renderer/store/teamResolvedMembers.test.ts new file mode 100644 index 00000000..6ea9f5ad --- /dev/null +++ b/test/renderer/store/teamResolvedMembers.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearResolvedMemberSelectorCaches, + getResolvedMemberSelectorCacheSnapshotForTeam, + selectResolvedMemberForTeamName, + selectResolvedMembersForTeamName, + shouldPreserveSelectedTeamSnapshot, +} from '../../../src/renderer/store/team/teamResolvedMembers'; + +import type { + TeamMemberActivityMeta, + TeamMemberSnapshot, + TeamSummary, + TeamTask, + TeamViewSnapshot, +} from '../../../src/shared/types'; + +function createTask(overrides: Partial = {}): TeamTask { + return { + id: 'task-1', + subject: 'Task', + owner: 'alice', + status: 'in_progress', + ...overrides, + }; +} + +function createSnapshot(overrides: Partial = {}): TeamViewSnapshot { + return { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + ...overrides, + } as TeamViewSnapshot; +} + +function createState( + snapshot: TeamViewSnapshot, + options: { + summary?: TeamSummary; + meta?: TeamMemberActivityMeta; + } = {} +) { + return { + selectedTeamName: snapshot.teamName, + selectedTeamData: snapshot, + teamDataCacheByName: { [snapshot.teamName]: snapshot }, + memberActivityMetaByTeam: options.meta ? { [snapshot.teamName]: options.meta } : {}, + teamByName: options.summary ? { [snapshot.teamName]: options.summary } : {}, + }; +} + +describe('teamResolvedMembers', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T12:00:00.000Z')); + clearResolvedMemberSelectorCaches(); + }); + + afterEach(() => { + vi.useRealTimers(); + clearResolvedMemberSelectorCaches(); + }); + + it('builds config fallback members when runtime snapshots are empty', () => { + const snapshot = createSnapshot({ + config: { + name: 'My Team', + members: [ + { name: 'team-lead', role: 'Lead' }, + { name: 'alice', agentId: 'agent-a', role: 'Engineer' }, + { name: 'Alice', agentId: 'duplicate' }, + ], + }, + tasks: [ + createTask({ id: 'task-active', owner: 'alice', status: 'in_progress' }), + createTask({ id: 'task-done', owner: 'alice', status: 'completed' }), + ], + }); + + const members = selectResolvedMembersForTeamName(createState(snapshot), 'my-team'); + + expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice']); + expect(members[1]).toMatchObject({ + name: 'alice', + agentId: 'agent-a', + currentTaskId: 'task-active', + taskCount: 2, + role: 'Engineer', + status: 'active', + messageCount: 0, + lastActiveAt: null, + }); + }); + + it('builds summary fallback members with a lead when config and runtime snapshots are empty', () => { + const snapshot = createSnapshot(); + const summary = { + teamName: 'my-team', + displayName: 'My Team', + memberCount: 2, + taskCount: 0, + lastActivity: null, + leadName: 'lead-one', + leadColor: '#fff', + members: [ + { name: 'lead-one', role: 'Lead' }, + { name: 'bob', agentId: 'agent-b', role: 'Reviewer', color: '#123456' }, + { name: 'Bob', agentId: 'duplicate' }, + ], + } as TeamSummary; + + const members = selectResolvedMembersForTeamName(createState(snapshot, { summary }), 'my-team'); + + expect(members.map((member) => member.name)).toEqual(['lead-one', 'bob']); + expect(members[0]).toMatchObject({ agentType: 'team-lead', role: 'Team Lead' }); + expect(members[1]).toMatchObject({ + agentId: 'agent-b', + role: 'Reviewer', + color: '#123456', + }); + }); + + it('memoizes selector results until resolved-member cache is cleared', () => { + const snapshot = createSnapshot({ + members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot], + }); + const state = createState(snapshot); + + const firstMembers = selectResolvedMembersForTeamName(state, 'my-team'); + const secondMembers = selectResolvedMembersForTeamName(state, 'my-team'); + const firstAlice = selectResolvedMemberForTeamName(state, 'my-team', 'alice'); + const secondAlice = selectResolvedMemberForTeamName(state, 'my-team', 'alice'); + + expect(secondMembers).toBe(firstMembers); + expect(secondAlice).toBe(firstAlice); + expect(getResolvedMemberSelectorCacheSnapshotForTeam('my-team')).toEqual({ + hasResolvedMembersSelector: true, + resolvedMemberSelectorCount: 1, + }); + + clearResolvedMemberSelectorCaches(); + + expect(selectResolvedMembersForTeamName(state, 'my-team')).not.toBe(firstMembers); + expect(getResolvedMemberSelectorCacheSnapshotForTeam('my-team')).toEqual({ + hasResolvedMembersSelector: true, + resolvedMemberSelectorCount: 0, + }); + }); + + it('derives activity status from member activity metadata', () => { + const snapshot = createSnapshot({ + members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot], + }); + const meta = { + teamName: 'my-team', + feedRevision: 'rev-1', + computedAt: '2026-04-17T12:00:00.000Z', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-04-17T11:57:00.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + }, + }, + } as TeamMemberActivityMeta; + + expect(selectResolvedMemberForTeamName(createState(snapshot, { meta }), 'my-team', 'alice')) + .toMatchObject({ + status: 'active', + messageCount: 3, + lastActiveAt: '2026-04-17T11:57:00.000Z', + }); + }); + + it('preserves the selected snapshot when an incoming empty snapshot is confirmed by summary', () => { + const current = createSnapshot({ + members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot], + }); + const incoming = createSnapshot({ members: [], config: { name: 'My Team' } }); + const summary = { + teamName: 'my-team', + displayName: 'My Team', + memberCount: 1, + expectedMemberCount: 1, + taskCount: 0, + lastActivity: null, + members: [{ name: 'alice' }], + } as TeamSummary; + + expect(shouldPreserveSelectedTeamSnapshot(current, current, incoming, summary)).toBe(true); + }); + + it('does not preserve the selected snapshot when incoming data has a config roster', () => { + const current = createSnapshot({ + members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot], + }); + const incoming = createSnapshot({ + members: [], + config: { name: 'My Team', members: [{ name: 'bob' }] }, + }); + + expect(shouldPreserveSelectedTeamSnapshot(current, current, incoming, undefined)).toBe(false); + }); +}); diff --git a/test/renderer/store/teamScopedStateCleanup.test.ts b/test/renderer/store/teamScopedStateCleanup.test.ts new file mode 100644 index 00000000..e071fd63 --- /dev/null +++ b/test/renderer/store/teamScopedStateCleanup.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildTeamScopedProgressTombstones, + collectTeamScopedStateRemovals, + collectTeamScopedVisibleLoadingResets, +} from '../../../src/renderer/store/team/teamScopedStateCleanup'; + +const teamScopedRecordKeys = [ + 'teamDataCacheByName', + 'teamAgentRuntimeByTeam', + 'teamMessagesByName', + 'memberActivityMetaByTeam', + 'provisioningSnapshotByTeam', + 'currentProvisioningRunIdByTeam', + 'currentRuntimeRunIdByTeam', + 'provisioningStartedAtFloorByTeam', + 'leadActivityByTeam', + 'leadContextByTeam', + 'activeTaskLogActivityByTeam', + 'activeToolsByTeam', + 'finishedVisibleByTeam', + 'toolHistoryByTeam', + 'memberSpawnStatusesByTeam', + 'memberSpawnSnapshotsByTeam', + 'provisioningErrorByTeam', +] as const; + +function buildRecord(label: string): Record { + return { + 'my-team': `${label}:mine`, + 'other-team': `${label}:other`, + }; +} + +function buildRemovalState(): Parameters[0] { + return { + provisioningRuns: { + 'run-mine-1': { teamName: 'my-team' }, + 'run-other': { teamName: 'other-team' }, + 'run-mine-2': { teamName: 'my-team' }, + }, + teamDataCacheByName: buildRecord('teamDataCacheByName'), + teamAgentRuntimeByTeam: buildRecord('teamAgentRuntimeByTeam'), + teamMessagesByName: buildRecord('teamMessagesByName'), + memberActivityMetaByTeam: buildRecord('memberActivityMetaByTeam'), + provisioningSnapshotByTeam: buildRecord('provisioningSnapshotByTeam'), + currentProvisioningRunIdByTeam: buildRecord('currentProvisioningRunIdByTeam'), + currentRuntimeRunIdByTeam: buildRecord('currentRuntimeRunIdByTeam'), + provisioningStartedAtFloorByTeam: buildRecord('provisioningStartedAtFloorByTeam'), + leadActivityByTeam: buildRecord('leadActivityByTeam'), + leadContextByTeam: buildRecord('leadContextByTeam'), + activeTaskLogActivityByTeam: buildRecord('activeTaskLogActivityByTeam'), + activeToolsByTeam: buildRecord('activeToolsByTeam'), + finishedVisibleByTeam: buildRecord('finishedVisibleByTeam'), + toolHistoryByTeam: buildRecord('toolHistoryByTeam'), + memberSpawnStatusesByTeam: buildRecord('memberSpawnStatusesByTeam'), + memberSpawnSnapshotsByTeam: buildRecord('memberSpawnSnapshotsByTeam'), + provisioningErrorByTeam: buildRecord('provisioningErrorByTeam'), + }; +} + +describe('teamScopedStateCleanup', () => { + it('resets visible team loading and message loading flags for the scoped team', () => { + const otherEntry = { + loadingHead: true, + loadingOlder: false, + marker: 'other', + }; + const patch = collectTeamScopedVisibleLoadingResets( + { + teamMessagesByName: { + 'my-team': { + loadingHead: true, + loadingOlder: true, + marker: 'mine', + }, + 'other-team': otherEntry, + }, + selectedTeamName: 'my-team', + selectedTeamLoading: true, + selectedTeamError: 'Boom', + }, + 'my-team' + ); + + expect(patch).toEqual({ + teamMessagesByName: { + 'my-team': { + loadingHead: false, + loadingOlder: false, + marker: 'mine', + }, + 'other-team': otherEntry, + }, + selectedTeamLoading: false, + selectedTeamError: null, + }); + }); + + it('does not emit visible loading changes when the scoped team is already idle', () => { + const patch = collectTeamScopedVisibleLoadingResets( + { + teamMessagesByName: { + 'my-team': { + loadingHead: false, + loadingOlder: false, + }, + }, + selectedTeamName: 'other-team', + selectedTeamLoading: false, + selectedTeamError: null, + }, + 'my-team' + ); + + expect(patch).toEqual({}); + }); + + it('removes scoped team records and provisioning runs while preserving other teams', () => { + const patch = collectTeamScopedStateRemovals(buildRemovalState(), 'my-team'); + + expect(patch.provisioningRuns).toEqual({ + 'run-other': { teamName: 'other-team' }, + }); + for (const key of teamScopedRecordKeys) { + expect(patch[key]).toEqual({ + 'other-team': `${key}:other`, + }); + } + }); + + it('does not emit removal changes when the team is absent', () => { + const state = buildRemovalState(); + const patch = collectTeamScopedStateRemovals(state, 'missing-team'); + + expect(patch).toEqual({}); + }); + + it('tombstones current provisioning and runtime run ids for the scoped team', () => { + const tombstones = buildTeamScopedProgressTombstones( + { + currentProvisioningRunIdByTeam: { + 'my-team': 'provisioning-run-1', + 'other-team': 'provisioning-run-2', + }, + currentRuntimeRunIdByTeam: { + 'my-team': 'runtime-run-1', + }, + ignoredProvisioningRunIds: { + old: 'old-team', + }, + ignoredRuntimeRunIds: { + 'old-runtime': 'old-team', + }, + provisioningStartedAtFloorByTeam: { + 'other-team': '2026-01-01T00:00:00.000Z', + }, + }, + 'my-team', + '2026-05-22T10:00:00.000Z' + ); + + expect(tombstones).toEqual({ + ignoredProvisioningRunIds: { + old: 'old-team', + 'provisioning-run-1': 'my-team', + }, + ignoredRuntimeRunIds: { + 'old-runtime': 'old-team', + 'runtime-run-1': 'my-team', + }, + provisioningStartedAtFloorByTeam: { + 'other-team': '2026-01-01T00:00:00.000Z', + 'my-team': '2026-05-22T10:00:00.000Z', + }, + }); + }); + + it('still records a floor when there are no current run ids to tombstone', () => { + const tombstones = buildTeamScopedProgressTombstones( + { + currentProvisioningRunIdByTeam: {}, + currentRuntimeRunIdByTeam: { + 'my-team': null, + }, + ignoredProvisioningRunIds: {}, + ignoredRuntimeRunIds: {}, + provisioningStartedAtFloorByTeam: {}, + }, + 'my-team', + '2026-05-22T10:00:00.000Z' + ); + + expect(tombstones).toEqual({ + ignoredProvisioningRunIds: {}, + ignoredRuntimeRunIds: {}, + provisioningStartedAtFloorByTeam: { + 'my-team': '2026-05-22T10:00:00.000Z', + }, + }); + }); +}); diff --git a/test/renderer/store/teamSnapshotStructuralSharing.test.ts b/test/renderer/store/teamSnapshotStructuralSharing.test.ts new file mode 100644 index 00000000..c5020f40 --- /dev/null +++ b/test/renderer/store/teamSnapshotStructuralSharing.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { + structurallySharePlainValue, + structurallyShareTeamSnapshot, +} from '../../../src/renderer/store/team/teamSnapshotStructuralSharing'; + +import type { TeamViewSnapshot } from '../../../src/shared/types'; + +function createSnapshot(overrides: Partial = {}): TeamViewSnapshot { + return { + teamName: 'my-team', + config: { name: 'My Team' }, + members: [], + tasks: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + ...overrides, + }; +} + +describe('teamSnapshotStructuralSharing', () => { + it('returns the next snapshot when there is no previous snapshot', () => { + const next = createSnapshot(); + + expect(structurallyShareTeamSnapshot(null, next)).toBe(next); + expect(structurallyShareTeamSnapshot(undefined, next)).toBe(next); + }); + + it('preserves the previous snapshot reference when values are deeply equal', () => { + const previous = createSnapshot({ + config: { name: 'My Team', description: 'Same description' }, + warnings: ['same warning'], + isAlive: true, + }); + const next = createSnapshot({ + config: { name: 'My Team', description: 'Same description' }, + warnings: ['same warning'], + isAlive: true, + }); + + expect(structurallyShareTeamSnapshot(previous, next)).toBe(previous); + }); + + it('replaces only changed snapshot branches while sharing unchanged branches', () => { + const previousWarnings = ['same warning']; + const previous = createSnapshot({ + config: { name: 'My Team', description: 'Old description' }, + warnings: previousWarnings, + isAlive: true, + }); + const next = createSnapshot({ + config: { name: 'My Team', description: 'New description' }, + warnings: ['same warning'], + isAlive: true, + }); + + const shared = structurallyShareTeamSnapshot(previous, next); + + expect(shared).not.toBe(previous); + expect(shared).toEqual(next); + expect(shared.config).not.toBe(previous.config); + expect(shared.warnings).toBe(previousWarnings); + expect(shared.members).toBe(previous.members); + expect(shared.tasks).toBe(previous.tasks); + expect(shared.kanbanState).toBe(previous.kanbanState); + expect(shared.processes).toBe(previous.processes); + }); + + it('shares unchanged array entries and replaces changed entries', () => { + const previous = [ + { id: 'task-1', title: 'Keep' }, + { id: 'task-2', title: 'Old' }, + ]; + const next = [ + { id: 'task-1', title: 'Keep' }, + { id: 'task-2', title: 'New' }, + ]; + + const shared = structurallySharePlainValue(previous, next); + + expect(shared).not.toBe(previous); + expect(shared).toEqual(next); + expect(shared[0]).toBe(previous[0]); + expect(shared[1]).not.toBe(previous[1]); + }); + + it('replaces objects when keys are added or removed', () => { + const previous = { id: 'task-1', title: 'Same', extra: true }; + const next = { id: 'task-1', title: 'Same' }; + + const shared = structurallySharePlainValue(previous, next); + + expect(shared).not.toBe(previous); + expect(shared).toEqual(next); + }); + + it('treats null-prototype objects as plain values', () => { + const previous = Object.assign(Object.create(null) as Record, { + id: 'task-1', + title: 'Same', + }); + const next = Object.assign(Object.create(null) as Record, { + id: 'task-1', + title: 'Same', + }); + + expect(structurallySharePlainValue(previous, next)).toBe(previous); + }); + + it('replaces non-plain objects instead of traversing them', () => { + const previous = new Date('2026-05-22T10:00:00.000Z'); + const next = new Date('2026-05-22T10:00:00.000Z'); + + expect(structurallySharePlainValue(previous, next)).toBe(next); + }); +}); diff --git a/test/renderer/store/teamToolApprovalSettings.test.ts b/test/renderer/store/teamToolApprovalSettings.test.ts new file mode 100644 index 00000000..8d7412a9 --- /dev/null +++ b/test/renderer/store/teamToolApprovalSettings.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { parseToolApprovalSettings } from '../../../src/renderer/store/team/teamToolApprovalSettings'; +import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '../../../src/shared/types/team'; + +describe('teamToolApprovalSettings', () => { + it('returns defaults for missing or invalid JSON', () => { + expect(parseToolApprovalSettings(null)).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS); + expect(parseToolApprovalSettings('')).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS); + expect(parseToolApprovalSettings('{not json')).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS); + }); + + it('parses valid complete settings', () => { + expect( + parseToolApprovalSettings( + JSON.stringify({ + autoAllowAll: true, + autoAllowFileEdits: true, + autoAllowSafeBash: true, + timeoutAction: 'allow', + timeoutSeconds: 120, + }) + ) + ).toEqual({ + autoAllowAll: true, + autoAllowFileEdits: true, + autoAllowSafeBash: true, + timeoutAction: 'allow', + timeoutSeconds: 120, + }); + }); + + it('falls back per field when values have invalid types', () => { + expect( + parseToolApprovalSettings( + JSON.stringify({ + autoAllowAll: 'yes', + autoAllowFileEdits: true, + autoAllowSafeBash: 1, + timeoutAction: 'maybe', + timeoutSeconds: '60', + }) + ) + ).toEqual({ + ...DEFAULT_TOOL_APPROVAL_SETTINGS, + autoAllowFileEdits: true, + }); + }); + + it('accepts timeout actions allow, deny, and wait', () => { + expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'allow' })).timeoutAction).toBe( + 'allow' + ); + expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'deny' })).timeoutAction).toBe( + 'deny' + ); + expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'wait' })).timeoutAction).toBe( + 'wait' + ); + }); + + it('accepts timeout seconds at inclusive boundaries', () => { + expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 5 })).timeoutSeconds).toBe(5); + expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 300 })).timeoutSeconds).toBe( + 300 + ); + }); + + it('rejects timeout seconds outside allowed boundaries or non-finite values', () => { + expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 4 })).timeoutSeconds).toBe( + DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds + ); + expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 301 })).timeoutSeconds).toBe( + DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds + ); + expect( + parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: Number.POSITIVE_INFINITY })) + .timeoutSeconds + ).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds); + }); +});