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,
|
||||
normalizeLeadProviderForMode,
|
||||
normalizeMemberDraftForProviderMode,
|
||||
normalizeProviderForMode,
|
||||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
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 { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||
|
|
@ -331,6 +331,36 @@ function validateRequest(
|
|||
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 = ({
|
||||
open,
|
||||
canCreate,
|
||||
|
|
@ -350,8 +380,9 @@ export const CreateTeamDialog = ({
|
|||
const anthropicProviderFastModeDefault = useStore(
|
||||
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
|
||||
);
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const { cliStatus, cliStatusLoading } = useStore(
|
||||
useShallow((s) => ({ cliStatus: s.cliStatus, cliStatusLoading: s.cliStatusLoading }))
|
||||
);
|
||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||
const openDashboard = useStore((s) => s.openDashboard);
|
||||
|
|
@ -413,6 +444,7 @@ export const CreateTeamDialog = ({
|
|||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||
const prepareRequestSeqRef = useRef(0);
|
||||
const prepareIdleHandleRef = useRef<ScheduledIdleHandle | null>(null);
|
||||
const appliedDefaultProjectPathRef = useRef<string | null>(null);
|
||||
const lastAutoDescriptionRef = useRef<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<{
|
||||
|
|
@ -435,7 +467,7 @@ export const CreateTeamDialog = ({
|
|||
const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState<string | null>(null);
|
||||
|
||||
// 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 [worktreeName, setWorktreeNameRaw] = useState('');
|
||||
const [customArgs, setCustomArgsRaw] = useState('');
|
||||
|
|
@ -454,38 +486,44 @@ export const CreateTeamDialog = ({
|
|||
setCustomArgsRaw(localStorage.getItem(`team:lastCustomArgs:${advancedKey}`) ?? '');
|
||||
}, [advancedKey]);
|
||||
|
||||
const setSelectedModel = (value: string): void => {
|
||||
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
|
||||
setSelectedModelRaw(normalizedValue);
|
||||
setStoredCreateTeamModel(selectedProviderId, normalizedValue);
|
||||
};
|
||||
const setSelectedModel = useCallback(
|
||||
(value: string): void => {
|
||||
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
|
||||
setSelectedModelRaw(normalizedValue);
|
||||
setStoredCreateTeamModel(selectedProviderId, normalizedValue);
|
||||
},
|
||||
[selectedProviderId]
|
||||
);
|
||||
|
||||
const setSelectedProviderId = (value: TeamProviderId): void => {
|
||||
const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled);
|
||||
setSelectedProviderIdRaw(normalizedValue);
|
||||
setStoredCreateTeamProvider(normalizedValue);
|
||||
setSelectedModelRaw(getStoredTeamModel(normalizedValue));
|
||||
};
|
||||
const setSelectedProviderId = useCallback(
|
||||
(value: TeamProviderId): void => {
|
||||
const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled);
|
||||
setSelectedProviderIdRaw(normalizedValue);
|
||||
setStoredCreateTeamProvider(normalizedValue);
|
||||
setSelectedModelRaw(getStoredTeamModel(normalizedValue));
|
||||
},
|
||||
[multimodelEnabled]
|
||||
);
|
||||
|
||||
const setLimitContext = (value: boolean): void => {
|
||||
const setLimitContext = useCallback((value: boolean): void => {
|
||||
setLimitContextRaw(value);
|
||||
setStoredCreateTeamLimitContext(value);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setSkipPermissions = (value: boolean): void => {
|
||||
const setSkipPermissions = useCallback((value: boolean): void => {
|
||||
setSkipPermissionsRaw(value);
|
||||
setStoredCreateTeamSkipPermissions(value);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setSelectedEffort = (value: string): void => {
|
||||
const setSelectedEffort = useCallback((value: string): void => {
|
||||
setSelectedEffortRaw(value);
|
||||
setStoredCreateTeamEffort(value);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setSelectedFastMode = (value: TeamFastMode): void => {
|
||||
const setSelectedFastMode = useCallback((value: TeamFastMode): void => {
|
||||
setSelectedFastModeRaw(value);
|
||||
setStoredCreateTeamFastMode(value);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setWorktreeEnabled = (value: boolean): void => {
|
||||
setWorktreeEnabledRaw(value);
|
||||
|
|
@ -541,7 +579,10 @@ export const CreateTeamDialog = ({
|
|||
() => [...new Set([...existingTeamNames, ...provisioningTeamNames])],
|
||||
[existingTeamNames, provisioningTeamNames]
|
||||
);
|
||||
const suggestedTeamName = getNextSuggestedTeamName(allTakenTeamNames);
|
||||
const suggestedTeamName = useMemo(
|
||||
() => getNextSuggestedTeamName(allTakenTeamNames),
|
||||
[allTakenTeamNames]
|
||||
);
|
||||
|
||||
// Clear stale provisioning error when dialog opens
|
||||
useEffect(() => {
|
||||
|
|
@ -717,7 +758,6 @@ export const CreateTeamDialog = ({
|
|||
}, [
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
effectiveCwd,
|
||||
prepareChecks,
|
||||
prepareRuntimeStatusSignature,
|
||||
runtimeBackendSummaryByProvider,
|
||||
selectedMemberProviders,
|
||||
|
|
@ -802,176 +842,197 @@ export const CreateTeamDialog = ({
|
|||
setPrepareWarnings([]);
|
||||
setPrepareChecks(initialChecks);
|
||||
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
let checks = initialChecks;
|
||||
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||
const selectedModelChecks = (() => {
|
||||
const next = new Set<string>();
|
||||
let hasDefaultSelection = false;
|
||||
const supportsProviderDefaultCheck =
|
||||
providerId === 'codex' ||
|
||||
providerId === 'gemini' ||
|
||||
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
||||
const leadModel = computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
selectedProviderId
|
||||
);
|
||||
if (selectedProviderId === providerId && selectedModel.trim()) {
|
||||
if (leadModel?.trim()) {
|
||||
next.add(leadModel.trim());
|
||||
}
|
||||
} else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
|
||||
hasDefaultSelection = true;
|
||||
}
|
||||
for (const member of effectiveMemberDrafts) {
|
||||
if (member.removedAt) {
|
||||
continue;
|
||||
}
|
||||
const scopedModel = resolveProviderScopedMemberModel({
|
||||
memberProviderId: member.providerId,
|
||||
memberModel: member.model,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById,
|
||||
});
|
||||
if (scopedModel.providerId !== providerId) {
|
||||
continue;
|
||||
}
|
||||
if (scopedModel.model) {
|
||||
next.add(scopedModel.model);
|
||||
} else if (supportsProviderDefaultCheck) {
|
||||
// Defer the heavy IPC orchestration until the renderer is idle so the
|
||||
// synchronous state updates above (setPrepareState etc.) can paint first.
|
||||
// Cancel any pending idle work from a superseded run so a stale callback
|
||||
// can't start expensive diagnostics for an obsolete request.
|
||||
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||
prepareIdleHandleRef.current = null;
|
||||
|
||||
prepareIdleHandleRef.current = scheduleIdle(() => {
|
||||
prepareIdleHandleRef.current = null;
|
||||
if (prepareRequestSeqRef.current !== requestSeq) return;
|
||||
void (async () => {
|
||||
let checks = initialChecks;
|
||||
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||
const selectedModelChecks = (() => {
|
||||
const next = new Set<string>();
|
||||
let hasDefaultSelection = false;
|
||||
const supportsProviderDefaultCheck =
|
||||
providerId === 'codex' ||
|
||||
providerId === 'gemini' ||
|
||||
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
||||
const leadModel = computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
selectedProviderId
|
||||
);
|
||||
if (selectedProviderId === providerId && selectedModel.trim()) {
|
||||
if (leadModel?.trim()) {
|
||||
next.add(leadModel.trim());
|
||||
}
|
||||
} else if (selectedProviderId === providerId && 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,
|
||||
backendSummary,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||
});
|
||||
const cachedModelResultsById = {
|
||||
...getShortLivedProviderPrepareModelResults({
|
||||
for (const member of effectiveMemberDrafts) {
|
||||
if (member.removedAt) {
|
||||
continue;
|
||||
}
|
||||
const scopedModel = resolveProviderScopedMemberModel({
|
||||
memberProviderId: member.providerId,
|
||||
memberModel: member.model,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById,
|
||||
});
|
||||
if (scopedModel.providerId !== providerId) {
|
||||
continue;
|
||||
}
|
||||
if (scopedModel.model) {
|
||||
next.add(scopedModel.model);
|
||||
} 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,
|
||||
cacheKey,
|
||||
}),
|
||||
...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}),
|
||||
};
|
||||
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,
|
||||
backendSummary,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||
});
|
||||
}
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
const providerResults = await Promise.all(
|
||||
providerPlans.map(async (plan) => {
|
||||
const prepResult = await runProviderPrepareDiagnostics({
|
||||
cwd: effectiveCwd,
|
||||
providerId: plan.providerId,
|
||||
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);
|
||||
}
|
||||
},
|
||||
const cachedModelResultsById = {
|
||||
...getShortLivedProviderPrepareModelResults({
|
||||
providerId,
|
||||
cacheKey,
|
||||
}),
|
||||
...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}),
|
||||
};
|
||||
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,
|
||||
});
|
||||
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,
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
const providerResults = await Promise.all(
|
||||
providerPlans.map(async (plan) => {
|
||||
const prepResult = await runProviderPrepareDiagnostics({
|
||||
cwd: effectiveCwd,
|
||||
providerId: plan.providerId,
|
||||
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, {
|
||||
status: plan.prepResult.status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.prepResult.details,
|
||||
});
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
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;
|
||||
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);
|
||||
})();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||
prepareIdleHandleRef.current = null;
|
||||
// Bump the request sequence so any callback that already woke up but
|
||||
// hasn't checked yet treats itself as superseded.
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
prepareRequestSeqRef.current += 1;
|
||||
}
|
||||
})();
|
||||
};
|
||||
}, [
|
||||
open,
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
|
@ -1896,51 +2017,8 @@ export const CreateTeamDialog = ({
|
|||
modelUnavailableReasonByProvider={
|
||||
shortLivedModelIssueReasons.modelUnavailableReasonByProvider
|
||||
}
|
||||
headerTop={
|
||||
<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>
|
||||
}
|
||||
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
|
||||
}
|
||||
headerTop={rosterHeaderTop}
|
||||
headerBottom={rosterHeaderBottom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ interface TeamRosterEditorSectionProps {
|
|||
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const TeamRosterEditorSection = ({
|
||||
const TeamRosterEditorSectionImpl = ({
|
||||
members,
|
||||
onMembersChange,
|
||||
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(
|
||||
current: CliInstallationStatus | null,
|
||||
incoming: CliInstallationStatus
|
||||
|
|
@ -222,6 +358,9 @@ export function mergeCliStatusPreservingHydratedProviders(
|
|||
current?.flavor !== 'agent_teams_orchestrator' ||
|
||||
incoming.flavor !== 'agent_teams_orchestrator'
|
||||
) {
|
||||
if (current && isCliInstallationStatusContentEqual(current, incoming)) {
|
||||
return current;
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +370,15 @@ export function mergeCliStatusPreservingHydratedProviders(
|
|||
const incomingProviderIds = new Set(incoming.providers.map((provider) => provider.providerId));
|
||||
const providers = incoming.providers.map((incomingProvider) => {
|
||||
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 incomingProvider;
|
||||
|
|
@ -248,12 +395,22 @@ export function mergeCliStatusPreservingHydratedProviders(
|
|||
|
||||
const authenticatedProvider = providers.find((provider) => provider.authenticated) ?? null;
|
||||
|
||||
return {
|
||||
const mergedProviders = areArraysEqual(providers, current.providers, Object.is)
|
||||
? current.providers
|
||||
: providers;
|
||||
|
||||
const merged: CliInstallationStatus = {
|
||||
...incoming,
|
||||
providers,
|
||||
authLoggedIn: providers.some((provider) => provider.authenticated),
|
||||
providers: mergedProviders,
|
||||
authLoggedIn: mergedProviders.some((provider) => provider.authenticated),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
};
|
||||
|
||||
if (isCliInstallationStatusContentEqual(current, merged)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function refreshOpenCodeProviderStatusAfterRuntimeInstall(
|
||||
|
|
|
|||
Loading…
Reference in a new issue