fix(ui): re-sync settings/thread forms on loaded record

This commit is contained in:
Gabriel Brown
2026-07-11 17:14:06 -04:00
parent bca86196fb
commit a3ec04ad99
3 changed files with 216 additions and 2 deletions
@@ -0,0 +1,145 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { getFunctionName } from 'convex/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { SpoonAgentSettingsForm } from '../../src/components/spoons/spoon-agent-settings-form';
const { mockUseMutation, mockUseQuery } = vi.hoisted(() => ({
mockUseMutation: vi.fn(),
mockUseQuery: vi.fn(),
}));
vi.mock('convex/react', () => ({
useMutation: mockUseMutation,
useQuery: mockUseQuery,
}));
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}));
const spoon = {
_id: 'spoon-1',
forkDefaultBranch: 'main',
upstreamDefaultBranch: 'main',
} as never;
const configuredProfile = {
_id: 'profile-1',
name: 'OpenAI',
provider: 'openai',
enabled: true,
configured: true,
isDefault: true,
supportedRuntimes: ['codex'],
reasoningEffort: 'high',
};
const modelCatalog = {
profiles: [
{
profileId: 'profile-1',
configured: true,
defaultModel: 'gpt-5',
models: [{ id: 'gpt-5', label: 'GPT-5' }],
},
],
};
const loadedSettings = {
_id: 'settings-1' as never,
enabled: true,
runtime: 'codex' as const,
defaultBaseBranch: 'develop',
branchPrefix: 'custom/prefix',
installCommand: 'bun install',
agentModel: 'gpt-5',
reasoningEffort: 'high' as const,
envFilePath: '.env',
aiProviderProfileId: 'profile-1' as never,
};
const wireQueries = () => {
const listMineName = getFunctionName(api.aiProviderProfiles.listMine);
const catalogName = getFunctionName(api.aiProviderModels.listAvailableForUser);
mockUseQuery.mockImplementation(
(ref: Parameters<typeof getFunctionName>[0]) => {
const name = getFunctionName(ref);
if (name === listMineName) return [configuredProfile];
if (name === catalogName) return modelCatalog;
return undefined;
},
);
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('SpoonAgentSettingsForm re-sync on loaded record', () => {
it('reflects the loaded settings after the record resolves late', async () => {
wireQueries();
mockUseMutation.mockReturnValue(vi.fn());
// Parent gates on a different query, so this form mounts before the
// agent settings query resolves (settings === undefined).
const { rerender } = render(
<SpoonAgentSettingsForm spoon={spoon} settings={undefined} />,
);
expect(screen.getByLabelText(/branch prefix/i)).toHaveValue('spoon/agent');
// The settings query resolves with a saved record carrying non-defaults.
rerender(<SpoonAgentSettingsForm spoon={spoon} settings={loadedSettings} />);
await waitFor(() =>
expect(screen.getByLabelText(/branch prefix/i)).toHaveValue(
'custom/prefix',
),
);
expect(screen.getByLabelText(/default base branch/i)).toHaveValue(
'develop',
);
expect(screen.getByLabelText(/install command/i)).toHaveValue(
'bun install',
);
});
it('saves the loaded values, not the stale defaults, after a late resolve', async () => {
wireQueries();
const update = vi.fn().mockResolvedValue('settings-1');
mockUseMutation.mockReturnValue(update);
const { rerender } = render(
<SpoonAgentSettingsForm spoon={spoon} settings={undefined} />,
);
rerender(<SpoonAgentSettingsForm spoon={spoon} settings={loadedSettings} />);
// Wait for the late record to hydrate the form before saving.
await waitFor(() =>
expect(screen.getByLabelText(/branch prefix/i)).toHaveValue(
'custom/prefix',
),
);
fireEvent.click(
screen.getByRole('button', { name: /save agent settings/i }),
);
await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
branchPrefix: 'custom/prefix',
defaultBaseBranch: 'develop',
installCommand: 'bun install',
agentModel: 'gpt-5',
}),
);
});
});