perf(team): defer enrichments after first paint
This commit is contained in:
parent
f57d15c68f
commit
b1b2e696e5
15 changed files with 1428 additions and 139 deletions
|
|
@ -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<string, unknown> {
|
||||
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<IpcResult<Te
|
|||
|
||||
async function handleGetData(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
teamName: unknown,
|
||||
rawOptions?: unknown
|
||||
): Promise<IpcResult<TeamViewSnapshot>> {
|
||||
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<TeamViewSnapshot> =>
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<TeamViewSnapshot> {
|
||||
async getTeamData(teamName: string, options?: TeamGetDataOptions): Promise<TeamViewSnapshot> {
|
||||
const includeMemberBranches = options?.includeMemberBranches !== false;
|
||||
const startedAt = Date.now();
|
||||
const marks: Record<string, number> = {};
|
||||
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('|')}` : ''
|
||||
}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
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<TeamDataWorkerRequest, { op: 'getTeamData' }>['payload'] {
|
||||
const normalizedOptions = normalizeTeamGetDataOptions(options);
|
||||
return normalizedOptions ? { teamName, options: normalizedOptions } : { teamName };
|
||||
}
|
||||
|
||||
function summarizeWorkerRequest(request: TeamDataWorkerRequest): Record<string, unknown> {
|
||||
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<unknown> {
|
||||
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<TeamViewSnapshot> {
|
||||
async getTeamData(teamName: string, options?: TeamGetDataOptions): Promise<TeamViewSnapshot> {
|
||||
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<TeamViewSnapshot>).finally(
|
||||
() => {
|
||||
if (this.getTeamDataInFlight.get(teamName) === promise) {
|
||||
this.getTeamDataInFlight.delete(teamName);
|
||||
}
|
||||
const payload = getTeamDataRequestPayload(teamName, options);
|
||||
const promise = (this.call('getTeamData', payload) as Promise<TeamViewSnapshot>).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 }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,6 +307,7 @@ import type {
|
|||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamGetDataOptions,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
|
|
@ -844,8 +845,11 @@ const electronAPI: ElectronAPI = {
|
|||
list: async () => {
|
||||
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
||||
},
|
||||
getData: async (teamName: string) => {
|
||||
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
|
||||
getData: async (teamName: string, options?: TeamGetDataOptions) => {
|
||||
if (options === undefined) {
|
||||
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
|
||||
}
|
||||
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName, options);
|
||||
},
|
||||
getTaskChangePresence: async (teamName: string) => {
|
||||
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
||||
|
|
|
|||
|
|
@ -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<TeamViewSnapshot> => {
|
||||
getData: async (
|
||||
_teamName: string,
|
||||
_options?: TeamGetDataOptions
|
||||
): Promise<TeamViewSnapshot> => {
|
||||
throw new Error('Teams detail is not available in browser mode');
|
||||
},
|
||||
getTaskChangePresence: async (): Promise<
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, Promise<TeamViewSnapshot>>();
|
||||
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
|
||||
const pendingFreshTeamDataRefreshes = new Set<string>();
|
||||
const queuedFullTeamDataRefreshesAfterThin = new Set<string>();
|
||||
interface PostPaintHandle {
|
||||
rafId?: number;
|
||||
timerId?: ReturnType<typeof setTimeout>;
|
||||
fallbackTimerId?: ReturnType<typeof setTimeout>;
|
||||
cancelled: boolean;
|
||||
ran: boolean;
|
||||
}
|
||||
const postPaintTeamEnrichmentTimers = new Map<string, PostPaintHandle>();
|
||||
const inFlightTeamMessagesHeadRequests = new Map<string, Promise<RefreshTeamMessagesHeadResult>>();
|
||||
const inFlightTeamMessagesOlderRequests = new Map<string, Promise<void>>();
|
||||
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<string, GraphOwnerSlotAssignment>;
|
||||
type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
|
||||
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<T>(promise: Promise<T>, ms: number, label: string): Promise
|
|||
});
|
||||
}
|
||||
|
||||
function fetchTeamDataDeduped(teamName: string): Promise<TeamViewSnapshot> {
|
||||
const existing = inFlightTeamDataRequests.get(teamName);
|
||||
function fetchTeamDataDeduped(
|
||||
teamName: string,
|
||||
options?: TeamGetDataOptions
|
||||
): Promise<TeamViewSnapshot> {
|
||||
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<TeamViewSnapshot> {
|
||||
function fetchTeamDataFresh(
|
||||
teamName: string,
|
||||
options?: TeamGetDataOptions
|
||||
): Promise<TeamViewSnapshot> {
|
||||
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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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);
|
||||
|
|
|
|||
|
|
@ -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<TeamSummary[]>;
|
||||
getData: (teamName: string) => Promise<TeamViewSnapshot>;
|
||||
getData: (teamName: string, options?: TeamGetDataOptions) => Promise<TeamViewSnapshot>;
|
||||
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
||||
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<T>() {
|
|||
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<Window>).requestAnimationFrame;
|
||||
} else {
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: requestAnimationFrame,
|
||||
});
|
||||
}
|
||||
if (cancelAnimationFrame === undefined) {
|
||||
delete (window as Partial<Window>).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<typeof setTimeout>)
|
||||
);
|
||||
}
|
||||
|
||||
function stubAnimationFrameNeverFires(): void {
|
||||
defineWindowAnimationFrame(
|
||||
() => 1,
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function flushPostPaintTeamEnrichments(): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(16);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
await flushMicrotasks();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
}
|
||||
|
||||
function createRuntimeSnapshot(overrides: Record<string, unknown> = {}) {
|
||||
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<string, never>;
|
||||
}>();
|
||||
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<ReturnType<typeof createTeamSnapshot>>();
|
||||
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<ReturnType<typeof createTeamSnapshot>>();
|
||||
|
||||
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<ReturnType<typeof createTeamSnapshot>>();
|
||||
const fullRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
|
||||
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<ReturnType<typeof createTeamSnapshot>>();
|
||||
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<ReturnType<typeof createTeamSnapshot>>();
|
||||
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<ReturnType<typeof createTeamSnapshot>>();
|
||||
|
||||
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<ReturnType<typeof createTeamSnapshot>>();
|
||||
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<ReturnType<typeof createTeamSnapshot>>();
|
||||
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue