fix(team): keep create preflight alive across rerenders

This commit is contained in:
iliya 2026-05-16 00:32:54 +03:00
parent 5610e13b98
commit 1b086f41b7
2 changed files with 234 additions and 10 deletions

View file

@ -691,6 +691,15 @@ export const CreateTeamDialog = ({
}
}, [open]);
useEffect(() => {
return () => {
cancelScheduledIdle(prepareIdleHandleRef.current);
prepareIdleHandleRef.current = null;
prepareRequestSeqRef.current += 1;
lastPrepareRequestSignatureRef.current = null;
};
}, []);
const prepareRuntimeStatusSignature = useMemo(
() =>
buildProviderPrepareRuntimeStatusSignature(
@ -800,12 +809,16 @@ export const CreateTeamDialog = ({
useEffect(() => {
if (!open || !canCreate || !launchTeam) {
cancelScheduledIdle(prepareIdleHandleRef.current);
prepareIdleHandleRef.current = null;
prepareRequestSeqRef.current += 1;
lastPrepareRequestSignatureRef.current = null;
return;
}
if (typeof api.teams.prepareProvisioning !== 'function') {
cancelScheduledIdle(prepareIdleHandleRef.current);
prepareIdleHandleRef.current = null;
prepareRequestSeqRef.current += 1;
lastPrepareRequestSignatureRef.current = null;
setPrepareState('failed');
@ -818,6 +831,8 @@ export const CreateTeamDialog = ({
}
if (!effectiveCwd) {
cancelScheduledIdle(prepareIdleHandleRef.current);
prepareIdleHandleRef.current = null;
prepareRequestSeqRef.current += 1;
lastPrepareRequestSignatureRef.current = null;
setPrepareState('idle');
@ -1023,16 +1038,6 @@ export const CreateTeamDialog = ({
}
})();
});
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,

View file

@ -8,6 +8,51 @@ const fetchCliStatus = vi.fn();
const createSchedule = vi.fn();
const updateSchedule = vi.fn();
const teamRosterEditorSectionMock = vi.hoisted(() => ({ lastProps: null as any }));
const createTeamDraftMock = vi.hoisted(() => ({
state: {
teamName: 'team-alpha',
setTeamName: vi.fn(),
members: [
{
id: 'member-opencode',
name: 'tom',
roleSelection: '',
customRole: 'Developer',
workflow: '',
providerId: 'opencode',
model: 'opencode/big-pickle',
},
{
id: 'member-codex',
name: 'bob',
roleSelection: '',
customRole: 'Developer',
workflow: '',
providerId: 'codex',
model: 'gpt-5.5',
},
],
setMembers: vi.fn(),
syncModelsWithLead: false,
setSyncModelsWithLead: vi.fn(),
teammateWorktreeDefault: false,
setTeammateWorktreeDefault: vi.fn(),
cwdMode: 'project' as const,
setCwdMode: vi.fn(),
selectedProjectPath: '/tmp/project',
setSelectedProjectPath: vi.fn(),
customCwd: '',
setCustomCwd: vi.fn(),
soloTeam: false,
setSoloTeam: vi.fn(),
launchTeam: true,
setLaunchTeam: vi.fn(),
teamColor: 'slate',
setTeamColor: vi.fn(),
isLoaded: true,
clearDraft: vi.fn(),
},
}));
const storeState = {
appConfig: { general: { multimodelEnabled: true } },
@ -141,6 +186,20 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
fastMode: draft.fastMode as 'inherit' | 'on' | 'off' | undefined,
})),
createMemberDraft: (member: any = {}) => ({
id: member.id ?? 'draft-member',
name: member.name ?? '',
originalName: member.originalName ?? member.name ?? '',
roleSelection: member.roleSelection ?? '',
customRole: member.customRole ?? '',
workflow: member.workflow ?? '',
isolation: member.isolation,
providerId: member.providerId,
providerBackendId: member.providerBackendId,
model: member.model ?? '',
effort: member.effort,
fastMode: member.fastMode,
}),
clearMemberModelOverrides: (member: unknown) => member,
createMemberDraftsFromInputs: (
members: Array<{
@ -228,6 +287,10 @@ vi.mock('@renderer/components/ui/button', () => ({
),
}));
vi.mock('@renderer/components/ui/auto-resize-textarea', () => ({
AutoResizeTextarea: (props: Record<string, unknown>) => React.createElement('textarea', props),
}));
vi.mock('@renderer/components/ui/checkbox', () => ({
Checkbox: ({
checked,
@ -307,6 +370,10 @@ vi.mock('@renderer/hooks/useChipDraftPersistence', () => ({
}),
}));
vi.mock('@renderer/hooks/useCreateTeamDraft', () => ({
useCreateTeamDraft: () => createTeamDraftMock.state,
}));
vi.mock('@renderer/hooks/useDraftPersistence', () => ({
useDraftPersistence: () => {
const [value, setValue] = React.useState('');
@ -447,6 +514,7 @@ vi.mock('@renderer/components/team/dialogs/CodexFastModeSelector', () => ({
}));
import { api } from '@renderer/api';
import { CreateTeamDialog } from '@renderer/components/team/dialogs/CreateTeamDialog';
import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog';
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
import { isTeamModelAvailableForUi } from '@renderer/utils/teamModelAvailability';
@ -461,6 +529,7 @@ describe('LaunchTeamDialog', () => {
afterEach(() => {
document.body.innerHTML = '';
localStorage.clear();
vi.useRealTimers();
vi.clearAllMocks();
storeState.cliStatus = { providers: [] };
storeState.launchParamsByTeam = {};
@ -1801,4 +1870,154 @@ describe('LaunchTeamDialog', () => {
await flush();
});
});
it('keeps create-team preflight alive across same-signature rerenders', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.useFakeTimers();
storeState.cliStatus = {
flavor: 'agent_teams_orchestrator',
providers: [
{
providerId: 'anthropic',
supported: true,
authenticated: true,
authMethod: 'api_key',
verificationState: 'verified',
modelVerificationState: 'verified',
statusMessage: null,
detailMessage: null,
models: ['haiku'],
modelCatalog: {
source: 'live',
status: 'ready',
models: [{ id: 'haiku' }],
},
capabilities: {
teamLaunch: true,
oneShot: true,
},
},
{
providerId: 'codex',
supported: true,
authenticated: true,
authMethod: 'chatgpt',
verificationState: 'verified',
modelVerificationState: 'verified',
statusMessage: null,
detailMessage: null,
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
models: ['gpt-5.5'],
modelCatalog: {
source: 'app-server',
status: 'ready',
models: [{ id: 'gpt-5.5' }],
},
capabilities: {
teamLaunch: true,
oneShot: true,
},
},
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
modelVerificationState: 'verified',
statusMessage: 'warming up',
detailMessage: 'first render',
models: ['opencode/big-pickle'],
modelCatalog: {
source: 'app-server',
status: 'ready',
models: [{ id: 'opencode/big-pickle' }],
},
capabilities: {
teamLaunch: true,
oneShot: false,
},
},
],
} as any;
let resolvePrepare!: (value: {
status: 'ready';
warnings: [];
details: [];
modelResultsById: {};
}) => void;
const preparePromise = new Promise<{
status: 'ready';
warnings: [];
details: [];
modelResultsById: {};
}>((resolve) => {
resolvePrepare = resolve;
});
vi.mocked(runProviderPrepareDiagnostics).mockReturnValue(preparePromise as any);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const renderDialog = async (): Promise<void> => {
root.render(
React.createElement(CreateTeamDialog, {
open: true,
canCreate: true,
provisioningErrorsByTeam: {},
clearProvisioningError: vi.fn(),
existingTeamNames: [],
provisioningTeamNames: [],
activeTeams: [],
defaultProjectPath: '/tmp/project',
onClose: vi.fn(),
onCreate: vi.fn(async () => {}),
onOpenTeam: vi.fn(),
})
);
await flush();
};
await act(async () => {
await renderDialog();
await flush();
});
await act(async () => {
vi.runOnlyPendingTimers();
await flush();
});
expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalled();
await act(async () => {
await renderDialog();
await flush();
});
const callsAfterSameSignatureRerender = vi.mocked(runProviderPrepareDiagnostics).mock.calls.length;
await act(async () => {
resolvePrepare({
status: 'ready',
warnings: [],
details: [],
modelResultsById: {},
});
await flush();
await flush();
});
expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(
callsAfterSameSignatureRerender
);
expect(host.textContent).toContain('Selected providers are ready.');
await act(async () => {
root.unmount();
await flush();
});
});
});