diff --git a/packages/app/src/hooks/use-agent-form-state.live.test.ts b/packages/app/src/hooks/use-agent-form-state.live.test.ts new file mode 100644 index 000000000..966607cbe --- /dev/null +++ b/packages/app/src/hooks/use-agent-form-state.live.test.ts @@ -0,0 +1,209 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { JSDOM } from "jsdom"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; +import type { FormPreferences } from "./use-form-preferences"; +import { useAgentFormState } from "./use-agent-form-state"; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + preferences: {} as FormPreferences, + isPreferencesLoading: false, + snapshotEntries: [] as ProviderSnapshotEntry[], + updatePreferences: vi.fn(), + refreshSnapshot: vi.fn(), + refetchSnapshotIfStale: vi.fn(), + }, +})); + +vi.mock("@/runtime/host-runtime", () => ({ + useHosts: () => [{ serverId: "host-1" }], +})); + +vi.mock("./use-form-preferences", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormPreferences: () => ({ + preferences: mocks.preferences, + isLoading: mocks.isPreferencesLoading, + updatePreferences: mocks.updatePreferences, + }), + }; +}); + +vi.mock("./use-providers-snapshot", () => ({ + useProvidersSnapshot: () => ({ + entries: mocks.snapshotEntries, + isLoading: false, + isFetching: false, + isRefreshing: false, + error: null, + supportsSnapshot: true, + refresh: mocks.refreshSnapshot, + refetchIfStale: mocks.refetchSnapshotIfStale, + }), +})); + +beforeAll(() => { + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, + writable: true, + }); +}); + +describe("useAgentFormState live preference hydration", () => { + beforeEach(() => { + const dom = new JSDOM("", { + url: "http://localhost", + }); + + Object.defineProperty(globalThis, "document", { + value: dom.window.document, + configurable: true, + }); + Object.defineProperty(globalThis, "window", { + value: dom.window, + configurable: true, + }); + Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + configurable: true, + }); + + mocks.preferences = {}; + mocks.isPreferencesLoading = false; + mocks.snapshotEntries = [ + { + provider: "codex", + status: "ready", + label: "Codex", + description: "Codex test provider", + defaultModeId: "auto", + modes: [ + { id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" }, + { + id: "full-access", + label: "Full Access", + icon: "ShieldAlert", + colorTier: "dangerous", + }, + ], + models: [ + { + provider: "codex", + id: "gpt-5.4", + label: "gpt-5.4", + isDefault: true, + defaultThinkingOptionId: "low", + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "xhigh", label: "XHigh" }, + ], + }, + ], + }, + { + provider: "claude", + status: "ready", + label: "Claude", + description: "Claude test provider", + defaultModeId: "default", + modes: [{ id: "default", label: "Default", icon: "ShieldCheck", colorTier: "safe" }], + models: [ + { + provider: "claude", + id: "claude-sonnet-4-6", + label: "Sonnet 4.6", + isDefault: true, + }, + ], + }, + ]; + mocks.updatePreferences.mockReset(); + mocks.refreshSnapshot.mockReset(); + mocks.refetchSnapshotIfStale.mockReset(); + }); + + it("hydrates from stored preferences once and ignores later preference writes from other composers", async () => { + mocks.preferences = { + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + mode: "full-access", + thinkingByModel: { + "gpt-5.4": "xhigh", + }, + }, + }, + }; + + const { result, rerender } = renderHook(() => + useAgentFormState({ + initialServerId: "host-1", + isVisible: true, + onlineServerIds: ["host-1"], + }), + ); + + await waitFor(() => { + expect(result.current.selectedProvider).toBe("codex"); + expect(result.current.selectedModel).toBe("gpt-5.4"); + expect(result.current.selectedMode).toBe("full-access"); + expect(result.current.selectedThinkingOptionId).toBe("xhigh"); + }); + + mocks.preferences = { + provider: "claude", + providerPreferences: { + claude: { + model: "claude-sonnet-4-6", + mode: "default", + }, + codex: { + model: "gpt-5.4", + mode: "auto", + thinkingByModel: { + "gpt-5.4": "low", + }, + }, + }, + }; + rerender(); + + await waitFor(() => { + expect(result.current.selectedProvider).toBe("codex"); + expect(result.current.selectedModel).toBe("gpt-5.4"); + expect(result.current.selectedMode).toBe("full-access"); + expect(result.current.selectedThinkingOptionId).toBe("xhigh"); + }); + }); + + it("uses the latest preferences when a separate composer hydrates later", async () => { + mocks.preferences = { + provider: "claude", + providerPreferences: { + claude: { + model: "claude-sonnet-4-6", + mode: "default", + }, + }, + }; + + const { result } = renderHook(() => + useAgentFormState({ + initialServerId: "host-1", + isVisible: true, + onlineServerIds: ["host-1"], + }), + ); + + await waitFor(() => { + expect(result.current.selectedProvider).toBe("claude" as AgentProvider); + expect(result.current.selectedModel).toBe("claude-sonnet-4-6"); + expect(result.current.selectedMode).toBe("default"); + }); + }); +}); diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts index b0a5e139a..7349db9c5 100644 --- a/packages/app/src/hooks/use-agent-form-state.test.ts +++ b/packages/app/src/hooks/use-agent-form-state.test.ts @@ -146,6 +146,112 @@ describe("useAgentFormState", () => { }); }); + describe("__private__.mergeSelectedComposerPreferences", () => { + it("stores the selected model for the selected provider", () => { + expect( + __private__.mergeSelectedComposerPreferences({ + preferences: {}, + provider: "codex", + updates: { + model: "gpt-5.4", + }, + }), + ).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + }, + }, + }); + }); + + it("preserves existing provider preferences when the selected model changes", () => { + expect( + __private__.mergeSelectedComposerPreferences({ + preferences: { + provider: "claude", + providerPreferences: { + codex: { + mode: "full-access", + thinkingByModel: { + "gpt-5.4-mini": "medium", + }, + featureValues: { + fast_mode: true, + }, + }, + claude: { + model: "claude-sonnet-4-6", + }, + }, + favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }], + }, + provider: "codex", + updates: { + model: "gpt-5.4", + }, + }), + ).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + mode: "full-access", + thinkingByModel: { + "gpt-5.4-mini": "medium", + }, + featureValues: { + fast_mode: true, + }, + }, + claude: { + model: "claude-sonnet-4-6", + }, + }, + favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }], + }); + }); + + it("stores mode and thinking preferences without dropping the selected model", () => { + expect( + __private__.mergeSelectedComposerPreferences({ + preferences: { + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + mode: "auto", + thinkingByModel: { + "gpt-5.4-mini": "low", + }, + }, + }, + }, + provider: "codex", + updates: { + mode: "full-access", + thinkingByModel: { + "gpt-5.4": "xhigh", + }, + }, + }), + ).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + mode: "full-access", + thinkingByModel: { + "gpt-5.4-mini": "low", + "gpt-5.4": "xhigh", + }, + }, + }, + }); + }); + }); + describe("__private__.resolveFormState", () => { const codexModels: AgentModelDefinition[] = [ { diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index d2bbbeb2b..1363a233b 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -157,6 +157,18 @@ function resolveThinkingOptionId(args: { return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? ""; } +function mergeSelectedComposerPreferences(args: { + preferences: FormPreferences; + provider: AgentProvider; + updates: Partial; +}): FormPreferences { + return mergeProviderPreferences({ + preferences: args.preferences, + provider: args.provider, + updates: args.updates, + }); +} + /** * Pure function that resolves form state from multiple data sources. * Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback @@ -389,12 +401,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg // Track if we've done initial resolution (to avoid flickering) const hasResolvedRef = useRef(false); + const hydrationPreferencesRef = useRef(null); // Reset user modifications when form becomes invisible useEffect(() => { if (!isVisible) { setUserModified(INITIAL_USER_MODIFIED); hasResolvedRef.current = false; + hydrationPreferencesRef.current = null; } }, [isVisible]); @@ -470,14 +484,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg return; } - // Wait for preferences to load before first resolution, unless explicit URL overrides exist. - if (isPreferencesLoading && !hasResolvedRef.current && !combinedInitialValues) { + if (isPreferencesLoading && !hasResolvedRef.current) { return; } + if (!hasResolvedRef.current) { + hydrationPreferencesRef.current = preferences; + } + const hydrationPreferences = hydrationPreferencesRef.current ?? preferences; + const resolved = resolveFormState( combinedInitialValues, - preferences, + hydrationPreferences, availableModels, userModified, formStateRef.current, @@ -616,15 +634,38 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg thinkingOptionId: nextThinkingOptionId, })); setUserModified((prev) => ({ ...prev, provider: true, model: true })); - void updatePreferences({ provider }); + void updatePreferences((current) => + mergeSelectedComposerPreferences({ + preferences: current, + provider, + updates: { + model: nextModelId || undefined, + }, + }), + ); }, [allProviderModels, selectableProviderDefinitionMap, updatePreferences], ); - const setModeFromUser = useCallback((modeId: string) => { - setFormState((prev) => ({ ...prev, modeId })); - setUserModified((prev) => ({ ...prev, modeId: true })); - }, []); + const setModeFromUser = useCallback( + (modeId: string) => { + setFormState((prev) => ({ ...prev, modeId })); + setUserModified((prev) => ({ ...prev, modeId: true })); + const provider = formStateRef.current.provider; + if (provider) { + void updatePreferences((current) => + mergeSelectedComposerPreferences({ + preferences: current, + provider, + updates: { + mode: modeId || undefined, + }, + }), + ); + } + }, + [updatePreferences], + ); const setModelFromUser = useCallback( (modelId: string) => { @@ -643,14 +684,44 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg thinkingOptionId: nextThinkingOptionId, })); setUserModified((prev) => ({ ...prev, model: true })); + const provider = formStateRef.current.provider; + if (provider) { + void updatePreferences((current) => + mergeSelectedComposerPreferences({ + preferences: current, + provider, + updates: { + model: nextModelId || undefined, + }, + }), + ); + } }, - [availableModels, userModified.thinkingOptionId], + [availableModels, updatePreferences, userModified.thinkingOptionId], ); - const setThinkingOptionFromUser = useCallback((thinkingOptionId: string) => { - setFormState((prev) => ({ ...prev, thinkingOptionId })); - setUserModified((prev) => ({ ...prev, thinkingOptionId: true })); - }, []); + const setThinkingOptionFromUser = useCallback( + (thinkingOptionId: string) => { + setFormState((prev) => ({ ...prev, thinkingOptionId })); + setUserModified((prev) => ({ ...prev, thinkingOptionId: true })); + const provider = formStateRef.current.provider; + const modelId = formStateRef.current.model; + if (provider && modelId) { + void updatePreferences((current) => + mergeSelectedComposerPreferences({ + preferences: current, + provider, + updates: { + thinkingByModel: { + [modelId]: thinkingOptionId, + }, + }, + }), + ); + } + }, + [updatePreferences], + ); const setWorkingDir = useCallback((value: string) => { setFormState((prev) => ({ ...prev, workingDir: value })); @@ -793,6 +864,7 @@ export const __private__ = { buildProviderDefinitionMap, buildProviderDefinitionMapForStatuses, combineInitialValues, + mergeSelectedComposerPreferences, resolveDefaultModel, resolveFormState, resolveThinkingOptionId,