diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index f86002b49..9a9648189 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -84,7 +84,7 @@ interface UseDraftAgentCreateFlowOptions { initialAttempt?: CreateAttempt | null; allowEmptyText?: boolean; validateBeforeSubmit?: (ctx: SubmitContext) => string | null; - onBeforeSubmit?: (ctx: CreateRequestContext) => void; + onBeforeSubmit?: (ctx: CreateRequestContext) => Promise | void; onCreateStart?: () => void; createRequest: (ctx: CreateRequestContext) => Promise>; buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent; @@ -168,7 +168,7 @@ export function useDraftAgentCreateFlow({ throw error; } - onBeforeSubmit?.({ + await onBeforeSubmit?.({ attempt, text: attempt.text, images: attempt.images, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 8c09efab0..c4bf82cac 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -441,8 +441,8 @@ export function WorkspaceDraftAgentTab({ workspaceDirectory: draftWorkingDirectory, hasClient: Boolean(client), }), - onBeforeSubmit: () => { - void composerState.persistFormPreferences(); + onBeforeSubmit: async () => { + await composerState.persistFormPreferences(); if (isWeb) { (document.activeElement as HTMLElement | null)?.blur?.(); } diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts new file mode 100644 index 000000000..70368f718 --- /dev/null +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { CreateAgentPreferencesService } from "./service"; +import { + mergeCreateAgentSelectionPreferences, + mergeProviderPreferences, + parseFormPreferences, +} from "./preferences"; +import { FakeCreateAgentPreferenceStorage } from "./test-utils/fake-preference-storage"; + +describe("create agent preferences", () => { + it("keeps the selected mode after saving model and thinking", async () => { + const storage = new FakeCreateAgentPreferenceStorage(); + const preferences = new CreateAgentPreferencesService(storage); + + const modelWrite = preferences.update((current) => + mergeProviderPreferences({ + preferences: current, + provider: "codex", + updates: { model: "gpt-5.5", thinkingByModel: { "gpt-5.5": "high" } }, + }), + ); + await storage.nextWrite(); + + const modeWrite = preferences.update((current) => + mergeProviderPreferences({ + preferences: current, + provider: "codex", + updates: { mode: "full-access" }, + }), + ); + + expect(storage.pendingWriteCount()).toBe(1); + storage.finishOldestWrite(); + await modelWrite; + + await storage.nextWrite(); + storage.finishOldestWrite(); + await modeWrite; + + expect(storage.savedPreferences()).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.5", + thinkingByModel: { "gpt-5.5": "high" }, + mode: "full-access", + }, + }, + }); + }); + + it("flushes the full create-agent selection into provider preferences", async () => { + const storage = new FakeCreateAgentPreferenceStorage(); + const preferences = new CreateAgentPreferencesService(storage); + + const saveSelection = preferences.update((current) => + mergeCreateAgentSelectionPreferences({ + preferences: current, + provider: "codex", + modelId: "gpt-5.5", + modeId: "full-access", + thinkingOptionId: "high", + featureValues: { fast_mode: true }, + }), + ); + + await storage.nextWrite(); + storage.finishOldestWrite(); + await saveSelection; + + expect(storage.savedPreferences()).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.5", + mode: "full-access", + thinkingByModel: { "gpt-5.5": "high" }, + featureValues: { fast_mode: true }, + }, + }, + }); + }); + + it("loads invalid stored preferences as empty preferences", () => { + expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({}); + }); +}); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts new file mode 100644 index 000000000..24405acb6 --- /dev/null +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -0,0 +1,147 @@ +import { z } from "zod"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; + +export interface FavoriteModelPreference { + provider: string; + modelId: string; +} + +export interface FavoriteModelRow { + favoriteKey: string; + provider: string; + providerLabel: string; + modelId: string; + modelLabel: string; + description?: string; +} + +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({ + provider: z.string().optional(), + providerPreferences: z.record(providerPreferencesSchema).optional(), + favoriteModels: z + .array( + z.object({ + provider: z.string(), + modelId: z.string(), + }), + ) + .optional(), +}); + +export type ProviderPreferences = z.infer; +export type FormPreferences = z.infer; + +export const DEFAULT_FORM_PREFERENCES: FormPreferences = {}; + +export function parseFormPreferences(value: unknown): FormPreferences { + const result = formPreferencesSchema.safeParse(value); + return result.success ? result.data : DEFAULT_FORM_PREFERENCES; +} + +export function mergeProviderPreferences(args: { + preferences: FormPreferences; + provider: AgentProvider; + updates: Partial; +}): FormPreferences { + const { preferences, provider, updates } = args; + const existingProviderPreferences = preferences.providerPreferences ?? {}; + const existing = existingProviderPreferences[provider] ?? {}; + const nextThinkingByModel = + updates.thinkingByModel === undefined + ? existing.thinkingByModel + : { + ...existing.thinkingByModel, + ...updates.thinkingByModel, + }; + const nextFeatureValues = + updates.featureValues === undefined + ? existing.featureValues + : { + ...existing.featureValues, + ...updates.featureValues, + }; + + return { + ...preferences, + provider, + providerPreferences: { + ...existingProviderPreferences, + [provider]: { + ...existing, + ...updates, + ...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}), + ...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}), + }, + }, + }; +} + +export function mergeCreateAgentSelectionPreferences(args: { + preferences: FormPreferences; + provider: AgentProvider | null; + modelId?: string | null; + modeId?: string | null; + thinkingOptionId?: string | null; + featureValues?: Record; +}): FormPreferences { + if (!args.provider) { + return args.preferences; + } + + const modelId = args.modelId?.trim() ?? ""; + const modeId = args.modeId?.trim() ?? ""; + const thinkingOptionId = args.thinkingOptionId?.trim() ?? ""; + + return mergeProviderPreferences({ + preferences: args.preferences, + provider: args.provider, + updates: { + model: modelId || undefined, + mode: modeId || undefined, + ...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), + ...(args.featureValues ? { featureValues: args.featureValues } : {}), + }, + }); +} + +export function buildFavoriteModelKey(input: FavoriteModelPreference): string { + return `${input.provider}:${input.modelId}`; +} + +export function isFavoriteModel(args: { + preferences: FormPreferences; + provider: string; + modelId: string; +}): boolean { + const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId }); + return (args.preferences.favoriteModels ?? []).some( + (favorite) => buildFavoriteModelKey(favorite) === favoriteKey, + ); +} + +export function toggleFavoriteModel(args: { + preferences: FormPreferences; + provider: string; + modelId: string; +}): FormPreferences { + const favorite = { provider: args.provider, modelId: args.modelId }; + const favoriteKey = buildFavoriteModelKey(favorite); + const existingFavorites = args.preferences.favoriteModels ?? []; + const hasFavorite = existingFavorites.some( + (entry) => buildFavoriteModelKey(entry) === favoriteKey, + ); + + return { + ...args.preferences, + favoriteModels: hasFavorite + ? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey) + : [...existingFavorites, favorite], + }; +} diff --git a/packages/app/src/create-agent-preferences/service.ts b/packages/app/src/create-agent-preferences/service.ts new file mode 100644 index 000000000..b3988623f --- /dev/null +++ b/packages/app/src/create-agent-preferences/service.ts @@ -0,0 +1,62 @@ +import { AsyncStorageCreateAgentPreferenceStorage } from "./storage"; +import { + DEFAULT_FORM_PREFERENCES, + parseFormPreferences, + type FormPreferences, +} from "./preferences"; +import type { CreateAgentPreferenceStorage } from "./storage"; + +export type FormPreferenceUpdate = + | Partial + | ((current: FormPreferences) => FormPreferences); + +export class CreateAgentPreferencesService { + private preferences: FormPreferences = DEFAULT_FORM_PREFERENCES; + private isLoaded = false; + private loadPromise: Promise | null = null; + private writeQueue: Promise = Promise.resolve(); + + constructor(private readonly storage: CreateAgentPreferenceStorage) {} + + async load(): Promise { + if (this.isLoaded) { + return this.preferences; + } + if (!this.loadPromise) { + this.loadPromise = this.storage.read().then((stored) => { + this.preferences = parseFormPreferences(stored); + this.isLoaded = true; + return this.preferences; + }); + } + return this.loadPromise; + } + + async update(update: FormPreferenceUpdate): Promise { + const previousWrite = this.writeQueue; + const operation = this.applyQueuedUpdate(previousWrite, update); + + this.writeQueue = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + private async applyQueuedUpdate( + previousWrite: Promise, + update: FormPreferenceUpdate, + ): Promise { + await previousWrite; + const current = await this.load(); + const next = typeof update === "function" ? update(current) : { ...current, ...update }; + this.preferences = parseFormPreferences(next); + this.isLoaded = true; + await this.storage.write(this.preferences); + return this.preferences; + } +} + +export const createAgentPreferencesService = new CreateAgentPreferencesService( + new AsyncStorageCreateAgentPreferenceStorage(), +); diff --git a/packages/app/src/create-agent-preferences/storage.ts b/packages/app/src/create-agent-preferences/storage.ts new file mode 100644 index 000000000..2dd231142 --- /dev/null +++ b/packages/app/src/create-agent-preferences/storage.ts @@ -0,0 +1,28 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { FormPreferences } from "./preferences"; + +export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences"; + +export interface CreateAgentPreferenceStorage { + read(): Promise; + write(preferences: FormPreferences): Promise; +} + +export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage { + async read(): Promise { + const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY); + if (!stored) { + return null; + } + + try { + return JSON.parse(stored); + } catch { + return null; + } + } + + async write(preferences: FormPreferences): Promise { + await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences)); + } +} diff --git a/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts b/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts new file mode 100644 index 000000000..884819c6b --- /dev/null +++ b/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts @@ -0,0 +1,65 @@ +import type { FormPreferences } from "../preferences"; +import type { CreateAgentPreferenceStorage } from "../storage"; + +interface PendingWrite { + preferences: FormPreferences; + finish: () => void; +} + +export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage { + private stored: unknown; + private readonly pendingWrites: PendingWrite[] = []; + private readonly pendingWriteWaiters: Array<(write: PendingWrite) => void> = []; + + constructor(input: { stored?: unknown } = {}) { + this.stored = input.stored ?? null; + } + + async read(): Promise { + return this.stored; + } + + write(preferences: FormPreferences): Promise { + return new Promise((resolve) => { + const write = { + preferences, + finish: () => { + this.stored = clone(preferences); + resolve(); + }, + }; + this.pendingWrites.push(write); + this.pendingWriteWaiters.shift()?.(write); + }); + } + + nextWrite(): Promise { + const next = this.pendingWrites[0]; + if (next) { + return Promise.resolve(next); + } + return new Promise((resolve) => { + this.pendingWriteWaiters.push(resolve); + }); + } + + pendingWriteCount(): number { + return this.pendingWrites.length; + } + + finishOldestWrite(): void { + const write = this.pendingWrites.shift(); + if (!write) { + throw new Error("No pending create-agent preference write"); + } + write.finish(); + } + + savedPreferences(): unknown { + return this.stored; + } +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index 55875fa0a..aeb161bc5 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -1,137 +1,35 @@ import { useCallback } from "react"; -import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { z } from "zod"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { + buildFavoriteModelKey, + DEFAULT_FORM_PREFERENCES, + isFavoriteModel, + mergeProviderPreferences, + toggleFavoriteModel, + type FavoriteModelPreference, + type FavoriteModelRow, + type FormPreferences, + type ProviderPreferences, +} from "@/create-agent-preferences/preferences"; +import { + createAgentPreferencesService, + type FormPreferenceUpdate, +} from "@/create-agent-preferences/service"; -const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences"; const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"]; -export interface FavoriteModelPreference { - provider: string; - modelId: string; -} +export type { FavoriteModelPreference, FavoriteModelRow, FormPreferences, ProviderPreferences }; -export interface FavoriteModelRow { - favoriteKey: string; - provider: string; - providerLabel: string; - modelId: string; - modelLabel: string; - description?: string; -} - -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({ - provider: z.string().optional(), - providerPreferences: z.record(providerPreferencesSchema).optional(), - favoriteModels: z - .array( - z.object({ - provider: z.string(), - modelId: z.string(), - }), - ) - .optional(), -}); - -export type ProviderPreferences = z.infer; -export type FormPreferences = z.infer; - -const DEFAULT_FORM_PREFERENCES: FormPreferences = {}; +export { buildFavoriteModelKey, isFavoriteModel, mergeProviderPreferences, toggleFavoriteModel }; async function loadFormPreferences(): Promise { - const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY); - if (!stored) return DEFAULT_FORM_PREFERENCES; - const result = formPreferencesSchema.safeParse(JSON.parse(stored)); - return result.success ? result.data : DEFAULT_FORM_PREFERENCES; + return createAgentPreferencesService.load(); } export interface UseFormPreferencesReturn { preferences: FormPreferences; isLoading: boolean; - updatePreferences: ( - updates: Partial | ((current: FormPreferences) => FormPreferences), - ) => Promise; -} - -export function mergeProviderPreferences(args: { - preferences: FormPreferences; - provider: AgentProvider; - updates: Partial; -}): FormPreferences { - const { preferences, provider, updates } = args; - const existingProviderPreferences = preferences.providerPreferences ?? {}; - const existing = existingProviderPreferences[provider] ?? {}; - const nextThinkingByModel = - updates.thinkingByModel === undefined - ? existing.thinkingByModel - : { - ...existing.thinkingByModel, - ...updates.thinkingByModel, - }; - const nextFeatureValues = - updates.featureValues === undefined - ? existing.featureValues - : { - ...existing.featureValues, - ...updates.featureValues, - }; - - return { - ...preferences, - provider, - providerPreferences: { - ...existingProviderPreferences, - [provider]: { - ...existing, - ...updates, - ...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}), - ...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}), - }, - }, - }; -} - -export function buildFavoriteModelKey(input: FavoriteModelPreference): string { - return `${input.provider}:${input.modelId}`; -} - -export function isFavoriteModel(args: { - preferences: FormPreferences; - provider: string; - modelId: string; -}): boolean { - const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId }); - return (args.preferences.favoriteModels ?? []).some( - (favorite) => buildFavoriteModelKey(favorite) === favoriteKey, - ); -} - -export function toggleFavoriteModel(args: { - preferences: FormPreferences; - provider: string; - modelId: string; -}): FormPreferences { - const favorite = { provider: args.provider, modelId: args.modelId }; - const favoriteKey = buildFavoriteModelKey(favorite); - const existingFavorites = args.preferences.favoriteModels ?? []; - const hasFavorite = existingFavorites.some( - (entry) => buildFavoriteModelKey(entry) === favoriteKey, - ); - - return { - ...args.preferences, - favoriteModels: hasFavorite - ? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey) - : [...existingFavorites, favorite], - }; + updatePreferences: (updates: FormPreferenceUpdate) => Promise; } export function useFormPreferences(): UseFormPreferencesReturn { @@ -146,13 +44,9 @@ export function useFormPreferences(): UseFormPreferencesReturn { const preferences = data ?? DEFAULT_FORM_PREFERENCES; const updatePreferences = useCallback( - async (updates: Partial | ((current: FormPreferences) => FormPreferences)) => { - const prev = - queryClient.getQueryData(FORM_PREFERENCES_QUERY_KEY) ?? - DEFAULT_FORM_PREFERENCES; - const next = typeof updates === "function" ? updates(prev) : { ...prev, ...updates }; + async (updates: FormPreferenceUpdate) => { + const next = await createAgentPreferencesService.update(updates); queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next); - await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); }, [queryClient], ); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 0c99b729a..b137188c9 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -747,9 +747,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { provider, clientMessageId, timestamp, - ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" - ? { modeId: composerState.selectedMode } - : {}), + ...(composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}), ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), ...(composerState.effectiveThinkingOptionId ? { thinkingOptionId: composerState.effectiveThinkingOptionId } @@ -1063,6 +1061,7 @@ export function NewWorkspaceScreen({ async (payload: MessagePayload) => { try { setErrorMessage(null); + await composerState?.persistFormPreferences(); if (isEmptyWorkspaceSubmission(payload)) { setPendingAction("empty"); await runCreateEmptyWorkspace({