fix(team): lazy load lead context detail

This commit is contained in:
777genius 2026-05-03 10:52:38 +03:00
parent 9421fad08d
commit b99be7d007
5 changed files with 565 additions and 121 deletions

View file

@ -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<string | null>(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;
});

View file

@ -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<string | null>(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 (
<>
<LeadSessionDetailGate
tabId={tabId}
projectId={projectId}
leadSessionId={leadSessionId}
enabled={shouldLoadFullLeadDetail}
/>
{isContextPanelVisible && (
<div className="w-80 shrink-0">
{leadSessionLoaded ? (
@ -765,11 +695,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
>
<button
onClick={() => {
const next = !isContextPanelVisible;
setContextPanelVisible(next);
if (tabId && projectId) {
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
}
setContextPanelVisible(!isContextPanelVisible);
}}
onMouseEnter={() => setIsContextButtonHovered(true)}
onMouseLeave={() => setIsContextButtonHovered(false)}
@ -792,7 +718,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
: leadSessionId
}
>
{contextUsedPercentLabel ?? 'Context'}
{contextUsedPercentLabel}
</button>
</div>
</>
@ -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 ? (
<LeadContextWatcher
teamName={teamName}
tabId={tabId}
projectId={projectId}
leadSessionId={leadSessionId}
sessionHistoryKey={sessionHistoryKey}
isThisTabActive={isThisTabActive}
isTeamAlive={data?.isAlive}
sessions={sessions}
sessionsLoading={sessionsLoading}
/>
) : 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()}
</>
);

View file

@ -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)}%`;
}

View file

@ -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(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-1"
enabled={false}
/>
);
});
expect(fetchSessionDetail).not.toHaveBeenCalled();
});
it('fetches once when enabled and unloaded', async () => {
await act(async () => {
root.render(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-1"
enabled
/>
);
});
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(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-1"
enabled
/>
);
});
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(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-1"
enabled
/>
);
});
expect(fetchSessionDetail).not.toHaveBeenCalled();
});
it('does not refetch the same attempted request on rerender', async () => {
await act(async () => {
root.render(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-1"
enabled
/>
);
});
await act(async () => {
root.render(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-1"
enabled
/>
);
});
expect(fetchSessionDetail).toHaveBeenCalledTimes(1);
});
it('fetches again when the requested lead session changes', async () => {
await act(async () => {
root.render(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-1"
enabled
/>
);
});
await act(async () => {
root.render(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-2"
enabled
/>
);
});
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(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-1"
enabled
/>
);
});
await act(async () => {
root.render(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-1"
enabled={false}
/>
);
});
await act(async () => {
root.render(
<LeadSessionDetailGate
tabId="tab-1"
projectId="project-1"
leadSessionId="lead-1"
enabled
/>
);
});
expect(fetchSessionDetail).toHaveBeenCalledTimes(2);
});
});

View file

@ -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%');
});
});
});