From 2fde1ad7fcaddf34d9f3f0a05f416c0a4aa8a85c Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 3 May 2026 09:42:14 +0300 Subject: [PATCH] fix(team): avoid full session scan on team open --- .../components/team/TeamDetailView.tsx | 8 +- .../components/team/teamSessionFetchGuards.ts | 92 ++++++++++++++++++ test/mocks/electronAPI.ts | 12 ++- .../team/teamSessionFetchGuards.test.ts | 97 ++++++++++++++++++- 4 files changed, 204 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index dc992781..fbdd7546 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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; diff --git a/src/renderer/components/team/teamSessionFetchGuards.ts b/src/renderer/components/team/teamSessionFetchGuards.ts index b5a976e4..8b9d53a5 100644 --- a/src/renderer/components/team/teamSessionFetchGuards.ts +++ b/src/renderer/components/team/teamSessionFetchGuards.ts @@ -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; + 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(); + 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 { + 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; diff --git a/test/mocks/electronAPI.ts b/test/mocks/electronAPI.ts index fe6fd666..9ab95cc1 100644 --- a/test/mocks/electronAPI.ts +++ b/test/mocks/electronAPI.ts @@ -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 + > + >; getSessionDetail: ReturnType< typeof vi.fn<(projectId: string, sessionId: string) => Promise> >; @@ -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([]), diff --git a/test/renderer/components/team/teamSessionFetchGuards.test.ts b/test/renderer/components/team/teamSessionFetchGuards.test.ts index ce2c8c3f..0ef071f7 100644 --- a/test/renderer/components/team/teamSessionFetchGuards.test.ts +++ b/test/renderer/components/team/teamSessionFetchGuards.test.ts @@ -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({