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]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
|
prepareIdleHandleRef.current = null;
|
||||||
|
prepareRequestSeqRef.current += 1;
|
||||||
|
lastPrepareRequestSignatureRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const prepareRuntimeStatusSignature = useMemo(
|
const prepareRuntimeStatusSignature = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildProviderPrepareRuntimeStatusSignature(
|
buildProviderPrepareRuntimeStatusSignature(
|
||||||
|
|
@ -800,12 +809,16 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !canCreate || !launchTeam) {
|
if (!open || !canCreate || !launchTeam) {
|
||||||
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
|
prepareIdleHandleRef.current = null;
|
||||||
prepareRequestSeqRef.current += 1;
|
prepareRequestSeqRef.current += 1;
|
||||||
lastPrepareRequestSignatureRef.current = null;
|
lastPrepareRequestSignatureRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof api.teams.prepareProvisioning !== 'function') {
|
if (typeof api.teams.prepareProvisioning !== 'function') {
|
||||||
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
|
prepareIdleHandleRef.current = null;
|
||||||
prepareRequestSeqRef.current += 1;
|
prepareRequestSeqRef.current += 1;
|
||||||
lastPrepareRequestSignatureRef.current = null;
|
lastPrepareRequestSignatureRef.current = null;
|
||||||
setPrepareState('failed');
|
setPrepareState('failed');
|
||||||
|
|
@ -818,6 +831,8 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!effectiveCwd) {
|
if (!effectiveCwd) {
|
||||||
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
|
prepareIdleHandleRef.current = null;
|
||||||
prepareRequestSeqRef.current += 1;
|
prepareRequestSeqRef.current += 1;
|
||||||
lastPrepareRequestSignatureRef.current = null;
|
lastPrepareRequestSignatureRef.current = null;
|
||||||
setPrepareState('idle');
|
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,
|
open,
|
||||||
canCreate,
|
canCreate,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,51 @@ const fetchCliStatus = vi.fn();
|
||||||
const createSchedule = vi.fn();
|
const createSchedule = vi.fn();
|
||||||
const updateSchedule = vi.fn();
|
const updateSchedule = vi.fn();
|
||||||
const teamRosterEditorSectionMock = vi.hoisted(() => ({ lastProps: null as any }));
|
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 = {
|
const storeState = {
|
||||||
appConfig: { general: { multimodelEnabled: true } },
|
appConfig: { general: { multimodelEnabled: true } },
|
||||||
|
|
@ -141,6 +186,20 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
||||||
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
|
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
|
||||||
fastMode: draft.fastMode as 'inherit' | 'on' | 'off' | 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,
|
clearMemberModelOverrides: (member: unknown) => member,
|
||||||
createMemberDraftsFromInputs: (
|
createMemberDraftsFromInputs: (
|
||||||
members: Array<{
|
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', () => ({
|
vi.mock('@renderer/components/ui/checkbox', () => ({
|
||||||
Checkbox: ({
|
Checkbox: ({
|
||||||
checked,
|
checked,
|
||||||
|
|
@ -307,6 +370,10 @@ vi.mock('@renderer/hooks/useChipDraftPersistence', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/hooks/useCreateTeamDraft', () => ({
|
||||||
|
useCreateTeamDraft: () => createTeamDraftMock.state,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/hooks/useDraftPersistence', () => ({
|
vi.mock('@renderer/hooks/useDraftPersistence', () => ({
|
||||||
useDraftPersistence: () => {
|
useDraftPersistence: () => {
|
||||||
const [value, setValue] = React.useState('');
|
const [value, setValue] = React.useState('');
|
||||||
|
|
@ -447,6 +514,7 @@ vi.mock('@renderer/components/team/dialogs/CodexFastModeSelector', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { CreateTeamDialog } from '@renderer/components/team/dialogs/CreateTeamDialog';
|
||||||
import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog';
|
import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog';
|
||||||
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
|
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
|
||||||
import { isTeamModelAvailableForUi } from '@renderer/utils/teamModelAvailability';
|
import { isTeamModelAvailableForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
|
|
@ -461,6 +529,7 @@ describe('LaunchTeamDialog', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
vi.useRealTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
storeState.cliStatus = { providers: [] };
|
storeState.cliStatus = { providers: [] };
|
||||||
storeState.launchParamsByTeam = {};
|
storeState.launchParamsByTeam = {};
|
||||||
|
|
@ -1801,4 +1870,154 @@ describe('LaunchTeamDialog', () => {
|
||||||
await flush();
|
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