fix(team): lazy load lead context detail
This commit is contained in:
parent
9421fad08d
commit
b99be7d007
5 changed files with 565 additions and 121 deletions
73
src/renderer/components/team/LeadSessionDetailGate.tsx
Normal file
73
src/renderer/components/team/LeadSessionDetailGate.tsx
Normal 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;
|
||||
});
|
||||
|
|
@ -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()}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
62
src/renderer/components/team/leadContextLoadGuards.ts
Normal file
62
src/renderer/components/team/leadContextLoadGuards.ts
Normal 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)}%`;
|
||||
}
|
||||
212
test/renderer/components/team/LeadSessionDetailGate.test.tsx
Normal file
212
test/renderer/components/team/LeadSessionDetailGate.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
187
test/renderer/components/team/leadContextLoadGuards.test.ts
Normal file
187
test/renderer/components/team/leadContextLoadGuards.test.ts
Normal 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%');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue