diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 5954ad2af..cc2f22917 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -962,13 +962,14 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat return; } void updatePreferences( - mergeProviderPreferences({ - preferences, - provider: agent.provider, - updates: { - model: modelId, - }, - }), + (current) => + mergeProviderPreferences({ + preferences: current, + provider: agent.provider, + updates: { + model: modelId, + }, + }), ).catch((error) => { console.warn("[AgentStatusBar] persist model preference failed", error); }); @@ -978,7 +979,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat }} favoriteKeys={favoriteKeys} onToggleFavoriteModel={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { console.warn("[AgentStatusBar] toggle favorite model failed", error); }); }} @@ -991,16 +992,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat const activeModelId = modelSelection.activeModelId; if (activeModelId) { void updatePreferences( - mergeProviderPreferences({ - preferences, - provider: agent.provider, - updates: { - model: activeModelId, - thinkingByModel: { - [activeModelId]: thinkingOptionId, + (current) => + mergeProviderPreferences({ + preferences: current, + provider: agent.provider, + updates: { + model: activeModelId, + thinkingByModel: { + [activeModelId]: thinkingOptionId, + }, }, - }, - }), + }), ).catch((error) => { console.warn("[AgentStatusBar] persist thinking preference failed", error); }); @@ -1014,6 +1016,20 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat if (!client) { return; } + void updatePreferences( + (current) => + mergeProviderPreferences({ + preferences: current, + provider: agent.provider, + updates: { + featureValues: { + [featureId]: value, + }, + }, + }), + ).catch((error) => { + console.warn("[AgentStatusBar] persist feature preference failed", error); + }); void client.setAgentFeature(agentId, featureId, value).catch((error) => { console.warn("[AgentStatusBar] setAgentFeature failed", error); }); @@ -1083,7 +1099,7 @@ export function DraftAgentStatusBar({ onSelect={onSelectProviderAndModel} favoriteKeys={favoriteKeys} onToggleFavorite={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); }); }} @@ -1129,7 +1145,7 @@ export function DraftAgentStatusBar({ isModelLoading={isAllModelsLoading} favoriteKeys={favoriteKeys} onToggleFavoriteModel={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); }); }} diff --git a/packages/app/src/hooks/feature-preferences.test.ts b/packages/app/src/hooks/feature-preferences.test.ts new file mode 100644 index 000000000..7cfca13a6 --- /dev/null +++ b/packages/app/src/hooks/feature-preferences.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { resolveFeatureValues } from "./feature-preferences"; + +describe("feature-preferences", () => { + const features = [ + { + type: "toggle" as const, + id: "fast_mode", + label: "Fast", + value: false, + }, + { + type: "toggle" as const, + id: "plan_mode", + label: "Plan", + value: false, + }, + ]; + + it("restores persisted values for available features", () => { + expect( + resolveFeatureValues({ + features, + persistedFeatureValues: { + fast_mode: true, + unknown_feature: true, + }, + localFeatureValues: {}, + }), + ).toEqual({ + fast_mode: true, + }); + }); + + it("prefers local values over persisted values", () => { + expect( + resolveFeatureValues({ + features, + persistedFeatureValues: { + fast_mode: true, + plan_mode: false, + }, + localFeatureValues: { + fast_mode: false, + }, + }), + ).toEqual({ + fast_mode: false, + plan_mode: false, + }); + }); +}); diff --git a/packages/app/src/hooks/feature-preferences.ts b/packages/app/src/hooks/feature-preferences.ts new file mode 100644 index 000000000..8fc3f1aaf --- /dev/null +++ b/packages/app/src/hooks/feature-preferences.ts @@ -0,0 +1,60 @@ +import type { AgentFeature } from "@server/server/agent/agent-sdk-types"; + +export function pruneFeatureValues( + featureValues: Record, + features: AgentFeature[], +): Record { + const allowedFeatureIds = new Set(features.map((feature) => feature.id)); + let changed = false; + const next: Record = {}; + + for (const [featureId, value] of Object.entries(featureValues)) { + if (!allowedFeatureIds.has(featureId)) { + changed = true; + continue; + } + next[featureId] = value; + } + + return changed ? next : featureValues; +} + +export function applyFeatureValues( + features: AgentFeature[], + featureValues: Record, +): AgentFeature[] { + if (Object.keys(featureValues).length === 0) { + return features; + } + + return features.map((feature) => { + if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) { + return feature; + } + + return { + ...feature, + value: featureValues[feature.id], + } as AgentFeature; + }); +} + +export function resolveFeatureValues(args: { + features: AgentFeature[]; + persistedFeatureValues: Record; + localFeatureValues: Record; +}): Record { + const next: Record = {}; + + for (const feature of args.features) { + if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) { + next[feature.id] = args.localFeatureValues[feature.id]; + continue; + } + if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) { + next[feature.id] = args.persistedFeatureValues[feature.id]; + } + } + + return next; +} diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 09e0ae2fd..fcf939101 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -651,30 +651,29 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); const modelId = resolvedModel?.id ?? formState.model; - const nextPreferences = mergeProviderPreferences({ - preferences: preferences ?? {}, - provider: formState.provider, - updates: { - model: modelId || undefined, - mode: formState.modeId || undefined, - ...(modelId && formState.thinkingOptionId - ? { - thinkingByModel: { - [modelId]: formState.thinkingOptionId, - }, - } - : {}), - } satisfies Partial, - }); - - await updatePreferences(nextPreferences); + await updatePreferences((current) => + mergeProviderPreferences({ + preferences: current, + provider: formState.provider, + updates: { + model: modelId || undefined, + mode: formState.modeId || undefined, + ...(modelId && formState.thinkingOptionId + ? { + thinkingByModel: { + [modelId]: formState.thinkingOptionId, + }, + } + : {}), + } satisfies Partial, + }), + ); }, [ availableModels, formState.model, formState.modeId, formState.provider, formState.thinkingOptionId, - preferences?.providerPreferences, updatePreferences, ]); diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts index eaef93d6f..3f9a4dce4 100644 --- a/packages/app/src/hooks/use-draft-agent-features.ts +++ b/packages/app/src/hooks/use-draft-agent-features.ts @@ -1,50 +1,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { - AgentFeature, AgentProvider, AgentSessionConfig, } from "@server/server/agent/agent-sdk-types"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; - -function pruneFeatureValues( - featureValues: Record, - features: AgentFeature[], -): Record { - const allowedFeatureIds = new Set(features.map((feature) => feature.id)); - let changed = false; - const next: Record = {}; - - for (const [featureId, value] of Object.entries(featureValues)) { - if (!allowedFeatureIds.has(featureId)) { - changed = true; - continue; - } - next[featureId] = value; - } - - return changed ? next : featureValues; -} - -function applyFeatureValues( - features: AgentFeature[], - featureValues: Record, -): AgentFeature[] { - if (Object.keys(featureValues).length === 0) { - return features; - } - - return features.map((feature) => { - if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) { - return feature; - } - - return { - ...feature, - value: featureValues[feature.id], - } as AgentFeature; - }); -} +import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences"; +import { + applyFeatureValues, + pruneFeatureValues, + resolveFeatureValues, +} from "./feature-preferences"; type DraftFeatureConfig = Pick< AgentSessionConfig, @@ -60,10 +26,15 @@ export function useDraftAgentFeatures(input: { thinkingOptionId: string | null | undefined; }) { const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input; - const [featureValues, setFeatureValues] = useState>({}); + const [localFeatureValues, setLocalFeatureValues] = useState>({}); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const { preferences, updatePreferences } = useFormPreferences(); const normalizedCwd = cwd?.trim() || ""; + const persistedFeatureValues = useMemo( + () => preferences.providerPreferences?.[provider]?.featureValues ?? {}, + [preferences.providerPreferences, provider], + ); const draftConfig = useMemo(() => { if (!normalizedCwd) { @@ -102,28 +73,56 @@ export function useDraftAgentFeatures(input: { return payload.features ?? []; }, }); + const availableFeatures = featuresQuery.data ?? []; + const featureValues = useMemo( + () => + resolveFeatureValues({ + features: availableFeatures, + persistedFeatureValues, + localFeatureValues, + }), + [availableFeatures, localFeatureValues, persistedFeatureValues], + ); const features = useMemo(() => { - return applyFeatureValues(featuresQuery.data ?? [], featureValues); - }, [featureValues, featuresQuery.data]); + return applyFeatureValues(availableFeatures, featureValues); + }, [availableFeatures, featureValues]); useEffect(() => { - const next = pruneFeatureValues(featureValues, features); - if (next !== featureValues) { - setFeatureValues(next); + setLocalFeatureValues({}); + }, [provider]); + + useEffect(() => { + const next = pruneFeatureValues(localFeatureValues, availableFeatures); + if (next !== localFeatureValues) { + setLocalFeatureValues(next); } - }, [featureValues, features]); + }, [availableFeatures, localFeatureValues]); const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined; const setFeatureValue = useCallback((featureId: string, value: unknown) => { - setFeatureValues((current) => { + setLocalFeatureValues((current) => { if (Object.is(current[featureId], value)) { return current; } return { ...current, [featureId]: value }; }); - }, []); + void updatePreferences( + (current) => + mergeProviderPreferences({ + preferences: current, + provider, + updates: { + featureValues: { + [featureId]: value, + }, + }, + }), + ).catch((error) => { + console.warn("[useDraftAgentFeatures] persist feature preference failed", error); + }); + }, [provider, updatePreferences]); return { features, diff --git a/packages/app/src/hooks/use-form-preferences.test.ts b/packages/app/src/hooks/use-form-preferences.test.ts index 55bb8fc56..4dd277420 100644 --- a/packages/app/src/hooks/use-form-preferences.test.ts +++ b/packages/app/src/hooks/use-form-preferences.test.ts @@ -59,6 +59,41 @@ describe("mergeProviderPreferences", () => { }, }); }); + + it("merges feature values without dropping existing entries", () => { + expect( + mergeProviderPreferences({ + preferences: { + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + featureValues: { + fast_mode: true, + }, + }, + }, + }, + provider: "codex", + updates: { + featureValues: { + plan_mode: true, + }, + }, + }), + ).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + featureValues: { + fast_mode: true, + plan_mode: true, + }, + }, + }, + }); + }); }); describe("favorite model preferences", () => { diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index ff04c8e84..cd14ebf66 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -25,6 +25,7 @@ const providerPreferencesSchema = z.object({ model: z.string().optional(), mode: z.string().optional(), thinkingByModel: z.record(z.string()).optional(), + featureValues: z.record(z.unknown()).optional(), }); const formPreferencesSchema = z.object({ @@ -53,7 +54,9 @@ async function loadFormPreferences(): Promise { export interface UseFormPreferencesReturn { preferences: FormPreferences; isLoading: boolean; - updatePreferences: (updates: Partial) => Promise; + updatePreferences: ( + updates: Partial | ((current: FormPreferences) => FormPreferences), + ) => Promise; } export function mergeProviderPreferences(args: { @@ -71,6 +74,13 @@ export function mergeProviderPreferences(args: { ...existing.thinkingByModel, ...updates.thinkingByModel, }; + const nextFeatureValues = + updates.featureValues === undefined + ? existing.featureValues + : { + ...existing.featureValues, + ...updates.featureValues, + }; return { ...preferences, @@ -81,6 +91,7 @@ export function mergeProviderPreferences(args: { ...existing, ...updates, ...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}), + ...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}), }, }, }; @@ -133,11 +144,12 @@ export function useFormPreferences(): UseFormPreferencesReturn { const preferences = data ?? DEFAULT_FORM_PREFERENCES; const updatePreferences = useCallback( - async (updates: Partial) => { + async (updates: Partial | ((current: FormPreferences) => FormPreferences)) => { const prev = queryClient.getQueryData(FORM_PREFERENCES_QUERY_KEY) ?? DEFAULT_FORM_PREFERENCES; - const next = { ...prev, ...updates }; + const next = + typeof updates === "function" ? updates(prev) : { ...prev, ...updates }; queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next); await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); },