fix(team): avoid full session scan on team open

This commit is contained in:
777genius 2026-05-03 09:42:14 +03:00
parent 511e4178be
commit 2fde1ad7fc
4 changed files with 204 additions and 5 deletions

View file

@ -121,6 +121,7 @@ import { ProcessesSection } from './ProcessesSection';
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import {
loadTeamSessionMetadata,
isLeadSessionMissing,
shouldSuppressMissingLeadSessionFetch,
} from './teamSessionFetchGuards';
@ -1525,7 +1526,10 @@ export const TeamDetailView = ({
void (async () => {
try {
const result = await api.getSessions(projectId);
const result = await loadTeamSessionMetadata(api, projectId, {
leadSessionId: data?.config.leadSessionId ?? null,
sessionHistory: data?.config.sessionHistory ?? [],
});
if (!cancelled) {
setSessions(result);
}
@ -1543,7 +1547,7 @@ export const TeamDetailView = ({
return () => {
cancelled = true;
};
}, [projectId]);
}, [data?.config.leadSessionId, projectId, sessionHistoryKey]);
// Live git branch tracking for the lead project and member worktrees
const teamProjectPath = data?.config.projectPath?.trim() ?? null;

View file

@ -1,5 +1,97 @@
import type { Session } from '@renderer/types/data';
export interface TeamSessionConfigLike {
leadSessionId?: string | null;
sessionHistory?: readonly unknown[] | null;
}
export interface TeamSessionMetadataApi {
getSessionsByIds: (
projectId: string,
sessionIds: string[],
options?: { metadataLevel?: 'light' | 'deep' }
) => Promise<Session[]>;
getSessionsPaginated: (
projectId: string,
cursor: string | null,
limit?: number,
options?: {
includeTotalCount?: boolean;
prefilterAll?: boolean;
metadataLevel?: 'light' | 'deep';
}
) => Promise<{
sessions: Session[];
nextCursor: string | null;
hasMore: boolean;
totalCount: number;
}>;
}
const DEFAULT_TEAM_SESSION_METADATA_LIMIT = 20;
export function buildTeamSessionIds(
config: TeamSessionConfigLike,
limit: number = DEFAULT_TEAM_SESSION_METADATA_LIMIT
): string[] {
const max = Math.max(0, Math.floor(limit));
if (max === 0) return [];
const sessionIds: string[] = [];
const seen = new Set<string>();
const push = (value: unknown): void => {
if (typeof value !== 'string') return;
const sessionId = value.trim();
if (!sessionId || seen.has(sessionId) || sessionIds.length >= max) return;
seen.add(sessionId);
sessionIds.push(sessionId);
};
push(config.leadSessionId);
if (Array.isArray(config.sessionHistory)) {
for (let index = config.sessionHistory.length - 1; index >= 0; index -= 1) {
push(config.sessionHistory[index]);
if (sessionIds.length >= max) break;
}
}
return sessionIds;
}
export async function loadTeamSessionMetadata(
api: TeamSessionMetadataApi,
projectId: string,
config: TeamSessionConfigLike,
limit: number = DEFAULT_TEAM_SESSION_METADATA_LIMIT
): Promise<Session[]> {
const sessionIds = buildTeamSessionIds(config, limit);
const leadSessionId = typeof config.leadSessionId === 'string' ? config.leadSessionId.trim() : '';
if (sessionIds.length === 0) {
const page = await api.getSessionsPaginated(projectId, null, limit, {
includeTotalCount: false,
prefilterAll: false,
metadataLevel: 'light',
});
return [...page.sessions].sort((a, b) => b.createdAt - a.createdAt);
}
const requestedOrder = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
const sessions = await api.getSessionsByIds(projectId, sessionIds, { metadataLevel: 'light' });
return [...sessions].sort((a, b) => {
if (leadSessionId) {
if (a.id === leadSessionId && b.id !== leadSessionId) return -1;
if (b.id === leadSessionId && a.id !== leadSessionId) return 1;
}
if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt;
return (
(requestedOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
(requestedOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER)
);
});
}
export function isLeadSessionMissing(params: {
leadSessionId: string | null;
projectId: string | null;

View file

@ -16,7 +16,7 @@ export interface MockElectronAPI {
projectId: string,
cursor: string | null,
limit?: number,
options?: { includeTotalCount?: boolean; prefilterAll?: boolean }
options?: { includeTotalCount?: boolean; prefilterAll?: boolean; metadataLevel?: 'light' | 'deep' }
) => Promise<{
sessions: Session[];
nextCursor: string | null;
@ -25,6 +25,15 @@ export interface MockElectronAPI {
}>
>
>;
getSessionsByIds: ReturnType<
typeof vi.fn<
(
projectId: string,
sessionIds: string[],
options?: { metadataLevel?: 'light' | 'deep' }
) => Promise<Session[]>
>
>;
getSessionDetail: ReturnType<
typeof vi.fn<(projectId: string, sessionId: string) => Promise<SessionDetail | null>>
>;
@ -90,6 +99,7 @@ export function createMockElectronAPI(): MockElectronAPI {
hasMore: false,
totalCount: 0,
}),
getSessionsByIds: vi.fn().mockResolvedValue([]),
getSessionDetail: vi.fn().mockResolvedValue(null),
getRepositoryGroups: vi.fn().mockResolvedValue([]),
getWorktreeSessions: vi.fn().mockResolvedValue([]),

View file

@ -1,8 +1,101 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { shouldSuppressMissingLeadSessionFetch } from '@renderer/components/team/teamSessionFetchGuards';
import {
buildTeamSessionIds,
loadTeamSessionMetadata,
shouldSuppressMissingLeadSessionFetch,
} from '@renderer/components/team/teamSessionFetchGuards';
import type { Session } from '@renderer/types/data';
function createSession(id: string, createdAt: number): Session {
return {
id,
projectId: 'project-1',
projectPath: '/tmp/project',
createdAt,
hasSubagents: false,
messageCount: 0,
};
}
describe('teamSessionFetchGuards', () => {
it('builds bounded team session ids with lead first and newest history first', () => {
expect(
buildTeamSessionIds({
leadSessionId: ' lead-session ',
sessionHistory: ['old-session', 'lead-session', '', 42, 'new-session'],
})
).toEqual(['lead-session', 'new-session', 'old-session']);
});
it('limits team session ids to avoid loading deep project history', () => {
const sessionHistory = Array.from({ length: 25 }, (_, index) => `session-${index}`);
const ids = buildTeamSessionIds({ leadSessionId: 'lead-session', sessionHistory }, 20);
expect(ids).toHaveLength(20);
expect(ids[0]).toBe('lead-session');
expect(ids[1]).toBe('session-24');
expect(ids).not.toContain('session-5');
});
it('loads targeted team session metadata without calling legacy getSessions', async () => {
const api = {
getSessions: vi.fn(),
getSessionsByIds: vi.fn().mockResolvedValue([
createSession('older-session', 100),
createSession('lead-session', 1),
createSession('newer-session', 200),
]),
getSessionsPaginated: vi.fn(),
};
const sessions = await loadTeamSessionMetadata(api, 'project-1', {
leadSessionId: 'lead-session',
sessionHistory: ['older-session', 'newer-session'],
});
expect(api.getSessionsByIds).toHaveBeenCalledWith(
'project-1',
['lead-session', 'newer-session', 'older-session'],
{ metadataLevel: 'light' }
);
expect(api.getSessionsPaginated).not.toHaveBeenCalled();
expect(api.getSessions).not.toHaveBeenCalled();
expect(sessions.map((session) => session.id)).toEqual([
'lead-session',
'newer-session',
'older-session',
]);
});
it('falls back to light paginated sessions for legacy teams without known session ids', async () => {
const api = {
getSessions: vi.fn(),
getSessionsByIds: vi.fn(),
getSessionsPaginated: vi.fn().mockResolvedValue({
sessions: [createSession('old-session', 10), createSession('new-session', 20)],
nextCursor: null,
hasMore: false,
totalCount: 2,
}),
};
const sessions = await loadTeamSessionMetadata(api, 'project-1', {
leadSessionId: null,
sessionHistory: [],
});
expect(api.getSessionsPaginated).toHaveBeenCalledWith('project-1', null, 20, {
includeTotalCount: false,
prefilterAll: false,
metadataLevel: 'light',
});
expect(api.getSessionsByIds).not.toHaveBeenCalled();
expect(api.getSessions).not.toHaveBeenCalled();
expect(sessions.map((session) => session.id)).toEqual(['new-session', 'old-session']);
});
it('suppresses repeated silent fetches for the same missing lead session id', () => {
expect(
shouldSuppressMissingLeadSessionFetch({