fix(team): keep create preflight alive across rerenders
This commit is contained in:
parent
5610e13b98
commit
1b086f41b7
2 changed files with 234 additions and 10 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue