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", () => {
|
describe("__private__.resolveFormState", () => {
|
||||||
const codexModels: AgentModelDefinition[] = [
|
const codexModels: AgentModelDefinition[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -157,6 +157,18 @@ function resolveThinkingOptionId(args: {
|
|||||||
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
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.
|
* Pure function that resolves form state from multiple data sources.
|
||||||
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
* 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)
|
// Track if we've done initial resolution (to avoid flickering)
|
||||||
const hasResolvedRef = useRef(false);
|
const hasResolvedRef = useRef(false);
|
||||||
|
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
||||||
|
|
||||||
// Reset user modifications when form becomes invisible
|
// Reset user modifications when form becomes invisible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
setUserModified(INITIAL_USER_MODIFIED);
|
setUserModified(INITIAL_USER_MODIFIED);
|
||||||
hasResolvedRef.current = false;
|
hasResolvedRef.current = false;
|
||||||
|
hydrationPreferencesRef.current = null;
|
||||||
}
|
}
|
||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
@@ -470,14 +484,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for preferences to load before first resolution, unless explicit URL overrides exist.
|
if (isPreferencesLoading && !hasResolvedRef.current) {
|
||||||
if (isPreferencesLoading && !hasResolvedRef.current && !combinedInitialValues) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasResolvedRef.current) {
|
||||||
|
hydrationPreferencesRef.current = preferences;
|
||||||
|
}
|
||||||
|
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
||||||
|
|
||||||
const resolved = resolveFormState(
|
const resolved = resolveFormState(
|
||||||
combinedInitialValues,
|
combinedInitialValues,
|
||||||
preferences,
|
hydrationPreferences,
|
||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
userModified,
|
||||||
formStateRef.current,
|
formStateRef.current,
|
||||||
@@ -616,15 +634,38 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
thinkingOptionId: nextThinkingOptionId,
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||||
void updatePreferences({ provider });
|
void updatePreferences((current) =>
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: current,
|
||||||
|
provider,
|
||||||
|
updates: {
|
||||||
|
model: nextModelId || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
|
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModeFromUser = useCallback((modeId: string) => {
|
const setModeFromUser = useCallback(
|
||||||
setFormState((prev) => ({ ...prev, modeId }));
|
(modeId: string) => {
|
||||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
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(
|
const setModelFromUser = useCallback(
|
||||||
(modelId: string) => {
|
(modelId: string) => {
|
||||||
@@ -643,14 +684,44 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
thinkingOptionId: nextThinkingOptionId,
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
setUserModified((prev) => ({ ...prev, model: true }));
|
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) => {
|
const setThinkingOptionFromUser = useCallback(
|
||||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
(thinkingOptionId: string) => {
|
||||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
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) => {
|
const setWorkingDir = useCallback((value: string) => {
|
||||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||||
@@ -793,6 +864,7 @@ export const __private__ = {
|
|||||||
buildProviderDefinitionMap,
|
buildProviderDefinitionMap,
|
||||||
buildProviderDefinitionMapForStatuses,
|
buildProviderDefinitionMapForStatuses,
|
||||||
combineInitialValues,
|
combineInitialValues,
|
||||||
|
mergeSelectedComposerPreferences,
|
||||||
resolveDefaultModel,
|
resolveDefaultModel,
|
||||||
resolveFormState,
|
resolveFormState,
|
||||||
resolveThinkingOptionId,
|
resolveThinkingOptionId,
|
||||||
|
|||||||
Reference in New Issue
Block a user