perf(team): cut CreateTeamDialog re-render storm during CLI verify
Stabilize cliStatus identity across cloned IPC snapshots, memoize the roster editor boundary, and defer/cancel provider prepare diagnostics idle work.
This commit is contained in:
parent
60f7b6a5fd
commit
69a907e714
4 changed files with 636 additions and 234 deletions
|
|
@ -24,7 +24,6 @@ import {
|
||||||
createMemberDraft,
|
createMemberDraft,
|
||||||
normalizeLeadProviderForMode,
|
normalizeLeadProviderForMode,
|
||||||
normalizeMemberDraftForProviderMode,
|
normalizeMemberDraftForProviderMode,
|
||||||
normalizeProviderForMode,
|
|
||||||
validateMemberNameInline,
|
validateMemberNameInline,
|
||||||
} from '@renderer/components/team/members/MembersEditorSection';
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
||||||
|
|
@ -85,6 +84,7 @@ import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSel
|
||||||
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||||
|
|
@ -331,6 +331,36 @@ function validateRequest(
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IdleWindow = Window & {
|
||||||
|
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||||
|
cancelIdleCallback?: (id: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ScheduledIdleHandle {
|
||||||
|
kind: 'idle' | 'timeout';
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleIdle(cb: () => void): ScheduledIdleHandle {
|
||||||
|
const idleWindow = window as IdleWindow;
|
||||||
|
if (typeof idleWindow.requestIdleCallback === 'function') {
|
||||||
|
return { kind: 'idle', id: idleWindow.requestIdleCallback(cb, { timeout: 2000 }) };
|
||||||
|
}
|
||||||
|
return { kind: 'timeout', id: window.setTimeout(cb, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelScheduledIdle(handle: ScheduledIdleHandle | null): void {
|
||||||
|
if (!handle) return;
|
||||||
|
if (handle.kind === 'idle') {
|
||||||
|
const idleWindow = window as IdleWindow;
|
||||||
|
if (typeof idleWindow.cancelIdleCallback === 'function') {
|
||||||
|
idleWindow.cancelIdleCallback(handle.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.clearTimeout(handle.id);
|
||||||
|
}
|
||||||
|
|
||||||
export const CreateTeamDialog = ({
|
export const CreateTeamDialog = ({
|
||||||
open,
|
open,
|
||||||
canCreate,
|
canCreate,
|
||||||
|
|
@ -350,8 +380,9 @@ export const CreateTeamDialog = ({
|
||||||
const anthropicProviderFastModeDefault = useStore(
|
const anthropicProviderFastModeDefault = useStore(
|
||||||
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
|
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
|
||||||
);
|
);
|
||||||
const cliStatus = useStore((s) => s.cliStatus);
|
const { cliStatus, cliStatusLoading } = useStore(
|
||||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
useShallow((s) => ({ cliStatus: s.cliStatus, cliStatusLoading: s.cliStatusLoading }))
|
||||||
|
);
|
||||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||||
const openDashboard = useStore((s) => s.openDashboard);
|
const openDashboard = useStore((s) => s.openDashboard);
|
||||||
|
|
@ -413,6 +444,7 @@ export const CreateTeamDialog = ({
|
||||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||||
const prepareRequestSeqRef = useRef(0);
|
const prepareRequestSeqRef = useRef(0);
|
||||||
|
const prepareIdleHandleRef = useRef<ScheduledIdleHandle | null>(null);
|
||||||
const appliedDefaultProjectPathRef = useRef<string | null>(null);
|
const appliedDefaultProjectPathRef = useRef<string | null>(null);
|
||||||
const lastAutoDescriptionRef = useRef<string | null>(null);
|
const lastAutoDescriptionRef = useRef<string | null>(null);
|
||||||
const [fieldErrors, setFieldErrors] = useState<{
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
|
|
@ -435,7 +467,7 @@ export const CreateTeamDialog = ({
|
||||||
const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState<string | null>(null);
|
const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
// Advanced CLI section state (use teamName-derived key for localStorage)
|
// Advanced CLI section state (use teamName-derived key for localStorage)
|
||||||
const advancedKey = sanitizeTeamName(teamName.trim()) || '_new_';
|
const advancedKey = useMemo(() => sanitizeTeamName(teamName.trim()) || '_new_', [teamName]);
|
||||||
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(false);
|
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(false);
|
||||||
const [worktreeName, setWorktreeNameRaw] = useState('');
|
const [worktreeName, setWorktreeNameRaw] = useState('');
|
||||||
const [customArgs, setCustomArgsRaw] = useState('');
|
const [customArgs, setCustomArgsRaw] = useState('');
|
||||||
|
|
@ -454,38 +486,44 @@ export const CreateTeamDialog = ({
|
||||||
setCustomArgsRaw(localStorage.getItem(`team:lastCustomArgs:${advancedKey}`) ?? '');
|
setCustomArgsRaw(localStorage.getItem(`team:lastCustomArgs:${advancedKey}`) ?? '');
|
||||||
}, [advancedKey]);
|
}, [advancedKey]);
|
||||||
|
|
||||||
const setSelectedModel = (value: string): void => {
|
const setSelectedModel = useCallback(
|
||||||
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
|
(value: string): void => {
|
||||||
setSelectedModelRaw(normalizedValue);
|
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
|
||||||
setStoredCreateTeamModel(selectedProviderId, normalizedValue);
|
setSelectedModelRaw(normalizedValue);
|
||||||
};
|
setStoredCreateTeamModel(selectedProviderId, normalizedValue);
|
||||||
|
},
|
||||||
|
[selectedProviderId]
|
||||||
|
);
|
||||||
|
|
||||||
const setSelectedProviderId = (value: TeamProviderId): void => {
|
const setSelectedProviderId = useCallback(
|
||||||
const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled);
|
(value: TeamProviderId): void => {
|
||||||
setSelectedProviderIdRaw(normalizedValue);
|
const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled);
|
||||||
setStoredCreateTeamProvider(normalizedValue);
|
setSelectedProviderIdRaw(normalizedValue);
|
||||||
setSelectedModelRaw(getStoredTeamModel(normalizedValue));
|
setStoredCreateTeamProvider(normalizedValue);
|
||||||
};
|
setSelectedModelRaw(getStoredTeamModel(normalizedValue));
|
||||||
|
},
|
||||||
|
[multimodelEnabled]
|
||||||
|
);
|
||||||
|
|
||||||
const setLimitContext = (value: boolean): void => {
|
const setLimitContext = useCallback((value: boolean): void => {
|
||||||
setLimitContextRaw(value);
|
setLimitContextRaw(value);
|
||||||
setStoredCreateTeamLimitContext(value);
|
setStoredCreateTeamLimitContext(value);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const setSkipPermissions = (value: boolean): void => {
|
const setSkipPermissions = useCallback((value: boolean): void => {
|
||||||
setSkipPermissionsRaw(value);
|
setSkipPermissionsRaw(value);
|
||||||
setStoredCreateTeamSkipPermissions(value);
|
setStoredCreateTeamSkipPermissions(value);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const setSelectedEffort = (value: string): void => {
|
const setSelectedEffort = useCallback((value: string): void => {
|
||||||
setSelectedEffortRaw(value);
|
setSelectedEffortRaw(value);
|
||||||
setStoredCreateTeamEffort(value);
|
setStoredCreateTeamEffort(value);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const setSelectedFastMode = (value: TeamFastMode): void => {
|
const setSelectedFastMode = useCallback((value: TeamFastMode): void => {
|
||||||
setSelectedFastModeRaw(value);
|
setSelectedFastModeRaw(value);
|
||||||
setStoredCreateTeamFastMode(value);
|
setStoredCreateTeamFastMode(value);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const setWorktreeEnabled = (value: boolean): void => {
|
const setWorktreeEnabled = (value: boolean): void => {
|
||||||
setWorktreeEnabledRaw(value);
|
setWorktreeEnabledRaw(value);
|
||||||
|
|
@ -541,7 +579,10 @@ export const CreateTeamDialog = ({
|
||||||
() => [...new Set([...existingTeamNames, ...provisioningTeamNames])],
|
() => [...new Set([...existingTeamNames, ...provisioningTeamNames])],
|
||||||
[existingTeamNames, provisioningTeamNames]
|
[existingTeamNames, provisioningTeamNames]
|
||||||
);
|
);
|
||||||
const suggestedTeamName = getNextSuggestedTeamName(allTakenTeamNames);
|
const suggestedTeamName = useMemo(
|
||||||
|
() => getNextSuggestedTeamName(allTakenTeamNames),
|
||||||
|
[allTakenTeamNames]
|
||||||
|
);
|
||||||
|
|
||||||
// Clear stale provisioning error when dialog opens
|
// Clear stale provisioning error when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -717,7 +758,6 @@ export const CreateTeamDialog = ({
|
||||||
}, [
|
}, [
|
||||||
effectiveAnthropicRuntimeLimitContext,
|
effectiveAnthropicRuntimeLimitContext,
|
||||||
effectiveCwd,
|
effectiveCwd,
|
||||||
prepareChecks,
|
|
||||||
prepareRuntimeStatusSignature,
|
prepareRuntimeStatusSignature,
|
||||||
runtimeBackendSummaryByProvider,
|
runtimeBackendSummaryByProvider,
|
||||||
selectedMemberProviders,
|
selectedMemberProviders,
|
||||||
|
|
@ -802,176 +842,197 @@ export const CreateTeamDialog = ({
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
setPrepareChecks(initialChecks);
|
setPrepareChecks(initialChecks);
|
||||||
|
|
||||||
void (async () => {
|
// Defer the heavy IPC orchestration until the renderer is idle so the
|
||||||
await Promise.resolve();
|
// synchronous state updates above (setPrepareState etc.) can paint first.
|
||||||
let checks = initialChecks;
|
// Cancel any pending idle work from a superseded run so a stale callback
|
||||||
const providerPlans = selectedMemberProviders.map((providerId) => {
|
// can't start expensive diagnostics for an obsolete request.
|
||||||
const selectedModelChecks = (() => {
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
const next = new Set<string>();
|
prepareIdleHandleRef.current = null;
|
||||||
let hasDefaultSelection = false;
|
|
||||||
const supportsProviderDefaultCheck =
|
prepareIdleHandleRef.current = scheduleIdle(() => {
|
||||||
providerId === 'codex' ||
|
prepareIdleHandleRef.current = null;
|
||||||
providerId === 'gemini' ||
|
if (prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
void (async () => {
|
||||||
const leadModel = computeEffectiveTeamModel(
|
let checks = initialChecks;
|
||||||
selectedModel,
|
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||||
effectiveAnthropicRuntimeLimitContext,
|
const selectedModelChecks = (() => {
|
||||||
selectedProviderId
|
const next = new Set<string>();
|
||||||
);
|
let hasDefaultSelection = false;
|
||||||
if (selectedProviderId === providerId && selectedModel.trim()) {
|
const supportsProviderDefaultCheck =
|
||||||
if (leadModel?.trim()) {
|
providerId === 'codex' ||
|
||||||
next.add(leadModel.trim());
|
providerId === 'gemini' ||
|
||||||
}
|
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
||||||
} else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
|
const leadModel = computeEffectiveTeamModel(
|
||||||
hasDefaultSelection = true;
|
selectedModel,
|
||||||
}
|
effectiveAnthropicRuntimeLimitContext,
|
||||||
for (const member of effectiveMemberDrafts) {
|
selectedProviderId
|
||||||
if (member.removedAt) {
|
);
|
||||||
continue;
|
if (selectedProviderId === providerId && selectedModel.trim()) {
|
||||||
}
|
if (leadModel?.trim()) {
|
||||||
const scopedModel = resolveProviderScopedMemberModel({
|
next.add(leadModel.trim());
|
||||||
memberProviderId: member.providerId,
|
}
|
||||||
memberModel: member.model,
|
} else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
|
||||||
selectedProviderId,
|
|
||||||
runtimeProviderStatusById,
|
|
||||||
});
|
|
||||||
if (scopedModel.providerId !== providerId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (scopedModel.model) {
|
|
||||||
next.add(scopedModel.model);
|
|
||||||
} else if (supportsProviderDefaultCheck) {
|
|
||||||
hasDefaultSelection = true;
|
hasDefaultSelection = true;
|
||||||
}
|
}
|
||||||
}
|
for (const member of effectiveMemberDrafts) {
|
||||||
if (supportsProviderDefaultCheck && hasDefaultSelection) {
|
if (member.removedAt) {
|
||||||
next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
|
continue;
|
||||||
}
|
}
|
||||||
return Array.from(next);
|
const scopedModel = resolveProviderScopedMemberModel({
|
||||||
})();
|
memberProviderId: member.providerId,
|
||||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
memberModel: member.model,
|
||||||
const cacheKey = buildProviderPrepareModelCacheKey({
|
selectedProviderId,
|
||||||
cwd: effectiveCwd,
|
runtimeProviderStatusById,
|
||||||
providerId,
|
});
|
||||||
backendSummary,
|
if (scopedModel.providerId !== providerId) {
|
||||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
continue;
|
||||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
}
|
||||||
});
|
if (scopedModel.model) {
|
||||||
const cachedModelResultsById = {
|
next.add(scopedModel.model);
|
||||||
...getShortLivedProviderPrepareModelResults({
|
} else if (supportsProviderDefaultCheck) {
|
||||||
|
hasDefaultSelection = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (supportsProviderDefaultCheck && hasDefaultSelection) {
|
||||||
|
next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
|
||||||
|
}
|
||||||
|
return Array.from(next);
|
||||||
|
})();
|
||||||
|
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||||
|
const cacheKey = buildProviderPrepareModelCacheKey({
|
||||||
|
cwd: effectiveCwd,
|
||||||
providerId,
|
providerId,
|
||||||
cacheKey,
|
backendSummary,
|
||||||
}),
|
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||||
...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}),
|
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||||
};
|
|
||||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
|
||||||
providerId,
|
|
||||||
selectedModelIds: selectedModelChecks,
|
|
||||||
cachedModelResultsById,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
providerId,
|
|
||||||
selectedModelChecks,
|
|
||||||
backendSummary,
|
|
||||||
cacheKey,
|
|
||||||
cachedModelResultsById,
|
|
||||||
cachedSnapshot,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const plan of providerPlans) {
|
|
||||||
checks = updateProviderCheck(checks, plan.providerId, {
|
|
||||||
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
|
||||||
backendSummary: plan.backendSummary,
|
|
||||||
details: plan.cachedSnapshot.details,
|
|
||||||
});
|
});
|
||||||
}
|
const cachedModelResultsById = {
|
||||||
if (prepareRequestSeqRef.current === requestSeq) {
|
...getShortLivedProviderPrepareModelResults({
|
||||||
setPrepareChecks(checks);
|
providerId,
|
||||||
}
|
cacheKey,
|
||||||
const providerResults = await Promise.all(
|
}),
|
||||||
providerPlans.map(async (plan) => {
|
...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}),
|
||||||
const prepResult = await runProviderPrepareDiagnostics({
|
};
|
||||||
cwd: effectiveCwd,
|
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||||
providerId: plan.providerId,
|
providerId,
|
||||||
selectedModelIds: plan.selectedModelChecks,
|
selectedModelIds: selectedModelChecks,
|
||||||
prepareProvisioning: api.teams.prepareProvisioning,
|
cachedModelResultsById,
|
||||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
});
|
||||||
cachedModelResultsById: plan.cachedModelResultsById,
|
return {
|
||||||
onModelProgress: ({ status, details }) => {
|
providerId,
|
||||||
checks = updateProviderCheck(checks, plan.providerId, {
|
selectedModelChecks,
|
||||||
status,
|
backendSummary,
|
||||||
backendSummary: plan.backendSummary,
|
cacheKey,
|
||||||
details,
|
cachedModelResultsById,
|
||||||
});
|
cachedSnapshot,
|
||||||
if (prepareRequestSeqRef.current === requestSeq) {
|
};
|
||||||
setPrepareChecks(checks);
|
});
|
||||||
}
|
|
||||||
},
|
try {
|
||||||
|
for (const plan of providerPlans) {
|
||||||
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
|
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
||||||
|
backendSummary: plan.backendSummary,
|
||||||
|
details: plan.cachedSnapshot.details,
|
||||||
});
|
});
|
||||||
return { ...plan, prepResult };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
let anyFailure = false;
|
|
||||||
let anyNotes = false;
|
|
||||||
const collectedWarnings: string[] = [];
|
|
||||||
for (const plan of providerResults) {
|
|
||||||
if (plan.prepResult.warnings.length > 0) {
|
|
||||||
anyNotes = true;
|
|
||||||
collectedWarnings.push(
|
|
||||||
...plan.prepResult.warnings.map(
|
|
||||||
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (plan.prepResult.status === 'failed') {
|
|
||||||
anyFailure = true;
|
|
||||||
} else if (plan.prepResult.status === 'notes') {
|
|
||||||
anyNotes = true;
|
|
||||||
}
|
}
|
||||||
if (prepareRequestSeqRef.current === requestSeq) {
|
if (prepareRequestSeqRef.current === requestSeq) {
|
||||||
const reusableModelResults = buildReusableProviderPrepareModelResults(
|
setPrepareChecks(checks);
|
||||||
plan.prepResult.modelResultsById
|
}
|
||||||
);
|
const providerResults = await Promise.all(
|
||||||
prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults);
|
providerPlans.map(async (plan) => {
|
||||||
storeShortLivedProviderPrepareModelResults({
|
const prepResult = await runProviderPrepareDiagnostics({
|
||||||
providerId: plan.providerId,
|
cwd: effectiveCwd,
|
||||||
cacheKey: plan.cacheKey,
|
providerId: plan.providerId,
|
||||||
modelResultsById: plan.prepResult.modelResultsById,
|
selectedModelIds: plan.selectedModelChecks,
|
||||||
|
prepareProvisioning: api.teams.prepareProvisioning,
|
||||||
|
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||||
|
cachedModelResultsById: plan.cachedModelResultsById,
|
||||||
|
onModelProgress: ({ status, details }) => {
|
||||||
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
|
status,
|
||||||
|
backendSummary: plan.backendSummary,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
if (prepareRequestSeqRef.current === requestSeq) {
|
||||||
|
setPrepareChecks(checks);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ...plan, prepResult };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let anyFailure = false;
|
||||||
|
let anyNotes = false;
|
||||||
|
const collectedWarnings: string[] = [];
|
||||||
|
for (const plan of providerResults) {
|
||||||
|
if (plan.prepResult.warnings.length > 0) {
|
||||||
|
anyNotes = true;
|
||||||
|
collectedWarnings.push(
|
||||||
|
...plan.prepResult.warnings.map(
|
||||||
|
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (plan.prepResult.status === 'failed') {
|
||||||
|
anyFailure = true;
|
||||||
|
} else if (plan.prepResult.status === 'notes') {
|
||||||
|
anyNotes = true;
|
||||||
|
}
|
||||||
|
if (prepareRequestSeqRef.current === requestSeq) {
|
||||||
|
const reusableModelResults = buildReusableProviderPrepareModelResults(
|
||||||
|
plan.prepResult.modelResultsById
|
||||||
|
);
|
||||||
|
prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults);
|
||||||
|
storeShortLivedProviderPrepareModelResults({
|
||||||
|
providerId: plan.providerId,
|
||||||
|
cacheKey: plan.cacheKey,
|
||||||
|
modelResultsById: plan.prepResult.modelResultsById,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
|
status: plan.prepResult.status,
|
||||||
|
backendSummary: plan.backendSummary,
|
||||||
|
details: plan.prepResult.details,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
checks = updateProviderCheck(checks, plan.providerId, {
|
if (prepareRequestSeqRef.current === requestSeq) {
|
||||||
status: plan.prepResult.status,
|
setPrepareChecks(checks);
|
||||||
backendSummary: plan.backendSummary,
|
}
|
||||||
details: plan.prepResult.details,
|
if (prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
});
|
const failureMessage =
|
||||||
|
getPrimaryProvisioningFailureDetail(checks) ??
|
||||||
|
'Some selected providers need attention.';
|
||||||
|
setPrepareState(anyFailure ? 'failed' : 'ready');
|
||||||
|
setPrepareMessage(
|
||||||
|
anyFailure
|
||||||
|
? failureMessage
|
||||||
|
: anyNotes
|
||||||
|
? 'Selected providers are ready with notes.'
|
||||||
|
: 'Selected providers are ready.'
|
||||||
|
);
|
||||||
|
setPrepareWarnings(collectedWarnings);
|
||||||
|
} catch (error) {
|
||||||
|
if (prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
|
const failureMessage =
|
||||||
|
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
|
||||||
|
setPrepareState('failed');
|
||||||
|
setPrepareWarnings([]);
|
||||||
|
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
|
||||||
|
setPrepareMessage(failureMessage);
|
||||||
}
|
}
|
||||||
if (prepareRequestSeqRef.current === requestSeq) {
|
})();
|
||||||
setPrepareChecks(checks);
|
});
|
||||||
}
|
|
||||||
if (prepareRequestSeqRef.current !== requestSeq) return;
|
return () => {
|
||||||
const failureMessage =
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.';
|
prepareIdleHandleRef.current = null;
|
||||||
setPrepareState(anyFailure ? 'failed' : 'ready');
|
// Bump the request sequence so any callback that already woke up but
|
||||||
setPrepareMessage(
|
// hasn't checked yet treats itself as superseded.
|
||||||
anyFailure
|
if (prepareRequestSeqRef.current === requestSeq) {
|
||||||
? failureMessage
|
prepareRequestSeqRef.current += 1;
|
||||||
: anyNotes
|
|
||||||
? 'Selected providers are ready with notes.'
|
|
||||||
: 'Selected providers are ready.'
|
|
||||||
);
|
|
||||||
setPrepareWarnings(collectedWarnings);
|
|
||||||
} catch (error) {
|
|
||||||
if (prepareRequestSeqRef.current !== requestSeq) return;
|
|
||||||
const failureMessage =
|
|
||||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
|
|
||||||
setPrepareState('failed');
|
|
||||||
setPrepareWarnings([]);
|
|
||||||
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
|
|
||||||
setPrepareMessage(failureMessage);
|
|
||||||
}
|
}
|
||||||
})();
|
};
|
||||||
}, [
|
}, [
|
||||||
open,
|
open,
|
||||||
canCreate,
|
canCreate,
|
||||||
|
|
@ -1747,6 +1808,66 @@ export const CreateTeamDialog = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rosterHeaderTop = useMemo(
|
||||||
|
() => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="solo-team"
|
||||||
|
checked={soloTeam}
|
||||||
|
onCheckedChange={(checked) => setSoloTeam(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="solo-team"
|
||||||
|
className="cursor-pointer text-xs font-normal text-text-secondary"
|
||||||
|
>
|
||||||
|
Solo team
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[setSoloTeam, soloTeam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rosterHeaderBottom = useMemo(
|
||||||
|
() =>
|
||||||
|
teammateRuntimeCompatibility.visible ||
|
||||||
|
soloTeam ||
|
||||||
|
(canCreate && hasSelectedWorktreeIsolation) ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<TeammateRuntimeCompatibilityNotice
|
||||||
|
analysis={teammateRuntimeCompatibility}
|
||||||
|
onOpenDashboard={() => {
|
||||||
|
onClose();
|
||||||
|
openDashboard();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{soloTeam ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||||
|
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||||
|
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||||
|
Only the team lead (main process) will be started — no teammates will be
|
||||||
|
spawned. Works like a regular agent session in your chosen runtime (Claude Code,
|
||||||
|
Codex, OpenCode, Gemini) but with access to the task board for planning. Saves
|
||||||
|
tokens by avoiding teammate coordination overhead. You can add members later from
|
||||||
|
the team settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{canCreate && hasSelectedWorktreeIsolation ? (
|
||||||
|
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
[
|
||||||
|
canCreate,
|
||||||
|
hasSelectedWorktreeIsolation,
|
||||||
|
onClose,
|
||||||
|
openDashboard,
|
||||||
|
soloTeam,
|
||||||
|
teammateRuntimeCompatibility,
|
||||||
|
worktreeGitReadiness,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -1896,51 +2017,8 @@ export const CreateTeamDialog = ({
|
||||||
modelUnavailableReasonByProvider={
|
modelUnavailableReasonByProvider={
|
||||||
shortLivedModelIssueReasons.modelUnavailableReasonByProvider
|
shortLivedModelIssueReasons.modelUnavailableReasonByProvider
|
||||||
}
|
}
|
||||||
headerTop={
|
headerTop={rosterHeaderTop}
|
||||||
<div className="flex items-center gap-2">
|
headerBottom={rosterHeaderBottom}
|
||||||
<Checkbox
|
|
||||||
id="solo-team"
|
|
||||||
checked={soloTeam}
|
|
||||||
onCheckedChange={(checked) => setSoloTeam(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="solo-team"
|
|
||||||
className="cursor-pointer text-xs font-normal text-text-secondary"
|
|
||||||
>
|
|
||||||
Solo team
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
headerBottom={
|
|
||||||
teammateRuntimeCompatibility.visible ||
|
|
||||||
soloTeam ||
|
|
||||||
(canCreate && hasSelectedWorktreeIsolation) ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<TeammateRuntimeCompatibilityNotice
|
|
||||||
analysis={teammateRuntimeCompatibility}
|
|
||||||
onOpenDashboard={() => {
|
|
||||||
onClose();
|
|
||||||
openDashboard();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{soloTeam ? (
|
|
||||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
|
||||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
|
||||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
|
||||||
Only the team lead (main process) will be started — no teammates
|
|
||||||
will be spawned. Works like a regular agent session in your chosen runtime
|
|
||||||
(Claude Code, Codex, OpenCode, Gemini) but with access to the task board
|
|
||||||
for planning. Saves tokens by avoiding teammate coordination overhead. You
|
|
||||||
can add members later from the team settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{canCreate && hasSelectedWorktreeIsolation ? (
|
|
||||||
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ interface TeamRosterEditorSectionProps {
|
||||||
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamRosterEditorSection = ({
|
const TeamRosterEditorSectionImpl = ({
|
||||||
members,
|
members,
|
||||||
onMembersChange,
|
onMembersChange,
|
||||||
fieldError,
|
fieldError,
|
||||||
|
|
@ -197,3 +197,6 @@ export const TeamRosterEditorSection = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TeamRosterEditorSection = React.memo(TeamRosterEditorSectionImpl);
|
||||||
|
TeamRosterEditorSection.displayName = 'TeamRosterEditorSection';
|
||||||
|
|
|
||||||
164
src/renderer/store/slices/__tests__/cliInstallerSlice.test.ts
Normal file
164
src/renderer/store/slices/__tests__/cliInstallerSlice.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import {
|
||||||
|
createLoadingMultimodelCliStatus,
|
||||||
|
mergeCliStatusPreservingHydratedProviders,
|
||||||
|
} from '@renderer/store/slices/cliInstallerSlice';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { CliProviderReasoningEffort } from '@shared/types/cliInstaller';
|
||||||
|
|
||||||
|
describe('mergeCliStatusPreservingHydratedProviders', () => {
|
||||||
|
it('returns the previous status reference when a structurally identical clone arrives', () => {
|
||||||
|
// This mirrors the real IPC path: `CliInstallerService.cloneCliInstallationStatus()`
|
||||||
|
// (called from `publishStatusSnapshot()`) hands the renderer a fresh
|
||||||
|
// `CliInstallationStatus` whose `providers` are also freshly-cloned
|
||||||
|
// objects, even when nothing has actually changed. The merge function
|
||||||
|
// must compare provider content (not just reference) so that no-op
|
||||||
|
// progress ticks do not produce a new `cliStatus` identity and trigger
|
||||||
|
// a re-render storm across every consumer.
|
||||||
|
const current = createLoadingMultimodelCliStatus();
|
||||||
|
const incoming = structuredClone(current);
|
||||||
|
|
||||||
|
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||||
|
|
||||||
|
expect(merged).toBe(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the previous status reference when an authenticated clone arrives', () => {
|
||||||
|
const base = createLoadingMultimodelCliStatus();
|
||||||
|
const current = {
|
||||||
|
...base,
|
||||||
|
authLoggedIn: true,
|
||||||
|
authStatusChecking: false,
|
||||||
|
authMethod: 'oauth' as const,
|
||||||
|
providers: base.providers.map((provider, index) =>
|
||||||
|
index === 0
|
||||||
|
? {
|
||||||
|
...provider,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'oauth' as const,
|
||||||
|
supported: true,
|
||||||
|
verificationState: 'verified' as const,
|
||||||
|
statusMessage: null,
|
||||||
|
models: ['model-a', 'model-b'],
|
||||||
|
}
|
||||||
|
: provider
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const incoming = structuredClone(current);
|
||||||
|
|
||||||
|
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||||
|
|
||||||
|
expect(merged).toBe(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a new status when an incoming provider field actually differs', () => {
|
||||||
|
const current = createLoadingMultimodelCliStatus();
|
||||||
|
const incoming = structuredClone(current);
|
||||||
|
incoming.providers[0] = {
|
||||||
|
...incoming.providers[0],
|
||||||
|
statusMessage: 'Verifying credentials...',
|
||||||
|
};
|
||||||
|
|
||||||
|
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||||
|
|
||||||
|
expect(merged).not.toBe(current);
|
||||||
|
expect(merged.providers[0].statusMessage).toBe('Verifying credentials...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns current when a structurally identical populated provider clone arrives', () => {
|
||||||
|
// Mirrors the real IPC flow with a fully-populated provider: ChatGPT-Codex
|
||||||
|
// authenticated, with a model catalog, model availability records,
|
||||||
|
// runtime capabilities, available backends, and a selected backend.
|
||||||
|
// None of these fields are reference-stable across IPC clones, so the
|
||||||
|
// equality guard must compare them by content, not reference.
|
||||||
|
const base = createLoadingMultimodelCliStatus();
|
||||||
|
const populatedProvider = {
|
||||||
|
...base.providers[1],
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'codex_chatgpt' as const,
|
||||||
|
supported: true,
|
||||||
|
verificationState: 'verified' as const,
|
||||||
|
statusMessage: null,
|
||||||
|
models: ['gpt-5.2'],
|
||||||
|
modelAvailability: [
|
||||||
|
{
|
||||||
|
modelId: 'gpt-5.2',
|
||||||
|
status: 'available' as const,
|
||||||
|
checkedAt: '2026-05-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeCapabilities: {
|
||||||
|
reasoningEffort: {
|
||||||
|
supported: true,
|
||||||
|
values: ['low', 'medium', 'high'] as CliProviderReasoningEffort[],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
availableBackends: [
|
||||||
|
{
|
||||||
|
id: 'codex-native',
|
||||||
|
label: 'Codex native',
|
||||||
|
description: 'App-managed Codex runtime',
|
||||||
|
selectable: true,
|
||||||
|
recommended: true,
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
backend: { kind: 'codex-cli' as const, label: 'Codex CLI' },
|
||||||
|
};
|
||||||
|
const current = {
|
||||||
|
...base,
|
||||||
|
authLoggedIn: true,
|
||||||
|
authStatusChecking: false,
|
||||||
|
authMethod: 'codex_chatgpt' as const,
|
||||||
|
providers: base.providers.map((provider, index) =>
|
||||||
|
index === 1 ? populatedProvider : provider
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const incoming = structuredClone(current);
|
||||||
|
|
||||||
|
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||||
|
|
||||||
|
expect(merged).toBe(current);
|
||||||
|
expect(merged.providers[1]).toBe(current.providers[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a new status when a cloned populated field actually changed', () => {
|
||||||
|
// Negative companion to the populated-clone test: confirms that when a
|
||||||
|
// cloned DTO field really differs, the merge does NOT preserve the
|
||||||
|
// previous reference (i.e. we never let stale data through).
|
||||||
|
const base = createLoadingMultimodelCliStatus();
|
||||||
|
const populatedProvider = {
|
||||||
|
...base.providers[1],
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'codex_chatgpt' as const,
|
||||||
|
supported: true,
|
||||||
|
verificationState: 'verified' as const,
|
||||||
|
models: ['gpt-5.2'],
|
||||||
|
availableBackends: [
|
||||||
|
{
|
||||||
|
id: 'codex-native',
|
||||||
|
label: 'Codex native',
|
||||||
|
description: 'App-managed Codex runtime',
|
||||||
|
selectable: true,
|
||||||
|
recommended: true,
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const current = {
|
||||||
|
...base,
|
||||||
|
providers: base.providers.map((provider, index) =>
|
||||||
|
index === 1 ? populatedProvider : provider
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const incoming = structuredClone(current);
|
||||||
|
// Flip a nested DTO field on the cloned snapshot.
|
||||||
|
incoming.providers[1].availableBackends![0].available = false;
|
||||||
|
|
||||||
|
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||||
|
|
||||||
|
expect(merged).not.toBe(current);
|
||||||
|
expect(merged.providers[1]).not.toBe(current.providers[1]);
|
||||||
|
expect(merged.providers[1].availableBackends?.[0].available).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -214,6 +214,142 @@ export function reconcileMultimodelProviderLoading(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function areArraysEqual<T>(
|
||||||
|
a: readonly T[],
|
||||||
|
b: readonly T[],
|
||||||
|
isEqual: (left: T, right: T) => boolean
|
||||||
|
): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
if (!isEqual(a[i], b[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content-level equality for cloned IPC DTO values. The provider snapshot is
|
||||||
|
* serialised by `CliInstallerService.cloneCliInstallationStatus()` and
|
||||||
|
* `publishStatusSnapshot()` before reaching the renderer, so every nested
|
||||||
|
* array/object arrives as a fresh reference even when nothing changed. These
|
||||||
|
* values are plain JSON-serialisable DTOs, so a stringify-based comparator is
|
||||||
|
* acceptable: false negatives are fine (we just produce a new merged status
|
||||||
|
* unnecessarily), but false positives are not (we must never preserve stale
|
||||||
|
* data).
|
||||||
|
*/
|
||||||
|
function areDtoValuesEqual<T>(a: T | null | undefined, b: T | null | undefined): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a == null || b == null) return a == null && b == null;
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function areExtensionCapabilitiesEqual(
|
||||||
|
a: CliProviderStatus['capabilities']['extensions']['plugins'],
|
||||||
|
b: CliProviderStatus['capabilities']['extensions']['plugins']
|
||||||
|
): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
return (
|
||||||
|
a.status === b.status &&
|
||||||
|
a.ownership === b.ownership &&
|
||||||
|
(a.reason ?? null) === (b.reason ?? null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function areProviderCapabilitiesEqual(
|
||||||
|
a: CliProviderStatus['capabilities'],
|
||||||
|
b: CliProviderStatus['capabilities']
|
||||||
|
): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
return (
|
||||||
|
a.teamLaunch === b.teamLaunch &&
|
||||||
|
a.oneShot === b.oneShot &&
|
||||||
|
areExtensionCapabilitiesEqual(a.extensions.plugins, b.extensions.plugins) &&
|
||||||
|
areExtensionCapabilitiesEqual(a.extensions.mcp, b.extensions.mcp) &&
|
||||||
|
areExtensionCapabilitiesEqual(a.extensions.skills, b.extensions.skills) &&
|
||||||
|
areExtensionCapabilitiesEqual(a.extensions.apiKeys, b.extensions.apiKeys)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function areProviderBackendsEqual(
|
||||||
|
a: CliProviderStatus['backend'],
|
||||||
|
b: CliProviderStatus['backend']
|
||||||
|
): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a == null || b == null) return false;
|
||||||
|
return (
|
||||||
|
a.kind === b.kind &&
|
||||||
|
a.label === b.label &&
|
||||||
|
(a.endpointLabel ?? null) === (b.endpointLabel ?? null) &&
|
||||||
|
(a.projectId ?? null) === (b.projectId ?? null) &&
|
||||||
|
(a.authMethodDetail ?? null) === (b.authMethodDetail ?? null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content-level equality check for a single CliProviderStatus.
|
||||||
|
*
|
||||||
|
* Compares all scalar fields explicitly, the well-typed nested structures
|
||||||
|
* (capabilities, backend) via dedicated comparators, and the cloned DTO
|
||||||
|
* fields (modelCatalog, modelAvailability, runtimeCapabilities,
|
||||||
|
* subscriptionRateLimits, connection, availableBackends,
|
||||||
|
* externalRuntimeDiagnostics) by content. This is necessary because the
|
||||||
|
* IPC path (`CliInstallerService.cloneCliInstallationStatus()` then
|
||||||
|
* `publishStatusSnapshot()`) hands the renderer freshly-deserialised
|
||||||
|
* provider objects on every tick — reference equality on those nested
|
||||||
|
* fields would never hold even when the snapshot is structurally
|
||||||
|
* identical.
|
||||||
|
*/
|
||||||
|
function areProviderStatusContentEqual(a: CliProviderStatus, b: CliProviderStatus): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
return (
|
||||||
|
a.providerId === b.providerId &&
|
||||||
|
a.displayName === b.displayName &&
|
||||||
|
a.supported === b.supported &&
|
||||||
|
a.authenticated === b.authenticated &&
|
||||||
|
a.authMethod === b.authMethod &&
|
||||||
|
a.verificationState === b.verificationState &&
|
||||||
|
(a.modelVerificationState ?? null) === (b.modelVerificationState ?? null) &&
|
||||||
|
(a.statusMessage ?? null) === (b.statusMessage ?? null) &&
|
||||||
|
(a.detailMessage ?? null) === (b.detailMessage ?? null) &&
|
||||||
|
a.canLoginFromUi === b.canLoginFromUi &&
|
||||||
|
(a.selectedBackendId ?? null) === (b.selectedBackendId ?? null) &&
|
||||||
|
(a.resolvedBackendId ?? null) === (b.resolvedBackendId ?? null) &&
|
||||||
|
areArraysEqual(a.models, b.models, (left, right) => left === right) &&
|
||||||
|
areProviderCapabilitiesEqual(a.capabilities, b.capabilities) &&
|
||||||
|
areProviderBackendsEqual(a.backend ?? null, b.backend ?? null) &&
|
||||||
|
areDtoValuesEqual(a.modelCatalog ?? null, b.modelCatalog ?? null) &&
|
||||||
|
areDtoValuesEqual(a.modelAvailability ?? [], b.modelAvailability ?? []) &&
|
||||||
|
areDtoValuesEqual(a.runtimeCapabilities ?? null, b.runtimeCapabilities ?? null) &&
|
||||||
|
areDtoValuesEqual(a.subscriptionRateLimits ?? null, b.subscriptionRateLimits ?? null) &&
|
||||||
|
areDtoValuesEqual(a.connection ?? null, b.connection ?? null) &&
|
||||||
|
areDtoValuesEqual(a.availableBackends ?? [], b.availableBackends ?? []) &&
|
||||||
|
areDtoValuesEqual(a.externalRuntimeDiagnostics ?? [], b.externalRuntimeDiagnostics ?? [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCliInstallationStatusContentEqual(
|
||||||
|
a: CliInstallationStatus,
|
||||||
|
b: CliInstallationStatus
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
a.flavor === b.flavor &&
|
||||||
|
a.displayName === b.displayName &&
|
||||||
|
a.supportsSelfUpdate === b.supportsSelfUpdate &&
|
||||||
|
a.showVersionDetails === b.showVersionDetails &&
|
||||||
|
a.showBinaryPath === b.showBinaryPath &&
|
||||||
|
a.installed === b.installed &&
|
||||||
|
a.installedVersion === b.installedVersion &&
|
||||||
|
a.binaryPath === b.binaryPath &&
|
||||||
|
(a.launchError ?? null) === (b.launchError ?? null) &&
|
||||||
|
a.latestVersion === b.latestVersion &&
|
||||||
|
a.updateAvailable === b.updateAvailable &&
|
||||||
|
a.authLoggedIn === b.authLoggedIn &&
|
||||||
|
a.authStatusChecking === b.authStatusChecking &&
|
||||||
|
a.authMethod === b.authMethod &&
|
||||||
|
areArraysEqual(a.providers, b.providers, Object.is)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeCliStatusPreservingHydratedProviders(
|
export function mergeCliStatusPreservingHydratedProviders(
|
||||||
current: CliInstallationStatus | null,
|
current: CliInstallationStatus | null,
|
||||||
incoming: CliInstallationStatus
|
incoming: CliInstallationStatus
|
||||||
|
|
@ -222,6 +358,9 @@ export function mergeCliStatusPreservingHydratedProviders(
|
||||||
current?.flavor !== 'agent_teams_orchestrator' ||
|
current?.flavor !== 'agent_teams_orchestrator' ||
|
||||||
incoming.flavor !== 'agent_teams_orchestrator'
|
incoming.flavor !== 'agent_teams_orchestrator'
|
||||||
) {
|
) {
|
||||||
|
if (current && isCliInstallationStatusContentEqual(current, incoming)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
return incoming;
|
return incoming;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,7 +370,15 @@ export function mergeCliStatusPreservingHydratedProviders(
|
||||||
const incomingProviderIds = new Set(incoming.providers.map((provider) => provider.providerId));
|
const incomingProviderIds = new Set(incoming.providers.map((provider) => provider.providerId));
|
||||||
const providers = incoming.providers.map((incomingProvider) => {
|
const providers = incoming.providers.map((incomingProvider) => {
|
||||||
const currentProvider = currentProvidersById.get(incomingProvider.providerId);
|
const currentProvider = currentProvidersById.get(incomingProvider.providerId);
|
||||||
if (currentProvider && shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) {
|
if (!currentProvider) {
|
||||||
|
return incomingProvider;
|
||||||
|
}
|
||||||
|
if (shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) {
|
||||||
|
return currentProvider;
|
||||||
|
}
|
||||||
|
// Preserve the current reference when content is identical so the
|
||||||
|
// providers array stays reference-stable across steady-state IPC polls.
|
||||||
|
if (areProviderStatusContentEqual(currentProvider, incomingProvider)) {
|
||||||
return currentProvider;
|
return currentProvider;
|
||||||
}
|
}
|
||||||
return incomingProvider;
|
return incomingProvider;
|
||||||
|
|
@ -248,12 +395,22 @@ export function mergeCliStatusPreservingHydratedProviders(
|
||||||
|
|
||||||
const authenticatedProvider = providers.find((provider) => provider.authenticated) ?? null;
|
const authenticatedProvider = providers.find((provider) => provider.authenticated) ?? null;
|
||||||
|
|
||||||
return {
|
const mergedProviders = areArraysEqual(providers, current.providers, Object.is)
|
||||||
|
? current.providers
|
||||||
|
: providers;
|
||||||
|
|
||||||
|
const merged: CliInstallationStatus = {
|
||||||
...incoming,
|
...incoming,
|
||||||
providers,
|
providers: mergedProviders,
|
||||||
authLoggedIn: providers.some((provider) => provider.authenticated),
|
authLoggedIn: mergedProviders.some((provider) => provider.authenticated),
|
||||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isCliInstallationStatusContentEqual(current, merged)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshOpenCodeProviderStatusAfterRuntimeInstall(
|
export async function refreshOpenCodeProviderStatusAfterRuntimeInstall(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue