From b99be7d00753e34078e892ab5824ec272de71ce3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 3 May 2026 10:52:38 +0300 Subject: [PATCH] fix(team): lazy load lead context detail --- .../components/team/LeadSessionDetailGate.tsx | 73 ++++++ .../components/team/TeamDetailView.tsx | 152 +++---------- .../components/team/leadContextLoadGuards.ts | 62 +++++ .../team/LeadSessionDetailGate.test.tsx | 212 ++++++++++++++++++ .../team/leadContextLoadGuards.test.ts | 187 +++++++++++++++ 5 files changed, 565 insertions(+), 121 deletions(-) create mode 100644 src/renderer/components/team/LeadSessionDetailGate.tsx create mode 100644 src/renderer/components/team/leadContextLoadGuards.ts create mode 100644 test/renderer/components/team/LeadSessionDetailGate.test.tsx create mode 100644 test/renderer/components/team/leadContextLoadGuards.test.ts diff --git a/src/renderer/components/team/LeadSessionDetailGate.tsx b/src/renderer/components/team/LeadSessionDetailGate.tsx new file mode 100644 index 00000000..e369bd03 --- /dev/null +++ b/src/renderer/components/team/LeadSessionDetailGate.tsx @@ -0,0 +1,73 @@ +import { memo, useEffect, useRef } from 'react'; + +import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; + +import { + buildLeadSessionDetailRequestKey, + shouldFetchLeadSessionDetail, + shouldLoadLeadSessionDetail, +} from './leadContextLoadGuards'; + +interface LeadSessionDetailGateProps { + tabId: string | null; + projectId: string | null; + leadSessionId: string | null; + enabled: boolean; +} + +export const LeadSessionDetailGate = memo(function LeadSessionDetailGate({ + tabId, + projectId, + leadSessionId, + enabled, +}: LeadSessionDetailGateProps): null { + const fetchSessionDetail = useStore((s) => s.fetchSessionDetail); + const { loadedSessionId, loading } = useStore( + useShallow((s) => { + const tabData = tabId ? (s.tabSessionData[tabId] ?? null) : null; + return { + loadedSessionId: tabData?.sessionDetail?.session?.id ?? null, + loading: tabData?.sessionDetailLoading ?? false, + }; + }) + ); + const startedRequestKeyRef = useRef(null); + + useEffect(() => { + if (!enabled) { + startedRequestKeyRef.current = null; + } + }, [enabled]); + + useEffect(() => { + const input = { tabId, projectId, leadSessionId, enabled }; + if (!shouldLoadLeadSessionDetail(input)) { + return; + } + + const requestKey = buildLeadSessionDetailRequestKey(input); + if ( + !shouldFetchLeadSessionDetail({ + requestedSessionId: input.leadSessionId, + loadedSessionId, + loading, + inFlightOrAttemptedRequestKey: startedRequestKeyRef.current, + nextRequestKey: requestKey, + }) + ) { + return; + } + + startedRequestKeyRef.current = requestKey; + void fetchSessionDetail(input.projectId, input.leadSessionId, input.tabId, { silent: false }); + }, [enabled, fetchSessionDetail, leadSessionId, loadedSessionId, loading, projectId, tabId]); + + useEffect(() => { + if (loadedSessionId === leadSessionId) { + startedRequestKeyRef.current = null; + } + }, [leadSessionId, loadedSessionId]); + + return null; +}); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index fbdd7546..45af604f 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -120,11 +120,9 @@ import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; -import { - loadTeamSessionMetadata, - isLeadSessionMissing, - shouldSuppressMissingLeadSessionFetch, -} from './teamSessionFetchGuards'; +import { deriveLeadContextButtonLabel } from './leadContextLoadGuards'; +import { LeadSessionDetailGate } from './LeadSessionDetailGate'; +import { loadTeamSessionMetadata } from './teamSessionFetchGuards'; import { TeamSessionsSection } from './TeamSessionsSection'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; @@ -324,17 +322,6 @@ type TeamSidebarRailBridgeProps = Omit< > & { messagesPanelProps: SharedTeamMessagesPanelProps; }; -interface LeadContextWatcherProps { - teamName: string; - tabId: string | null; - projectId: string | null; - leadSessionId: string | null; - sessionHistoryKey: string; - isThisTabActive: boolean; - isTeamAlive?: boolean; - sessions: readonly Session[]; - sessionsLoading: boolean; -} interface LeadContextBridgeProps { teamName: string; tabId: string | null; @@ -342,6 +329,7 @@ interface LeadContextBridgeProps { leadSessionId: string | null; leadProviderId?: TeamProviderId; fallbackProjectRoot?: string; + isThisTabActive: boolean; } // Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet. @@ -477,81 +465,6 @@ const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({ return null; }); -const LeadContextWatcher = memo(function LeadContextWatcher({ - teamName, - tabId, - projectId, - leadSessionId, - sessionHistoryKey, - isThisTabActive, - isTeamAlive, - sessions, - sessionsLoading, -}: LeadContextWatcherProps): null { - const fetchSessionDetail = useStore((s) => s.fetchSessionDetail); - const missingLeadSessionFetchKeyRef = useRef(null); - const missingLeadSessionFetchKey = useMemo( - () => `${teamName}:${projectId ?? ''}:${leadSessionId ?? ''}:${sessionHistoryKey}`, - [teamName, projectId, leadSessionId, sessionHistoryKey] - ); - - useEffect(() => { - missingLeadSessionFetchKeyRef.current = null; - }, [missingLeadSessionFetchKey]); - - useEffect(() => { - if (!isThisTabActive) return; - if (!tabId || !projectId || !leadSessionId) return; - - const leadSessionMissing = isLeadSessionMissing({ - leadSessionId, - projectId, - sessionsLoading, - knownSessions: sessions, - }); - if (leadSessionMissing) { - missingLeadSessionFetchKeyRef.current = missingLeadSessionFetchKey; - return; - } - - const fetchLeadSessionDetail = () => { - const suppressRepeatedFetch = shouldSuppressMissingLeadSessionFetch({ - leadSessionId, - projectId, - sessionsLoading, - knownSessions: sessions, - suppressionKey: missingLeadSessionFetchKeyRef.current, - currentKey: missingLeadSessionFetchKey, - }); - if (suppressRepeatedFetch) { - return; - } - void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); - }; - - fetchLeadSessionDetail(); - - if (!isTeamAlive) return; - - const id = window.setInterval(() => { - fetchLeadSessionDetail(); - }, 10_000); - return () => window.clearInterval(id); - }, [ - fetchSessionDetail, - isTeamAlive, - isThisTabActive, - leadSessionId, - missingLeadSessionFetchKey, - projectId, - sessions, - sessionsLoading, - tabId, - ]); - - return null; -}); - const LeadContextBridge = memo(function LeadContextBridge({ teamName, tabId, @@ -559,6 +472,7 @@ const LeadContextBridge = memo(function LeadContextBridge({ leadSessionId, leadProviderId, fallbackProjectRoot, + isThisTabActive, }: LeadContextBridgeProps): React.JSX.Element | null { const { leadTabData, @@ -567,7 +481,6 @@ const LeadContextBridge = memo(function LeadContextBridge({ selectedContextPhase, setContextPanelVisibleForTab, setSelectedContextPhaseForTab, - fetchSessionDetail, } = useStore( useShallow((s) => ({ leadTabData: tabId ? (s.tabSessionData[tabId] ?? null) : null, @@ -576,7 +489,6 @@ const LeadContextBridge = memo(function LeadContextBridge({ selectedContextPhase: tabId ? (s.tabUIStates.get(tabId)?.selectedContextPhase ?? null) : null, setContextPanelVisibleForTab: s.setContextPanelVisibleForTab, setSelectedContextPhaseForTab: s.setSelectedContextPhaseForTab, - fetchSessionDetail: s.fetchSessionDetail, })) ); const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); @@ -692,12 +604,23 @@ const LeadContextBridge = memo(function LeadContextBridge({ visibleContextTokens, ] ); - const contextUsedPercentLabel = useMemo(() => { - const percent = - contextMetrics.contextUsedPercentOfContextWindow ?? leadContextSnapshot?.contextUsedPercent; - return percent === null || percent === undefined ? null : `${percent.toFixed(1)}%`; - }, [contextMetrics.contextUsedPercentOfContextWindow, leadContextSnapshot?.contextUsedPercent]); + const contextUsedPercentLabel = useMemo( + () => + deriveLeadContextButtonLabel({ + liveContextUsedPercent: leadContextSnapshot?.contextUsedPercent, + fullContextUsedPercent: contextMetrics.contextUsedPercentOfContextWindow, + contextPanelOpen: isContextPanelVisible, + }), + [ + contextMetrics.contextUsedPercentOfContextWindow, + isContextPanelVisible, + leadContextSnapshot?.contextUsedPercent, + ] + ); const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); + const shouldLoadFullLeadDetail = Boolean( + leadSessionId && shouldShowLeadContextUi && isThisTabActive && isContextPanelVisible + ); useEffect(() => { if (!shouldShowLeadContextUi && isContextPanelVisible) { @@ -711,6 +634,13 @@ const LeadContextBridge = memo(function LeadContextBridge({ return ( <> + + {isContextPanelVisible && (
{leadSessionLoaded ? ( @@ -765,11 +695,7 @@ const LeadContextBridge = memo(function LeadContextBridge({ >
@@ -1705,8 +1631,6 @@ export const TeamDetailView = ({ if (configuredLeadProviderId) return configuredLeadProviderId; return launchParams?.providerId; }, [activeMembers, data?.config.members, launchParams?.providerId]); - const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); - const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); const taskMapRef = useRef(taskMap); taskMapRef.current = taskMap; @@ -2097,20 +2021,6 @@ export const TeamDetailView = ({ isThisTabActive={isThisTabActive} /> ); - const leadContextWatcher = shouldShowLeadContextUi ? ( - - ) : null; - const renderBody = (): React.JSX.Element => { if ((loading && !data) || (data && data.teamName !== teamName)) { return ( @@ -2221,6 +2131,7 @@ export const TeamDetailView = ({ leadSessionId={leadSessionId} leadProviderId={leadProviderId} fallbackProjectRoot={data.config.projectPath} + isThisTabActive={isThisTabActive} /> {/* Messages sidebar (left, after context panel) */} @@ -3163,7 +3074,6 @@ export const TeamDetailView = ({ <> {spawnStatusWatcher} {teamAgentRuntimeWatcher} - {leadContextWatcher} {renderBody()} ); diff --git a/src/renderer/components/team/leadContextLoadGuards.ts b/src/renderer/components/team/leadContextLoadGuards.ts new file mode 100644 index 00000000..3662a2e2 --- /dev/null +++ b/src/renderer/components/team/leadContextLoadGuards.ts @@ -0,0 +1,62 @@ +export interface LeadSessionDetailLoadInput { + tabId: string | null; + projectId: string | null; + leadSessionId: string | null; + enabled: boolean; +} + +export interface ResolvedLeadSessionDetailLoadInput { + tabId: string; + projectId: string; + leadSessionId: string; + enabled: true; +} + +export function shouldLoadLeadSessionDetail( + input: LeadSessionDetailLoadInput +): input is ResolvedLeadSessionDetailLoadInput { + return Boolean( + input.enabled && input.tabId?.trim() && input.projectId?.trim() && input.leadSessionId?.trim() + ); +} + +export function buildLeadSessionDetailRequestKey(input: { + tabId: string; + projectId: string; + leadSessionId: string; +}): string { + return `${input.tabId}:${input.projectId}:${input.leadSessionId}`; +} + +export function shouldFetchLeadSessionDetail(input: { + requestedSessionId: string | null; + loadedSessionId: string | null; + loading: boolean; + inFlightOrAttemptedRequestKey: string | null; + nextRequestKey: string | null; +}): boolean { + const requested = input.requestedSessionId?.trim() ?? ''; + if (!requested) return false; + if (input.loading) return false; + if (input.loadedSessionId === requested) return false; + if (input.nextRequestKey && input.inFlightOrAttemptedRequestKey === input.nextRequestKey) { + return false; + } + return true; +} + +export function deriveLeadContextButtonLabel(input: { + liveContextUsedPercent?: number | null; + fullContextUsedPercent?: number | null; + contextPanelOpen: boolean; +}): string { + const percent = input.contextPanelOpen + ? (input.fullContextUsedPercent ?? input.liveContextUsedPercent) + : input.liveContextUsedPercent; + + if (typeof percent !== 'number' || !Number.isFinite(percent)) { + return 'Context'; + } + + return `${percent.toFixed(1)}%`; +} diff --git a/test/renderer/components/team/LeadSessionDetailGate.test.tsx b/test/renderer/components/team/LeadSessionDetailGate.test.tsx new file mode 100644 index 00000000..96678afc --- /dev/null +++ b/test/renderer/components/team/LeadSessionDetailGate.test.tsx @@ -0,0 +1,212 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchSessionDetail = vi.fn(async () => undefined); +const storeState = { + fetchSessionDetail, + tabSessionData: {} as Record< + string, + { + sessionDetail?: { session?: { id?: string } | null } | null; + sessionDetailLoading?: boolean; + } + >, +}; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})); + +import { LeadSessionDetailGate } from '@renderer/components/team/LeadSessionDetailGate'; + +describe('LeadSessionDetailGate', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + fetchSessionDetail.mockClear(); + storeState.tabSessionData = {}; + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('does not fetch while disabled', async () => { + await act(async () => { + root.render( + + ); + }); + + expect(fetchSessionDetail).not.toHaveBeenCalled(); + }); + + it('fetches once when enabled and unloaded', async () => { + await act(async () => { + root.render( + + ); + }); + + expect(fetchSessionDetail).toHaveBeenCalledTimes(1); + expect(fetchSessionDetail).toHaveBeenCalledWith('project-1', 'lead-1', 'tab-1', { + silent: false, + }); + }); + + it('does not fetch an already loaded lead session', async () => { + storeState.tabSessionData = { + 'tab-1': { + sessionDetail: { session: { id: 'lead-1' } }, + sessionDetailLoading: false, + }, + }; + + await act(async () => { + root.render( + + ); + }); + + expect(fetchSessionDetail).not.toHaveBeenCalled(); + }); + + it('does not fetch while tab detail is loading', async () => { + storeState.tabSessionData = { + 'tab-1': { + sessionDetail: null, + sessionDetailLoading: true, + }, + }; + + await act(async () => { + root.render( + + ); + }); + + expect(fetchSessionDetail).not.toHaveBeenCalled(); + }); + + it('does not refetch the same attempted request on rerender', async () => { + await act(async () => { + root.render( + + ); + }); + await act(async () => { + root.render( + + ); + }); + + expect(fetchSessionDetail).toHaveBeenCalledTimes(1); + }); + + it('fetches again when the requested lead session changes', async () => { + await act(async () => { + root.render( + + ); + }); + await act(async () => { + root.render( + + ); + }); + + expect(fetchSessionDetail).toHaveBeenCalledTimes(2); + expect(fetchSessionDetail).toHaveBeenLastCalledWith('project-1', 'lead-2', 'tab-1', { + silent: false, + }); + }); + + it('allows retry after disabling and enabling the gate', async () => { + await act(async () => { + root.render( + + ); + }); + await act(async () => { + root.render( + + ); + }); + await act(async () => { + root.render( + + ); + }); + + expect(fetchSessionDetail).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/renderer/components/team/leadContextLoadGuards.test.ts b/test/renderer/components/team/leadContextLoadGuards.test.ts new file mode 100644 index 00000000..b46c6f77 --- /dev/null +++ b/test/renderer/components/team/leadContextLoadGuards.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildLeadSessionDetailRequestKey, + deriveLeadContextButtonLabel, + shouldFetchLeadSessionDetail, + shouldLoadLeadSessionDetail, +} from '@renderer/components/team/leadContextLoadGuards'; + +describe('leadContextLoadGuards', () => { + describe('shouldLoadLeadSessionDetail', () => { + it('does not load when disabled', () => { + expect( + shouldLoadLeadSessionDetail({ + enabled: false, + tabId: 'tab-1', + projectId: 'project-1', + leadSessionId: 'lead-1', + }) + ).toBe(false); + }); + + it('requires all identifiers', () => { + expect( + shouldLoadLeadSessionDetail({ + enabled: true, + tabId: null, + projectId: 'project-1', + leadSessionId: 'lead-1', + }) + ).toBe(false); + expect( + shouldLoadLeadSessionDetail({ + enabled: true, + tabId: 'tab-1', + projectId: null, + leadSessionId: 'lead-1', + }) + ).toBe(false); + expect( + shouldLoadLeadSessionDetail({ + enabled: true, + tabId: 'tab-1', + projectId: 'project-1', + leadSessionId: null, + }) + ).toBe(false); + }); + + it('loads only when enabled and identifiers are valid', () => { + expect( + shouldLoadLeadSessionDetail({ + enabled: true, + tabId: 'tab-1', + projectId: 'project-1', + leadSessionId: 'lead-1', + }) + ).toBe(true); + }); + }); + + describe('buildLeadSessionDetailRequestKey', () => { + it('builds a stable per-tab request key', () => { + expect( + buildLeadSessionDetailRequestKey({ + tabId: 'tab-1', + projectId: 'project-1', + leadSessionId: 'lead-1', + }) + ).toBe('tab-1:project-1:lead-1'); + }); + }); + + describe('shouldFetchLeadSessionDetail', () => { + it('does not fetch without a requested session', () => { + expect( + shouldFetchLeadSessionDetail({ + requestedSessionId: null, + loadedSessionId: null, + loading: false, + inFlightOrAttemptedRequestKey: null, + nextRequestKey: null, + }) + ).toBe(false); + }); + + it('does not fetch while loading', () => { + expect( + shouldFetchLeadSessionDetail({ + requestedSessionId: 'lead-1', + loadedSessionId: null, + loading: true, + inFlightOrAttemptedRequestKey: null, + nextRequestKey: 'tab-1:project-1:lead-1', + }) + ).toBe(false); + }); + + it('does not fetch an already loaded session', () => { + expect( + shouldFetchLeadSessionDetail({ + requestedSessionId: 'lead-1', + loadedSessionId: 'lead-1', + loading: false, + inFlightOrAttemptedRequestKey: null, + nextRequestKey: 'tab-1:project-1:lead-1', + }) + ).toBe(false); + }); + + it('does not refetch the same attempted request key', () => { + expect( + shouldFetchLeadSessionDetail({ + requestedSessionId: 'lead-1', + loadedSessionId: null, + loading: false, + inFlightOrAttemptedRequestKey: 'tab-1:project-1:lead-1', + nextRequestKey: 'tab-1:project-1:lead-1', + }) + ).toBe(false); + }); + + it('fetches when the requested session is unloaded and not already attempted', () => { + expect( + shouldFetchLeadSessionDetail({ + requestedSessionId: 'lead-2', + loadedSessionId: 'lead-1', + loading: false, + inFlightOrAttemptedRequestKey: null, + nextRequestKey: 'tab-1:project-1:lead-2', + }) + ).toBe(true); + }); + }); + + describe('deriveLeadContextButtonLabel', () => { + it('uses live percent while the panel is closed', () => { + expect( + deriveLeadContextButtonLabel({ + liveContextUsedPercent: 25, + fullContextUsedPercent: 90, + contextPanelOpen: false, + }) + ).toBe('25.0%'); + }); + + it('prefers full percent while the panel is open', () => { + expect( + deriveLeadContextButtonLabel({ + liveContextUsedPercent: 25, + fullContextUsedPercent: 90, + contextPanelOpen: true, + }) + ).toBe('90.0%'); + }); + + it('falls back to live percent while open when full percent is unavailable', () => { + expect( + deriveLeadContextButtonLabel({ + liveContextUsedPercent: 25, + fullContextUsedPercent: null, + contextPanelOpen: true, + }) + ).toBe('25.0%'); + }); + + it('falls back to Context when no percent is available', () => { + expect( + deriveLeadContextButtonLabel({ + liveContextUsedPercent: null, + fullContextUsedPercent: null, + contextPanelOpen: false, + }) + ).toBe('Context'); + }); + + it('does not clamp full percent values', () => { + expect( + deriveLeadContextButtonLabel({ + liveContextUsedPercent: null, + fullContextUsedPercent: 125.5, + contextPanelOpen: true, + }) + ).toBe('125.5%'); + }); + }); +});