From b2e48c696683f8023b79ee7786a8639211d2fe7d Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Feb 2026 21:40:47 +0200 Subject: [PATCH] feat: enhance team management with improved session and project path history handling - Introduced constants for maximum session and project path history limits to optimize memory usage. - Updated `TeamConfigReader` and `TeamProvisioningService` to limit session and project path history to defined maximums. - Enhanced `TeamMembersMetaStore` to handle large meta files more efficiently by checking file size before processing. - Refactored state management in `teamSlice` to include optimized lookups for team summaries by name and session ID. --- src/main/services/team/TeamConfigReader.ts | 24 +++--------------- .../services/team/TeamMembersMetaStore.ts | 10 ++++++++ .../services/team/TeamProvisioningService.ts | 13 ++++++++-- src/renderer/components/chat/ChatHistory.tsx | 12 ++++----- .../components/sidebar/SidebarTaskItem.tsx | 2 +- src/renderer/store/slices/teamSlice.ts | 25 +++++++++++++++++-- 6 files changed, 55 insertions(+), 31 deletions(-) diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 53977602..d16b776c 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -12,6 +12,8 @@ const logger = createLogger('Service:TeamConfigReader'); const TEAM_LIST_CONCURRENCY = process.platform === 'win32' ? 4 : 12; const LARGE_CONFIG_BYTES = 512 * 1024; const CONFIG_HEAD_BYTES = 64 * 1024; +const MAX_SESSION_HISTORY_IN_SUMMARY = 2000; +const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; async function mapLimit( items: readonly T[], @@ -131,10 +133,10 @@ export class TeamConfigReader { ? config.leadSessionId : undefined; projectPathHistory = Array.isArray(config.projectPathHistory) - ? config.projectPathHistory + ? config.projectPathHistory.slice(-MAX_PROJECT_PATH_HISTORY_IN_SUMMARY) : undefined; sessionHistory = Array.isArray(config.sessionHistory) - ? config.sessionHistory + ? config.sessionHistory.slice(-MAX_SESSION_HISTORY_IN_SUMMARY) : undefined; deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined; } @@ -165,24 +167,6 @@ export class TeamConfigReader { } } - const removedNames = new Set(); - try { - const metaMembers = await this.membersMetaStore.getMembers(teamName); - for (const member of metaMembers) { - if (member.removedAt) { - removedNames.add(member.name.trim()); - } else { - addMember(member); - } - } - } catch { - logger.debug(`Failed to read members.meta.json for team: ${teamName}`); - } - - for (const name of removedNames) { - memberMap.delete(name); - } - const members = Array.from(memberMap.values()); const summary: TeamSummary = { teamName, diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index feee8bbd..2f4f7b8b 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -11,6 +11,8 @@ interface TeamMembersMetaFile { members: TeamMember[]; } +const MAX_META_FILE_BYTES = 256 * 1024; + function normalizeMember(member: TeamMember): TeamMember | null { const trimmedName = member.name?.trim(); if (!trimmedName) { @@ -35,6 +37,14 @@ export class TeamMembersMetaStore { async getMembers(teamName: string): Promise { const metaPath = this.getMetaPath(teamName); + try { + const stat = await fs.promises.stat(metaPath); + if (stat.isFile() && stat.size > MAX_META_FILE_BYTES) { + return []; + } + } catch { + // ignore - readFile below will handle ENOENT and throw on other errors + } let raw: string; try { raw = await fs.promises.readFile(metaPath, 'utf8'); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 932a5f07..cce0adf6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2620,6 +2620,8 @@ export class TeamProvisioningService { projectPath: string, detectedSessionId: string | null ): Promise { + const MAX_SESSION_HISTORY = 5000; + const MAX_PROJECT_PATH_HISTORY = 500; const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { const raw = await fs.promises.readFile(configPath, 'utf8'); @@ -2657,7 +2659,11 @@ export class TeamProvisioningService { logger.info(`[${teamName}] Updated leadSessionId: ${newSessionId}`); } - config.sessionHistory = sessionHistory; + if (sessionHistory.length > MAX_SESSION_HISTORY) { + config.sessionHistory = sessionHistory.slice(-MAX_SESSION_HISTORY); + } else { + config.sessionHistory = sessionHistory; + } // Save current language setting const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system'; @@ -2672,7 +2678,10 @@ export class TeamProvisioningService { ) : []; pathHistory.push(projectPath); - config.projectPathHistory = pathHistory; + config.projectPathHistory = + pathHistory.length > MAX_PROJECT_PATH_HISTORY + ? pathHistory.slice(-MAX_PROJECT_PATH_HISTORY) + : pathHistory; } await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 6991f4b2..93b30be2 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -64,7 +64,6 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { syncSearchMatchesWithRendered, selectSearchMatch, setTabVisibleAIGroup, - teams, openTeamTab, openSessionReport, } = useStore( @@ -79,7 +78,6 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { syncSearchMatchesWithRendered: s.syncSearchMatchesWithRendered, selectSearchMatch: s.selectSearchMatch, setTabVisibleAIGroup: s.setTabVisibleAIGroup, - teams: s.teams, openTeamTab: s.openTeamTab, openSessionReport: s.openSessionReport, })) @@ -126,12 +124,14 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null; const pendingNavigation = thisTab?.pendingNavigation; + const teamBySessionId = useStore((s) => s.teamBySessionId); + // Look up whether this session belongs to a team const sessionTeam = useMemo(() => { - if (!sessionDetail?.session?.id) return null; - const sid = sessionDetail.session.id; - return teams.find((t) => t.leadSessionId === sid || t.sessionHistory?.includes(sid)) ?? null; - }, [teams, sessionDetail?.session?.id]); + const sid = sessionDetail?.session?.id; + if (!sid) return null; + return teamBySessionId[sid] ?? null; + }, [teamBySessionId, sessionDetail?.session?.id]); // Compute all accumulated context injections (phase-aware) const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index d3fdce0c..b8c644aa 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -64,7 +64,7 @@ export const SidebarTaskItem = ({ showTeamName, }: SidebarTaskItemProps): React.JSX.Element => { const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); - const teamMembers = useStore((s) => s.teams.find((t) => t.teamName === task.teamName)?.members); + const teamMembers = useStore((s) => s.teamByName[task.teamName]?.members); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const cfg = task.kanbanColumn === 'approved' diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 7ec5a138..3a402151 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -92,6 +92,10 @@ export interface GlobalTaskDetailState { export interface TeamSlice { teams: TeamSummary[]; + /** O(1) lookup to avoid array scans in render-hot paths */ + teamByName: Record; + /** O(1) lookup: sessionId -> owning team (lead + history) */ + teamBySessionId: Record; teamsLoading: boolean; teamsError: string | null; globalTasks: GlobalTask[]; @@ -175,6 +179,8 @@ export interface TeamSlice { export const createTeamSlice: StateCreator = (set, get) => ({ teams: [], + teamByName: {}, + teamBySessionId: {}, teamsLoading: false, teamsError: null, globalTasks: [], @@ -223,7 +229,22 @@ export const createTeamSlice: StateCreator = (set, } try { const teams = await unwrapIpc('team:list', () => api.teams.list()); - set({ teams, teamsLoading: false, teamsError: null }); + const teamByName: Record = {}; + const teamBySessionId: Record = {}; + for (const team of teams) { + teamByName[team.teamName] = team; + if (team.leadSessionId) { + teamBySessionId[team.leadSessionId] = team; + } + if (Array.isArray(team.sessionHistory)) { + for (const sid of team.sessionHistory) { + if (typeof sid === 'string' && sid) { + teamBySessionId[sid] = team; + } + } + } + } + set({ teams, teamByName, teamBySessionId, teamsLoading: false, teamsError: null }); } catch (error) { // On refresh failure, keep existing teams visible set({ @@ -313,7 +334,7 @@ export const createTeamSlice: StateCreator = (set, const state = get(); // Use display name from teams list or selected team data if available - const teamSummary = state.teams.find((t) => t.teamName === teamName); + const teamSummary = state.teamByName[teamName]; const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName; const allTabs = state.getAllPaneTabs();