fix(team): avoid full session scan on team open
This commit is contained in:
parent
511e4178be
commit
2fde1ad7fc
4 changed files with 204 additions and 5 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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([]),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue