Persist composer preferences and stabilize hydration
This commit is contained in:
@@ -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<typeof import("./use-form-preferences")>();
|
||||
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("<!doctype html><html><body></body></html>", {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -157,6 +157,18 @@ function resolveThinkingOptionId(args: {
|
||||
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||
}
|
||||
|
||||
function mergeSelectedComposerPreferences(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: AgentProvider;
|
||||
updates: Partial<ProviderPreferences>;
|
||||
}): 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<FormPreferences | null>(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,
|
||||
|
||||
Reference in New Issue
Block a user