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 { ProcessesSection } from './ProcessesSection';
|
||||||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
|
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
|
||||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||||
import {
|
import { deriveLeadContextButtonLabel } from './leadContextLoadGuards';
|
||||||
loadTeamSessionMetadata,
|
import { LeadSessionDetailGate } from './LeadSessionDetailGate';
|
||||||
isLeadSessionMissing,
|
import { loadTeamSessionMetadata } from './teamSessionFetchGuards';
|
||||||
shouldSuppressMissingLeadSessionFetch,
|
|
||||||
} from './teamSessionFetchGuards';
|
|
||||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||||
|
|
||||||
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
||||||
|
|
@ -324,17 +322,6 @@ type TeamSidebarRailBridgeProps = Omit<
|
||||||
> & {
|
> & {
|
||||||
messagesPanelProps: SharedTeamMessagesPanelProps;
|
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 {
|
interface LeadContextBridgeProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
tabId: string | null;
|
tabId: string | null;
|
||||||
|
|
@ -342,6 +329,7 @@ interface LeadContextBridgeProps {
|
||||||
leadSessionId: string | null;
|
leadSessionId: string | null;
|
||||||
leadProviderId?: TeamProviderId;
|
leadProviderId?: TeamProviderId;
|
||||||
fallbackProjectRoot?: string;
|
fallbackProjectRoot?: string;
|
||||||
|
isThisTabActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet.
|
// 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;
|
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({
|
const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
teamName,
|
teamName,
|
||||||
tabId,
|
tabId,
|
||||||
|
|
@ -559,6 +472,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
leadSessionId,
|
leadSessionId,
|
||||||
leadProviderId,
|
leadProviderId,
|
||||||
fallbackProjectRoot,
|
fallbackProjectRoot,
|
||||||
|
isThisTabActive,
|
||||||
}: LeadContextBridgeProps): React.JSX.Element | null {
|
}: LeadContextBridgeProps): React.JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
leadTabData,
|
leadTabData,
|
||||||
|
|
@ -567,7 +481,6 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
selectedContextPhase,
|
selectedContextPhase,
|
||||||
setContextPanelVisibleForTab,
|
setContextPanelVisibleForTab,
|
||||||
setSelectedContextPhaseForTab,
|
setSelectedContextPhaseForTab,
|
||||||
fetchSessionDetail,
|
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
leadTabData: tabId ? (s.tabSessionData[tabId] ?? null) : null,
|
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,
|
selectedContextPhase: tabId ? (s.tabUIStates.get(tabId)?.selectedContextPhase ?? null) : null,
|
||||||
setContextPanelVisibleForTab: s.setContextPanelVisibleForTab,
|
setContextPanelVisibleForTab: s.setContextPanelVisibleForTab,
|
||||||
setSelectedContextPhaseForTab: s.setSelectedContextPhaseForTab,
|
setSelectedContextPhaseForTab: s.setSelectedContextPhaseForTab,
|
||||||
fetchSessionDetail: s.fetchSessionDetail,
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
|
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
|
||||||
|
|
@ -692,12 +604,23 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
visibleContextTokens,
|
visibleContextTokens,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const contextUsedPercentLabel = useMemo(() => {
|
const contextUsedPercentLabel = useMemo(
|
||||||
const percent =
|
() =>
|
||||||
contextMetrics.contextUsedPercentOfContextWindow ?? leadContextSnapshot?.contextUsedPercent;
|
deriveLeadContextButtonLabel({
|
||||||
return percent === null || percent === undefined ? null : `${percent.toFixed(1)}%`;
|
liveContextUsedPercent: leadContextSnapshot?.contextUsedPercent,
|
||||||
}, [contextMetrics.contextUsedPercentOfContextWindow, leadContextSnapshot?.contextUsedPercent]);
|
fullContextUsedPercent: contextMetrics.contextUsedPercentOfContextWindow,
|
||||||
|
contextPanelOpen: isContextPanelVisible,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
contextMetrics.contextUsedPercentOfContextWindow,
|
||||||
|
isContextPanelVisible,
|
||||||
|
leadContextSnapshot?.contextUsedPercent,
|
||||||
|
]
|
||||||
|
);
|
||||||
const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId);
|
const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId);
|
||||||
|
const shouldLoadFullLeadDetail = Boolean(
|
||||||
|
leadSessionId && shouldShowLeadContextUi && isThisTabActive && isContextPanelVisible
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldShowLeadContextUi && isContextPanelVisible) {
|
if (!shouldShowLeadContextUi && isContextPanelVisible) {
|
||||||
|
|
@ -711,6 +634,13 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<LeadSessionDetailGate
|
||||||
|
tabId={tabId}
|
||||||
|
projectId={projectId}
|
||||||
|
leadSessionId={leadSessionId}
|
||||||
|
enabled={shouldLoadFullLeadDetail}
|
||||||
|
/>
|
||||||
|
|
||||||
{isContextPanelVisible && (
|
{isContextPanelVisible && (
|
||||||
<div className="w-80 shrink-0">
|
<div className="w-80 shrink-0">
|
||||||
{leadSessionLoaded ? (
|
{leadSessionLoaded ? (
|
||||||
|
|
@ -765,11 +695,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = !isContextPanelVisible;
|
setContextPanelVisible(!isContextPanelVisible);
|
||||||
setContextPanelVisible(next);
|
|
||||||
if (tabId && projectId) {
|
|
||||||
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setIsContextButtonHovered(true)}
|
onMouseEnter={() => setIsContextButtonHovered(true)}
|
||||||
onMouseLeave={() => setIsContextButtonHovered(false)}
|
onMouseLeave={() => setIsContextButtonHovered(false)}
|
||||||
|
|
@ -792,7 +718,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
: leadSessionId
|
: leadSessionId
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{contextUsedPercentLabel ?? 'Context'}
|
{contextUsedPercentLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -1705,8 +1631,6 @@ export const TeamDetailView = ({
|
||||||
if (configuredLeadProviderId) return configuredLeadProviderId;
|
if (configuredLeadProviderId) return configuredLeadProviderId;
|
||||||
return launchParams?.providerId;
|
return launchParams?.providerId;
|
||||||
}, [activeMembers, data?.config.members, 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 taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||||||
const taskMapRef = useRef(taskMap);
|
const taskMapRef = useRef(taskMap);
|
||||||
taskMapRef.current = taskMap;
|
taskMapRef.current = taskMap;
|
||||||
|
|
@ -2097,20 +2021,6 @@ export const TeamDetailView = ({
|
||||||
isThisTabActive={isThisTabActive}
|
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 => {
|
const renderBody = (): React.JSX.Element => {
|
||||||
if ((loading && !data) || (data && data.teamName !== teamName)) {
|
if ((loading && !data) || (data && data.teamName !== teamName)) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -2221,6 +2131,7 @@ export const TeamDetailView = ({
|
||||||
leadSessionId={leadSessionId}
|
leadSessionId={leadSessionId}
|
||||||
leadProviderId={leadProviderId}
|
leadProviderId={leadProviderId}
|
||||||
fallbackProjectRoot={data.config.projectPath}
|
fallbackProjectRoot={data.config.projectPath}
|
||||||
|
isThisTabActive={isThisTabActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Messages sidebar (left, after context panel) */}
|
{/* Messages sidebar (left, after context panel) */}
|
||||||
|
|
@ -3163,7 +3074,6 @@ export const TeamDetailView = ({
|
||||||
<>
|
<>
|
||||||
{spawnStatusWatcher}
|
{spawnStatusWatcher}
|
||||||
{teamAgentRuntimeWatcher}
|
{teamAgentRuntimeWatcher}
|
||||||
{leadContextWatcher}
|
|
||||||
{renderBody()}
|
{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