From 1b086f41b714d72e9c9ba039962d95e96713641c Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 00:32:54 +0300 Subject: [PATCH] fix(team): keep create preflight alive across rerenders --- .../team/dialogs/CreateTeamDialog.tsx | 25 +- .../team/dialogs/LaunchTeamDialog.test.ts | 219 ++++++++++++++++++ 2 files changed, 234 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index b72b6ac3..4f353e34 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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, diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index 788e5343..4f794a3a 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -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) => 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 => { + 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(); + }); + }); });