From b1b2e696e584aa9b2ffdd7aaeeea5efe2db3a6b9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 4 May 2026 14:47:46 +0300 Subject: [PATCH] perf(team): defer enrichments after first paint --- src/main/ipc/teams.ts | 61 +- src/main/services/team/TeamDataService.ts | 14 +- .../services/team/TeamDataWorkerClient.ts | 129 ++-- src/main/services/team/teamDataWorkerTypes.ts | 2 + src/main/workers/team-data-worker.ts | 2 +- src/preload/index.ts | 8 +- src/renderer/api/httpClient.ts | 6 +- src/renderer/components/team/TeamListView.tsx | 8 +- src/renderer/store/slices/teamSlice.ts | 438 +++++++++++--- src/shared/types/api.ts | 3 +- src/shared/types/team.ts | 8 + test/main/ipc/teams.test.ts | 107 ++++ .../services/team/TeamDataService.test.ts | 81 +++ .../team/TeamDataWorkerClient.test.ts | 138 +++++ test/renderer/store/teamSlice.test.ts | 562 +++++++++++++++++- 15 files changed, 1428 insertions(+), 139 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 4dd234aa..9165f98d 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -202,6 +202,7 @@ import type { TeamCreateRequest, TeamCreateResponse, TeamFastMode, + TeamGetDataOptions, TeamLaunchRequest, TeamLaunchResponse, TeamMemberActivityMeta, @@ -227,6 +228,42 @@ const logger = createLogger('IPC:teams'); const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000; const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250; +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 validateTeamGetDataOptions( + value: unknown +): { valid: true; value: TeamGetDataOptions | undefined } | { valid: false; error: string } { + if (value === undefined) { + return { valid: true, value: undefined }; + } + if (!isPlainObject(value)) { + return { valid: false, error: 'options must be an object' }; + } + + const allowed = new Set(['includeMemberBranches']); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + return { valid: false, error: `Unknown getData option: ${key}` }; + } + } + + const includeMemberBranches = value.includeMemberBranches; + if (includeMemberBranches !== undefined && typeof includeMemberBranches !== 'boolean') { + return { valid: false, error: 'includeMemberBranches must be a boolean' }; + } + + return { + valid: true, + value: includeMemberBranches === false ? { includeMemberBranches: false } : undefined, + }; +} + /** * In-memory set of rate-limit message keys already processed. * Independent of NotificationManager storage — survives notification deletion/pruning. @@ -944,17 +981,27 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } + const optionsResult = validateTeamGetDataOptions(rawOptions); + if (!optionsResult.valid) { + return { success: false, error: optionsResult.error }; + } const tn = validated.value!; + const getDataOptions = optionsResult.value; const startedAt = Date.now(); let data: TeamViewSnapshot; let dataSource: 'worker' | 'main-fallback' | 'main-unavailable' = 'main-unavailable'; let workerAvailable = false; + const readFromMain = (): Promise => + getDataOptions === undefined + ? getTeamDataService().getTeamData(tn) + : getTeamDataService().getTeamData(tn, getDataOptions); setCurrentMainOp('team:getData'); try { // Prefer worker thread to keep main event loop responsive @@ -970,19 +1017,22 @@ async function handleGetData( if (workerAvailable) { try { - data = await worker.getTeamData(tn); + data = + getDataOptions === undefined + ? await worker.getTeamData(tn) + : await worker.getTeamData(tn, getDataOptions); dataSource = 'worker'; } catch (workerErr) { logger.warn( `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` ); noteHeavyTeamDataWorkerFallback('teams:getData'); - data = await getTeamDataService().getTeamData(tn); + data = await readFromMain(); dataSource = 'main-fallback'; } } else { noteHeavyTeamDataWorkerFallback('teams:getData'); - data = await getTeamDataService().getTeamData(tn); + data = await readFromMain(); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -1011,8 +1061,9 @@ async function handleGetData( const getDataMs = Date.now() - startedAt; if (getDataMs >= 1500) { + const branchMode = getDataOptions?.includeMemberBranches === false ? 'skipped' : 'full'; logger.warn( - `[teams:getData] slow team=${tn} ms=${getDataMs} source=${dataSource} workerAvailable=${workerAvailable}` + `[teams:getData] slow team=${tn} ms=${getDataMs} source=${dataSource} workerAvailable=${workerAvailable} branchMode=${branchMode}` ); } const teamDataService = getTeamDataService(); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ca226987..a4add099 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -86,6 +86,7 @@ import type { TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, + TeamGetDataOptions, TeamMember, TeamMemberActivityMeta, TeamMemberSnapshot, @@ -1128,7 +1129,8 @@ export class TeamDataService { TeamTaskReader.invalidateAllTasksCache(); } - async getTeamData(teamName: string): Promise { + async getTeamData(teamName: string, options?: TeamGetDataOptions): Promise { + const includeMemberBranches = options?.includeMemberBranches !== false; const startedAt = Date.now(); const marks: Record = {}; const mark = (label: string): void => { @@ -1376,8 +1378,11 @@ export class TeamDataService { } mark('runtimeAdvisories'); - // Enrich members with git branch when it differs from lead's branch - await this.enrichMemberBranches(members, config); + // Enrich members with git branch when it differs from lead's branch. + // UI-first reads can skip this because the renderer hydrates branches through branch sync. + if (includeMemberBranches) { + await this.enrichMemberBranches(members, config); + } mark('enrichBranches'); mark('syncComments'); @@ -1392,6 +1397,7 @@ export class TeamDataService { const totalMs = Date.now() - startedAt; if (totalMs >= 1500) { const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`; + const branchMode = includeMemberBranches ? 'full' : 'skipped'; logger.warn( `getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince( 'inboxNames' @@ -1412,7 +1418,7 @@ export class TeamDataService { )}/enrichBranches=${msBetween( 'runtimeAdvisories', 'enrichBranches' - )}/processes=${msBetween('syncComments', 'processes')} ${counts}${ + )}/processes=${msBetween('syncComments', 'processes')} branchMode=${branchMode} ${counts}${ warnings.length > 0 ? ` warnings=${warnings.join('|')}` : '' }` ); diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index ed7398a7..c0a09973 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -17,6 +17,7 @@ import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWo import type { MemberLogSummary, MessagesPage, + TeamGetDataOptions, TeamMemberActivityMeta, TeamViewSnapshot, } from '@shared/types'; @@ -63,38 +64,59 @@ interface PendingEntry { reject: (e: Error) => void; } -function summarizeWorkerPayload( - payload: TeamDataWorkerRequest['payload'] -): Record { - if (!payload) { - return {}; - } - if ('taskId' in payload) { - return { - teamName: payload.teamName, - taskId: payload.taskId, - owner: payload.options?.owner, - status: payload.options?.status, - intervals: Array.isArray(payload.options?.intervals) - ? payload.options.intervals.length - : undefined, - since: payload.options?.since, - }; - } - if ('options' in payload) { - return { - teamName: payload.teamName, - cursor: - typeof payload.options.cursor === 'string' - ? payload.options.cursor.slice(0, 24) - : payload.options.cursor, - limit: payload.options.limit, - }; - } - if ('teamName' in payload) { - return { - teamName: payload.teamName, - }; +function normalizeTeamGetDataOptions(options?: TeamGetDataOptions): TeamGetDataOptions | undefined { + return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined; +} + +function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string { + const normalizedOptions = normalizeTeamGetDataOptions(options); + return `${teamName}\u0000branches:${normalizedOptions ? '0' : '1'}`; +} + +function getTeamDataRequestPayload( + teamName: string, + options?: TeamGetDataOptions +): Extract['payload'] { + const normalizedOptions = normalizeTeamGetDataOptions(options); + return normalizedOptions ? { teamName, options: normalizedOptions } : { teamName }; +} + +function summarizeWorkerRequest(request: TeamDataWorkerRequest): Record { + switch (request.op) { + case 'warmup': + return {}; + case 'getTeamData': { + const { teamName, options } = request.payload; + return { + teamName, + includeMemberBranches: options?.includeMemberBranches !== false, + }; + } + case 'getMessagesPage': { + const { teamName, options } = request.payload; + return { + teamName, + cursor: typeof options.cursor === 'string' ? options.cursor.slice(0, 24) : options.cursor, + limit: options.limit, + }; + } + case 'getMemberActivityMeta': + case 'invalidateTeamConfig': + case 'invalidateTeamMessageFeed': + return { + teamName: request.payload.teamName, + }; + case 'findLogsForTask': + return { + teamName: request.payload.teamName, + taskId: request.payload.taskId, + owner: request.payload.options?.owner, + status: request.payload.options?.status, + intervals: Array.isArray(request.payload.options?.intervals) + ? request.payload.options.intervals.length + : undefined, + since: request.payload.options?.since, + }; } return {}; } @@ -169,6 +191,7 @@ export class TeamDataWorkerClient { ): Promise { const worker = this.ensureWorker(); const id = makeId(); + const request = { id, op, payload } as TeamDataWorkerRequest; const startedAt = Date.now(); const pendingAtStart = this.pending.size; @@ -177,7 +200,7 @@ export class TeamDataWorkerClient { const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`); logger.warn( `worker call timeout op=${op} ms=${Date.now() - startedAt} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( - summarizeWorkerPayload(payload) + summarizeWorkerRequest(request) )}` ); this.failWorker(worker, timeoutError); @@ -192,7 +215,7 @@ export class TeamDataWorkerClient { if (ms >= 1500) { logger.warn( `worker call slow op=${op} ms=${ms} workerTotalMs=${String(diag?.totalMs ?? 'unknown')} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( - summarizeWorkerPayload(payload) + summarizeWorkerRequest(request) )}` ); } @@ -204,7 +227,7 @@ export class TeamDataWorkerClient { if (ms >= 1500) { logger.warn( `worker call failed slow op=${op} ms=${ms} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( - summarizeWorkerPayload(payload) + summarizeWorkerRequest(request) )} error=${error.message}` ); } @@ -212,7 +235,7 @@ export class TeamDataWorkerClient { }, }); - worker.postMessage({ id, op, payload } as TeamDataWorkerRequest); + worker.postMessage(request); }); } @@ -237,36 +260,37 @@ export class TeamDataWorkerClient { ): void { const worker = this.worker; if (!worker) return; + const request = { id: makeId(), op, payload } as TeamDataWorkerRequest; try { - worker.postMessage({ id: makeId(), op, payload } as TeamDataWorkerRequest); + worker.postMessage(request); } catch (error) { logger.debug( `worker best-effort post failed op=${op} payload=${JSON.stringify( - summarizeWorkerPayload(payload) + summarizeWorkerRequest(request) )} error=${error instanceof Error ? error.message : String(error)}` ); } } - async getTeamData(teamName: string): Promise { + async getTeamData(teamName: string, options?: TeamGetDataOptions): Promise { if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); - const existing = this.getTeamDataInFlight.get(teamName); + const key = getTeamDataRequestKey(teamName, options); + const existing = this.getTeamDataInFlight.get(key); if (existing) return existing; - const promise = (this.call('getTeamData', { teamName }) as Promise).finally( - () => { - if (this.getTeamDataInFlight.get(teamName) === promise) { - this.getTeamDataInFlight.delete(teamName); - } + const payload = getTeamDataRequestPayload(teamName, options); + const promise = (this.call('getTeamData', payload) as Promise).finally(() => { + if (this.getTeamDataInFlight.get(key) === promise) { + this.getTeamDataInFlight.delete(key); } - ); - this.getTeamDataInFlight.set(teamName, promise); + }); + this.getTeamDataInFlight.set(key, promise); return promise; } invalidateTeamConfig(teamName: string): void { if (!SAFE_NAME_RE.test(teamName)) return; - this.getTeamDataInFlight.delete(teamName); + this.clearTeamDataInFlightForTeam(teamName); this.clearMessagesPageInFlightForTeam(teamName); this.postBestEffort('invalidateTeamConfig', { teamName }); } @@ -286,6 +310,15 @@ export class TeamDataWorkerClient { } } + private clearTeamDataInFlightForTeam(teamName: string): void { + const prefix = `${teamName}\u0000`; + for (const key of this.getTeamDataInFlight.keys()) { + if (key.startsWith(prefix)) { + this.getTeamDataInFlight.delete(key); + } + } + } + async getMessagesPage( teamName: string, options: { cursor?: string | null; limit: number } diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts index 11a75219..3a33dd64 100644 --- a/src/main/services/team/teamDataWorkerTypes.ts +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -5,6 +5,7 @@ import type { MemberLogSummary, MessagesPage, + TeamGetDataOptions, TeamMemberActivityMeta, TeamViewSnapshot, } from '@shared/types'; @@ -13,6 +14,7 @@ import type { export interface GetTeamDataPayload { teamName: string; + options?: TeamGetDataOptions; } export interface GetMessagesPagePayload { diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 931f26ec..ea4d9f58 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -50,7 +50,7 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { break; } case 'getTeamData': { - const result = await teamDataService.getTeamData(msg.payload.teamName); + const result = await teamDataService.getTeamData(msg.payload.teamName, msg.payload.options); respond({ id: msg.id, ok: true, result, diag: buildDiag() }); break; } diff --git a/src/preload/index.ts b/src/preload/index.ts index f175881a..c248d009 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -307,6 +307,7 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, + TeamGetDataOptions, TeamLaunchRequest, TeamLaunchResponse, TeamMemberActivityMeta, @@ -844,8 +845,11 @@ const electronAPI: ElectronAPI = { list: async () => { return invokeIpcWithResult(TEAM_LIST); }, - getData: async (teamName: string) => { - return invokeIpcWithResult(TEAM_GET_DATA, teamName); + getData: async (teamName: string, options?: TeamGetDataOptions) => { + if (options === undefined) { + return invokeIpcWithResult(TEAM_GET_DATA, teamName); + } + return invokeIpcWithResult(TEAM_GET_DATA, teamName, options); }, getTaskChangePresence: async (teamName: string) => { return invokeIpcWithResult>( diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 77479873..2a2ad181 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -61,6 +61,7 @@ import type { TeamClaudeLogsResponse, TeamCreateRequest, TeamCreateResponse, + TeamGetDataOptions, TeamLaunchRequest, TeamLaunchResponse, TeamMemberActivityMeta, @@ -706,7 +707,10 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] teams API is not available in browser mode'); return []; }, - getData: async (_teamName: string): Promise => { + getData: async ( + _teamName: string, + _options?: TeamGetDataOptions + ): Promise => { throw new Error('Teams detail is not available in browser mode'); }, getTaskChangePresence: async (): Promise< diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index b3ab27f7..9a1e0567 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -601,7 +601,9 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { e.stopPropagation(); void (async () => { try { - const data = await api.teams.getData(teamName); + const data = await api.teams.getData(teamName, { + includeMemberBranches: false, + }); const existingNames = teams.map((t) => t.teamName); const uniqueName = generateUniqueName(teamName, existingNames); const members = (data.members ?? []) @@ -653,7 +655,9 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { e.stopPropagation(); if (!projectPath) return; try { - const data = await api.teams.getData(teamName); + const data = await api.teams.getData(teamName, { + includeMemberBranches: false, + }); setLaunchDialogTeamName(teamName); setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? [])); setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 4e69decd..f1cd371d 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -53,6 +53,7 @@ import type { TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot, TeamCreateRequest, + TeamGetDataOptions, TeamLaunchRequest, TeamMemberActivityMeta, TeamMemberSnapshot, @@ -77,9 +78,19 @@ const TEAM_FETCH_TIMEOUT_MS = 30_000; const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000; const TEAM_REFRESH_BURST_WINDOW_MS = 4_000; const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000; +const POST_PAINT_TEAM_ENRICHMENT_FALLBACK_MS = 500; const inFlightTeamDataRequests = new Map>(); const inFlightRefreshTeamDataCalls = new Map>(); const pendingFreshTeamDataRefreshes = new Set(); +const queuedFullTeamDataRefreshesAfterThin = new Set(); +interface PostPaintHandle { + rafId?: number; + timerId?: ReturnType; + fallbackTimerId?: ReturnType; + cancelled: boolean; + ran: boolean; +} +const postPaintTeamEnrichmentTimers = new Map(); const inFlightTeamMessagesHeadRequests = new Map>(); const inFlightTeamMessagesOlderRequests = new Map>(); const queuedTeamMessagesHeadRefreshesAfterOlder = new Map< @@ -105,6 +116,55 @@ 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)); +} + +function hasThinTeamDataRequestForTeam(teamName: string): boolean { + return inFlightTeamDataRequests.has(getThinTeamDataRequestKey(teamName)); +} + +function clearTeamDataRequestsForTeam(teamName: string): void { + const prefix = `${teamName}\u0000`; + for (const key of inFlightTeamDataRequests.keys()) { + if (key.startsWith(prefix)) { + inFlightTeamDataRequests.delete(key); + } + } +} + type TeamGraphSlotAssignments = Record; type TeamGraphMemberSeedInput = Pick; type TeamGraphConfigMemberSeedInput = Pick< @@ -118,9 +178,10 @@ interface TeamGraphLayoutSessionState { export function isTeamDataRefreshPending(teamName: string): boolean { return ( - inFlightTeamDataRequests.has(teamName) || + hasFullTeamDataRequestForTeam(teamName) || (inFlightRefreshTeamDataCalls.get(teamName)?.size ?? 0) > 0 || - pendingFreshTeamDataRefreshes.has(teamName) + pendingFreshTeamDataRefreshes.has(teamName) || + queuedFullTeamDataRefreshesAfterThin.has(teamName) ); } @@ -144,6 +205,11 @@ export function __resetTeamSliceModuleStateForTests(): void { inFlightTeamDataRequests.clear(); inFlightRefreshTeamDataCalls.clear(); pendingFreshTeamDataRefreshes.clear(); + queuedFullTeamDataRefreshesAfterThin.clear(); + for (const teamName of postPaintTeamEnrichmentTimers.keys()) { + cancelPostPaintTeamEnrichments(teamName); + } + postPaintTeamEnrichmentTimers.clear(); inFlightTeamMessagesHeadRequests.clear(); inFlightTeamMessagesOlderRequests.clear(); queuedTeamMessagesHeadRefreshesAfterOlder.clear(); @@ -184,9 +250,11 @@ function clearTeamScopedSelectorCaches(teamName: string): void { } function clearTeamScopedTransientState(teamName: string): void { - inFlightTeamDataRequests.delete(teamName); + clearTeamDataRequestsForTeam(teamName); inFlightRefreshTeamDataCalls.delete(teamName); pendingFreshTeamDataRefreshes.delete(teamName); + queuedFullTeamDataRefreshesAfterThin.delete(teamName); + cancelPostPaintTeamEnrichments(teamName); inFlightTeamMessagesHeadRequests.delete(teamName); inFlightTeamMessagesOlderRequests.delete(teamName); queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName); @@ -389,12 +457,171 @@ function endInFlightTeamDataRefresh(teamName: string, token: symbol): void { } } +function cancelPostPaintTeamEnrichments(teamName: string): void { + const handle = postPaintTeamEnrichmentTimers.get(teamName); + if (!handle) { + return; + } + + handle.cancelled = true; + if ( + handle.rafId !== undefined && + typeof window !== 'undefined' && + typeof window.cancelAnimationFrame === 'function' + ) { + window.cancelAnimationFrame(handle.rafId); + } + if (handle.timerId !== undefined) { + clearTimeout(handle.timerId); + } + if (handle.fallbackTimerId !== undefined) { + clearTimeout(handle.fallbackTimerId); + } + postPaintTeamEnrichmentTimers.delete(teamName); +} + +function scheduleAfterPaint(run: () => void): PostPaintHandle { + const handle: PostPaintHandle = { + cancelled: false, + ran: false, + }; + + const runOnce = (): void => { + if (handle.cancelled || handle.ran) { + return; + } + handle.ran = true; + + if ( + handle.rafId !== undefined && + typeof window !== 'undefined' && + typeof window.cancelAnimationFrame === 'function' + ) { + window.cancelAnimationFrame(handle.rafId); + handle.rafId = undefined; + } + if (handle.timerId !== undefined) { + clearTimeout(handle.timerId); + handle.timerId = undefined; + } + if (handle.fallbackTimerId !== undefined) { + clearTimeout(handle.fallbackTimerId); + handle.fallbackTimerId = undefined; + } + + run(); + }; + + const scheduleTimer = (): void => { + handle.timerId = setTimeout(runOnce, 0); + }; + + handle.fallbackTimerId = setTimeout(runOnce, POST_PAINT_TEAM_ENRICHMENT_FALLBACK_MS); + + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + handle.rafId = window.requestAnimationFrame(() => { + handle.rafId = undefined; + scheduleTimer(); + }); + return handle; + } + + scheduleTimer(); + return handle; +} + +function drainQueuedFullRefreshAfterThinSettles(teamName: string, get: () => TeamSlice): void { + if (!queuedFullTeamDataRefreshesAfterThin.delete(teamName)) { + return; + } + void get().refreshTeamData(teamName, { withDedup: true }); +} + +function isSelectedTeamLoadStillCurrent( + get: () => TeamSlice, + teamName: string, + requestNonce: number, + teamStateEpoch: number +): boolean { + const state = get(); + return ( + isTeamLocalStateEpochCurrent(teamName, teamStateEpoch) && + state.selectedTeamName === teamName && + state.selectedTeamLoadNonce === requestNonce && + state.selectedTeamData?.teamName === teamName + ); +} + +function schedulePostPaintTeamEnrichments(params: { + teamName: string; + requestNonce: number; + teamStateEpoch: number; + get: () => TeamSlice; +}): void { + const { teamName, requestNonce, teamStateEpoch, get } = params; + + cancelPostPaintTeamEnrichments(teamName); + + const handle = scheduleAfterPaint(() => { + if (postPaintTeamEnrichmentTimers.get(teamName) !== handle) { + return; + } + postPaintTeamEnrichmentTimers.delete(teamName); + + void (async () => { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + queuedFullTeamDataRefreshesAfterThin.delete(teamName); + return; + } + + const state = get(); + if (state.selectedTeamName !== teamName) { + drainQueuedFullRefreshAfterThinSettles(teamName, get); + return; + } + + if (state.selectedTeamLoadNonce !== requestNonce) { + return; + } + + if (state.selectedTeamData?.teamName !== teamName) { + queuedFullTeamDataRefreshesAfterThin.delete(teamName); + return; + } + + if (queuedFullTeamDataRefreshesAfterThin.delete(teamName)) { + void get().refreshTeamData(teamName, { withDedup: true }); + } + + try { + const headResult = await get().refreshTeamMessagesHead(teamName); + if (!isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, teamStateEpoch)) { + return; + } + if (headResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) { + await get().refreshMemberActivityMeta(teamName); + } + } catch (error) { + logger.debug( + `post-paint team enrichments skipped team=${teamName} error=${ + error instanceof Error ? error.message : String(error) + }` + ); + } + })(); + }); + + postPaintTeamEnrichmentTimers.set(teamName, handle); +} + export function __getTeamScopedTransientStateForTests(teamName: string): { hasResolvedMembersSelector: boolean; resolvedMemberSelectorCount: number; hasMergedMessagesSelector: boolean; memberMessagesSelectorCount: number; hasPendingFreshTeamDataRefresh: boolean; + hasQueuedFullTeamDataRefreshAfterThin: boolean; + hasPostPaintTeamEnrichmentTimer: boolean; hasQueuedHeadRefreshAfterOlder: boolean; hasPendingFreshMessagesHeadRefresh: boolean; hasPendingFreshMemberActivityMetaRefresh: boolean; @@ -425,6 +652,8 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName), memberMessagesSelectorCount, hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName), + hasQueuedFullTeamDataRefreshAfterThin: queuedFullTeamDataRefreshesAfterThin.has(teamName), + hasPostPaintTeamEnrichmentTimer: postPaintTeamEnrichmentTimers.has(teamName), hasQueuedHeadRefreshAfterOlder: queuedTeamMessagesHeadRefreshesAfterOlder.has(teamName), hasPendingFreshMessagesHeadRefresh: pendingFreshTeamMessagesHeadRefreshes.has(teamName), hasPendingFreshMemberActivityMetaRefresh: @@ -536,31 +765,48 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }); } -function fetchTeamDataDeduped(teamName: string): Promise { - const existing = inFlightTeamDataRequests.get(teamName); +function fetchTeamDataDeduped( + teamName: string, + options?: TeamGetDataOptions +): Promise { + const normalizedOptions = normalizeTeamGetDataOptions(options); + const key = getTeamDataRequestKey(teamName, normalizedOptions); + const existing = inFlightTeamDataRequests.get(key); if (existing) { return existing; } const request = withTimeout( - unwrapIpc('team:getData', () => api.teams.getData(teamName)), + unwrapIpc('team:getData', () => + normalizedOptions === undefined + ? api.teams.getData(teamName) + : api.teams.getData(teamName, normalizedOptions) + ), TEAM_GET_DATA_TIMEOUT_MS, - `team:getData(${teamName})` + getTeamDataRequestLabel(teamName, normalizedOptions) ).finally(() => { - if (inFlightTeamDataRequests.get(teamName) === request) { - inFlightTeamDataRequests.delete(teamName); + if (inFlightTeamDataRequests.get(key) === request) { + inFlightTeamDataRequests.delete(key); } }); - inFlightTeamDataRequests.set(teamName, request); + inFlightTeamDataRequests.set(key, request); return request; } -function fetchTeamDataFresh(teamName: string): Promise { +function fetchTeamDataFresh( + teamName: string, + options?: TeamGetDataOptions +): Promise { + const normalizedOptions = normalizeTeamGetDataOptions(options); return withTimeout( - unwrapIpc('team:getData', () => api.teams.getData(teamName)), + unwrapIpc('team:getData', () => + normalizedOptions === undefined + ? api.teams.getData(teamName) + : api.teams.getData(teamName, normalizedOptions) + ), TEAM_GET_DATA_TIMEOUT_MS, - `team:getData(${teamName})` + getTeamDataRequestLabel(teamName, normalizedOptions) ); } @@ -3412,6 +3658,8 @@ export const createTeamSlice: StateCreator = (set, const requestNonce = get().selectedTeamLoadNonce + 1; const previousData = selectTeamDataForName(get(), teamName); + cancelPostPaintTeamEnrichments(teamName); + // Repoint selection synchronously to the new team's cached snapshot when available. // Never keep the previous team's snapshot attached to a newly selected team. set({ @@ -3426,12 +3674,20 @@ export const createTeamSlice: StateCreator = (set, }); try { - const data = await fetchTeamDataDeduped(teamName); + const data = await fetchTeamDataDeduped(teamName, { + includeMemberBranches: false, + }); if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + queuedFullTeamDataRefreshesAfterThin.delete(teamName); return; } // Stale check: user may have switched to another team during the async call - if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) { + const stateAfterLoad = get(); + if (stateAfterLoad.selectedTeamName !== teamName) { + drainQueuedFullRefreshAfterThinSettles(teamName, get); + return; + } + if (stateAfterLoad.selectedTeamLoadNonce !== requestNonce) { return; } // Eagerly patch teamByName with color/displayName from detailed data @@ -3479,80 +3735,105 @@ export const createTeamSlice: StateCreator = (set, }; }); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); - const invalidationState = previousData - ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) - : { cacheKeys: [], taskIds: [] }; - if (invalidationState.cacheKeys.length > 0) { - get().invalidateTaskChangePresence(invalidationState.cacheKeys); - } - if (invalidationState.taskIds.length > 0) { - await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); - } - // Sync tab label with the team's display name from config - const displayName = data.config.name || teamName; - const allTabs = get().getAllPaneTabs(); - const relatedTabs = allTabs.filter( - (tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName - ); - for (const tab of relatedTabs) { - const nextLabel = tab.type === 'graph' ? `${displayName} Graph` : displayName; - if (tab.label !== nextLabel) { - get().updateTabLabel(tab.id, nextLabel); + + try { + const invalidationState = previousData + ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) + : { cacheKeys: [], taskIds: [] }; + if (invalidationState.cacheKeys.length > 0) { + get().invalidateTaskChangePresence(invalidationState.cacheKeys); + } + if (invalidationState.taskIds.length > 0) { + void api.review + .invalidateTaskChangeSummaries(teamName, invalidationState.taskIds) + .catch(() => undefined); } - } - const messagesHeadResult = await get().refreshTeamMessagesHead(teamName); - if (messagesHeadResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) { - await get().refreshMemberActivityMeta(teamName); - } - - if (opts?.skipProjectAutoSelect) { - return; - } - - // Auto-select the project associated with this team's cwd/projectPath. - // Must search both flat projects and grouped repositoryGroups/worktrees - // because the default viewMode is 'grouped' and flat projects may be empty. - const projectPath = data.config.projectPath; - if (projectPath) { - const state = get(); - const normalizedTeamPath = normalizePath(projectPath); - - // 1. Try flat projects list - const matchingProject = state.projects.find( - (p) => normalizePath(p.path) === normalizedTeamPath + // Sync tab label with the team's display name from config. + const displayName = data.config.name || teamName; + const allTabs = get().getAllPaneTabs(); + const relatedTabs = allTabs.filter( + (tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName ); - if (matchingProject && state.selectedProjectId !== matchingProject.id) { - state.selectProject(matchingProject.id); - } else if (!matchingProject) { - // 2. Try grouped view: search worktrees across all repository groups - for (const repo of state.repositoryGroups) { - const matchingWorktree = repo.worktrees.find( - (wt) => normalizePath(wt.path) === normalizedTeamPath - ); - if (matchingWorktree) { - if (state.selectedWorktreeId !== matchingWorktree.id) { - set(getWorktreeNavigationState(repo.id, matchingWorktree.id)); - void get().fetchSessionsInitial(matchingWorktree.id); + for (const tab of relatedTabs) { + const nextLabel = tab.type === 'graph' ? `${displayName} Graph` : displayName; + if (tab.label !== nextLabel) { + get().updateTabLabel(tab.id, nextLabel); + } + } + + // Auto-select the project associated with this team's cwd/projectPath. + // Must search both flat projects and grouped repositoryGroups/worktrees + // because the default viewMode is 'grouped' and flat projects may be empty. + const projectPath = data.config.projectPath; + if ( + !opts?.skipProjectAutoSelect && + projectPath && + isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, teamStateEpoch) + ) { + const state = get(); + const normalizedTeamPath = normalizePath(projectPath); + + // 1. Try flat projects list + const matchingProject = state.projects.find( + (p) => normalizePath(p.path) === normalizedTeamPath + ); + if (matchingProject && state.selectedProjectId !== matchingProject.id) { + state.selectProject(matchingProject.id); + } else if (!matchingProject) { + // 2. Try grouped view: search worktrees across all repository groups + for (const repo of state.repositoryGroups) { + const matchingWorktree = repo.worktrees.find( + (wt) => normalizePath(wt.path) === normalizedTeamPath + ); + if (matchingWorktree) { + if (state.selectedWorktreeId !== matchingWorktree.id) { + set(getWorktreeNavigationState(repo.id, matchingWorktree.id)); + void get().fetchSessionsInitial(matchingWorktree.id); + } + break; } - break; } } } + } catch (error) { + logger.debug( + `selectTeam(${teamName}) post-structural sync work failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + try { + schedulePostPaintTeamEnrichments({ + teamName, + requestNonce, + teamStateEpoch, + get, + }); + } catch (error) { + logger.debug( + `selectTeam(${teamName}) failed to schedule post-paint enrichments: ${ + error instanceof Error ? error.message : String(error) + }` + ); } } catch (error) { if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + queuedFullTeamDataRefreshesAfterThin.delete(teamName); return; } // If provisioning is in progress for this team, stay in loading state; // file watcher / progress callback will refresh once config is written. const currentState = get(); - if ( - currentState.selectedTeamName !== teamName || - currentState.selectedTeamLoadNonce !== requestNonce - ) { + if (currentState.selectedTeamName !== teamName) { + queuedFullTeamDataRefreshesAfterThin.delete(teamName); return; } + if (currentState.selectedTeamLoadNonce !== requestNonce) { + return; + } + queuedFullTeamDataRefreshesAfterThin.delete(teamName); const isProvisioning = isTeamProvisioningActive(currentState, teamName); const msg = error instanceof Error ? error.message : String(error); @@ -3591,12 +3872,21 @@ export const createTeamSlice: StateCreator = (set, }, refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => { + const fullKey = getFullTeamDataRequestKey(teamName); + const reusedInFlightRequest = opts?.withDedup === true && inFlightTeamDataRequests.has(fullKey); + const queuedBehindThinRequest = + opts?.withDedup === true && !reusedInFlightRequest && hasThinTeamDataRequestForTeam(teamName); + + if (queuedBehindThinRequest) { + queuedFullTeamDataRefreshesAfterThin.add(teamName); + logger.debug(`refreshTeamData(${teamName}) queued behind thin team:getData`); + return; + } + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); const refreshToken = beginInFlightTeamDataRefresh(teamName); // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). - const reusedInFlightRequest = - opts?.withDedup === true && inFlightTeamDataRequests.has(teamName); noteTeamRefreshBurst(teamName); if (reusedInFlightRequest) { pendingFreshTeamDataRefreshes.add(teamName); diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index b76e809a..d679956b 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -73,6 +73,7 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, + TeamGetDataOptions, TeamLaunchRequest, TeamLaunchResponse, TeamMemberActivityMeta, @@ -440,7 +441,7 @@ export interface HttpServerAPI { export interface TeamsAPI { list: () => Promise; - getData: (teamName: string) => Promise; + getData: (teamName: string, options?: TeamGetDataOptions) => Promise; getTaskChangePresence: (teamName: string) => Promise>; setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise; setToolActivityTracking: (teamName: string, enabled: boolean) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 4c2def63..0ebede0a 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -875,6 +875,14 @@ export interface TeamMemberActivityMeta { feedRevision: string; } +export interface TeamGetDataOptions { + /** + * Default true. + * Set false only for UI-first reads where branch labels can arrive through branch sync. + */ + includeMemberBranches?: boolean; +} + export interface TeamViewSnapshot { teamName: string; config: TeamConfig; diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index ae41c0da..f011669b 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -1274,6 +1274,113 @@ describe('ipc teams handlers', () => { (electron.app as { isPackaged: boolean }).isPackaged = false; }); + it('forwards thin TEAM_GET_DATA options to the worker without changing full request shape', async () => { + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'my-team', { + includeMemberBranches: false, + })) as { + success: boolean; + data?: { teamName: string }; + }; + + expect(result.success).toBe(true); + expect(result.data?.teamName).toBe('my-team'); + expect(mockTeamDataWorkerClient.getTeamData).toHaveBeenCalledWith('my-team', { + includeMemberBranches: false, + }); + expect(service.getTeamData).not.toHaveBeenCalled(); + }); + + it('normalizes explicit full TEAM_GET_DATA options to the existing one-argument call shape', async () => { + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'my-team', { + includeMemberBranches: true, + })) as { + success: boolean; + data?: { teamName: string }; + }; + + expect(result.success).toBe(true); + expect(mockTeamDataWorkerClient.getTeamData).toHaveBeenCalledWith('my-team'); + }); + + it('forwards thin TEAM_GET_DATA options through packaged main-thread fallback', async () => { + const electron = await import('electron'); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); + (electron.app as { isPackaged: boolean }).isPackaged = true; + + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'my-team', { + includeMemberBranches: false, + })) as { + success: boolean; + data?: { teamName: string }; + }; + + expect(result.success).toBe(true); + expect(service.getTeamData).toHaveBeenCalledWith('my-team', { + includeMemberBranches: false, + }); + vi.mocked(console.error).mockClear(); + + (electron.app as { isPackaged: boolean }).isPackaged = false; + }); + + it('rejects malformed TEAM_GET_DATA options before dispatching to service or worker', async () => { + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'my-team', { + includeMemberBranches: 'false', + })) as { + success: boolean; + error?: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain('includeMemberBranches'); + expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled(); + expect(service.getTeamData).not.toHaveBeenCalled(); + }); + + it.each([ + ['null options', null, 'options must be an object'], + ['array options', [], 'options must be an object'], + ['unknown option key', { includeMemberBranches: false, thin: true }, 'Unknown getData option'], + ])( + 'rejects malformed TEAM_GET_DATA %s before dispatching to service or worker', + async (_label, rawOptions, expectedError) => { + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'my-team', rawOptions)) as { + success: boolean; + error?: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain(expectedError); + expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled(); + expect(service.getTeamData).not.toHaveBeenCalled(); + } + ); + it('classifies draft teams before asking the team-data worker for a full snapshot', async () => { const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-get-data-')); setClaudeBasePathOverride(claudeRoot); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 441f7d2e..fedcd09b 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -10,6 +10,7 @@ import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigR import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader'; +import { gitIdentityResolver } from '../../../../src/main/services/parsing/GitIdentityResolver'; import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore'; import type { @@ -4594,6 +4595,86 @@ describe('TeamDataService', () => { expect(harness.getConfig).not.toHaveBeenCalled(); }); + it('skips member branch enrichment for thin UI team data snapshots', async () => { + const getBranchSpy = vi.spyOn(gitIdentityResolver, 'getBranch').mockResolvedValue('main'); + const harness = createGetTeamDataHarness({ + config: buildDefaultTeamConfig({ + projectPath: '/repo', + members: [ + { name: 'team-lead', role: 'Lead', cwd: '/repo' }, + { name: 'alice', role: 'Developer', cwd: '/repo-alice' }, + ], + }), + resolveMembers: () => [ + { ...buildResolvedMember('team-lead'), cwd: '/repo' }, + { ...buildResolvedMember('alice'), cwd: '/repo-alice' }, + ], + }); + + const data = await harness.service.getTeamData('my-team', { + includeMemberBranches: false, + }); + + expect(getBranchSpy).not.toHaveBeenCalled(); + expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBeUndefined(); + }); + + it('keeps member branch enrichment on by default for full UI team data snapshots', async () => { + const getBranchSpy = vi + .spyOn(gitIdentityResolver, 'getBranch') + .mockImplementation(async (cwd) => (cwd === '/repo-alice' ? 'feature/alice' : 'main')); + const harness = createGetTeamDataHarness({ + config: buildDefaultTeamConfig({ + projectPath: '/repo', + members: [ + { name: 'team-lead', role: 'Lead', cwd: '/repo' }, + { name: 'alice', role: 'Developer', cwd: '/repo-alice' }, + ], + }), + resolveMembers: () => [ + { ...buildResolvedMember('team-lead'), cwd: '/repo' }, + { ...buildResolvedMember('alice'), cwd: '/repo-alice' }, + ], + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(getBranchSpy).toHaveBeenCalledWith('/repo'); + expect(getBranchSpy).toHaveBeenCalledWith('/repo-alice'); + expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe( + 'feature/alice' + ); + }); + + it('keeps member branch enrichment on for explicit full UI team data snapshots', async () => { + const getBranchSpy = vi + .spyOn(gitIdentityResolver, 'getBranch') + .mockImplementation(async (cwd) => (cwd === '/repo-alice' ? 'feature/alice' : 'main')); + const harness = createGetTeamDataHarness({ + config: buildDefaultTeamConfig({ + projectPath: '/repo', + members: [ + { name: 'team-lead', role: 'Lead', cwd: '/repo' }, + { name: 'alice', role: 'Developer', cwd: '/repo-alice' }, + ], + }), + resolveMembers: () => [ + { ...buildResolvedMember('team-lead'), cwd: '/repo' }, + { ...buildResolvedMember('alice'), cwd: '/repo-alice' }, + ], + }); + + const data = await harness.service.getTeamData('my-team', { + includeMemberBranches: true, + }); + + expect(getBranchSpy).toHaveBeenCalledWith('/repo'); + expect(getBranchSpy).toHaveBeenCalledWith('/repo-alice'); + expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe( + 'feature/alice' + ); + }); + it('uses snapshot config reads for UI message feed snapshots', async () => { const harness = createGetTeamDataHarness(); diff --git a/test/main/services/team/TeamDataWorkerClient.test.ts b/test/main/services/team/TeamDataWorkerClient.test.ts index a3cd518a..d3ef99ae 100644 --- a/test/main/services/team/TeamDataWorkerClient.test.ts +++ b/test/main/services/team/TeamDataWorkerClient.test.ts @@ -93,6 +93,80 @@ describe('TeamDataWorkerClient', () => { client.dispose(); }); + it('does not deduplicate thin and full getTeamData calls together', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + await Promise.all([ + client.getTeamData('my-team'), + client.getTeamData('my-team', { includeMemberBranches: false }), + ]); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(2); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'getTeamData', + payload: { teamName: 'my-team' }, + }); + expect(hoisted.workers[0].messages[0]).not.toMatchObject({ + payload: { options: expect.anything() }, + }); + expect(hoisted.workers[0].messages[1]).toMatchObject({ + op: 'getTeamData', + payload: { teamName: 'my-team', options: { includeMemberBranches: false } }, + }); + + client.dispose(); + }); + + it('deduplicates explicit full getTeamData options with the default request', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + await Promise.all([ + client.getTeamData('my-team'), + client.getTeamData('my-team', { includeMemberBranches: true }), + ]); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'getTeamData', + payload: { teamName: 'my-team' }, + }); + expect(hoisted.workers[0].messages[0]).not.toMatchObject({ + payload: { options: expect.anything() }, + }); + + client.dispose(); + }); + + it('deduplicates concurrent thin getTeamData calls for the same team', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + const [first, second] = await Promise.all([ + client.getTeamData('my-team', { includeMemberBranches: false }), + client.getTeamData('my-team', { includeMemberBranches: false }), + ]); + + expect(first).toEqual(second); + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'getTeamData', + payload: { teamName: 'my-team', options: { includeMemberBranches: false } }, + }); + + client.dispose(); + }); + it('does not queue warmup behind an already running worker', async () => { const { TeamDataWorkerClient } = await import( '../../../../src/main/services/team/TeamDataWorkerClient' @@ -220,6 +294,70 @@ describe('TeamDataWorkerClient', () => { client.dispose(); }); + it('clears both thin and full getTeamData dedupe when invalidating team config', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + const firstFull = client.getTeamData('my-team'); + const firstThin = client.getTeamData('my-team', { includeMemberBranches: false }); + client.invalidateTeamConfig('my-team'); + const secondFull = client.getTeamData('my-team'); + const secondThin = client.getTeamData('my-team', { includeMemberBranches: false }); + + await Promise.all([firstFull, firstThin, secondFull, secondThin]); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages.map((message) => (message as { op: string }).op)).toEqual([ + 'getTeamData', + 'getTeamData', + 'invalidateTeamConfig', + 'getTeamData', + 'getTeamData', + ]); + + const payloads = hoisted.workers[0].messages.map( + (message) => (message as { payload: unknown }).payload + ); + expect(payloads).toEqual([ + { teamName: 'my-team' }, + { teamName: 'my-team', options: { includeMemberBranches: false } }, + { teamName: 'my-team' }, + { teamName: 'my-team' }, + { teamName: 'my-team', options: { includeMemberBranches: false } }, + ]); + + client.dispose(); + }); + + it('rejects and clears thin and full getTeamData requests on dispose', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + hoisted.skipResponsesForOps.add('getTeamData'); + const client = new TeamDataWorkerClient(); + + const full = client.getTeamData('my-team'); + const thin = client.getTeamData('my-team', { includeMemberBranches: false }); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(2); + + client.dispose(); + + await expect(full).rejects.toThrow('Client disposed'); + await expect(thin).rejects.toThrow('Client disposed'); + + hoisted.skipResponsesForOps.delete('getTeamData'); + + await client.getTeamData('my-team'); + expect(hoisted.workers).toHaveLength(2); + expect(hoisted.workers[1].messages).toHaveLength(1); + + client.dispose(); + }); + it('does not spawn a worker only to send config invalidation', async () => { const { TeamDataWorkerClient } = await import( '../../../../src/main/services/team/TeamDataWorkerClient' diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 78e5e4c7..670c550b 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from 'zustand'; import { @@ -42,6 +42,16 @@ const hoisted = vi.hoisted(() => ({ onProvisioningProgress: vi.fn(() => () => undefined), })); +const originalWindowAnimationFrame = + typeof window === 'undefined' + ? null + : { + hasRequest: Object.prototype.hasOwnProperty.call(window, 'requestAnimationFrame'), + hasCancel: Object.prototype.hasOwnProperty.call(window, 'cancelAnimationFrame'), + requestAnimationFrame: window.requestAnimationFrame, + cancelAnimationFrame: window.cancelAnimationFrame, + }; + vi.mock('@renderer/api', () => ({ api: { teams: { @@ -179,6 +189,83 @@ function createDeferredPromise() { return { promise, resolve, reject }; } +function defineWindowAnimationFrame( + requestAnimationFrame: ((callback: FrameRequestCallback) => number) | undefined, + cancelAnimationFrame: ((handle: number) => void) | undefined +): void { + if (typeof window === 'undefined') { + return; + } + if (requestAnimationFrame === undefined) { + delete (window as Partial).requestAnimationFrame; + } else { + Object.defineProperty(window, 'requestAnimationFrame', { + configurable: true, + writable: true, + value: requestAnimationFrame, + }); + } + if (cancelAnimationFrame === undefined) { + delete (window as Partial).cancelAnimationFrame; + } else { + Object.defineProperty(window, 'cancelAnimationFrame', { + configurable: true, + writable: true, + value: cancelAnimationFrame, + }); + } +} + +function restoreWindowAnimationFrame(): void { + if (typeof window === 'undefined' || originalWindowAnimationFrame === null) { + return; + } + defineWindowAnimationFrame( + originalWindowAnimationFrame.hasRequest + ? originalWindowAnimationFrame.requestAnimationFrame + : undefined, + originalWindowAnimationFrame.hasCancel ? originalWindowAnimationFrame.cancelAnimationFrame : undefined + ); +} + +function stubAnimationFrameWithTimer(): void { + defineWindowAnimationFrame( + (callback) => setTimeout(() => callback(Date.now()), 16) as unknown as number, + (handle) => clearTimeout(handle as unknown as ReturnType) + ); +} + +function stubAnimationFrameNeverFires(): void { + defineWindowAnimationFrame( + () => 1, + () => undefined + ); +} + +async function flushPostPaintTeamEnrichments(): Promise { + await vi.advanceTimersByTimeAsync(16); + await vi.runOnlyPendingTimersAsync(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +async function flushAsyncWork(): Promise { + await flushMicrotasks(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + await flushMicrotasks(); +} + function createRuntimeSnapshot(overrides: Record = {}) { return { teamName: 'my-team', @@ -245,6 +332,11 @@ describe('teamSlice actions', () => { hoisted.skipMemberForLaunch.mockResolvedValue(undefined); }); + afterEach(() => { + restoreWindowAnimationFrame(); + vi.useRealTimers(); + }); + it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => { const store = createSliceStore(); const fetchTeams = vi.fn(async () => undefined); @@ -963,6 +1055,470 @@ describe('teamSlice actions', () => { expect(store.getState().selectedTeamData).toBe(cachedBeta); }); + it('commits selectTeam thin snapshot before post-paint messages and activity meta refreshes', async () => { + vi.useFakeTimers(); + stubAnimationFrameWithTimer(); + const store = createSliceStore(); + const messagesRequest = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + const metaRequest = createDeferredPromise<{ + teamName: string; + computedAt: string; + feedRevision: string; + members: Record; + }>(); + const thinSnapshot = createTeamSnapshot({ + config: { name: 'Thin Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }); + + hoisted.getData.mockResolvedValueOnce(thinSnapshot); + hoisted.getMessagesPage.mockImplementationOnce(() => messagesRequest.promise); + hoisted.getMemberActivityMeta.mockImplementationOnce(() => metaRequest.promise); + + await store.getState().selectTeam('my-team'); + + expect(hoisted.getData).toHaveBeenCalledWith('my-team', { + includeMemberBranches: false, + }); + expect(store.getState().selectedTeamLoading).toBe(false); + expect(store.getState().selectedTeamData).toEqual(thinSnapshot); + expect(hoisted.getMessagesPage).not.toHaveBeenCalled(); + expect(hoisted.getMemberActivityMeta).not.toHaveBeenCalled(); + + await flushPostPaintTeamEnrichments(); + + expect(hoisted.getMessagesPage).toHaveBeenCalledWith('my-team', { limit: 50 }); + expect(hoisted.getMemberActivityMeta).not.toHaveBeenCalled(); + + messagesRequest.resolve({ + messages: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-thin', + }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(hoisted.getMemberActivityMeta).toHaveBeenCalledWith('my-team'); + + metaRequest.resolve({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'rev-thin', + members: {}, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.getState().selectedTeamData).toEqual(thinSnapshot); + expect(store.getState().selectedTeamError).toBeNull(); + }); + + it('keeps selected team data visible when post-paint message refresh fails', async () => { + vi.useFakeTimers(); + stubAnimationFrameWithTimer(); + const store = createSliceStore(); + const thinSnapshot = createTeamSnapshot({ + config: { name: 'Thin Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }); + + hoisted.getData.mockResolvedValueOnce(thinSnapshot); + hoisted.getMessagesPage.mockRejectedValueOnce(new Error('message feed unavailable')); + + await store.getState().selectTeam('my-team'); + await flushPostPaintTeamEnrichments(); + await Promise.resolve(); + await Promise.resolve(); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1); + expect(store.getState().selectedTeamData).toEqual(thinSnapshot); + expect(store.getState().selectedTeamError).toBeNull(); + expect(store.getState().teamMessagesByName['my-team']?.loadingHead).toBe(false); + }); + + it('queues a full team refresh behind an in-flight thin selectTeam snapshot', async () => { + vi.useFakeTimers(); + stubAnimationFrameWithTimer(); + const store = createSliceStore(); + const thinRequest = createDeferredPromise>(); + const thinSnapshot = createTeamSnapshot({ + config: { name: 'Thin Team' }, + }); + const fullSnapshot = createTeamSnapshot({ + config: { name: 'Full Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }], + }); + + hoisted.getData + .mockImplementationOnce(() => thinRequest.promise) + .mockResolvedValueOnce(fullSnapshot); + + const selectPromise = store.getState().selectTeam('my-team'); + await Promise.resolve(); + + await store.getState().refreshTeamData('my-team', { withDedup: true }); + + expect(hoisted.getData).toHaveBeenCalledTimes(1); + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: true, + }); + + thinRequest.resolve(thinSnapshot); + await selectPromise; + + expect(store.getState().selectedTeamData).toEqual(thinSnapshot); + expect(hoisted.getData).toHaveBeenCalledTimes(1); + + await flushPostPaintTeamEnrichments(); + await Promise.resolve(); + await Promise.resolve(); + + expect(hoisted.getData).toHaveBeenCalledTimes(2); + expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']); + expect(store.getState().selectedTeamData).toEqual(fullSnapshot); + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: false, + }); + }); + + it('drains queued full team refresh through the post-paint fallback when rAF never fires', async () => { + vi.useFakeTimers(); + stubAnimationFrameNeverFires(); + const store = createSliceStore(); + const thinRequest = createDeferredPromise>(); + + hoisted.getData + .mockImplementationOnce(() => thinRequest.promise) + .mockResolvedValueOnce( + createTeamSnapshot({ + config: { name: 'Full Team After Fallback' }, + }) + ); + + const selectPromise = store.getState().selectTeam('my-team'); + await Promise.resolve(); + await store.getState().refreshTeamData('my-team', { withDedup: true }); + + thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } })); + await selectPromise; + + await vi.advanceTimersByTimeAsync(499); + expect(hoisted.getData).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await Promise.resolve(); + await Promise.resolve(); + + expect(hoisted.getData).toHaveBeenCalledTimes(2); + expect(store.getState().selectedTeamData?.config.name).toBe('Full Team After Fallback'); + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: false, + hasPostPaintTeamEnrichmentTimer: false, + }); + }); + + it('keeps selected team data visible when post-paint activity meta refresh fails', async () => { + vi.useFakeTimers(); + stubAnimationFrameWithTimer(); + const store = createSliceStore(); + const thinSnapshot = createTeamSnapshot({ + config: { name: 'Thin Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }); + + hoisted.getData.mockResolvedValueOnce(thinSnapshot); + hoisted.getMessagesPage.mockResolvedValueOnce({ + messages: [ + { + from: 'alice', + text: 'Fresh message', + timestamp: '2026-03-12T10:00:00.000Z', + messageId: 'msg-fresh', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-meta-fail', + }); + hoisted.getMemberActivityMeta.mockRejectedValueOnce(new Error('meta unavailable')); + + await store.getState().selectTeam('my-team'); + await flushPostPaintTeamEnrichments(); + await flushMicrotasks(); + + expect(hoisted.getMemberActivityMeta).toHaveBeenCalledWith('my-team'); + expect(store.getState().selectedTeamData).toEqual(thinSnapshot); + expect(store.getState().selectedTeamError).toBeNull(); + }); + + it('does not share a forced full refresh request with an in-flight thin selectTeam request', async () => { + const store = createSliceStore(); + const thinRequest = createDeferredPromise>(); + const fullRequest = createDeferredPromise>(); + const thinSnapshot = createTeamSnapshot({ config: { name: 'Thin Team' } }); + const fullSnapshot = createTeamSnapshot({ + config: { name: 'Full Team' }, + members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }], + }); + + hoisted.getData + .mockImplementationOnce(() => thinRequest.promise) + .mockImplementationOnce(() => fullRequest.promise); + + const selectPromise = store.getState().selectTeam('my-team'); + await flushMicrotasks(); + + const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); + + expect(hoisted.getData).toHaveBeenCalledTimes(2); + expect(hoisted.getData.mock.calls[0]).toEqual([ + 'my-team', + { includeMemberBranches: false }, + ]); + expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']); + + thinRequest.resolve(thinSnapshot); + await selectPromise; + fullRequest.resolve(fullSnapshot); + await fullPromise; + + expect(store.getState().selectedTeamData).toEqual(fullSnapshot); + }); + + it('keeps one queued full refresh for repeated fanout while thin selectTeam is pending', async () => { + vi.useFakeTimers(); + stubAnimationFrameWithTimer(); + const store = createSliceStore(); + const thinRequest = createDeferredPromise>(); + const fullSnapshot = createTeamSnapshot({ + config: { name: 'Full Team Once' }, + }); + + hoisted.getData + .mockImplementationOnce(() => thinRequest.promise) + .mockResolvedValueOnce(fullSnapshot); + + const selectPromise = store.getState().selectTeam('my-team'); + await flushMicrotasks(); + + await Promise.all([ + store.getState().refreshTeamData('my-team', { withDedup: true }), + store.getState().refreshTeamData('my-team', { withDedup: true }), + ]); + + expect(hoisted.getData).toHaveBeenCalledTimes(1); + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: true, + }); + + thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } })); + await selectPromise; + await flushPostPaintTeamEnrichments(); + await flushMicrotasks(); + + expect(hoisted.getData).toHaveBeenCalledTimes(2); + expect(store.getState().selectedTeamData).toEqual(fullSnapshot); + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: false, + }); + }); + + it('drains queued full refresh when thin selectTeam becomes stale after switching teams', async () => { + const store = createSliceStore(); + const alphaThin = createDeferredPromise>(); + const alphaFull = createTeamSnapshot({ + teamName: 'alpha-team', + config: { name: 'Alpha Full' }, + }); + + hoisted.getData + .mockImplementationOnce(() => alphaThin.promise) + .mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })) + .mockResolvedValueOnce(alphaFull); + + const alphaSelect = store.getState().selectTeam('alpha-team'); + await flushMicrotasks(); + await store.getState().refreshTeamData('alpha-team', { withDedup: true }); + + expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: true, + }); + + await store.getState().selectTeam('beta-team'); + + alphaThin.resolve(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } })); + await alphaSelect; + await flushAsyncWork(); + + expect(hoisted.getData).toHaveBeenCalledTimes(3); + expect(hoisted.getData.mock.calls[2]).toEqual(['alpha-team']); + expect(store.getState().selectedTeamName).toBe('beta-team'); + expect(store.getState().teamDataCacheByName['alpha-team']).toEqual(alphaFull); + expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: false, + }); + }); + + it('clears queued full refresh when thin selectTeam fails structurally', async () => { + const store = createSliceStore(); + const thinRequest = createDeferredPromise>(); + + hoisted.getData.mockImplementationOnce(() => thinRequest.promise); + + const selectPromise = store.getState().selectTeam('my-team'); + await flushMicrotasks(); + await store.getState().refreshTeamData('my-team', { withDedup: true }); + + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: true, + }); + + thinRequest.reject(new Error('TEAM_DRAFT')); + await selectPromise; + + expect(store.getState().selectedTeamError).toBe('TEAM_DRAFT'); + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: false, + }); + }); + + it('lets the newer same-team selectTeam drain queued full refresh after its own paint', async () => { + vi.useFakeTimers(); + stubAnimationFrameWithTimer(); + const store = createSliceStore(); + const thinRequest = createDeferredPromise>(); + const fullSnapshot = createTeamSnapshot({ + config: { name: 'Full After Newer Paint' }, + }); + + hoisted.getData + .mockImplementationOnce(() => thinRequest.promise) + .mockResolvedValueOnce(fullSnapshot); + + const firstSelect = store.getState().selectTeam('my-team'); + await flushMicrotasks(); + await store.getState().refreshTeamData('my-team', { withDedup: true }); + const secondSelect = store + .getState() + .selectTeam('my-team', { allowReloadWhileProvisioning: true }); + + thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } })); + await Promise.all([firstSelect, secondSelect]); + + expect(hoisted.getData).toHaveBeenCalledTimes(1); + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: true, + hasPostPaintTeamEnrichmentTimer: true, + }); + + await flushPostPaintTeamEnrichments(); + await flushMicrotasks(); + + expect(hoisted.getData).toHaveBeenCalledTimes(2); + expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']); + expect(store.getState().selectedTeamData).toEqual(fullSnapshot); + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: false, + }); + }); + + it('does not run stale post-paint messages for a team after switching away', async () => { + vi.useFakeTimers(); + stubAnimationFrameWithTimer(); + const store = createSliceStore(); + + hoisted.getData + .mockResolvedValueOnce(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } })) + .mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })); + + await store.getState().selectTeam('alpha-team'); + expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({ + hasPostPaintTeamEnrichmentTimer: true, + }); + + await store.getState().selectTeam('beta-team'); + await flushPostPaintTeamEnrichments(); + await flushMicrotasks(); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1); + expect(hoisted.getMessagesPage).toHaveBeenCalledWith('beta-team', { limit: 50 }); + expect(hoisted.getMessagesPage).not.toHaveBeenCalledWith('alpha-team', { limit: 50 }); + }); + + it('clears queued full refresh and post-paint timer when deleting a loaded team', async () => { + vi.useFakeTimers(); + stubAnimationFrameWithTimer(); + const store = createSliceStore(); + const thinRequest = createDeferredPromise>(); + + hoisted.getData.mockImplementationOnce(() => thinRequest.promise); + + const selectPromise = store.getState().selectTeam('my-team'); + await flushMicrotasks(); + await store.getState().refreshTeamData('my-team', { withDedup: true }); + + thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } })); + await selectPromise; + + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: true, + hasPostPaintTeamEnrichmentTimer: true, + }); + + await store.getState().deleteTeam('my-team'); + + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasQueuedFullTeamDataRefreshAfterThin: false, + hasPostPaintTeamEnrichmentTimer: false, + }); + + await flushPostPaintTeamEnrichments(); + + expect(hoisted.getMessagesPage).not.toHaveBeenCalled(); + expect(hoisted.getData).toHaveBeenCalledTimes(1); + }); + + it('keeps selected team data visible when post-structural sync work throws', async () => { + const store = createSliceStore(); + const thinSnapshot = createTeamSnapshot({ + config: { name: 'Renamed Team' }, + }); + const updateTabLabel = vi.fn(() => { + throw new Error('tab label failed'); + }); + + store.setState({ + getAllPaneTabs: vi.fn(() => [ + { id: 'tab-1', type: 'team', teamName: 'my-team', label: 'Old Team' }, + ]), + updateTabLabel, + }); + hoisted.getData.mockResolvedValueOnce(thinSnapshot); + + await store.getState().selectTeam('my-team'); + + expect(updateTabLabel).toHaveBeenCalledWith('tab-1', 'Renamed Team'); + expect(store.getState().selectedTeamData).toEqual(thinSnapshot); + expect(store.getState().selectedTeamError).toBeNull(); + }); + it('distinguishes historical feed changes from visible head changes in refreshTeamMessagesHead', async () => { const store = createSliceStore(); const existingMessages = [ @@ -1994,6 +2550,8 @@ describe('teamSlice actions', () => { hasMergedMessagesSelector: false, memberMessagesSelectorCount: 0, hasPendingFreshTeamDataRefresh: false, + hasQueuedFullTeamDataRefreshAfterThin: false, + hasPostPaintTeamEnrichmentTimer: false, hasQueuedHeadRefreshAfterOlder: false, hasPendingFreshMessagesHeadRefresh: false, hasPendingFreshMemberActivityMetaRefresh: false, @@ -2087,6 +2645,8 @@ describe('teamSlice actions', () => { hasMergedMessagesSelector: false, memberMessagesSelectorCount: 0, hasPendingFreshTeamDataRefresh: false, + hasQueuedFullTeamDataRefreshAfterThin: false, + hasPostPaintTeamEnrichmentTimer: false, hasQueuedHeadRefreshAfterOlder: false, hasPendingFreshMessagesHeadRefresh: false, hasPendingFreshMemberActivityMetaRefresh: false,