fix(ui): re-sync settings/thread forms on loaded record
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -33,6 +33,7 @@ const runtimeLabels: Record<RuntimeName, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AgentSettings = {
|
type AgentSettings = {
|
||||||
|
_id?: Id<'spoonAgentSettings'>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
runtime?: RuntimeName | 'openai_direct';
|
runtime?: RuntimeName | 'openai_direct';
|
||||||
defaultBaseBranch?: string;
|
defaultBaseBranch?: string;
|
||||||
@@ -127,6 +128,46 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
: settings.reasoningEffort,
|
: settings.reasoningEffort,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The parent gates rendering on a different query than `settings`, so this
|
||||||
|
// form can mount before the agent settings record resolves. Re-seed every
|
||||||
|
// field once the real record arrives (and again if the parent swaps to a
|
||||||
|
// different spoon) so a Save cannot persist the initial defaults over the
|
||||||
|
// user's saved configuration.
|
||||||
|
const hydratedSpoonId = useRef<Id<'spoons'> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings || hydratedSpoonId.current === spoon._id) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setEnabled(settings.enabled);
|
||||||
|
setDefaultBaseBranch(
|
||||||
|
settings.defaultBaseBranch ??
|
||||||
|
spoon.forkDefaultBranch ??
|
||||||
|
spoon.upstreamDefaultBranch,
|
||||||
|
);
|
||||||
|
setBranchPrefix(settings.branchPrefix);
|
||||||
|
setInstallCommand(settings.installCommand ?? '');
|
||||||
|
setCheckCommand(settings.checkCommand ?? '');
|
||||||
|
setTestCommand(settings.testCommand ?? '');
|
||||||
|
setEnvFilePath(settings.envFilePath ?? '.env.local');
|
||||||
|
setCustomEnvFilePath(settings.customEnvFilePath ?? '');
|
||||||
|
setMaterializeEnvFileByDefault(
|
||||||
|
settings.materializeEnvFileByDefault ?? false,
|
||||||
|
);
|
||||||
|
setAutoDetectCommands(settings.autoDetectCommands ?? true);
|
||||||
|
setAllowUserFileEditing(settings.allowUserFileEditing ?? true);
|
||||||
|
setAiProviderProfileId(settings.aiProviderProfileId ?? '__default');
|
||||||
|
setAgentModel(settings.aiProviderProfileId ? settings.agentModel : '');
|
||||||
|
setReasoningEffort(
|
||||||
|
!settings.aiProviderProfileId
|
||||||
|
? 'medium'
|
||||||
|
: settings.reasoningEffort === 'none'
|
||||||
|
? 'minimal'
|
||||||
|
: settings.reasoningEffort,
|
||||||
|
);
|
||||||
|
hydratedSpoonId.current = spoon._id;
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [settings, spoon]);
|
||||||
|
|
||||||
const supportedRuntimes = (selectedProfile?.supportedRuntimes ??
|
const supportedRuntimes = (selectedProfile?.supportedRuntimes ??
|
||||||
[]) as RuntimeName[];
|
[]) as RuntimeName[];
|
||||||
const settingsRuntime = settings?.runtime as RuntimeName | undefined;
|
const settingsRuntime = settings?.runtime as RuntimeName | undefined;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { MessageSquarePlus } from 'lucide-react';
|
import { MessageSquarePlus } from 'lucide-react';
|
||||||
@@ -34,6 +34,7 @@ const runtimeLabels: Record<RuntimeName, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AgentSettings = {
|
type AgentSettings = {
|
||||||
|
_id?: Id<'spoonAgentSettings'>;
|
||||||
defaultBaseBranch?: string;
|
defaultBaseBranch?: string;
|
||||||
runtime?: RuntimeName | 'openai_direct';
|
runtime?: RuntimeName | 'openai_direct';
|
||||||
agentModel: string;
|
agentModel: string;
|
||||||
@@ -82,6 +83,33 @@ export const ThreadWorkspaceForm = ({
|
|||||||
);
|
);
|
||||||
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
|
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// The parent gates rendering on a different query than `agentSettings`, so
|
||||||
|
// this form can mount before the agent settings record resolves. Re-seed the
|
||||||
|
// settings-derived fields once the real record arrives (and again if the
|
||||||
|
// parent swaps to a different spoon) so the form reflects saved config rather
|
||||||
|
// than the initial defaults.
|
||||||
|
const hydratedSpoonId = useRef<Id<'spoons'> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agentSettings || hydratedSpoonId.current === spoon._id) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setBaseBranch(
|
||||||
|
agentSettings.defaultBaseBranch ??
|
||||||
|
spoon.forkDefaultBranch ??
|
||||||
|
spoon.upstreamDefaultBranch,
|
||||||
|
);
|
||||||
|
setMaterializeEnvFile(agentSettings.materializeEnvFileByDefault ?? false);
|
||||||
|
setEnvFilePath(
|
||||||
|
agentSettings.envFilePath === 'custom'
|
||||||
|
? (agentSettings.customEnvFilePath ?? '.env.local')
|
||||||
|
: (agentSettings.envFilePath ?? '.env.local'),
|
||||||
|
);
|
||||||
|
setAiProviderProfileId(agentSettings.aiProviderProfileId ?? '__settings');
|
||||||
|
hydratedSpoonId.current = spoon._id;
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [agentSettings, spoon]);
|
||||||
|
|
||||||
const effectiveProviderProfileId =
|
const effectiveProviderProfileId =
|
||||||
aiProviderProfileId === '__settings'
|
aiProviderProfileId === '__settings'
|
||||||
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user