diff --git a/packages/cli/src/api-types.ts b/packages/cli/src/api-types.ts index f100bca1..13b48377 100644 --- a/packages/cli/src/api-types.ts +++ b/packages/cli/src/api-types.ts @@ -1,4 +1,5 @@ import type { + AgentModelSelection, AgentModelSelections, ConfigFile, ModelPreference, @@ -107,6 +108,7 @@ export type WorkspaceFileSearchResponse = FileSystemEntry[] export interface InstanceData { messageHistory: string[] + agentModelSelections: AgentModelSelection } export interface BinaryRecord { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 2ebda652..f9b641d6 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -13,7 +13,6 @@ const PreferencesSchema = z.object({ lastUsedBinary: z.string().optional(), environmentVariables: z.record(z.string()).default({}), modelRecents: z.array(ModelPreferenceSchema).default([]), - agentModelSelections: AgentModelSelectionsSchema.default({}), diffViewMode: z.enum(["split", "unified"]).default("split"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), diff --git a/packages/cli/src/server/http-server.ts b/packages/cli/src/server/http-server.ts index f4c04530..81e1b487 100644 --- a/packages/cli/src/server/http-server.ts +++ b/packages/cli/src/server/http-server.ts @@ -67,7 +67,11 @@ export function createHttpServer(deps: HttpServerDeps) { registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient }) - registerStorageRoutes(app, { instanceStore: deps.instanceStore, eventBus: deps.eventBus }) + registerStorageRoutes(app, { + instanceStore: deps.instanceStore, + eventBus: deps.eventBus, + workspaceManager: deps.workspaceManager, + }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) diff --git a/packages/cli/src/server/routes/storage.ts b/packages/cli/src/server/routes/storage.ts index e4211a34..a2a874ee 100644 --- a/packages/cli/src/server/routes/storage.ts +++ b/packages/cli/src/server/routes/storage.ts @@ -2,25 +2,36 @@ import { FastifyInstance } from "fastify" import { z } from "zod" import { InstanceStore } from "../../storage/instance-store" import { EventBus } from "../../events/bus" +import { ModelPreferenceSchema } from "../../config/schema" import type { InstanceData } from "../../api-types" +import { WorkspaceManager } from "../../workspaces/manager" interface RouteDeps { instanceStore: InstanceStore eventBus: EventBus + workspaceManager: WorkspaceManager } const InstanceDataSchema = z.object({ messageHistory: z.array(z.string()).default([]), + agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}), }) const EMPTY_INSTANCE_DATA: InstanceData = { messageHistory: [], + agentModelSelections: {}, } export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) { + const resolveStorageKey = (instanceId: string): string => { + const workspace = deps.workspaceManager.get(instanceId) + return workspace?.path ?? instanceId + } + app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => { try { - const data = await deps.instanceStore.read(request.params.id) + const storageId = resolveStorageKey(request.params.id) + const data = await deps.instanceStore.read(storageId) return data } catch (error) { reply.code(500) @@ -31,7 +42,8 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) { app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => { try { const body = InstanceDataSchema.parse(request.body ?? {}) - await deps.instanceStore.write(request.params.id, body) + const storageId = resolveStorageKey(request.params.id) + await deps.instanceStore.write(storageId, body) deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: body }) reply.code(204) } catch (error) { @@ -42,7 +54,8 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) { app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => { try { - await deps.instanceStore.delete(request.params.id) + const storageId = resolveStorageKey(request.params.id) + await deps.instanceStore.delete(storageId) deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: EMPTY_INSTANCE_DATA }) reply.code(204) } catch (error) { diff --git a/packages/cli/src/storage/instance-store.ts b/packages/cli/src/storage/instance-store.ts index a63973d3..48550840 100644 --- a/packages/cli/src/storage/instance-store.ts +++ b/packages/cli/src/storage/instance-store.ts @@ -6,6 +6,7 @@ import type { InstanceData } from "../api-types" const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], + agentModelSelections: {}, } export class InstanceStore { diff --git a/packages/ui/src/lib/storage.ts b/packages/ui/src/lib/storage.ts index 434c7a6c..b55c1ca3 100644 --- a/packages/ui/src/lib/storage.ts +++ b/packages/ui/src/lib/storage.ts @@ -6,6 +6,7 @@ export type ConfigData = AppConfig const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], + agentModelSelections: {}, } function isDeepEqual(a: unknown, b: unknown): boolean { @@ -150,9 +151,11 @@ export class ServerStorage { private normalizeInstanceData(data?: InstanceData | null): InstanceData { const source = data ?? DEFAULT_INSTANCE_DATA const messageHistory = Array.isArray(source.messageHistory) ? [...source.messageHistory] : [] + const agentModelSelections = { ...(source.agentModelSelections ?? {}) } return { ...source, messageHistory, + agentModelSelections, } } diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 080336f0..cbcf57d6 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -2,6 +2,7 @@ import { render } from "solid-js/web" import App from "./App" import { ThemeProvider } from "./lib/theme" import { ConfigProvider } from "./stores/preferences" +import { InstanceConfigProvider } from "./stores/instance-config" import "./index.css" import "@git-diff-view/solid/styles/diff-view-pure.css" @@ -14,9 +15,11 @@ if (!root) { render( () => ( - - - + + + + + ), root, diff --git a/packages/ui/src/stores/instance-config.tsx b/packages/ui/src/stores/instance-config.tsx new file mode 100644 index 00000000..598c1e00 --- /dev/null +++ b/packages/ui/src/stores/instance-config.tsx @@ -0,0 +1,138 @@ +import { createContext, createMemo, createSignal, onCleanup, type Accessor, type ParentComponent, useContext } from "solid-js" +import type { InstanceData } from "../../../cli/src/api-types" +import { storage } from "../lib/storage" + +const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {} } + +const [instanceDataMap, setInstanceDataMap] = createSignal>(new Map()) +const loadPromises = new Map>() +const instanceSubscriptions = new Map void>() + +function cloneInstanceData(data?: InstanceData | null): InstanceData { + const source = data ?? DEFAULT_INSTANCE_DATA + return { + ...source, + messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [], + agentModelSelections: { ...(source.agentModelSelections ?? {}) }, + } +} + +function attachSubscription(instanceId: string) { + if (instanceSubscriptions.has(instanceId)) return + const unsubscribe = storage.onInstanceDataChanged(instanceId, (data) => { + setInstanceData(instanceId, data) + }) + instanceSubscriptions.set(instanceId, unsubscribe) +} + +function detachSubscription(instanceId: string) { + const unsubscribe = instanceSubscriptions.get(instanceId) + if (!unsubscribe) return + unsubscribe() + instanceSubscriptions.delete(instanceId) +} + +function setInstanceData(instanceId: string, data: InstanceData) { + setInstanceDataMap((prev) => { + const next = new Map(prev) + next.set(instanceId, cloneInstanceData(data)) + return next + }) +} + +async function ensureInstanceConfig(instanceId: string): Promise { + if (!instanceId) return + if (instanceDataMap().has(instanceId)) return + if (loadPromises.has(instanceId)) { + await loadPromises.get(instanceId) + return + } + const promise = storage + .loadInstanceData(instanceId) + .then((data) => { + setInstanceData(instanceId, data) + attachSubscription(instanceId) + }) + .catch((error) => { + console.warn("Failed to load instance data:", error) + setInstanceData(instanceId, DEFAULT_INSTANCE_DATA) + attachSubscription(instanceId) + }) + .finally(() => { + loadPromises.delete(instanceId) + }) + loadPromises.set(instanceId, promise) + await promise +} + +async function updateInstanceConfig(instanceId: string, mutator: (draft: InstanceData) => void): Promise { + if (!instanceId) return + await ensureInstanceConfig(instanceId) + const current = instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA + const draft = cloneInstanceData(current) + mutator(draft) + try { + await storage.saveInstanceData(instanceId, draft) + } catch (error) { + console.warn("Failed to persist instance data:", error) + } + setInstanceData(instanceId, draft) +} + +function getInstanceConfig(instanceId: string): InstanceData { + return instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA +} + +function useInstanceConfig(instanceId: string): Accessor { + const context = useContext(InstanceConfigContext) + if (!context) { + throw new Error("useInstanceConfig must be used within InstanceConfigProvider") + } + return createMemo(() => instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA) +} + +function clearInstanceConfig(instanceId: string): void { + setInstanceDataMap((prev) => { + if (!prev.has(instanceId)) return prev + const next = new Map(prev) + next.delete(instanceId) + return next + }) + detachSubscription(instanceId) +} + +interface InstanceConfigContextValue { + getInstanceConfig: typeof getInstanceConfig + ensureInstanceConfig: typeof ensureInstanceConfig + updateInstanceConfig: typeof updateInstanceConfig + clearInstanceConfig: typeof clearInstanceConfig +} + +const InstanceConfigContext = createContext() + +const contextValue: InstanceConfigContextValue = { + getInstanceConfig, + ensureInstanceConfig, + updateInstanceConfig, + clearInstanceConfig, +} + +const InstanceConfigProvider: ParentComponent = (props) => { + onCleanup(() => { + for (const unsubscribe of instanceSubscriptions.values()) { + unsubscribe() + } + instanceSubscriptions.clear() + }) + + return {props.children} +} + +export { + InstanceConfigProvider, + useInstanceConfig, + ensureInstanceConfig as ensureInstanceConfigLoaded, + getInstanceConfig, + updateInstanceConfig, + clearInstanceConfig, +} diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index ed0c0bfa..26261f06 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -7,6 +7,7 @@ import { sseManager } from "../lib/sse-manager" import { cliApi } from "../lib/api-client" import { cliEvents } from "../lib/cli-events" import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } from "../../../cli/src/api-types" +import { ensureInstanceConfigLoaded } from "./instance-config" import { fetchSessions, fetchAgents, @@ -20,6 +21,7 @@ import { computeDisplayParts } from "./session-messages" import { withSession, setSessionPendingPermission } from "./session-state" import { setHasInstances } from "./ui" + const [instances, setInstances] = createSignal>(new Map()) const [activeInstanceId, setActiveInstanceId] = createSignal(null) const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) @@ -116,6 +118,7 @@ async function hydrateInstanceData(instanceId: string) { await fetchSessions(instanceId) await fetchAgents(instanceId) await fetchProviders(instanceId) + await ensureInstanceConfigLoaded(instanceId) const instance = instances().get(instanceId) if (!instance?.client) return await fetchCommands(instanceId, instance.client) diff --git a/packages/ui/src/stores/message-history.ts b/packages/ui/src/stores/message-history.ts index 4c15357c..7008dc6f 100644 --- a/packages/ui/src/stores/message-history.ts +++ b/packages/ui/src/stores/message-history.ts @@ -1,88 +1,35 @@ import type { InstanceData } from "../../../cli/src/api-types" -import { storage } from "../lib/storage" +import { + ensureInstanceConfigLoaded, + getInstanceConfig, + updateInstanceConfig, +} from "./instance-config" const MAX_HISTORY = 100 -const instanceDataCache = new Map() -const instanceSubscriptions = new Map void>() - export async function addToHistory(instanceId: string, text: string): Promise { - const data = await ensureInstanceData(instanceId) - const nextHistory = [text, ...data.messageHistory] - if (nextHistory.length > MAX_HISTORY) { - nextHistory.length = MAX_HISTORY - } - - const nextData: InstanceData = { - ...data, - messageHistory: nextHistory, - } - - instanceDataCache.set(instanceId, cloneInstanceData(nextData)) - - try { - await storage.saveInstanceData(instanceId, nextData) - } catch (err) { - console.warn("Failed to persist message history:", err) - } + if (!instanceId || !text) return + await ensureInstanceConfigLoaded(instanceId) + await updateInstanceConfig(instanceId, (draft) => { + const nextHistory = [text, ...(draft.messageHistory ?? [])] + if (nextHistory.length > MAX_HISTORY) { + nextHistory.length = MAX_HISTORY + } + draft.messageHistory = nextHistory + }) } export async function getHistory(instanceId: string): Promise { - const data = await ensureInstanceData(instanceId) - return [...data.messageHistory] + if (!instanceId) return [] + await ensureInstanceConfigLoaded(instanceId) + const data = getInstanceConfig(instanceId) + return [...(data.messageHistory ?? [])] } export async function clearHistory(instanceId: string): Promise { - const data = await ensureInstanceData(instanceId) - const nextData: InstanceData = { - ...data, - messageHistory: [], - } - - instanceDataCache.set(instanceId, cloneInstanceData(nextData)) - - try { - await storage.saveInstanceData(instanceId, nextData) - } catch (error) { - console.warn("Failed to clear history:", error) - } -} - -async function ensureInstanceData(instanceId: string): Promise { - const cached = instanceDataCache.get(instanceId) - if (cached) { - return cached - } - - try { - const data = await storage.loadInstanceData(instanceId) - const normalized = cloneInstanceData(data) - instanceDataCache.set(instanceId, normalized) - attachInstanceSubscription(instanceId) - return normalized - } catch (error) { - console.warn("Failed to load history:", error) - const fallback = cloneInstanceData({ messageHistory: [] }) - instanceDataCache.set(instanceId, fallback) - attachInstanceSubscription(instanceId) - return fallback - } -} - -function attachInstanceSubscription(instanceId: string) { - if (instanceSubscriptions.has(instanceId)) { - return - } - const unsubscribe = storage.onInstanceDataChanged(instanceId, (data) => { - instanceDataCache.set(instanceId, cloneInstanceData(data)) + if (!instanceId) return + await ensureInstanceConfigLoaded(instanceId) + await updateInstanceConfig(instanceId, (draft) => { + draft.messageHistory = [] }) - instanceSubscriptions.set(instanceId, unsubscribe) -} - -function cloneInstanceData(data?: InstanceData | null): InstanceData { - const source: InstanceData = data ?? { messageHistory: [] } - return { - ...source, - messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [], - } } diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index b54dbe85..0c1e7aed 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,6 +1,11 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" import { storage, type ConfigData } from "../lib/storage" +import { + ensureInstanceConfigLoaded, + getInstanceConfig, + updateInstanceConfig as updateInstanceData, +} from "./instance-config" type DeepReadonly = T extends (...args: any[]) => unknown ? T @@ -27,7 +32,6 @@ export interface Preferences { lastUsedBinary?: string environmentVariables: Record modelRecents: ModelPreference[] - agentModelSelections: AgentModelSelections diffViewMode: DiffViewMode toolOutputExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference @@ -53,7 +57,6 @@ const defaultPreferences: Preferences = { showThinkingBlocks: false, environmentVariables: {}, modelRecents: [], - agentModelSelections: {}, diffViewMode: "split", toolOutputExpansion: "expanded", diagnosticsExpansion: "expanded", @@ -71,32 +74,24 @@ function deepEqual(a: unknown, b: unknown): boolean { return false } -function normalizePreferences(pref?: Partial): Preferences { +function normalizePreferences(pref?: Partial & { agentModelSelections?: unknown }): Preferences { + const sanitized = pref ?? {} const environmentVariables = { ...defaultPreferences.environmentVariables, - ...(pref?.environmentVariables ?? {}), + ...(sanitized.environmentVariables ?? {}), } - const sourceModelRecents = pref?.modelRecents ?? defaultPreferences.modelRecents + const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents const modelRecents = sourceModelRecents.map((item) => ({ ...item })) - const sourceAgentSelections = pref?.agentModelSelections ?? defaultPreferences.agentModelSelections - const agentModelSelections: AgentModelSelections = {} - for (const [instanceId, selections] of Object.entries(sourceAgentSelections)) { - agentModelSelections[instanceId] = Object.fromEntries( - Object.entries(selections).map(([agentId, selection]) => [agentId, { ...selection }]), - ) - } - return { - showThinkingBlocks: pref?.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, - lastUsedBinary: pref?.lastUsedBinary ?? defaultPreferences.lastUsedBinary, + showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, + lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary, environmentVariables, modelRecents, - agentModelSelections, - diffViewMode: pref?.diffViewMode ?? defaultPreferences.diffViewMode, - toolOutputExpansion: pref?.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion, - diagnosticsExpansion: pref?.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, + diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode, + toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion, + diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, } } @@ -122,10 +117,22 @@ function buildFallbackConfig(): ConfigData { return normalizeConfig() } +function removeLegacyAgentSelections(config?: ConfigData | null): { cleaned: ConfigData; migrated: boolean } { + const migrated = Boolean((config?.preferences as { agentModelSelections?: unknown } | undefined)?.agentModelSelections) + const cleanedConfig = normalizeConfig(config) + return { cleaned: cleanedConfig, migrated } +} + async function syncConfig(source?: ConfigData): Promise { try { - const configData = source ?? (await storage.loadConfig()) - applyConfig(configData) + const loaded = source ?? (await storage.loadConfig()) + const { cleaned, migrated } = removeLegacyAgentSelections(loaded) + applyConfig(cleaned) + if (migrated) { + void storage.updateConfig(cleaned).catch((error: unknown) => { + console.error("Failed to persist legacy config cleanup:", error) + }) + } } catch (error) { console.error("Failed to load config:", error) applyConfig(buildFallbackConfig()) @@ -328,27 +335,25 @@ function addRecentModelPreference(model: ModelPreference): void { updatePreferences({ modelRecents: updated }) } -function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): void { +async function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): Promise { if (!instanceId || !agent || !model.providerId || !model.modelId) return - const selections = preferences().agentModelSelections ?? {} - const instanceSelections = selections[instanceId] ?? {} - const existing = instanceSelections[agent] - if (existing && existing.providerId === model.providerId && existing.modelId === model.modelId) { - return - } - updatePreferences({ - agentModelSelections: { - ...selections, - [instanceId]: { - ...instanceSelections, - [agent]: model, - }, - }, + await ensureInstanceConfigLoaded(instanceId) + await updateInstanceData(instanceId, (draft) => { + const selections = { ...(draft.agentModelSelections ?? {}) } + const existing = selections[agent] + if (existing && existing.providerId === model.providerId && existing.modelId === model.modelId) { + return + } + selections[agent] = model + draft.agentModelSelections = selections }) } -function getAgentModelPreference(instanceId: string, agent: string): ModelPreference | undefined { - return preferences().agentModelSelections?.[instanceId]?.[agent] +async function getAgentModelPreference(instanceId: string, agent: string): Promise { + if (!instanceId || !agent) return undefined + await ensureInstanceConfigLoaded(instanceId) + const selections = getInstanceConfig(instanceId).agentModelSelections ?? {} + return selections[agent] } void ensureConfigLoaded().catch((error: unknown) => { diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 10dd713a..d7e8da24 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -306,7 +306,7 @@ async function updateSessionAgent(instanceId: string, sessionId: string, agent: }) if (agent && shouldApplyModel) { - setAgentModelPreference(instanceId, agent, nextModel) + await setAgentModelPreference(instanceId, agent, nextModel) } if (shouldApplyModel) { @@ -335,7 +335,7 @@ async function updateSessionModel( }) if (session.agent) { - setAgentModelPreference(instanceId, session.agent, model) + await setAgentModelPreference(instanceId, session.agent, model) } addRecentModelPreference(model) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index ee366b31..3c63b44d 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -159,7 +159,7 @@ async function createSession(instanceId: string, agent?: string): Promise { diff --git a/packages/ui/src/stores/session-models.ts b/packages/ui/src/stores/session-models.ts index 09fdd7a8..d03fd423 100644 --- a/packages/ui/src/stores/session-models.ts +++ b/packages/ui/src/stores/session-models.ts @@ -32,13 +32,6 @@ async function getDefaultModel( const instanceProviders = providers().get(instanceId) || [] const instanceAgents = agents().get(instanceId) || [] - if (agentName) { - const stored = getAgentModelPreference(instanceId, agentName) - if (isModelValid(instanceId, stored)) { - return stored - } - } - if (agentName) { const agent = instanceAgents.find((a) => a.name === agentName) if (agent && agent.model && isModelValid(instanceId, agent.model)) { @@ -47,6 +40,11 @@ async function getDefaultModel( modelId: agent.model.modelId, } } + + const stored = await getAgentModelPreference(instanceId, agentName) + if (isModelValid(instanceId, stored)) { + return stored + } } const recent = getRecentModelPreferenceForInstance(instanceId)