Persist draft agent feature preferences

This commit is contained in:
Mohamed Boudra
2026-04-10 09:53:38 +07:00
parent 2683dae5f9
commit 17bd036359
7 changed files with 263 additions and 89 deletions
@@ -962,13 +962,14 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
return; return;
} }
void updatePreferences( void updatePreferences(
mergeProviderPreferences({ (current) =>
preferences, mergeProviderPreferences({
provider: agent.provider, preferences: current,
updates: { provider: agent.provider,
model: modelId, updates: {
}, model: modelId,
}), },
}),
).catch((error) => { ).catch((error) => {
console.warn("[AgentStatusBar] persist model preference failed", error); console.warn("[AgentStatusBar] persist model preference failed", error);
}); });
@@ -978,7 +979,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
}} }}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => { 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); console.warn("[AgentStatusBar] toggle favorite model failed", error);
}); });
}} }}
@@ -991,16 +992,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
const activeModelId = modelSelection.activeModelId; const activeModelId = modelSelection.activeModelId;
if (activeModelId) { if (activeModelId) {
void updatePreferences( void updatePreferences(
mergeProviderPreferences({ (current) =>
preferences, mergeProviderPreferences({
provider: agent.provider, preferences: current,
updates: { provider: agent.provider,
model: activeModelId, updates: {
thinkingByModel: { model: activeModelId,
[activeModelId]: thinkingOptionId, thinkingByModel: {
[activeModelId]: thinkingOptionId,
},
}, },
}, }),
}),
).catch((error) => { ).catch((error) => {
console.warn("[AgentStatusBar] persist thinking preference failed", error); console.warn("[AgentStatusBar] persist thinking preference failed", error);
}); });
@@ -1014,6 +1016,20 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
if (!client) { if (!client) {
return; 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) => { void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentStatusBar] setAgentFeature failed", error); console.warn("[AgentStatusBar] setAgentFeature failed", error);
}); });
@@ -1083,7 +1099,7 @@ export function DraftAgentStatusBar({
onSelect={onSelectProviderAndModel} onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
onToggleFavorite={(provider, modelId) => { 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); console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
}); });
}} }}
@@ -1129,7 +1145,7 @@ export function DraftAgentStatusBar({
isModelLoading={isAllModelsLoading} isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => { 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); console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
}); });
}} }}
@@ -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,
});
});
});
@@ -0,0 +1,60 @@
import type { AgentFeature } from "@server/server/agent/agent-sdk-types";
export function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
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<string, unknown>,
): 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<string, unknown>;
localFeatureValues: Record<string, unknown>;
}): Record<string, unknown> {
const next: Record<string, unknown> = {};
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;
}
+17 -18
View File
@@ -651,30 +651,29 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const persistFormPreferences = useCallback(async () => { const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model; const modelId = resolvedModel?.id ?? formState.model;
const nextPreferences = mergeProviderPreferences({ await updatePreferences((current) =>
preferences: preferences ?? {}, mergeProviderPreferences({
provider: formState.provider, preferences: current,
updates: { provider: formState.provider,
model: modelId || undefined, updates: {
mode: formState.modeId || undefined, model: modelId || undefined,
...(modelId && formState.thinkingOptionId mode: formState.modeId || undefined,
? { ...(modelId && formState.thinkingOptionId
thinkingByModel: { ? {
[modelId]: formState.thinkingOptionId, thinkingByModel: {
}, [modelId]: formState.thinkingOptionId,
} },
: {}), }
} satisfies Partial<ProviderPreferences>, : {}),
}); } satisfies Partial<ProviderPreferences>,
}),
await updatePreferences(nextPreferences); );
}, [ }, [
availableModels, availableModels,
formState.model, formState.model,
formState.modeId, formState.modeId,
formState.provider, formState.provider,
formState.thinkingOptionId, formState.thinkingOptionId,
preferences?.providerPreferences,
updatePreferences, updatePreferences,
]); ]);
@@ -1,50 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { import type {
AgentFeature,
AgentProvider, AgentProvider,
AgentSessionConfig, AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
function pruneFeatureValues( import {
featureValues: Record<string, unknown>, applyFeatureValues,
features: AgentFeature[], pruneFeatureValues,
): Record<string, unknown> { resolveFeatureValues,
const allowedFeatureIds = new Set(features.map((feature) => feature.id)); } from "./feature-preferences";
let changed = false;
const next: Record<string, unknown> = {};
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<string, unknown>,
): 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;
});
}
type DraftFeatureConfig = Pick< type DraftFeatureConfig = Pick<
AgentSessionConfig, AgentSessionConfig,
@@ -60,10 +26,15 @@ export function useDraftAgentFeatures(input: {
thinkingOptionId: string | null | undefined; thinkingOptionId: string | null | undefined;
}) { }) {
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input; const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({}); const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>({});
const client = useHostRuntimeClient(serverId ?? ""); const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || ""; const normalizedCwd = cwd?.trim() || "";
const persistedFeatureValues = useMemo(
() => preferences.providerPreferences?.[provider]?.featureValues ?? {},
[preferences.providerPreferences, provider],
);
const draftConfig = useMemo<DraftFeatureConfig | null>(() => { const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedCwd) { if (!normalizedCwd) {
@@ -102,28 +73,56 @@ export function useDraftAgentFeatures(input: {
return payload.features ?? []; return payload.features ?? [];
}, },
}); });
const availableFeatures = featuresQuery.data ?? [];
const featureValues = useMemo(
() =>
resolveFeatureValues({
features: availableFeatures,
persistedFeatureValues,
localFeatureValues,
}),
[availableFeatures, localFeatureValues, persistedFeatureValues],
);
const features = useMemo(() => { const features = useMemo(() => {
return applyFeatureValues(featuresQuery.data ?? [], featureValues); return applyFeatureValues(availableFeatures, featureValues);
}, [featureValues, featuresQuery.data]); }, [availableFeatures, featureValues]);
useEffect(() => { useEffect(() => {
const next = pruneFeatureValues(featureValues, features); setLocalFeatureValues({});
if (next !== featureValues) { }, [provider]);
setFeatureValues(next);
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 effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback((featureId: string, value: unknown) => { const setFeatureValue = useCallback((featureId: string, value: unknown) => {
setFeatureValues((current) => { setLocalFeatureValues((current) => {
if (Object.is(current[featureId], value)) { if (Object.is(current[featureId], value)) {
return current; return current;
} }
return { ...current, [featureId]: value }; 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 { return {
features, features,
@@ -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", () => { describe("favorite model preferences", () => {
+15 -3
View File
@@ -25,6 +25,7 @@ const providerPreferencesSchema = z.object({
model: z.string().optional(), model: z.string().optional(),
mode: z.string().optional(), mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(), thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
}); });
const formPreferencesSchema = z.object({ const formPreferencesSchema = z.object({
@@ -53,7 +54,9 @@ async function loadFormPreferences(): Promise<FormPreferences> {
export interface UseFormPreferencesReturn { export interface UseFormPreferencesReturn {
preferences: FormPreferences; preferences: FormPreferences;
isLoading: boolean; isLoading: boolean;
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>; updatePreferences: (
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
) => Promise<void>;
} }
export function mergeProviderPreferences(args: { export function mergeProviderPreferences(args: {
@@ -71,6 +74,13 @@ export function mergeProviderPreferences(args: {
...existing.thinkingByModel, ...existing.thinkingByModel,
...updates.thinkingByModel, ...updates.thinkingByModel,
}; };
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return { return {
...preferences, ...preferences,
@@ -81,6 +91,7 @@ export function mergeProviderPreferences(args: {
...existing, ...existing,
...updates, ...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}), ...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
}, },
}, },
}; };
@@ -133,11 +144,12 @@ export function useFormPreferences(): UseFormPreferencesReturn {
const preferences = data ?? DEFAULT_FORM_PREFERENCES; const preferences = data ?? DEFAULT_FORM_PREFERENCES;
const updatePreferences = useCallback( const updatePreferences = useCallback(
async (updates: Partial<FormPreferences>) => { async (updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences)) => {
const prev = const prev =
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ?? queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
DEFAULT_FORM_PREFERENCES; DEFAULT_FORM_PREFERENCES;
const next = { ...prev, ...updates }; const next =
typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next); queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
}, },