feat: improve provider status startup hydration
Keep connected provider details visible while refreshes are in flight, restore reusable provider status UI, and separate fast startup summaries from heavier provider hydration. Replace the fixed 30s startup wait with an idle-aware scheduler with a 30s safety cap and cover the Electron timer binding crash.
This commit is contained in:
parent
91b153459a
commit
e9cebe64ff
21 changed files with 1379 additions and 505 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { scheduleStartupIdleTask } from '@renderer/utils/startupIdleTask';
|
||||
|
||||
import type {
|
||||
CodexAccountSnapshotDto,
|
||||
|
|
@ -11,7 +12,9 @@ const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000;
|
|||
const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000;
|
||||
const CODEX_VISIBLE_STANDARD_REFRESH_MS = 20_000;
|
||||
const CODEX_HIDDEN_REFRESH_MS = 60_000;
|
||||
export const CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS = 30_000;
|
||||
export const CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS = 2_000;
|
||||
export const CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS = 30_000;
|
||||
export const CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS = CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS;
|
||||
|
||||
function isDocumentVisible(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
|
|
@ -43,6 +46,7 @@ export function useCodexAccountSnapshot(options: {
|
|||
enabled: boolean;
|
||||
includeRateLimits?: boolean;
|
||||
initialRefreshDelayMs?: number;
|
||||
initialRefreshMaxDelayMs?: number;
|
||||
}): {
|
||||
snapshot: CodexAccountSnapshotDto | null;
|
||||
loading: boolean;
|
||||
|
|
@ -65,6 +69,7 @@ export function useCodexAccountSnapshot(options: {
|
|||
const [visible, setVisible] = useState(() => isDocumentVisible());
|
||||
const lastUpdatedAtRef = useRef<number | null>(null);
|
||||
const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0;
|
||||
const initialRefreshMaxDelayMs = options.initialRefreshMaxDelayMs;
|
||||
const [initialRefreshAttempted, setInitialRefreshAttempted] = useState(
|
||||
() => initialRefreshDelayMs <= 0
|
||||
);
|
||||
|
|
@ -124,7 +129,7 @@ export function useCodexAccountSnapshot(options: {
|
|||
}
|
||||
|
||||
let active = true;
|
||||
let initialRefreshTimer: number | null = null;
|
||||
let cancelInitialRefresh: (() => void) | null = null;
|
||||
|
||||
const startInitialSnapshotRequest = (): void => {
|
||||
if (!active || lastUpdatedAtRef.current !== null) {
|
||||
|
|
@ -169,7 +174,18 @@ export function useCodexAccountSnapshot(options: {
|
|||
};
|
||||
|
||||
if (initialRefreshDelayMs > 0) {
|
||||
initialRefreshTimer = window.setTimeout(startInitialSnapshotRequest, initialRefreshDelayMs);
|
||||
if (typeof initialRefreshMaxDelayMs === 'number') {
|
||||
cancelInitialRefresh = scheduleStartupIdleTask(startInitialSnapshotRequest, {
|
||||
minDelayMs: initialRefreshDelayMs,
|
||||
maxDelayMs: initialRefreshMaxDelayMs,
|
||||
});
|
||||
} else {
|
||||
const initialRefreshTimer = window.setTimeout(
|
||||
startInitialSnapshotRequest,
|
||||
initialRefreshDelayMs
|
||||
);
|
||||
cancelInitialRefresh = () => window.clearTimeout(initialRefreshTimer);
|
||||
}
|
||||
} else {
|
||||
startInitialSnapshotRequest();
|
||||
}
|
||||
|
|
@ -180,15 +196,14 @@ export function useCodexAccountSnapshot(options: {
|
|||
|
||||
return () => {
|
||||
active = false;
|
||||
if (initialRefreshTimer) {
|
||||
window.clearTimeout(initialRefreshTimer);
|
||||
}
|
||||
cancelInitialRefresh?.();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [
|
||||
applySnapshot,
|
||||
electronMode,
|
||||
initialRefreshDelayMs,
|
||||
initialRefreshMaxDelayMs,
|
||||
options.enabled,
|
||||
options.includeRateLimits,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export {
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
|
||||
useCodexAccountSnapshot,
|
||||
} from './hooks/useCodexAccountSnapshot';
|
||||
export { mergeCodexCliStatusWithSnapshot } from './mergeCodexCliStatusWithSnapshot';
|
||||
|
|
|
|||
|
|
@ -86,11 +86,28 @@ function isDeferredProviderStatusSnapshot(status: CliInstallationStatus): boolea
|
|||
);
|
||||
}
|
||||
|
||||
function canUseLatestSnapshotForCacheKey(
|
||||
function hasDeferredProviderStatus(status: CliInstallationStatus): boolean {
|
||||
return (
|
||||
status.flavor === 'agent_teams_orchestrator' &&
|
||||
status.providers.some(
|
||||
(provider) => provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canUseStatusForCacheKey(
|
||||
cacheKey: CliInstallerProviderStatusMode,
|
||||
status: CliInstallationStatus
|
||||
): boolean {
|
||||
return cacheKey === 'defer' || !isDeferredProviderStatusSnapshot(status);
|
||||
if (cacheKey === 'defer') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
!status.authStatusChecking &&
|
||||
!hasDeferredProviderStatus(status) &&
|
||||
!isDeferredProviderStatusSnapshot(status)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -140,7 +157,7 @@ async function handleGetStatus(
|
|||
const latestSnapshot = service.getLatestStatusSnapshot();
|
||||
const cached = cachedStatus.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < STATUS_CACHE_TTL_MS) {
|
||||
if (latestSnapshot && canUseLatestSnapshotForCacheKey(cacheKey, latestSnapshot)) {
|
||||
if (latestSnapshot && canUseStatusForCacheKey(cacheKey, latestSnapshot)) {
|
||||
cachedStatus.set(cacheKey, { value: latestSnapshot, at: Date.now() });
|
||||
return { success: true, data: latestSnapshot };
|
||||
}
|
||||
|
|
@ -153,7 +170,7 @@ async function handleGetStatus(
|
|||
const request = service
|
||||
.getStatus(normalizedOptions)
|
||||
.then((status) => {
|
||||
if (generation === statusCacheGeneration) {
|
||||
if (generation === statusCacheGeneration && canUseStatusForCacheKey(cacheKey, status)) {
|
||||
cachedStatus.set(cacheKey, { value: status, at: Date.now() });
|
||||
}
|
||||
return status;
|
||||
|
|
|
|||
|
|
@ -1,88 +1,17 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
|
||||
mergeCodexCliStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { ProviderBrandLogo } from './ProviderBrandLogo';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
interface ProviderActivityState {
|
||||
provider: CliProviderStatus;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
(!provider.authenticated &&
|
||||
(provider.statusMessage === 'Checking...' ||
|
||||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldMaskCodexNegativeBootstrapState(
|
||||
sourceProvider: CliProviderStatus | null,
|
||||
mergedProvider: CliProviderStatus
|
||||
): boolean {
|
||||
return (
|
||||
sourceProvider?.providerId === 'codex' &&
|
||||
sourceProvider.statusMessage === 'Checking...' &&
|
||||
mergedProvider.providerId === 'codex' &&
|
||||
mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' &&
|
||||
mergedProvider.connection.codex.login.status === 'idle'
|
||||
);
|
||||
}
|
||||
|
||||
function getActivityToneStyles(tone: 'loading' | 'checked' | 'error'): {
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
statusColor: string;
|
||||
} {
|
||||
switch (tone) {
|
||||
case 'checked':
|
||||
return {
|
||||
borderColor: 'rgba(34, 197, 94, 0.22)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.08)',
|
||||
textColor: '#dcfce7',
|
||||
statusColor: '#86efac',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
borderColor: 'rgba(239, 68, 68, 0.28)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||
textColor: '#fee2e2',
|
||||
statusColor: '#fca5a5',
|
||||
};
|
||||
case 'loading':
|
||||
default:
|
||||
return {
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
textColor: 'var(--color-text-secondary)',
|
||||
statusColor: 'var(--color-text-muted)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function areProviderIdListsEqual(nextIds: CliProviderId[], prevIds: CliProviderId[]): boolean {
|
||||
return nextIds.length === prevIds.length && nextIds.every((id, index) => prevIds[index] === id);
|
||||
}
|
||||
import { ProviderActivityStatusStrip } from './ProviderActivityStatusStrip';
|
||||
|
||||
export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
|
|
@ -109,7 +38,6 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
|||
};
|
||||
})
|
||||
);
|
||||
const [cycleProviderIds, setCycleProviderIds] = useState<CliProviderId[]>([]);
|
||||
|
||||
const loadingCliStatus = useMemo(
|
||||
() =>
|
||||
|
|
@ -126,7 +54,8 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
|||
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
||||
includeRateLimits: false,
|
||||
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
|
||||
initialRefreshMaxDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
|
||||
});
|
||||
|
||||
const effectiveCliStatus = useMemo(
|
||||
|
|
@ -138,177 +67,19 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
|||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
|
||||
!codexAccount.snapshot;
|
||||
|
||||
const sourceProviderMap = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(loadingCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider])
|
||||
),
|
||||
[loadingCliStatus?.providers]
|
||||
);
|
||||
|
||||
const providerStates = useMemo<ProviderActivityState[]>(() => {
|
||||
const visibleProviders = filterMainScreenCliProviders(effectiveCliStatus?.providers ?? []);
|
||||
|
||||
return visibleProviders.map((provider) => {
|
||||
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
|
||||
const loading =
|
||||
isProviderCardLoading(provider, cliProviderStatusLoading[provider.providerId] === true) ||
|
||||
(provider.providerId === 'codex' && codexSnapshotPending) ||
|
||||
shouldMaskCodexNegativeBootstrapState(sourceProvider, provider);
|
||||
|
||||
return {
|
||||
provider,
|
||||
loading,
|
||||
error: !loading && provider.verificationState === 'error',
|
||||
};
|
||||
});
|
||||
}, [
|
||||
cliProviderStatusLoading,
|
||||
codexSnapshotPending,
|
||||
effectiveCliStatus?.providers,
|
||||
sourceProviderMap,
|
||||
]);
|
||||
|
||||
const visibleProviderIds = useMemo(
|
||||
() => providerStates.map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const loadingProviderIds = useMemo(
|
||||
() => providerStates.filter((state) => state.loading).map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const errorProviderIds = useMemo(
|
||||
() => providerStates.filter((state) => state.error).map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const providerStateMap = useMemo(
|
||||
() => new Map(providerStates.map((state) => [state.provider.providerId, state])),
|
||||
[providerStates]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCycleProviderIds((previousIds) => {
|
||||
const visiblePreviousIds = previousIds.filter((providerId) =>
|
||||
visibleProviderIds.includes(providerId)
|
||||
);
|
||||
|
||||
if (loadingProviderIds.length > 0) {
|
||||
const nextIds = [...visiblePreviousIds];
|
||||
for (const providerId of loadingProviderIds) {
|
||||
if (!nextIds.includes(providerId)) {
|
||||
nextIds.push(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
return areProviderIdListsEqual(nextIds, previousIds) ? previousIds : nextIds;
|
||||
}
|
||||
|
||||
if (errorProviderIds.length > 0) {
|
||||
return areProviderIdListsEqual(errorProviderIds, previousIds)
|
||||
? previousIds
|
||||
: errorProviderIds;
|
||||
}
|
||||
|
||||
return previousIds.length === 0 ? previousIds : [];
|
||||
});
|
||||
}, [errorProviderIds, loadingProviderIds, visibleProviderIds]);
|
||||
|
||||
const displayProviderIds = useMemo(() => {
|
||||
if (loadingProviderIds.length > 0) {
|
||||
const activeCycleIds = (
|
||||
cycleProviderIds.length > 0 ? cycleProviderIds : loadingProviderIds
|
||||
).filter((providerId) => providerStateMap.has(providerId));
|
||||
return Array.from(new Set([...activeCycleIds, ...errorProviderIds]));
|
||||
}
|
||||
|
||||
if (errorProviderIds.length > 0) {
|
||||
return errorProviderIds;
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [cycleProviderIds, errorProviderIds, loadingProviderIds, providerStateMap]);
|
||||
|
||||
if (
|
||||
!isElectron ||
|
||||
isDashboardFocused ||
|
||||
!multimodelEnabled ||
|
||||
effectiveCliStatus?.flavor !== 'agent_teams_orchestrator' ||
|
||||
!effectiveCliStatus.installed ||
|
||||
displayProviderIds.length === 0
|
||||
) {
|
||||
if (isDashboardFocused) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-sidebar)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 text-[11px] font-medium uppercase tracking-[0.08em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
Provider Activity
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{displayProviderIds.map((providerId) => {
|
||||
const providerState = providerStateMap.get(providerId);
|
||||
if (!providerState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tone = providerState.loading
|
||||
? 'loading'
|
||||
: providerState.error
|
||||
? 'error'
|
||||
: 'checked';
|
||||
const styles = getActivityToneStyles(tone);
|
||||
const statusText =
|
||||
tone === 'loading'
|
||||
? 'Checking...'
|
||||
: tone === 'error'
|
||||
? formatProviderStatusText(providerState.provider)
|
||||
: 'Checked';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={providerId}
|
||||
data-testid={`global-provider-status-${providerId}`}
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: styles.borderColor,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
color: styles.textColor,
|
||||
}}
|
||||
>
|
||||
{tone === 'loading' ? (
|
||||
<Loader2
|
||||
className="size-3 shrink-0 animate-spin"
|
||||
style={{ color: styles.statusColor }}
|
||||
/>
|
||||
) : tone === 'error' ? (
|
||||
<AlertTriangle className="size-3 shrink-0" style={{ color: styles.statusColor }} />
|
||||
) : (
|
||||
<CheckCircle2 className="size-3 shrink-0" style={{ color: styles.statusColor }} />
|
||||
)}
|
||||
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0 font-medium" style={{ color: styles.textColor }}>
|
||||
{providerState.provider.displayName}
|
||||
</span>
|
||||
<span
|
||||
className="max-w-[280px] truncate"
|
||||
style={{ color: styles.statusColor }}
|
||||
title={statusText}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ProviderActivityStatusStrip
|
||||
cliStatus={effectiveCliStatus}
|
||||
sourceCliStatus={loadingCliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-sidebar)] px-4 py-2"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
323
src/renderer/components/common/ProviderActivityStatusStrip.tsx
Normal file
323
src/renderer/components/common/ProviderActivityStatusStrip.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import { ProviderBrandLogo } from './ProviderBrandLogo';
|
||||
|
||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
interface ProviderActivityState {
|
||||
provider: CliProviderStatus;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
interface ProviderActivityStatusStripProps {
|
||||
readonly cliStatus: CliInstallationStatus | null | undefined;
|
||||
readonly sourceCliStatus?: CliInstallationStatus | null | undefined;
|
||||
readonly cliStatusLoading: boolean;
|
||||
readonly cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||
readonly multimodelEnabled: boolean;
|
||||
readonly codexSnapshotPending?: boolean;
|
||||
readonly providerIds?: readonly CliProviderId[];
|
||||
readonly className?: string;
|
||||
readonly label?: string | null;
|
||||
}
|
||||
|
||||
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
(!provider.authenticated &&
|
||||
(provider.statusMessage === 'Checking...' ||
|
||||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldMaskCodexNegativeBootstrapState(
|
||||
sourceProvider: CliProviderStatus | null,
|
||||
mergedProvider: CliProviderStatus
|
||||
): boolean {
|
||||
return (
|
||||
sourceProvider?.providerId === 'codex' &&
|
||||
sourceProvider.statusMessage === 'Checking...' &&
|
||||
mergedProvider.providerId === 'codex' &&
|
||||
mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' &&
|
||||
mergedProvider.connection.codex.login.status === 'idle'
|
||||
);
|
||||
}
|
||||
|
||||
function getActivityToneStyles(tone: 'loading' | 'checked' | 'error'): {
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
statusColor: string;
|
||||
} {
|
||||
switch (tone) {
|
||||
case 'checked':
|
||||
return {
|
||||
borderColor: 'rgba(34, 197, 94, 0.22)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.08)',
|
||||
textColor: '#dcfce7',
|
||||
statusColor: '#86efac',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
borderColor: 'rgba(239, 68, 68, 0.28)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||
textColor: '#fee2e2',
|
||||
statusColor: '#fca5a5',
|
||||
};
|
||||
case 'loading':
|
||||
default:
|
||||
return {
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
textColor: 'var(--color-text-secondary)',
|
||||
statusColor: 'var(--color-text-muted)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function areProviderIdListsEqual(nextIds: CliProviderId[], prevIds: CliProviderId[]): boolean {
|
||||
return nextIds.length === prevIds.length && nextIds.every((id, index) => prevIds[index] === id);
|
||||
}
|
||||
|
||||
function useProviderActivityDisplay({
|
||||
cliStatus,
|
||||
sourceCliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
multimodelEnabled,
|
||||
codexSnapshotPending = false,
|
||||
providerIds,
|
||||
}: Pick<
|
||||
ProviderActivityStatusStripProps,
|
||||
| 'cliStatus'
|
||||
| 'sourceCliStatus'
|
||||
| 'cliStatusLoading'
|
||||
| 'cliProviderStatusLoading'
|
||||
| 'multimodelEnabled'
|
||||
| 'codexSnapshotPending'
|
||||
| 'providerIds'
|
||||
>): {
|
||||
displayProviderIds: CliProviderId[];
|
||||
providerStateMap: Map<CliProviderId, ProviderActivityState>;
|
||||
shouldRender: boolean;
|
||||
} {
|
||||
const [cycleProviderIds, setCycleProviderIds] = useState<CliProviderId[]>([]);
|
||||
const renderCliStatus = useMemo(
|
||||
() =>
|
||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||
? createLoadingMultimodelCliStatus()
|
||||
: (cliStatus ?? null),
|
||||
[cliStatus, cliStatusLoading, multimodelEnabled]
|
||||
);
|
||||
const sourceStatus = sourceCliStatus ?? renderCliStatus;
|
||||
const providerIdSet = useMemo(
|
||||
() => (providerIds ? new Set<CliProviderId>(providerIds) : null),
|
||||
[providerIds]
|
||||
);
|
||||
const sourceProviderMap = useMemo(
|
||||
() =>
|
||||
new Map((sourceStatus?.providers ?? []).map((provider) => [provider.providerId, provider])),
|
||||
[sourceStatus?.providers]
|
||||
);
|
||||
|
||||
const providerStates = useMemo<ProviderActivityState[]>(() => {
|
||||
const visibleProviders = filterMainScreenCliProviders(renderCliStatus?.providers ?? []).filter(
|
||||
(provider) => !providerIdSet || providerIdSet.has(provider.providerId)
|
||||
);
|
||||
|
||||
return visibleProviders.map((provider) => {
|
||||
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
|
||||
const loading =
|
||||
isProviderCardLoading(provider, cliProviderStatusLoading[provider.providerId] === true) ||
|
||||
(provider.providerId === 'codex' && codexSnapshotPending) ||
|
||||
shouldMaskCodexNegativeBootstrapState(sourceProvider, provider);
|
||||
|
||||
return {
|
||||
provider,
|
||||
loading,
|
||||
error: !loading && provider.verificationState === 'error',
|
||||
};
|
||||
});
|
||||
}, [
|
||||
cliProviderStatusLoading,
|
||||
codexSnapshotPending,
|
||||
providerIdSet,
|
||||
renderCliStatus?.providers,
|
||||
sourceProviderMap,
|
||||
]);
|
||||
|
||||
const visibleProviderIds = useMemo(
|
||||
() => providerStates.map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const loadingProviderIds = useMemo(
|
||||
() => providerStates.filter((state) => state.loading).map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const errorProviderIds = useMemo(
|
||||
() => providerStates.filter((state) => state.error).map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const providerStateMap = useMemo(
|
||||
() => new Map(providerStates.map((state) => [state.provider.providerId, state])),
|
||||
[providerStates]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCycleProviderIds((previousIds) => {
|
||||
const visiblePreviousIds = previousIds.filter((providerId) =>
|
||||
visibleProviderIds.includes(providerId)
|
||||
);
|
||||
|
||||
if (loadingProviderIds.length > 0) {
|
||||
const nextIds = [...visiblePreviousIds];
|
||||
for (const providerId of loadingProviderIds) {
|
||||
if (!nextIds.includes(providerId)) {
|
||||
nextIds.push(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
return areProviderIdListsEqual(nextIds, previousIds) ? previousIds : nextIds;
|
||||
}
|
||||
|
||||
if (errorProviderIds.length > 0) {
|
||||
return areProviderIdListsEqual(errorProviderIds, previousIds)
|
||||
? previousIds
|
||||
: errorProviderIds;
|
||||
}
|
||||
|
||||
return previousIds.length === 0 ? previousIds : [];
|
||||
});
|
||||
}, [errorProviderIds, loadingProviderIds, visibleProviderIds]);
|
||||
|
||||
const displayProviderIds = useMemo(() => {
|
||||
if (loadingProviderIds.length > 0) {
|
||||
const activeCycleIds = (
|
||||
cycleProviderIds.length > 0 ? cycleProviderIds : loadingProviderIds
|
||||
).filter((providerId) => providerStateMap.has(providerId));
|
||||
return Array.from(new Set([...activeCycleIds, ...errorProviderIds]));
|
||||
}
|
||||
|
||||
if (errorProviderIds.length > 0) {
|
||||
return errorProviderIds;
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [cycleProviderIds, errorProviderIds, loadingProviderIds, providerStateMap]);
|
||||
|
||||
return {
|
||||
displayProviderIds,
|
||||
providerStateMap,
|
||||
shouldRender:
|
||||
isElectronMode() &&
|
||||
multimodelEnabled &&
|
||||
renderCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||
renderCliStatus.installed &&
|
||||
displayProviderIds.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const ProviderActivityStatusStrip = ({
|
||||
cliStatus,
|
||||
sourceCliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
multimodelEnabled,
|
||||
codexSnapshotPending = false,
|
||||
providerIds,
|
||||
className = '',
|
||||
label = 'Provider Activity',
|
||||
}: ProviderActivityStatusStripProps): React.JSX.Element | null => {
|
||||
const { displayProviderIds, providerStateMap, shouldRender } = useProviderActivityDisplay({
|
||||
cliStatus,
|
||||
sourceCliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
multimodelEnabled,
|
||||
codexSnapshotPending,
|
||||
providerIds,
|
||||
});
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex min-w-0 flex-wrap items-center gap-2 ${className}`.trim()}>
|
||||
{label ? (
|
||||
<span
|
||||
className="shrink-0 text-[11px] font-medium uppercase tracking-[0.08em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{displayProviderIds.map((providerId) => {
|
||||
const providerState = providerStateMap.get(providerId);
|
||||
if (!providerState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tone = providerState.loading
|
||||
? 'loading'
|
||||
: providerState.error
|
||||
? 'error'
|
||||
: 'checked';
|
||||
const styles = getActivityToneStyles(tone);
|
||||
const statusText =
|
||||
tone === 'loading'
|
||||
? 'Checking...'
|
||||
: tone === 'error'
|
||||
? formatProviderStatusText(providerState.provider)
|
||||
: 'Checked';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={providerId}
|
||||
data-testid={`provider-activity-status-${providerId}`}
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: styles.borderColor,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
color: styles.textColor,
|
||||
}}
|
||||
>
|
||||
{tone === 'loading' ? (
|
||||
<Loader2
|
||||
className="size-3 shrink-0 animate-spin"
|
||||
style={{ color: styles.statusColor }}
|
||||
/>
|
||||
) : tone === 'error' ? (
|
||||
<AlertTriangle className="size-3 shrink-0" style={{ color: styles.statusColor }} />
|
||||
) : (
|
||||
<CheckCircle2 className="size-3 shrink-0" style={{ color: styles.statusColor }} />
|
||||
)}
|
||||
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0 font-medium" style={{ color: styles.textColor }}>
|
||||
{providerState.provider.displayName}
|
||||
</span>
|
||||
<span
|
||||
className="max-w-[280px] truncate"
|
||||
style={{ color: styles.statusColor }}
|
||||
title={statusText}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,7 +10,8 @@
|
|||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
|
||||
mergeCodexProviderStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
isConnectionManagedRuntimeProvider,
|
||||
isOpenCodeCatalogHydrating,
|
||||
shouldShowProviderConnectAction,
|
||||
shouldShowProviderStatusSkeleton,
|
||||
} from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||
|
|
@ -445,17 +447,6 @@ const ProviderDetailSkeleton = (): React.JSX.Element => {
|
|||
);
|
||||
};
|
||||
|
||||
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
(!provider.authenticated &&
|
||||
(provider.statusMessage === 'Checking...' ||
|
||||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null)
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexSnapshotPending(
|
||||
provider: CliProviderStatus,
|
||||
codexSnapshotPending: boolean
|
||||
|
|
@ -973,7 +964,7 @@ const InstalledBanner = ({
|
|||
provider
|
||||
);
|
||||
const showSkeleton =
|
||||
isProviderCardLoading(provider, providerLoading) ||
|
||||
shouldShowProviderStatusSkeleton(provider, providerLoading) ||
|
||||
isCodexSnapshotPending(provider, codexSnapshotPending) ||
|
||||
maskNegativeBootstrapState;
|
||||
const anthropicRateLimitsLoading =
|
||||
|
|
@ -1376,7 +1367,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
|
||||
initialRefreshMaxDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
|
||||
});
|
||||
const visibleCliProviders = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -258,13 +258,21 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
}, [fetchPluginCatalog, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const cliStatusMatchesCurrentMode =
|
||||
cliStatus &&
|
||||
(multimodelEnabled
|
||||
? cliStatus.flavor === 'agent_teams_orchestrator'
|
||||
: cliStatus.flavor !== 'agent_teams_orchestrator');
|
||||
if (cliStatusLoading || cliStatusMatchesCurrentMode) {
|
||||
return;
|
||||
}
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
providerStatusMode: 'defer',
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
|
||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
// Fetch MCP installed state on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -513,6 +521,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
effectiveCliStatus,
|
||||
effectiveCliStatusLoading,
|
||||
openDashboard,
|
||||
runtimeDisplayName,
|
||||
]);
|
||||
|
||||
// Browser mode guard
|
||||
|
|
@ -587,7 +596,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
{tabState.activeSubTab === 'mcp-servers' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={mcpMutationDisableReason ? 0 : -1}>
|
||||
<span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
|
||||
import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types';
|
||||
|
||||
const CODEX_NATIVE_LABEL = 'Codex native';
|
||||
|
|
@ -130,6 +132,48 @@ export function isOpenCodeCatalogHydrating(
|
|||
);
|
||||
}
|
||||
|
||||
function hasKnownProviderStatus(
|
||||
provider: Pick<
|
||||
CliProviderStatus,
|
||||
| 'authenticated'
|
||||
| 'supported'
|
||||
| 'statusMessage'
|
||||
| 'models'
|
||||
| 'backend'
|
||||
| 'availableBackends'
|
||||
| 'connection'
|
||||
| 'modelCatalog'
|
||||
>
|
||||
): boolean {
|
||||
const statusMessage = provider.statusMessage?.trim() ?? '';
|
||||
return (
|
||||
provider.authenticated ||
|
||||
provider.supported ||
|
||||
provider.models.length > 0 ||
|
||||
provider.backend != null ||
|
||||
(provider.availableBackends?.length ?? 0) > 0 ||
|
||||
provider.connection != null ||
|
||||
provider.modelCatalog != null ||
|
||||
(statusMessage.length > 0 &&
|
||||
statusMessage !== 'Checking...' &&
|
||||
statusMessage !== CLI_PROVIDER_STATUS_DEFERRED_MESSAGE)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowProviderStatusSkeleton(
|
||||
provider: CliProviderStatus,
|
||||
providerLoading: boolean
|
||||
): boolean {
|
||||
const isPlaceholder =
|
||||
!provider.authenticated &&
|
||||
(provider.statusMessage === 'Checking...' ||
|
||||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null;
|
||||
|
||||
return isPlaceholder || (providerLoading && !hasKnownProviderStatus(provider));
|
||||
}
|
||||
|
||||
export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean {
|
||||
return provider.providerId === 'codex';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
isConnectionManagedRuntimeProvider,
|
||||
isOpenCodeCatalogHydrating,
|
||||
shouldShowProviderConnectAction,
|
||||
shouldShowProviderStatusSkeleton,
|
||||
} from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||
|
|
@ -42,7 +43,6 @@ import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
|||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||
import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog';
|
||||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
|
|
@ -84,17 +84,6 @@ const ProviderDetailSkeleton = (): React.JSX.Element => {
|
|||
);
|
||||
};
|
||||
|
||||
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
(!provider.authenticated &&
|
||||
(provider.statusMessage === 'Checking...' ||
|
||||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null)
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexSnapshotPending(
|
||||
provider: CliProviderStatus,
|
||||
codexSnapshotPending: boolean
|
||||
|
|
@ -476,7 +465,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
const providerLoading =
|
||||
cliProviderStatusLoading[provider.providerId] === true;
|
||||
const showSkeleton =
|
||||
isProviderCardLoading(provider, providerLoading) ||
|
||||
shouldShowProviderStatusSkeleton(provider, providerLoading) ||
|
||||
isCodexSnapshotPending(provider, codexSnapshotPending);
|
||||
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
|
||||
? getProviderCurrentRuntimeSummary(provider)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
resolveCodexRuntimeSelection,
|
||||
} from '@features/codex-runtime-profile/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
buildMemberDraftSuggestions,
|
||||
|
|
@ -396,8 +397,12 @@ export const CreateTeamDialog = ({
|
|||
const anthropicProviderFastModeDefault = useStore(
|
||||
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
|
||||
);
|
||||
const { cliStatus, cliStatusLoading } = useStore(
|
||||
useShallow((s) => ({ cliStatus: s.cliStatus, cliStatusLoading: s.cliStatusLoading }))
|
||||
const { cliStatus, cliStatusLoading, cliProviderStatusLoading } = useStore(
|
||||
useShallow((s) => ({
|
||||
cliStatus: s.cliStatus,
|
||||
cliStatusLoading: s.cliStatusLoading,
|
||||
cliProviderStatusLoading: s.cliProviderStatusLoading,
|
||||
}))
|
||||
);
|
||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||
|
|
@ -419,6 +424,10 @@ export const CreateTeamDialog = ({
|
|||
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
|
||||
[loadingCliStatus, codexAccount.snapshot]
|
||||
);
|
||||
const codexSnapshotPending =
|
||||
codexAccount.loading &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
|
||||
!codexAccount.snapshot;
|
||||
|
||||
// ── Persisted draft state (survives tab navigation) ──────────────────
|
||||
const {
|
||||
|
|
@ -2457,6 +2466,18 @@ export const CreateTeamDialog = ({
|
|||
|
||||
<DialogFooter className="pt-4 sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{canCreate && launchTeam ? (
|
||||
<ProviderActivityStatusStrip
|
||||
cliStatus={effectiveCliStatus}
|
||||
sourceCliStatus={loadingCliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
providerIds={selectedMemberProviders}
|
||||
className="mb-2"
|
||||
/>
|
||||
) : null}
|
||||
{canCreate &&
|
||||
launchTeam &&
|
||||
(effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
resolveCodexRuntimeSelection,
|
||||
} from '@features/codex-runtime-profile/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
|
||||
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
|
|
@ -356,6 +357,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
);
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading);
|
||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||
const isLaunchMode = props.mode === 'launch' || props.mode === 'relaunch';
|
||||
|
|
@ -377,6 +379,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
|
||||
[loadingCliStatus, codexAccount.snapshot]
|
||||
);
|
||||
const codexSnapshotPending =
|
||||
codexAccount.loading &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
|
||||
!codexAccount.snapshot;
|
||||
const isSchedule = props.mode === 'schedule';
|
||||
const schedule = isSchedule ? (props.schedule ?? null) : null;
|
||||
const isEditing = isSchedule && !!schedule;
|
||||
|
|
@ -3040,6 +3046,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
{/* Launch-only: CLI warm-up status */}
|
||||
{isLaunchMode ? (
|
||||
<div className="min-w-0">
|
||||
<ProviderActivityStatusStrip
|
||||
cliStatus={effectiveCliStatus}
|
||||
sourceCliStatus={loadingCliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
providerIds={selectedMemberProviders}
|
||||
className="mb-2"
|
||||
/>
|
||||
{effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { syncRendererTelemetry } from '@renderer/sentry';
|
|||
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { scheduleStartupIdleTask } from '@renderer/utils/startupIdleTask';
|
||||
import {
|
||||
buildTaskChangePresenceKey,
|
||||
buildTaskChangeRequestOptions,
|
||||
|
|
@ -97,7 +98,8 @@ const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
|||
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
|
||||
const TASK_LOG_ACTIVITY_PULSE_MS = 3_500;
|
||||
const STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000;
|
||||
const STARTUP_PROVIDER_STATUS_IDLE_DELAY_MS = 30_000;
|
||||
const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000;
|
||||
const STARTUP_PROVIDER_STATUS_MAX_DELAY_MS = 30_000;
|
||||
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
|
||||
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
||||
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
|
||||
|
|
@ -213,7 +215,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
cleanupFns.push(installTeamRefreshFanoutDebugBridge());
|
||||
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let deferredProviderStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let deferredProviderStatusCleanup: (() => void) | null = null;
|
||||
useStore.getState().subscribeProvisioningProgress();
|
||||
cleanupFns.push(() => {
|
||||
useStore.getState().unsubscribeProvisioningProgress();
|
||||
|
|
@ -249,16 +251,24 @@ export function initializeNotificationListeners(): () => void {
|
|||
await useStore
|
||||
.getState()
|
||||
.bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' });
|
||||
if (deferredProviderStatusTimer) {
|
||||
clearTimeout(deferredProviderStatusTimer);
|
||||
if (deferredProviderStatusCleanup) {
|
||||
deferredProviderStatusCleanup();
|
||||
}
|
||||
deferredProviderStatusTimer = setTimeout(() => {
|
||||
const providerIds = getIncompleteMultimodelProviderIds(useStore.getState().cliStatus);
|
||||
for (const providerId of providerIds) {
|
||||
void useStore.getState().fetchCliProviderStatus(providerId, { silent: false });
|
||||
deferredProviderStatusCleanup = scheduleStartupIdleTask(
|
||||
() => {
|
||||
const providerIds = getIncompleteMultimodelProviderIds(
|
||||
useStore.getState().cliStatus
|
||||
);
|
||||
for (const providerId of providerIds) {
|
||||
void useStore.getState().fetchCliProviderStatus(providerId, { silent: false });
|
||||
}
|
||||
deferredProviderStatusCleanup = null;
|
||||
},
|
||||
{
|
||||
minDelayMs: STARTUP_PROVIDER_STATUS_MIN_DELAY_MS,
|
||||
maxDelayMs: STARTUP_PROVIDER_STATUS_MAX_DELAY_MS,
|
||||
}
|
||||
deferredProviderStatusTimer = null;
|
||||
}, STARTUP_PROVIDER_STATUS_IDLE_DELAY_MS);
|
||||
);
|
||||
})();
|
||||
} else {
|
||||
void useStore.getState().fetchCliStatus();
|
||||
|
|
@ -287,7 +297,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
cleanupFns.push(() => {
|
||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
|
||||
if (deferredProviderStatusTimer) clearTimeout(deferredProviderStatusTimer);
|
||||
if (deferredProviderStatusCleanup) deferredProviderStatusCleanup();
|
||||
});
|
||||
// TODO(task-change-presence): re-enable this only after the board uses a bounded
|
||||
// batch/priority presence pipeline. The old one-task-per-tick poll was accurate
|
||||
|
|
|
|||
96
src/renderer/utils/startupIdleTask.ts
Normal file
96
src/renderer/utils/startupIdleTask.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
type StartupIdleTask = () => void;
|
||||
type StartupIdleDeadline = {
|
||||
didTimeout: boolean;
|
||||
timeRemaining: () => number;
|
||||
};
|
||||
type StartupIdleCallback = (deadline: StartupIdleDeadline) => void;
|
||||
type StartupIdleHandle = number;
|
||||
|
||||
export interface StartupIdleTaskScheduler {
|
||||
setTimeout: typeof setTimeout;
|
||||
clearTimeout: typeof clearTimeout;
|
||||
requestIdleCallback?: (
|
||||
callback: StartupIdleCallback,
|
||||
options?: { timeout?: number }
|
||||
) => StartupIdleHandle;
|
||||
cancelIdleCallback?: (handle: StartupIdleHandle) => void;
|
||||
}
|
||||
|
||||
export interface StartupIdleTaskOptions {
|
||||
minDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
scheduler?: StartupIdleTaskScheduler;
|
||||
}
|
||||
|
||||
function getDefaultStartupIdleTaskScheduler(): StartupIdleTaskScheduler {
|
||||
const timerHost = typeof window === 'undefined' ? globalThis : window;
|
||||
const idleWindow =
|
||||
typeof window === 'undefined'
|
||||
? null
|
||||
: (window as Window &
|
||||
typeof globalThis & {
|
||||
requestIdleCallback?: StartupIdleTaskScheduler['requestIdleCallback'];
|
||||
cancelIdleCallback?: StartupIdleTaskScheduler['cancelIdleCallback'];
|
||||
});
|
||||
|
||||
return {
|
||||
setTimeout: timerHost.setTimeout.bind(timerHost) as typeof setTimeout,
|
||||
clearTimeout: timerHost.clearTimeout.bind(timerHost) as typeof clearTimeout,
|
||||
requestIdleCallback: idleWindow?.requestIdleCallback?.bind(idleWindow),
|
||||
cancelIdleCallback: idleWindow?.cancelIdleCallback?.bind(idleWindow),
|
||||
};
|
||||
}
|
||||
|
||||
export function scheduleStartupIdleTask(
|
||||
task: StartupIdleTask,
|
||||
options: StartupIdleTaskOptions
|
||||
): () => void {
|
||||
const scheduler = options.scheduler ?? getDefaultStartupIdleTaskScheduler();
|
||||
const minDelayMs = Math.max(0, options.minDelayMs);
|
||||
const maxDelayMs = Math.max(minDelayMs, options.maxDelayMs);
|
||||
let cancelled = false;
|
||||
let ran = false;
|
||||
let delayTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let idleHandle: StartupIdleHandle | null = null;
|
||||
|
||||
const runOnce = (): void => {
|
||||
if (cancelled || ran) {
|
||||
return;
|
||||
}
|
||||
ran = true;
|
||||
task();
|
||||
};
|
||||
|
||||
delayTimer = scheduler.setTimeout(() => {
|
||||
delayTimer = null;
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idleTimeoutMs = maxDelayMs - minDelayMs;
|
||||
if (scheduler.requestIdleCallback && idleTimeoutMs > 0) {
|
||||
idleHandle = scheduler.requestIdleCallback(
|
||||
() => {
|
||||
idleHandle = null;
|
||||
runOnce();
|
||||
},
|
||||
{ timeout: idleTimeoutMs }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runOnce();
|
||||
}, minDelayMs);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (delayTimer) {
|
||||
scheduler.clearTimeout(delayTimer);
|
||||
delayTimer = null;
|
||||
}
|
||||
if (idleHandle !== null && scheduler.cancelIdleCallback) {
|
||||
scheduler.cancelIdleCallback(idleHandle);
|
||||
idleHandle = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -16,6 +16,11 @@ const apiMocks = vi.hoisted(() => ({
|
|||
onCodexAccountSnapshotChanged: vi.fn(() => () => undefined),
|
||||
}));
|
||||
|
||||
type IdleCallbackForTest = (deadline: {
|
||||
didTimeout: boolean;
|
||||
timeRemaining: () => number;
|
||||
}) => void;
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: apiMocks,
|
||||
isElectronMode: () => true,
|
||||
|
|
@ -87,6 +92,8 @@ describe('useCodexAccountSnapshot', () => {
|
|||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
Reflect.deleteProperty(window, 'requestIdleCallback');
|
||||
Reflect.deleteProperty(window, 'cancelIdleCallback');
|
||||
});
|
||||
|
||||
it('loads the initial Codex snapshot through refresh when rate limits are requested', async () => {
|
||||
|
|
@ -180,6 +187,80 @@ describe('useCodexAccountSnapshot', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('uses idle scheduling for deferred initial Codex snapshots when a max delay is provided', async () => {
|
||||
vi.useFakeTimers();
|
||||
const snapshot = createSnapshot();
|
||||
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
|
||||
let idleCallback: IdleCallbackForTest = () => undefined;
|
||||
const requestIdleCallback = vi.fn((callback, options?: { timeout?: number }) => {
|
||||
idleCallback = callback;
|
||||
expect(options).toEqual({ timeout: 28_000 });
|
||||
return 7;
|
||||
});
|
||||
const cancelIdleCallback = vi.fn();
|
||||
Object.defineProperty(window, 'requestIdleCallback', {
|
||||
configurable: true,
|
||||
value: requestIdleCallback,
|
||||
});
|
||||
Object.defineProperty(window, 'cancelIdleCallback', {
|
||||
configurable: true,
|
||||
value: cancelIdleCallback,
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const state = useCodexAccountSnapshot({
|
||||
enabled: true,
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: 2_000,
|
||||
initialRefreshMaxDelayMs: 30_000,
|
||||
});
|
||||
|
||||
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_999);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||
expect(requestIdleCallback).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
idleCallback({ didTimeout: false, timeRemaining: () => 10 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({
|
||||
includeRateLimits: true,
|
||||
});
|
||||
expect(host.textContent).toContain('belief@example.com');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
expect(cancelIdleCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a deferred initial Codex snapshot timer on unmount', async () => {
|
||||
vi.useFakeTimers();
|
||||
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(createSnapshot());
|
||||
|
|
|
|||
|
|
@ -381,6 +381,55 @@ describe('cliInstaller IPC handlers', () => {
|
|||
expect(cached.data?.providers[0]?.statusMessage).toBe('Connected');
|
||||
});
|
||||
|
||||
it('does not cache incomplete full provider status responses', async () => {
|
||||
const incompleteFullStatus = status([
|
||||
provider({
|
||||
providerId: 'anthropic',
|
||||
supported: false,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Provider status will refresh when needed.',
|
||||
}),
|
||||
provider({
|
||||
providerId: 'codex',
|
||||
supported: false,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
const freshFullStatus = status([
|
||||
provider({
|
||||
providerId: 'anthropic',
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Connected',
|
||||
}),
|
||||
provider({
|
||||
providerId: 'codex',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
}),
|
||||
]);
|
||||
incompleteFullStatus.authStatusChecking = true;
|
||||
service.getStatus
|
||||
.mockResolvedValueOnce(incompleteFullStatus)
|
||||
.mockResolvedValueOnce(freshFullStatus);
|
||||
|
||||
const first = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
|
||||
const second = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
|
||||
|
||||
expect(service.getStatus).toHaveBeenCalledTimes(2);
|
||||
expect(first.success).toBe(true);
|
||||
expect(first.data?.providers[0]?.statusMessage).toBe(
|
||||
'Provider status will refresh when needed.'
|
||||
);
|
||||
expect(second.success).toBe(true);
|
||||
expect(second.data?.authLoggedIn).toBe(true);
|
||||
expect(second.data?.providers[0]?.statusMessage).toBe('Connected');
|
||||
});
|
||||
|
||||
it('does not let a stale in-flight provider refresh patch the cache after invalidation', async () => {
|
||||
const staleProviderRequest = deferred<CliProviderStatus | null>();
|
||||
service.getStatus
|
||||
|
|
|
|||
|
|
@ -583,6 +583,92 @@ describe('CLI status visibility during completed install state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps connected provider details visible while a refresh is in flight', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
authLoggedIn: true,
|
||||
authStatusChecking: true,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Connected via Anthropic subscription',
|
||||
models: ['claude-3-5-sonnet'],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
backend: null,
|
||||
},
|
||||
createCodexNativeRolloutProvider({
|
||||
state: 'ready',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
models: ['gpt-5-codex'],
|
||||
}),
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
modelCatalog: null,
|
||||
modelCatalogRefreshState: 'idle',
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: {
|
||||
dynamic: true,
|
||||
source: 'runtime',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
storeState.cliProviderStatusLoading = {
|
||||
codex: true,
|
||||
opencode: true,
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Providers: 3/3 connected');
|
||||
expect(host.textContent).toContain('ChatGPT account ready');
|
||||
expect(host.textContent).toContain('Loading models...');
|
||||
expect(host.textContent).not.toContain('Checking...');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an OpenCode install action on the dashboard when the OpenCode CLI is missing', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface StoreState {
|
||||
cliStatus: Record<string, unknown> | null;
|
||||
|
|
@ -28,9 +27,9 @@ interface StoreState {
|
|||
|
||||
const storeState = {} as StoreState;
|
||||
const codexAccountHookState = {
|
||||
snapshot: null as CodexAccountSnapshotDto | null,
|
||||
snapshot: null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
error: null,
|
||||
refresh: vi.fn(() => Promise.resolve(undefined)),
|
||||
startChatgptLogin: vi.fn(() => Promise.resolve(true)),
|
||||
cancelChatgptLogin: vi.fn(() => Promise.resolve(true)),
|
||||
|
|
@ -60,18 +59,15 @@ vi.mock('@renderer/store', () => ({
|
|||
|
||||
import { GlobalProviderStatusHeader } from '@renderer/components/common/GlobalProviderStatusHeader';
|
||||
|
||||
function createProvider(
|
||||
overrides: Partial<Record<string, unknown>> & {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
}
|
||||
): Record<string, unknown> {
|
||||
function createProvider(overrides: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
modelVerificationState: 'idle',
|
||||
|
|
@ -80,10 +76,7 @@ function createProvider(
|
|||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
plugins: { status: 'unsupported' },
|
||||
mcp: { status: 'unsupported' },
|
||||
},
|
||||
extensions: {},
|
||||
},
|
||||
backend: null,
|
||||
availableBackends: [],
|
||||
|
|
@ -127,18 +120,15 @@ function setFocusedTab(type: string): void {
|
|||
describe('GlobalProviderStatusHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = null;
|
||||
storeState.cliStatus = createMultimodelStatus([createProvider({})]);
|
||||
storeState.cliStatusLoading = false;
|
||||
storeState.cliProviderStatusLoading = {};
|
||||
storeState.cliProviderStatusLoading = { anthropic: true };
|
||||
storeState.appConfig = {
|
||||
general: {
|
||||
multimodelEnabled: true,
|
||||
},
|
||||
};
|
||||
setFocusedTab('team');
|
||||
codexAccountHookState.snapshot = null;
|
||||
codexAccountHookState.loading = false;
|
||||
codexAccountHookState.error = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -146,17 +136,7 @@ describe('GlobalProviderStatusHeader', () => {
|
|||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows loading providers on non-dashboard screens', async () => {
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: true };
|
||||
|
||||
it('shows provider activity on non-dashboard screens', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
|
@ -176,18 +156,8 @@ describe('GlobalProviderStatusHeader', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('hides on dashboard tabs', async () => {
|
||||
it('hides on dashboard screens', async () => {
|
||||
setFocusedTab('dashboard');
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: true };
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
|
@ -204,165 +174,4 @@ describe('GlobalProviderStatusHeader', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps completed providers visible as Checked while the same cycle still has loading work, then hides when clean', async () => {
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: true, codex: true };
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Not connected',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: false, codex: true };
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Checked');
|
||||
expect(host.textContent).toContain('Codex');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Not connected',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: false, codex: false };
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('stays visible for provider errors after loading finishes', async () => {
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Failed to refresh anthropic status',
|
||||
}),
|
||||
]);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Failed to refresh anthropic status');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('masks the negative Codex bootstrap snapshot while placeholder loading is still active', async () => {
|
||||
storeState.cliStatus = null;
|
||||
storeState.cliStatusLoading = true;
|
||||
codexAccountHookState.snapshot = {
|
||||
preferredAuthMode: 'chatgpt',
|
||||
effectiveAuthMode: null,
|
||||
launchAllowed: false,
|
||||
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
|
||||
launchReadinessState: 'missing_auth',
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
managedAccount: null,
|
||||
apiKey: {
|
||||
available: false,
|
||||
source: null,
|
||||
sourceLabel: null,
|
||||
},
|
||||
requiresOpenaiAuth: true,
|
||||
localAccountArtifactsPresent: false,
|
||||
localActiveChatgptAccountPresent: false,
|
||||
login: {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
},
|
||||
rateLimits: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Codex');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
expect(host.textContent).not.toContain(
|
||||
'Connect a ChatGPT account to use your Codex subscription.'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
isElectronMode: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
|
||||
ProviderBrandLogo: ({ providerId }: { providerId: string }) =>
|
||||
React.createElement('span', { 'data-testid': `provider-logo-${providerId}` }, providerId),
|
||||
}));
|
||||
|
||||
function createProvider(
|
||||
overrides: Partial<CliProviderStatus> & {
|
||||
providerId: CliProviderId;
|
||||
displayName: string;
|
||||
}
|
||||
): CliProviderStatus {
|
||||
const { providerId, displayName, ...rest } = overrides;
|
||||
return {
|
||||
providerId,
|
||||
displayName,
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
modelVerificationState: 'idle',
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
backend: null,
|
||||
availableBackends: [],
|
||||
connection: null,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function createMultimodelStatus(providers: CliProviderStatus[]): CliInstallationStatus {
|
||||
return {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
installed: true,
|
||||
installedVersion: '0.0.3',
|
||||
binaryPath: '/tmp/claude-multimodel',
|
||||
launchError: null,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: providers.some((provider) => provider.authenticated === true),
|
||||
authStatusChecking: false,
|
||||
authMethod: null,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
function renderStrip(
|
||||
host: HTMLElement,
|
||||
props: Partial<React.ComponentProps<typeof ProviderActivityStatusStrip>> & {
|
||||
cliStatus: CliInstallationStatus | null;
|
||||
}
|
||||
): ReturnType<typeof createRoot> {
|
||||
const root = createRoot(host);
|
||||
root.render(
|
||||
React.createElement(ProviderActivityStatusStrip, {
|
||||
sourceCliStatus: props.cliStatus,
|
||||
cliStatusLoading: false,
|
||||
cliProviderStatusLoading: {},
|
||||
multimodelEnabled: true,
|
||||
...props,
|
||||
})
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe('ProviderActivityStatusStrip', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows loading providers', async () => {
|
||||
const cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
||||
let root!: ReturnType<typeof createRoot>;
|
||||
await act(async () => {
|
||||
root = renderStrip(host, {
|
||||
cliStatus,
|
||||
cliProviderStatusLoading: { anthropic: true },
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Provider Activity');
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters to selected provider ids', async () => {
|
||||
const cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
||||
let root!: ReturnType<typeof createRoot>;
|
||||
await act(async () => {
|
||||
root = renderStrip(host, {
|
||||
cliStatus,
|
||||
cliProviderStatusLoading: { anthropic: true, codex: true },
|
||||
providerIds: ['codex'],
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Codex');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps completed providers visible as Checked while the same cycle still has loading work, then hides when clean', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProviderActivityStatusStrip, {
|
||||
cliStatus: createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]),
|
||||
sourceCliStatus: null,
|
||||
cliStatusLoading: false,
|
||||
cliProviderStatusLoading: { anthropic: true, codex: true },
|
||||
multimodelEnabled: true,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProviderActivityStatusStrip, {
|
||||
cliStatus: createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Not connected',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]),
|
||||
sourceCliStatus: null,
|
||||
cliStatusLoading: false,
|
||||
cliProviderStatusLoading: { anthropic: false, codex: true },
|
||||
multimodelEnabled: true,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Checked');
|
||||
expect(host.textContent).toContain('Codex');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProviderActivityStatusStrip, {
|
||||
cliStatus: createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Not connected',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
}),
|
||||
]),
|
||||
sourceCliStatus: null,
|
||||
cliStatusLoading: false,
|
||||
cliProviderStatusLoading: { anthropic: false, codex: false },
|
||||
multimodelEnabled: true,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('stays visible for provider errors after loading finishes', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
||||
let root!: ReturnType<typeof createRoot>;
|
||||
await act(async () => {
|
||||
root = renderStrip(host, {
|
||||
cliStatus: createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Failed to refresh anthropic status',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Failed to refresh anthropic status');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('masks a negative Codex bootstrap state while source placeholder loading is still active', async () => {
|
||||
const sourceCliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
const cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Connect a ChatGPT account to use your Codex subscription.',
|
||||
connection: {
|
||||
supportsOAuth: false,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
|
||||
configuredAuthMode: 'chatgpt',
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
codex: {
|
||||
preferredAuthMode: 'chatgpt',
|
||||
effectiveAuthMode: null,
|
||||
launchAllowed: false,
|
||||
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
|
||||
launchReadinessState: 'missing_auth',
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
managedAccount: null,
|
||||
requiresOpenaiAuth: true,
|
||||
localAccountArtifactsPresent: false,
|
||||
localActiveChatgptAccountPresent: false,
|
||||
login: {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
},
|
||||
rateLimits: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
||||
let root!: ReturnType<typeof createRoot>;
|
||||
await act(async () => {
|
||||
root = renderStrip(host, {
|
||||
cliStatus,
|
||||
sourceCliStatus,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Codex');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
expect(host.textContent).not.toContain(
|
||||
'Connect a ChatGPT account to use your Codex subscription.'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -356,10 +356,7 @@ describe('ExtensionStoreView provider loading placeholders', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({
|
||||
multimodelEnabled: true,
|
||||
providerStatusMode: 'defer',
|
||||
});
|
||||
expect(storeState.bootstrapCliStatus).not.toHaveBeenCalled();
|
||||
expect(storeState.fetchCliStatus).not.toHaveBeenCalled();
|
||||
expect(storeState.fetchApiKeys).not.toHaveBeenCalled();
|
||||
|
||||
|
|
@ -410,6 +407,7 @@ describe('ExtensionStoreView provider loading placeholders', () => {
|
|||
multimodelEnabled: false,
|
||||
},
|
||||
};
|
||||
storeState.cliStatusLoading = false;
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ const storeState = {
|
|||
appConfig: { general: { multimodelEnabled: true } },
|
||||
cliStatus: { providers: [] },
|
||||
cliStatusLoading: false,
|
||||
cliProviderStatusLoading: {},
|
||||
fetchCliStatus,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
|
|
@ -412,6 +413,7 @@ vi.mock('@renderer/hooks/useTheme', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/utils/geminiUiFreeze', () => ({
|
||||
filterMainScreenCliProviders: <T,>(providers: readonly T[]) => [...providers],
|
||||
isGeminiUiFrozen: () => false,
|
||||
normalizeCreateLaunchProviderForUi: (providerId: unknown) => providerId ?? 'anthropic',
|
||||
}));
|
||||
|
|
|
|||
182
test/renderer/utils/startupIdleTask.test.ts
Normal file
182
test/renderer/utils/startupIdleTask.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
scheduleStartupIdleTask,
|
||||
type StartupIdleTaskScheduler,
|
||||
} from '../../../src/renderer/utils/startupIdleTask';
|
||||
|
||||
describe('scheduleStartupIdleTask', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('binds native browser timers before scheduling the default task', async () => {
|
||||
type WindowSetTimeout = typeof window.setTimeout;
|
||||
type WindowClearTimeout = typeof window.clearTimeout;
|
||||
const windowSetTimeoutDescriptor = Object.getOwnPropertyDescriptor(window, 'setTimeout');
|
||||
const windowClearTimeoutDescriptor = Object.getOwnPropertyDescriptor(window, 'clearTimeout');
|
||||
const globalSetTimeoutDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'setTimeout');
|
||||
const globalClearTimeoutDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'clearTimeout'
|
||||
);
|
||||
const nativeWindowSetTimeout = window.setTimeout.bind(window);
|
||||
const nativeWindowClearTimeout = window.clearTimeout.bind(window);
|
||||
const strictSetTimeout = function (
|
||||
this: unknown,
|
||||
...args: Parameters<WindowSetTimeout>
|
||||
): ReturnType<WindowSetTimeout> {
|
||||
if (this !== window && this !== globalThis) {
|
||||
throw new TypeError('Illegal invocation');
|
||||
}
|
||||
return nativeWindowSetTimeout(...args) as unknown as ReturnType<WindowSetTimeout>;
|
||||
} as WindowSetTimeout;
|
||||
const strictClearTimeout = function (
|
||||
this: unknown,
|
||||
...args: Parameters<WindowClearTimeout>
|
||||
): ReturnType<WindowClearTimeout> {
|
||||
if (this !== window && this !== globalThis) {
|
||||
throw new TypeError('Illegal invocation');
|
||||
}
|
||||
return nativeWindowClearTimeout(...args) as ReturnType<WindowClearTimeout>;
|
||||
} as WindowClearTimeout;
|
||||
|
||||
try {
|
||||
Object.defineProperty(window, 'setTimeout', {
|
||||
configurable: true,
|
||||
value: strictSetTimeout,
|
||||
});
|
||||
Object.defineProperty(window, 'clearTimeout', {
|
||||
configurable: true,
|
||||
value: strictClearTimeout,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'setTimeout', {
|
||||
configurable: true,
|
||||
value: strictSetTimeout as typeof setTimeout,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'clearTimeout', {
|
||||
configurable: true,
|
||||
value: strictClearTimeout as typeof clearTimeout,
|
||||
});
|
||||
|
||||
const task = vi.fn();
|
||||
scheduleStartupIdleTask(task, { minDelayMs: 0, maxDelayMs: 0 });
|
||||
|
||||
await new Promise<void>((resolve) => nativeWindowSetTimeout(resolve, 0));
|
||||
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (windowSetTimeoutDescriptor) {
|
||||
Object.defineProperty(window, 'setTimeout', windowSetTimeoutDescriptor);
|
||||
}
|
||||
if (windowClearTimeoutDescriptor) {
|
||||
Object.defineProperty(window, 'clearTimeout', windowClearTimeoutDescriptor);
|
||||
}
|
||||
if (globalSetTimeoutDescriptor) {
|
||||
Object.defineProperty(globalThis, 'setTimeout', globalSetTimeoutDescriptor);
|
||||
}
|
||||
if (globalClearTimeoutDescriptor) {
|
||||
Object.defineProperty(globalThis, 'clearTimeout', globalClearTimeoutDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('runs after the minimum delay when idle scheduling is unavailable', () => {
|
||||
vi.useFakeTimers();
|
||||
const task = vi.fn();
|
||||
|
||||
scheduleStartupIdleTask(task, {
|
||||
minDelayMs: 2_000,
|
||||
maxDelayMs: 30_000,
|
||||
scheduler: {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
},
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1_999);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses requestIdleCallback after the minimum delay and before the max cap', () => {
|
||||
vi.useFakeTimers();
|
||||
const task = vi.fn();
|
||||
let idleCallback: Parameters<
|
||||
NonNullable<StartupIdleTaskScheduler['requestIdleCallback']>
|
||||
>[0] = () => undefined;
|
||||
const requestIdleCallback = vi.fn((callback, options) => {
|
||||
idleCallback = callback;
|
||||
expect(options).toEqual({ timeout: 28_000 });
|
||||
return 42;
|
||||
});
|
||||
|
||||
scheduleStartupIdleTask(task, {
|
||||
minDelayMs: 2_000,
|
||||
maxDelayMs: 30_000,
|
||||
scheduler: {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestIdleCallback,
|
||||
},
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(requestIdleCallback).toHaveBeenCalledTimes(1);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
|
||||
idleCallback({ didTimeout: false, timeRemaining: () => 10 });
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the max delay as a safety cap for busy renderers', () => {
|
||||
vi.useFakeTimers();
|
||||
const task = vi.fn();
|
||||
|
||||
scheduleStartupIdleTask(task, {
|
||||
minDelayMs: 2_000,
|
||||
maxDelayMs: 30_000,
|
||||
scheduler: {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestIdleCallback: (callback, options) =>
|
||||
setTimeout(
|
||||
() => callback({ didTimeout: true, timeRemaining: () => 0 }),
|
||||
options?.timeout ?? 0
|
||||
) as unknown as number,
|
||||
cancelIdleCallback: (handle) => clearTimeout(handle),
|
||||
},
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(29_999);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels both pending delay and idle callbacks', () => {
|
||||
vi.useFakeTimers();
|
||||
const task = vi.fn();
|
||||
const cancelIdleCallback = vi.fn();
|
||||
const cleanup = scheduleStartupIdleTask(task, {
|
||||
minDelayMs: 2_000,
|
||||
maxDelayMs: 30_000,
|
||||
scheduler: {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestIdleCallback: () => 42,
|
||||
cancelIdleCallback,
|
||||
},
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
cleanup();
|
||||
expect(cancelIdleCallback).toHaveBeenCalledWith(42);
|
||||
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue