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,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamFastMode,
|
TeamFastMode,
|
||||||
|
TeamGetDataOptions,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
TeamMemberActivityMeta,
|
TeamMemberActivityMeta,
|
||||||
|
|
@ -227,6 +228,42 @@ const logger = createLogger('IPC:teams');
|
||||||
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
|
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
|
||||||
const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250;
|
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.
|
* In-memory set of rate-limit message keys already processed.
|
||||||
* Independent of NotificationManager storage — survives notification deletion/pruning.
|
* Independent of NotificationManager storage — survives notification deletion/pruning.
|
||||||
|
|
@ -944,17 +981,27 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
||||||
|
|
||||||
async function handleGetData(
|
async function handleGetData(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
teamName: unknown,
|
||||||
|
rawOptions?: unknown
|
||||||
): Promise<IpcResult<TeamViewSnapshot>> {
|
): Promise<IpcResult<TeamViewSnapshot>> {
|
||||||
const validated = validateTeamName(teamName);
|
const validated = validateTeamName(teamName);
|
||||||
if (!validated.valid) {
|
if (!validated.valid) {
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
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 tn = validated.value!;
|
||||||
|
const getDataOptions = optionsResult.value;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let data: TeamViewSnapshot;
|
let data: TeamViewSnapshot;
|
||||||
let dataSource: 'worker' | 'main-fallback' | 'main-unavailable' = 'main-unavailable';
|
let dataSource: 'worker' | 'main-fallback' | 'main-unavailable' = 'main-unavailable';
|
||||||
let workerAvailable = false;
|
let workerAvailable = false;
|
||||||
|
const readFromMain = (): Promise<TeamViewSnapshot> =>
|
||||||
|
getDataOptions === undefined
|
||||||
|
? getTeamDataService().getTeamData(tn)
|
||||||
|
: getTeamDataService().getTeamData(tn, getDataOptions);
|
||||||
setCurrentMainOp('team:getData');
|
setCurrentMainOp('team:getData');
|
||||||
try {
|
try {
|
||||||
// Prefer worker thread to keep main event loop responsive
|
// Prefer worker thread to keep main event loop responsive
|
||||||
|
|
@ -970,19 +1017,22 @@ async function handleGetData(
|
||||||
|
|
||||||
if (workerAvailable) {
|
if (workerAvailable) {
|
||||||
try {
|
try {
|
||||||
data = await worker.getTeamData(tn);
|
data =
|
||||||
|
getDataOptions === undefined
|
||||||
|
? await worker.getTeamData(tn)
|
||||||
|
: await worker.getTeamData(tn, getDataOptions);
|
||||||
dataSource = 'worker';
|
dataSource = 'worker';
|
||||||
} catch (workerErr) {
|
} catch (workerErr) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||||
);
|
);
|
||||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await readFromMain();
|
||||||
dataSource = 'main-fallback';
|
dataSource = 'main-fallback';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await readFromMain();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
@ -1011,8 +1061,9 @@ async function handleGetData(
|
||||||
const getDataMs = Date.now() - startedAt;
|
const getDataMs = Date.now() - startedAt;
|
||||||
|
|
||||||
if (getDataMs >= 1500) {
|
if (getDataMs >= 1500) {
|
||||||
|
const branchMode = getDataOptions?.includeMemberBranches === false ? 'skipped' : 'full';
|
||||||
logger.warn(
|
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();
|
const teamDataService = getTeamDataService();
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ import type {
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
|
TeamGetDataOptions,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
TeamMemberActivityMeta,
|
TeamMemberActivityMeta,
|
||||||
TeamMemberSnapshot,
|
TeamMemberSnapshot,
|
||||||
|
|
@ -1128,7 +1129,8 @@ export class TeamDataService {
|
||||||
TeamTaskReader.invalidateAllTasksCache();
|
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 startedAt = Date.now();
|
||||||
const marks: Record<string, number> = {};
|
const marks: Record<string, number> = {};
|
||||||
const mark = (label: string): void => {
|
const mark = (label: string): void => {
|
||||||
|
|
@ -1376,8 +1378,11 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
mark('runtimeAdvisories');
|
mark('runtimeAdvisories');
|
||||||
|
|
||||||
// Enrich members with git branch when it differs from lead's branch
|
// Enrich members with git branch when it differs from lead's branch.
|
||||||
await this.enrichMemberBranches(members, config);
|
// UI-first reads can skip this because the renderer hydrates branches through branch sync.
|
||||||
|
if (includeMemberBranches) {
|
||||||
|
await this.enrichMemberBranches(members, config);
|
||||||
|
}
|
||||||
mark('enrichBranches');
|
mark('enrichBranches');
|
||||||
mark('syncComments');
|
mark('syncComments');
|
||||||
|
|
||||||
|
|
@ -1392,6 +1397,7 @@ export class TeamDataService {
|
||||||
const totalMs = Date.now() - startedAt;
|
const totalMs = Date.now() - startedAt;
|
||||||
if (totalMs >= 1500) {
|
if (totalMs >= 1500) {
|
||||||
const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`;
|
const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`;
|
||||||
|
const branchMode = includeMemberBranches ? 'full' : 'skipped';
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
||||||
'inboxNames'
|
'inboxNames'
|
||||||
|
|
@ -1412,7 +1418,7 @@ export class TeamDataService {
|
||||||
)}/enrichBranches=${msBetween(
|
)}/enrichBranches=${msBetween(
|
||||||
'runtimeAdvisories',
|
'runtimeAdvisories',
|
||||||
'enrichBranches'
|
'enrichBranches'
|
||||||
)}/processes=${msBetween('syncComments', 'processes')} ${counts}${
|
)}/processes=${msBetween('syncComments', 'processes')} branchMode=${branchMode} ${counts}${
|
||||||
warnings.length > 0 ? ` warnings=${warnings.join('|')}` : ''
|
warnings.length > 0 ? ` warnings=${warnings.join('|')}` : ''
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWo
|
||||||
import type {
|
import type {
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
|
TeamGetDataOptions,
|
||||||
TeamMemberActivityMeta,
|
TeamMemberActivityMeta,
|
||||||
TeamViewSnapshot,
|
TeamViewSnapshot,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -63,38 +64,59 @@ interface PendingEntry {
|
||||||
reject: (e: Error) => void;
|
reject: (e: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeWorkerPayload(
|
function normalizeTeamGetDataOptions(options?: TeamGetDataOptions): TeamGetDataOptions | undefined {
|
||||||
payload: TeamDataWorkerRequest['payload']
|
return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined;
|
||||||
): Record<string, unknown> {
|
}
|
||||||
if (!payload) {
|
|
||||||
return {};
|
function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string {
|
||||||
}
|
const normalizedOptions = normalizeTeamGetDataOptions(options);
|
||||||
if ('taskId' in payload) {
|
return `${teamName}\u0000branches:${normalizedOptions ? '0' : '1'}`;
|
||||||
return {
|
}
|
||||||
teamName: payload.teamName,
|
|
||||||
taskId: payload.taskId,
|
function getTeamDataRequestPayload(
|
||||||
owner: payload.options?.owner,
|
teamName: string,
|
||||||
status: payload.options?.status,
|
options?: TeamGetDataOptions
|
||||||
intervals: Array.isArray(payload.options?.intervals)
|
): Extract<TeamDataWorkerRequest, { op: 'getTeamData' }>['payload'] {
|
||||||
? payload.options.intervals.length
|
const normalizedOptions = normalizeTeamGetDataOptions(options);
|
||||||
: undefined,
|
return normalizedOptions ? { teamName, options: normalizedOptions } : { teamName };
|
||||||
since: payload.options?.since,
|
}
|
||||||
};
|
|
||||||
}
|
function summarizeWorkerRequest(request: TeamDataWorkerRequest): Record<string, unknown> {
|
||||||
if ('options' in payload) {
|
switch (request.op) {
|
||||||
return {
|
case 'warmup':
|
||||||
teamName: payload.teamName,
|
return {};
|
||||||
cursor:
|
case 'getTeamData': {
|
||||||
typeof payload.options.cursor === 'string'
|
const { teamName, options } = request.payload;
|
||||||
? payload.options.cursor.slice(0, 24)
|
return {
|
||||||
: payload.options.cursor,
|
teamName,
|
||||||
limit: payload.options.limit,
|
includeMemberBranches: options?.includeMemberBranches !== false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if ('teamName' in payload) {
|
case 'getMessagesPage': {
|
||||||
return {
|
const { teamName, options } = request.payload;
|
||||||
teamName: payload.teamName,
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +191,7 @@ export class TeamDataWorkerClient {
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const worker = this.ensureWorker();
|
const worker = this.ensureWorker();
|
||||||
const id = makeId();
|
const id = makeId();
|
||||||
|
const request = { id, op, payload } as TeamDataWorkerRequest;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const pendingAtStart = this.pending.size;
|
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`);
|
const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`worker call timeout op=${op} ms=${Date.now() - startedAt} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
|
`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);
|
this.failWorker(worker, timeoutError);
|
||||||
|
|
@ -192,7 +215,7 @@ export class TeamDataWorkerClient {
|
||||||
if (ms >= 1500) {
|
if (ms >= 1500) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`worker call slow op=${op} ms=${ms} workerTotalMs=${String(diag?.totalMs ?? 'unknown')} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
|
`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) {
|
if (ms >= 1500) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`worker call failed slow op=${op} ms=${ms} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
|
`worker call failed slow op=${op} ms=${ms} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
|
||||||
summarizeWorkerPayload(payload)
|
summarizeWorkerRequest(request)
|
||||||
)} error=${error.message}`
|
)} 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 {
|
): void {
|
||||||
const worker = this.worker;
|
const worker = this.worker;
|
||||||
if (!worker) return;
|
if (!worker) return;
|
||||||
|
const request = { id: makeId(), op, payload } as TeamDataWorkerRequest;
|
||||||
try {
|
try {
|
||||||
worker.postMessage({ id: makeId(), op, payload } as TeamDataWorkerRequest);
|
worker.postMessage(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`worker best-effort post failed op=${op} payload=${JSON.stringify(
|
`worker best-effort post failed op=${op} payload=${JSON.stringify(
|
||||||
summarizeWorkerPayload(payload)
|
summarizeWorkerRequest(request)
|
||||||
)} error=${error instanceof Error ? error.message : String(error)}`
|
)} 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');
|
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;
|
if (existing) return existing;
|
||||||
|
|
||||||
const promise = (this.call('getTeamData', { teamName }) as Promise<TeamViewSnapshot>).finally(
|
const payload = getTeamDataRequestPayload(teamName, options);
|
||||||
() => {
|
const promise = (this.call('getTeamData', payload) as Promise<TeamViewSnapshot>).finally(() => {
|
||||||
if (this.getTeamDataInFlight.get(teamName) === promise) {
|
if (this.getTeamDataInFlight.get(key) === promise) {
|
||||||
this.getTeamDataInFlight.delete(teamName);
|
this.getTeamDataInFlight.delete(key);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
this.getTeamDataInFlight.set(teamName, promise);
|
this.getTeamDataInFlight.set(key, promise);
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateTeamConfig(teamName: string): void {
|
invalidateTeamConfig(teamName: string): void {
|
||||||
if (!SAFE_NAME_RE.test(teamName)) return;
|
if (!SAFE_NAME_RE.test(teamName)) return;
|
||||||
this.getTeamDataInFlight.delete(teamName);
|
this.clearTeamDataInFlightForTeam(teamName);
|
||||||
this.clearMessagesPageInFlightForTeam(teamName);
|
this.clearMessagesPageInFlightForTeam(teamName);
|
||||||
this.postBestEffort('invalidateTeamConfig', { 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(
|
async getMessagesPage(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options: { cursor?: string | null; limit: number }
|
options: { cursor?: string | null; limit: number }
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import type {
|
import type {
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
|
TeamGetDataOptions,
|
||||||
TeamMemberActivityMeta,
|
TeamMemberActivityMeta,
|
||||||
TeamViewSnapshot,
|
TeamViewSnapshot,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -13,6 +14,7 @@ import type {
|
||||||
|
|
||||||
export interface GetTeamDataPayload {
|
export interface GetTeamDataPayload {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
options?: TeamGetDataOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetMessagesPagePayload {
|
export interface GetMessagesPagePayload {
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'getTeamData': {
|
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() });
|
respond({ id: msg.id, ok: true, result, diag: buildDiag() });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,7 @@ import type {
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
|
TeamGetDataOptions,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
TeamMemberActivityMeta,
|
TeamMemberActivityMeta,
|
||||||
|
|
@ -844,8 +845,11 @@ const electronAPI: ElectronAPI = {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
||||||
},
|
},
|
||||||
getData: async (teamName: string) => {
|
getData: async (teamName: string, options?: TeamGetDataOptions) => {
|
||||||
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
|
if (options === undefined) {
|
||||||
|
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
|
||||||
|
}
|
||||||
|
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName, options);
|
||||||
},
|
},
|
||||||
getTaskChangePresence: async (teamName: string) => {
|
getTaskChangePresence: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ import type {
|
||||||
TeamClaudeLogsResponse,
|
TeamClaudeLogsResponse,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
|
TeamGetDataOptions,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
TeamMemberActivityMeta,
|
TeamMemberActivityMeta,
|
||||||
|
|
@ -706,7 +707,10 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
||||||
return [];
|
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');
|
throw new Error('Teams detail is not available in browser mode');
|
||||||
},
|
},
|
||||||
getTaskChangePresence: async (): Promise<
|
getTaskChangePresence: async (): Promise<
|
||||||
|
|
|
||||||
|
|
@ -601,7 +601,9 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
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 existingNames = teams.map((t) => t.teamName);
|
||||||
const uniqueName = generateUniqueName(teamName, existingNames);
|
const uniqueName = generateUniqueName(teamName, existingNames);
|
||||||
const members = (data.members ?? [])
|
const members = (data.members ?? [])
|
||||||
|
|
@ -653,7 +655,9 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!projectPath) return;
|
if (!projectPath) return;
|
||||||
try {
|
try {
|
||||||
const data = await api.teams.getData(teamName);
|
const data = await api.teams.getData(teamName, {
|
||||||
|
includeMemberBranches: false,
|
||||||
|
});
|
||||||
setLaunchDialogTeamName(teamName);
|
setLaunchDialogTeamName(teamName);
|
||||||
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
|
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
|
||||||
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import type {
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
TeamAgentRuntimeSnapshot,
|
TeamAgentRuntimeSnapshot,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
|
TeamGetDataOptions,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamMemberActivityMeta,
|
TeamMemberActivityMeta,
|
||||||
TeamMemberSnapshot,
|
TeamMemberSnapshot,
|
||||||
|
|
@ -77,9 +78,19 @@ const TEAM_FETCH_TIMEOUT_MS = 30_000;
|
||||||
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000;
|
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000;
|
||||||
const TEAM_REFRESH_BURST_WINDOW_MS = 4_000;
|
const TEAM_REFRESH_BURST_WINDOW_MS = 4_000;
|
||||||
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_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 inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
|
||||||
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
|
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
|
||||||
const pendingFreshTeamDataRefreshes = new Set<string>();
|
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 inFlightTeamMessagesHeadRequests = new Map<string, Promise<RefreshTeamMessagesHeadResult>>();
|
||||||
const inFlightTeamMessagesOlderRequests = new Map<string, Promise<void>>();
|
const inFlightTeamMessagesOlderRequests = new Map<string, Promise<void>>();
|
||||||
const queuedTeamMessagesHeadRefreshesAfterOlder = new Map<
|
const queuedTeamMessagesHeadRefreshesAfterOlder = new Map<
|
||||||
|
|
@ -105,6 +116,55 @@ interface RefreshTeamDataOptions {
|
||||||
withDedup?: boolean;
|
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 TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
||||||
type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
|
type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
|
||||||
type TeamGraphConfigMemberSeedInput = Pick<
|
type TeamGraphConfigMemberSeedInput = Pick<
|
||||||
|
|
@ -118,9 +178,10 @@ interface TeamGraphLayoutSessionState {
|
||||||
|
|
||||||
export function isTeamDataRefreshPending(teamName: string): boolean {
|
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||||
return (
|
return (
|
||||||
inFlightTeamDataRequests.has(teamName) ||
|
hasFullTeamDataRequestForTeam(teamName) ||
|
||||||
(inFlightRefreshTeamDataCalls.get(teamName)?.size ?? 0) > 0 ||
|
(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();
|
inFlightTeamDataRequests.clear();
|
||||||
inFlightRefreshTeamDataCalls.clear();
|
inFlightRefreshTeamDataCalls.clear();
|
||||||
pendingFreshTeamDataRefreshes.clear();
|
pendingFreshTeamDataRefreshes.clear();
|
||||||
|
queuedFullTeamDataRefreshesAfterThin.clear();
|
||||||
|
for (const teamName of postPaintTeamEnrichmentTimers.keys()) {
|
||||||
|
cancelPostPaintTeamEnrichments(teamName);
|
||||||
|
}
|
||||||
|
postPaintTeamEnrichmentTimers.clear();
|
||||||
inFlightTeamMessagesHeadRequests.clear();
|
inFlightTeamMessagesHeadRequests.clear();
|
||||||
inFlightTeamMessagesOlderRequests.clear();
|
inFlightTeamMessagesOlderRequests.clear();
|
||||||
queuedTeamMessagesHeadRefreshesAfterOlder.clear();
|
queuedTeamMessagesHeadRefreshesAfterOlder.clear();
|
||||||
|
|
@ -184,9 +250,11 @@ function clearTeamScopedSelectorCaches(teamName: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTeamScopedTransientState(teamName: string): void {
|
function clearTeamScopedTransientState(teamName: string): void {
|
||||||
inFlightTeamDataRequests.delete(teamName);
|
clearTeamDataRequestsForTeam(teamName);
|
||||||
inFlightRefreshTeamDataCalls.delete(teamName);
|
inFlightRefreshTeamDataCalls.delete(teamName);
|
||||||
pendingFreshTeamDataRefreshes.delete(teamName);
|
pendingFreshTeamDataRefreshes.delete(teamName);
|
||||||
|
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
||||||
|
cancelPostPaintTeamEnrichments(teamName);
|
||||||
inFlightTeamMessagesHeadRequests.delete(teamName);
|
inFlightTeamMessagesHeadRequests.delete(teamName);
|
||||||
inFlightTeamMessagesOlderRequests.delete(teamName);
|
inFlightTeamMessagesOlderRequests.delete(teamName);
|
||||||
queuedTeamMessagesHeadRefreshesAfterOlder.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): {
|
export function __getTeamScopedTransientStateForTests(teamName: string): {
|
||||||
hasResolvedMembersSelector: boolean;
|
hasResolvedMembersSelector: boolean;
|
||||||
resolvedMemberSelectorCount: number;
|
resolvedMemberSelectorCount: number;
|
||||||
hasMergedMessagesSelector: boolean;
|
hasMergedMessagesSelector: boolean;
|
||||||
memberMessagesSelectorCount: number;
|
memberMessagesSelectorCount: number;
|
||||||
hasPendingFreshTeamDataRefresh: boolean;
|
hasPendingFreshTeamDataRefresh: boolean;
|
||||||
|
hasQueuedFullTeamDataRefreshAfterThin: boolean;
|
||||||
|
hasPostPaintTeamEnrichmentTimer: boolean;
|
||||||
hasQueuedHeadRefreshAfterOlder: boolean;
|
hasQueuedHeadRefreshAfterOlder: boolean;
|
||||||
hasPendingFreshMessagesHeadRefresh: boolean;
|
hasPendingFreshMessagesHeadRefresh: boolean;
|
||||||
hasPendingFreshMemberActivityMetaRefresh: boolean;
|
hasPendingFreshMemberActivityMetaRefresh: boolean;
|
||||||
|
|
@ -425,6 +652,8 @@ export function __getTeamScopedTransientStateForTests(teamName: string): {
|
||||||
hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName),
|
hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName),
|
||||||
memberMessagesSelectorCount,
|
memberMessagesSelectorCount,
|
||||||
hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName),
|
hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName),
|
||||||
|
hasQueuedFullTeamDataRefreshAfterThin: queuedFullTeamDataRefreshesAfterThin.has(teamName),
|
||||||
|
hasPostPaintTeamEnrichmentTimer: postPaintTeamEnrichmentTimers.has(teamName),
|
||||||
hasQueuedHeadRefreshAfterOlder: queuedTeamMessagesHeadRefreshesAfterOlder.has(teamName),
|
hasQueuedHeadRefreshAfterOlder: queuedTeamMessagesHeadRefreshesAfterOlder.has(teamName),
|
||||||
hasPendingFreshMessagesHeadRefresh: pendingFreshTeamMessagesHeadRefreshes.has(teamName),
|
hasPendingFreshMessagesHeadRefresh: pendingFreshTeamMessagesHeadRefreshes.has(teamName),
|
||||||
hasPendingFreshMemberActivityMetaRefresh:
|
hasPendingFreshMemberActivityMetaRefresh:
|
||||||
|
|
@ -536,31 +765,48 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchTeamDataDeduped(teamName: string): Promise<TeamViewSnapshot> {
|
function fetchTeamDataDeduped(
|
||||||
const existing = inFlightTeamDataRequests.get(teamName);
|
teamName: string,
|
||||||
|
options?: TeamGetDataOptions
|
||||||
|
): Promise<TeamViewSnapshot> {
|
||||||
|
const normalizedOptions = normalizeTeamGetDataOptions(options);
|
||||||
|
const key = getTeamDataRequestKey(teamName, normalizedOptions);
|
||||||
|
const existing = inFlightTeamDataRequests.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = withTimeout(
|
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_GET_DATA_TIMEOUT_MS,
|
||||||
`team:getData(${teamName})`
|
getTeamDataRequestLabel(teamName, normalizedOptions)
|
||||||
).finally(() => {
|
).finally(() => {
|
||||||
if (inFlightTeamDataRequests.get(teamName) === request) {
|
if (inFlightTeamDataRequests.get(key) === request) {
|
||||||
inFlightTeamDataRequests.delete(teamName);
|
inFlightTeamDataRequests.delete(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
inFlightTeamDataRequests.set(teamName, request);
|
inFlightTeamDataRequests.set(key, request);
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchTeamDataFresh(teamName: string): Promise<TeamViewSnapshot> {
|
function fetchTeamDataFresh(
|
||||||
|
teamName: string,
|
||||||
|
options?: TeamGetDataOptions
|
||||||
|
): Promise<TeamViewSnapshot> {
|
||||||
|
const normalizedOptions = normalizeTeamGetDataOptions(options);
|
||||||
return withTimeout(
|
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_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 requestNonce = get().selectedTeamLoadNonce + 1;
|
||||||
const previousData = selectTeamDataForName(get(), teamName);
|
const previousData = selectTeamDataForName(get(), teamName);
|
||||||
|
|
||||||
|
cancelPostPaintTeamEnrichments(teamName);
|
||||||
|
|
||||||
// Repoint selection synchronously to the new team's cached snapshot when available.
|
// 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.
|
// Never keep the previous team's snapshot attached to a newly selected team.
|
||||||
set({
|
set({
|
||||||
|
|
@ -3426,12 +3674,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchTeamDataDeduped(teamName);
|
const data = await fetchTeamDataDeduped(teamName, {
|
||||||
|
includeMemberBranches: false,
|
||||||
|
});
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Stale check: user may have switched to another team during the async call
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
// Eagerly patch teamByName with color/displayName from detailed data
|
// 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());
|
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||||
const invalidationState = previousData
|
|
||||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
try {
|
||||||
: { cacheKeys: [], taskIds: [] };
|
const invalidationState = previousData
|
||||||
if (invalidationState.cacheKeys.length > 0) {
|
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||||
get().invalidateTaskChangePresence(invalidationState.cacheKeys);
|
: { cacheKeys: [], taskIds: [] };
|
||||||
}
|
if (invalidationState.cacheKeys.length > 0) {
|
||||||
if (invalidationState.taskIds.length > 0) {
|
get().invalidateTaskChangePresence(invalidationState.cacheKeys);
|
||||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
}
|
||||||
}
|
if (invalidationState.taskIds.length > 0) {
|
||||||
// Sync tab label with the team's display name from config
|
void api.review
|
||||||
const displayName = data.config.name || teamName;
|
.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds)
|
||||||
const allTabs = get().getAllPaneTabs();
|
.catch(() => undefined);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const messagesHeadResult = await get().refreshTeamMessagesHead(teamName);
|
// Sync tab label with the team's display name from config.
|
||||||
if (messagesHeadResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) {
|
const displayName = data.config.name || teamName;
|
||||||
await get().refreshMemberActivityMeta(teamName);
|
const allTabs = get().getAllPaneTabs();
|
||||||
}
|
const relatedTabs = allTabs.filter(
|
||||||
|
(tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === 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
|
|
||||||
);
|
);
|
||||||
if (matchingProject && state.selectedProjectId !== matchingProject.id) {
|
for (const tab of relatedTabs) {
|
||||||
state.selectProject(matchingProject.id);
|
const nextLabel = tab.type === 'graph' ? `${displayName} Graph` : displayName;
|
||||||
} else if (!matchingProject) {
|
if (tab.label !== nextLabel) {
|
||||||
// 2. Try grouped view: search worktrees across all repository groups
|
get().updateTabLabel(tab.id, nextLabel);
|
||||||
for (const repo of state.repositoryGroups) {
|
}
|
||||||
const matchingWorktree = repo.worktrees.find(
|
}
|
||||||
(wt) => normalizePath(wt.path) === normalizedTeamPath
|
|
||||||
);
|
// Auto-select the project associated with this team's cwd/projectPath.
|
||||||
if (matchingWorktree) {
|
// Must search both flat projects and grouped repositoryGroups/worktrees
|
||||||
if (state.selectedWorktreeId !== matchingWorktree.id) {
|
// because the default viewMode is 'grouped' and flat projects may be empty.
|
||||||
set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
|
const projectPath = data.config.projectPath;
|
||||||
void get().fetchSessionsInitial(matchingWorktree.id);
|
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) {
|
} catch (error) {
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If provisioning is in progress for this team, stay in loading state;
|
// If provisioning is in progress for this team, stay in loading state;
|
||||||
// file watcher / progress callback will refresh once config is written.
|
// file watcher / progress callback will refresh once config is written.
|
||||||
const currentState = get();
|
const currentState = get();
|
||||||
if (
|
if (currentState.selectedTeamName !== teamName) {
|
||||||
currentState.selectedTeamName !== teamName ||
|
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
||||||
currentState.selectedTeamLoadNonce !== requestNonce
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (currentState.selectedTeamLoadNonce !== requestNonce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
||||||
const isProvisioning = isTeamProvisioningActive(currentState, teamName);
|
const isProvisioning = isTeamProvisioningActive(currentState, teamName);
|
||||||
|
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
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) => {
|
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 teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
||||||
// Silent refresh — update data without showing loading skeleton.
|
// Silent refresh — update data without showing loading skeleton.
|
||||||
// Only selectTeam() sets loading: true (for initial load).
|
// Only selectTeam() sets loading: true (for initial load).
|
||||||
const reusedInFlightRequest =
|
|
||||||
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
|
|
||||||
noteTeamRefreshBurst(teamName);
|
noteTeamRefreshBurst(teamName);
|
||||||
if (reusedInFlightRequest) {
|
if (reusedInFlightRequest) {
|
||||||
pendingFreshTeamDataRefreshes.add(teamName);
|
pendingFreshTeamDataRefreshes.add(teamName);
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ import type {
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
|
TeamGetDataOptions,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
TeamMemberActivityMeta,
|
TeamMemberActivityMeta,
|
||||||
|
|
@ -440,7 +441,7 @@ export interface HttpServerAPI {
|
||||||
|
|
||||||
export interface TeamsAPI {
|
export interface TeamsAPI {
|
||||||
list: () => Promise<TeamSummary[]>;
|
list: () => Promise<TeamSummary[]>;
|
||||||
getData: (teamName: string) => Promise<TeamViewSnapshot>;
|
getData: (teamName: string, options?: TeamGetDataOptions) => Promise<TeamViewSnapshot>;
|
||||||
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
||||||
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||||
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -875,6 +875,14 @@ export interface TeamMemberActivityMeta {
|
||||||
feedRevision: string;
|
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 {
|
export interface TeamViewSnapshot {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
config: TeamConfig;
|
config: TeamConfig;
|
||||||
|
|
|
||||||
|
|
@ -1274,6 +1274,113 @@ describe('ipc teams handlers', () => {
|
||||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
(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 () => {
|
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-'));
|
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-get-data-'));
|
||||||
setClaudeBasePathOverride(claudeRoot);
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigR
|
||||||
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
||||||
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
||||||
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
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 { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -4594,6 +4595,86 @@ describe('TeamDataService', () => {
|
||||||
expect(harness.getConfig).not.toHaveBeenCalled();
|
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 () => {
|
it('uses snapshot config reads for UI message feed snapshots', async () => {
|
||||||
const harness = createGetTeamDataHarness();
|
const harness = createGetTeamDataHarness();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,80 @@ describe('TeamDataWorkerClient', () => {
|
||||||
client.dispose();
|
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 () => {
|
it('does not queue warmup behind an already running worker', async () => {
|
||||||
const { TeamDataWorkerClient } = await import(
|
const { TeamDataWorkerClient } = await import(
|
||||||
'../../../../src/main/services/team/TeamDataWorkerClient'
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
|
@ -220,6 +294,70 @@ describe('TeamDataWorkerClient', () => {
|
||||||
client.dispose();
|
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 () => {
|
it('does not spawn a worker only to send config invalidation', async () => {
|
||||||
const { TeamDataWorkerClient } = await import(
|
const { TeamDataWorkerClient } = await import(
|
||||||
'../../../../src/main/services/team/TeamDataWorkerClient'
|
'../../../../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 { create } from 'zustand';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -42,6 +42,16 @@ const hoisted = vi.hoisted(() => ({
|
||||||
onProvisioningProgress: vi.fn(() => () => undefined),
|
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', () => ({
|
vi.mock('@renderer/api', () => ({
|
||||||
api: {
|
api: {
|
||||||
teams: {
|
teams: {
|
||||||
|
|
@ -179,6 +189,83 @@ function createDeferredPromise<T>() {
|
||||||
return { promise, resolve, reject };
|
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> = {}) {
|
function createRuntimeSnapshot(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
teamName: 'my-team',
|
teamName: 'my-team',
|
||||||
|
|
@ -245,6 +332,11 @@ describe('teamSlice actions', () => {
|
||||||
hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
|
hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreWindowAnimationFrame();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => {
|
it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
const fetchTeams = vi.fn(async () => undefined);
|
const fetchTeams = vi.fn(async () => undefined);
|
||||||
|
|
@ -963,6 +1055,470 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().selectedTeamData).toBe(cachedBeta);
|
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 () => {
|
it('distinguishes historical feed changes from visible head changes in refreshTeamMessagesHead', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
const existingMessages = [
|
const existingMessages = [
|
||||||
|
|
@ -1994,6 +2550,8 @@ describe('teamSlice actions', () => {
|
||||||
hasMergedMessagesSelector: false,
|
hasMergedMessagesSelector: false,
|
||||||
memberMessagesSelectorCount: 0,
|
memberMessagesSelectorCount: 0,
|
||||||
hasPendingFreshTeamDataRefresh: false,
|
hasPendingFreshTeamDataRefresh: false,
|
||||||
|
hasQueuedFullTeamDataRefreshAfterThin: false,
|
||||||
|
hasPostPaintTeamEnrichmentTimer: false,
|
||||||
hasQueuedHeadRefreshAfterOlder: false,
|
hasQueuedHeadRefreshAfterOlder: false,
|
||||||
hasPendingFreshMessagesHeadRefresh: false,
|
hasPendingFreshMessagesHeadRefresh: false,
|
||||||
hasPendingFreshMemberActivityMetaRefresh: false,
|
hasPendingFreshMemberActivityMetaRefresh: false,
|
||||||
|
|
@ -2087,6 +2645,8 @@ describe('teamSlice actions', () => {
|
||||||
hasMergedMessagesSelector: false,
|
hasMergedMessagesSelector: false,
|
||||||
memberMessagesSelectorCount: 0,
|
memberMessagesSelectorCount: 0,
|
||||||
hasPendingFreshTeamDataRefresh: false,
|
hasPendingFreshTeamDataRefresh: false,
|
||||||
|
hasQueuedFullTeamDataRefreshAfterThin: false,
|
||||||
|
hasPostPaintTeamEnrichmentTimer: false,
|
||||||
hasQueuedHeadRefreshAfterOlder: false,
|
hasQueuedHeadRefreshAfterOlder: false,
|
||||||
hasPendingFreshMessagesHeadRefresh: false,
|
hasPendingFreshMessagesHeadRefresh: false,
|
||||||
hasPendingFreshMemberActivityMetaRefresh: false,
|
hasPendingFreshMemberActivityMetaRefresh: false,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue