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 { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
|
||||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||||
import {
|
import {
|
||||||
|
loadTeamSessionMetadata,
|
||||||
isLeadSessionMissing,
|
isLeadSessionMissing,
|
||||||
shouldSuppressMissingLeadSessionFetch,
|
shouldSuppressMissingLeadSessionFetch,
|
||||||
} from './teamSessionFetchGuards';
|
} from './teamSessionFetchGuards';
|
||||||
|
|
@ -1525,7 +1526,10 @@ export const TeamDetailView = ({
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.getSessions(projectId);
|
const result = await loadTeamSessionMetadata(api, projectId, {
|
||||||
|
leadSessionId: data?.config.leadSessionId ?? null,
|
||||||
|
sessionHistory: data?.config.sessionHistory ?? [],
|
||||||
|
});
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSessions(result);
|
setSessions(result);
|
||||||
}
|
}
|
||||||
|
|
@ -1543,7 +1547,7 @@ export const TeamDetailView = ({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [projectId]);
|
}, [data?.config.leadSessionId, projectId, sessionHistoryKey]);
|
||||||
|
|
||||||
// Live git branch tracking for the lead project and member worktrees
|
// Live git branch tracking for the lead project and member worktrees
|
||||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,97 @@
|
||||||
import type { Session } from '@renderer/types/data';
|
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: {
|
export function isLeadSessionMissing(params: {
|
||||||
leadSessionId: string | null;
|
leadSessionId: string | null;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export interface MockElectronAPI {
|
||||||
projectId: string,
|
projectId: string,
|
||||||
cursor: string | null,
|
cursor: string | null,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
options?: { includeTotalCount?: boolean; prefilterAll?: boolean }
|
options?: { includeTotalCount?: boolean; prefilterAll?: boolean; metadataLevel?: 'light' | 'deep' }
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
nextCursor: string | null;
|
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<
|
getSessionDetail: ReturnType<
|
||||||
typeof vi.fn<(projectId: string, sessionId: string) => Promise<SessionDetail | null>>
|
typeof vi.fn<(projectId: string, sessionId: string) => Promise<SessionDetail | null>>
|
||||||
>;
|
>;
|
||||||
|
|
@ -90,6 +99,7 @@ export function createMockElectronAPI(): MockElectronAPI {
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
}),
|
}),
|
||||||
|
getSessionsByIds: vi.fn().mockResolvedValue([]),
|
||||||
getSessionDetail: vi.fn().mockResolvedValue(null),
|
getSessionDetail: vi.fn().mockResolvedValue(null),
|
||||||
getRepositoryGroups: vi.fn().mockResolvedValue([]),
|
getRepositoryGroups: vi.fn().mockResolvedValue([]),
|
||||||
getWorktreeSessions: 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', () => {
|
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', () => {
|
it('suppresses repeated silent fetches for the same missing lead session id', () => {
|
||||||
expect(
|
expect(
|
||||||
shouldSuppressMissingLeadSessionFetch({
|
shouldSuppressMissingLeadSessionFetch({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue