diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index fdbf822b..a9b940c0 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -97,7 +97,7 @@ function readListeningModeFromConfig(): ListeningMode { return "local" } - const mode = parsed?.preferences?.listeningMode + const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode if (mode === "local" || mode === "all") { return mode } diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index c81d7cf8..be820634 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.1.53" + "@opencode-ai/plugin": "1.1.59" } } \ No newline at end of file diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index a48cf096..c3dea831 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -1,7 +1,6 @@ import type { AgentModelSelection, AgentModelSelections, - ConfigFile, ModelPreference, OpenCodeBinary, Preferences, @@ -183,9 +182,9 @@ export interface BinaryRecord { validationError?: string } -export type AppConfig = ConfigFile -export type AppConfigResponse = AppConfig -export type AppConfigUpdateRequest = Partial +export type SettingsOwner = string +export type SettingsBucket = Record +export type SettingsDoc = Record export interface BinaryListResponse { binaries: BinaryRecord[] @@ -214,8 +213,8 @@ export type WorkspaceEventType = | "workspace.error" | "workspace.stopped" | "workspace.log" - | "config.appChanged" - | "config.binariesChanged" + | "storage.configChanged" + | "storage.stateChanged" | "instance.dataChanged" | "instance.event" | "instance.eventStatus" @@ -226,8 +225,8 @@ export type WorkspaceEventPayload = | { type: "workspace.error"; workspace: WorkspaceDescriptor } | { type: "workspace.stopped"; workspaceId: string } | { type: "workspace.log"; entry: WorkspaceLogEntry } - | { type: "config.appChanged"; config: AppConfig } - | { type: "config.binariesChanged"; binaries: BinaryRecord[] } + | { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket } + | { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } diff --git a/packages/server/src/config/binaries.ts b/packages/server/src/config/binaries.ts deleted file mode 100644 index 56d86d50..00000000 --- a/packages/server/src/config/binaries.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - BinaryCreateRequest, - BinaryRecord, - BinaryUpdateRequest, - BinaryValidationResult, -} from "../api-types" -import { spawnSync } from "child_process" -import { ConfigStore } from "./store" -import { EventBus } from "../events/bus" -import type { ConfigFile } from "./schema" -import { Logger } from "../logger" -import { buildSpawnSpec } from "../workspaces/runtime" - -export class BinaryRegistry { - constructor( - private readonly configStore: ConfigStore, - private readonly eventBus: EventBus | undefined, - private readonly logger: Logger, - ) {} - - list(): BinaryRecord[] { - return this.mapRecords() - } - - resolveDefault(): BinaryRecord { - const binaries = this.mapRecords() - if (binaries.length === 0) { - this.logger.warn("No configured binaries found, falling back to opencode") - return this.buildFallbackRecord("opencode") - } - return binaries.find((binary) => binary.isDefault) ?? binaries[0] - } - - create(request: BinaryCreateRequest): BinaryRecord { - this.logger.debug({ path: request.path }, "Registering OpenCode binary") - const entry = { - path: request.path, - version: undefined, - lastUsed: Date.now(), - label: request.label, - } - - const config = this.configStore.get() - const nextConfig = this.cloneConfig(config) - const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path) - nextConfig.opencodeBinaries = [entry, ...deduped] - - if (request.makeDefault) { - nextConfig.preferences.lastUsedBinary = request.path - } - - this.configStore.replace(nextConfig) - const record = this.getById(request.path) - this.emitChange() - return record - } - - update(id: string, updates: BinaryUpdateRequest): BinaryRecord { - this.logger.debug({ id }, "Updating OpenCode binary") - const config = this.configStore.get() - const nextConfig = this.cloneConfig(config) - nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) => - binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary, - ) - - if (updates.makeDefault) { - nextConfig.preferences.lastUsedBinary = id - } - - this.configStore.replace(nextConfig) - const record = this.getById(id) - this.emitChange() - return record - } - - remove(id: string) { - this.logger.debug({ id }, "Removing OpenCode binary") - const config = this.configStore.get() - const nextConfig = this.cloneConfig(config) - const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id) - nextConfig.opencodeBinaries = remaining - - if (nextConfig.preferences.lastUsedBinary === id) { - nextConfig.preferences.lastUsedBinary = remaining[0]?.path - } - - this.configStore.replace(nextConfig) - this.emitChange() - } - - validatePath(path: string): BinaryValidationResult { - this.logger.debug({ path }, "Validating OpenCode binary path") - return this.validateRecord({ - id: path, - path, - label: this.prettyLabel(path), - isDefault: false, - }) - } - - private cloneConfig(config: ConfigFile): ConfigFile { - return JSON.parse(JSON.stringify(config)) as ConfigFile - } - - private mapRecords(): BinaryRecord[] { - - const config = this.configStore.get() - const configuredBinaries = config.opencodeBinaries.map((binary) => ({ - id: binary.path, - path: binary.path, - label: binary.label ?? this.prettyLabel(binary.path), - version: binary.version, - isDefault: false, - })) - - const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode" - - const annotated = configuredBinaries.map((binary) => ({ - ...binary, - isDefault: binary.path === defaultPath, - })) - - if (!annotated.some((binary) => binary.path === defaultPath)) { - annotated.unshift(this.buildFallbackRecord(defaultPath)) - } - - return annotated - } - - private getById(id: string): BinaryRecord { - return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id) - } - - private emitChange() { - this.logger.debug("Emitting binaries changed event") - this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() }) - } - - private validateRecord(record: BinaryRecord): BinaryValidationResult { - const inputPath = record.path - if (!inputPath) { - return { valid: false, error: "Missing binary path" } - } - - const spec = buildSpawnSpec(inputPath, ["--version"]) - - try { - const result = spawnSync(spec.command, spec.args, { - encoding: "utf8", - windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments), - }) - - if (result.error) { - return { valid: false, error: result.error.message } - } - - if (result.status !== 0) { - const stderr = result.stderr?.trim() - const stdout = result.stdout?.trim() - const combined = stderr || stdout - const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` - return { valid: false, error } - } - - const stdout = (result.stdout ?? "").trim() - const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0) - const normalized = firstLine?.trim() - - const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) - const version = versionMatch?.[1] - - return { valid: true, version } - } catch (error) { - return { valid: false, error: error instanceof Error ? error.message : String(error) } - } - } - - private buildFallbackRecord(path: string): BinaryRecord { - return { - id: path, - path, - label: this.prettyLabel(path), - isDefault: true, - } - } - - private prettyLabel(path: string) { - const parts = path.split(/[\\/]/) - const last = parts[parts.length - 1] || path - return last || path - } -} diff --git a/packages/server/src/config/store.ts b/packages/server/src/config/store.ts deleted file mode 100644 index 5d736f9c..00000000 --- a/packages/server/src/config/store.ts +++ /dev/null @@ -1,244 +0,0 @@ -import fs from "fs" -import path from "path" -import { parse as parseYaml, stringify as stringifyYaml } from "yaml" -import { EventBus } from "../events/bus" -import { Logger } from "../logger" -import { - ConfigFile, - ConfigFileSchema, - ConfigYamlSchema, - DEFAULT_CONFIG, - DEFAULT_CONFIG_YAML, - DEFAULT_STATE, - StateFile, - StateFileSchema, -} from "./schema" -import type { ConfigLocation } from "./location" - -export class ConfigStore { - private cache: ConfigFile = DEFAULT_CONFIG - private state: StateFile = DEFAULT_STATE - private loaded = false - - constructor( - private readonly location: ConfigLocation, - private readonly eventBus: EventBus | undefined, - private readonly logger: Logger, - ) {} - - load(): ConfigFile { - if (this.loaded) { - return this.cache - } - - try { - const configYamlPath = this.location.configYamlPath - const stateYamlPath = this.location.stateYamlPath - const legacyJsonPath = this.location.legacyJsonPath - - if (fs.existsSync(configYamlPath)) { - const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config") - const stateDoc = fs.existsSync(stateYamlPath) - ? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state") - : DEFAULT_STATE - - this.state = stateDoc - this.cache = this.mergeDocs(configDoc, stateDoc) - this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state") - } else if (fs.existsSync(legacyJsonPath)) { - const migrated = this.migrateFromLegacyJson(legacyJsonPath) - this.state = migrated.state - this.cache = migrated.config - } else { - // Fresh install: write defaults. - this.state = DEFAULT_STATE - this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE) - this.persist() - this.logger.debug( - { configYamlPath, stateYamlPath }, - "No config files found, created default YAML config/state", - ) - } - } catch (error) { - this.logger.warn({ err: error }, "Failed to load config/state, using defaults") - this.state = DEFAULT_STATE - this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE) - } - - this.loaded = true - return this.cache - } - - get(): ConfigFile { - return this.load() - } - - replace(config: ConfigFile) { - const validated = ConfigFileSchema.parse(config) - this.commit(validated) - } - - /** - * Apply a merge-patch update to the current config. - * - Missing keys are preserved. - * - Object values are merged recursively. - * - Explicit `null` deletes keys. - * - Arrays are replaced. - */ - mergePatch(patch: unknown) { - if (!patch || typeof patch !== "object" || Array.isArray(patch)) { - throw new Error("Config patch must be a JSON object") - } - const current = this.get() - const next = applyMergePatch(current as any, patch as any) - const validated = ConfigFileSchema.parse(next) - this.commit(validated) - } - - private commit(next: ConfigFile) { - this.cache = next - this.loaded = true - this.state = { - ...this.state, - recentFolders: next.recentFolders, - } - this.persist() - const published = Boolean(this.eventBus) - this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) - this.logger.debug({ broadcast: published }, "Config SSE event emitted") - this.logger.trace({ config: this.cache }, "Config payload") - } - - private persist() { - try { - const configYamlPath = this.location.configYamlPath - const stateYamlPath = this.location.stateYamlPath - - fs.mkdirSync(this.location.baseDir, { recursive: true }) - fs.mkdirSync(path.dirname(configYamlPath), { recursive: true }) - - const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any) - const stateYaml = stringifyYaml(this.state as any) - - fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8") - fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8") - - this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state") - } catch (error) { - this.logger.warn({ err: error }, "Failed to persist config") - } - } - - private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile { - const merged = { - ...(configDoc as any), - // State wins for recent folders. - recentFolders: stateDoc.recentFolders ?? [], - } - - return ConfigFileSchema.parse(merged) - } - - private readYamlFile( - filePath: string, - fallback: T, - schema: { parse: (value: unknown) => T }, - label: string, - ): T { - try { - const content = fs.readFileSync(filePath, "utf-8") - const parsed = parseYaml(content) - return schema.parse(parsed ?? {}) - } catch (error) { - this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults") - return fallback - } - } - - private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } { - const configYamlPath = this.location.configYamlPath - const stateYamlPath = this.location.stateYamlPath - - const content = fs.readFileSync(legacyJsonPath, "utf-8") - const parsed = JSON.parse(content) - const legacy = ConfigFileSchema.parse(parsed) - - const state: StateFile = StateFileSchema.parse({ - ...DEFAULT_STATE, - recentFolders: legacy.recentFolders ?? [], - }) - - const merged = this.mergeDocs(stripRecentFolders(legacy), state) - - // Persist YAML docs first, then move legacy aside. - try { - fs.mkdirSync(this.location.baseDir, { recursive: true }) - fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8") - fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8") - this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML") - } catch (error) { - this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state") - } - - try { - const bakPath = pickBackupPath(legacyJsonPath) - fs.renameSync(legacyJsonPath, bakPath) - this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup") - } catch (error) { - this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup") - } - - return { config: merged, state } - } -} - -function ensureTrailingNewline(content: string): string { - if (!content) return "\n" - return content.endsWith("\n") ? content : `${content}\n` -} - -function stripRecentFolders(config: ConfigFile): Omit & Record { - const clone: Record = { ...(config as any) } - delete clone.recentFolders - return clone as any -} - -function isPlainObject(value: unknown): value is Record { - if (!value || typeof value !== "object") return false - if (Array.isArray(value)) return false - const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - -function applyMergePatch(current: any, patch: any): any { - // RFC 7396-ish merge patch with explicit null deletes. - if (!isPlainObject(patch)) { - return patch - } - - const base = isPlainObject(current) ? { ...current } : {} - for (const [key, value] of Object.entries(patch)) { - if (value === null) { - delete base[key] - continue - } - - if (isPlainObject(value) && isPlainObject(base[key])) { - base[key] = applyMergePatch(base[key], value) - continue - } - - // Arrays and scalars replace. - base[key] = value - } - return base -} - -function pickBackupPath(legacyJsonPath: string): string { - const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath - const preferred = `${base}.json.bak` - if (!fs.existsSync(preferred)) { - return preferred - } - return `${base}.json.bak.${Date.now()}` -} diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index 61453024..7673f00a 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -24,8 +24,8 @@ export class EventBus extends EventEmitter { this.on("workspace.error", handler) this.on("workspace.stopped", handler) this.on("workspace.log", handler) - this.on("config.appChanged", handler) - this.on("config.binariesChanged", handler) + this.on("storage.configChanged", handler) + this.on("storage.stateChanged", handler) this.on("instance.dataChanged", handler) this.on("instance.event", handler) this.on("instance.eventStatus", handler) @@ -35,8 +35,8 @@ export class EventBus extends EventEmitter { this.off("workspace.error", handler) this.off("workspace.stopped", handler) this.off("workspace.log", handler) - this.off("config.appChanged", handler) - this.off("config.binariesChanged", handler) + this.off("storage.configChanged", handler) + this.off("storage.stateChanged", handler) this.off("instance.dataChanged", handler) this.off("instance.event", handler) this.off("instance.eventStatus", handler) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e1450244..74f0f0d6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,9 +8,9 @@ import { fileURLToPath } from "url" import { createRequire } from "module" import { createHttpServer } from "./server/http-server" import { WorkspaceManager } from "./workspaces/manager" -import { ConfigStore } from "./config/store" import { resolveConfigLocation } from "./config/location" -import { BinaryRegistry } from "./config/binaries" +import { SettingsService } from "./settings/service" +import { BinaryResolver } from "./settings/binaries" import { FileSystemBrowser } from "./filesystem/browser" import { EventBus } from "./events/bus" import { ServerMeta } from "./api-types" @@ -291,21 +291,12 @@ async function main() { const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined - const configStore = new ConfigStore(configLocation, eventBus, configLogger) - - // Eagerly load config at boot so migrations run immediately - // (instead of waiting for the first /api/config request). - try { - configStore.get() - } catch (error) { - configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults") - } - - const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) + const settings = new SettingsService(configLocation, eventBus, configLogger) + const binaryResolver = new BinaryResolver(settings) const workspaceManager = new WorkspaceManager({ rootDir: options.rootDir, - configStore, - binaryRegistry, + settings, + binaryResolver, eventBus, logger: workspaceLogger, getServerBaseUrl: () => serverMeta.localUrl, @@ -392,8 +383,7 @@ async function main() { defaultPort: options.httpPort, protocol: "http", workspaceManager, - configStore, - binaryRegistry, + settings, fileSystemBrowser, eventBus, serverMeta, @@ -413,8 +403,7 @@ async function main() { protocol: "https", httpsOptions: tlsResolution?.httpsOptions, workspaceManager, - configStore, - binaryRegistry, + settings, fileSystemBrowser, eventBus, serverMeta, diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index caa90c21..3fc7106e 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -9,12 +9,11 @@ import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" -import { ConfigStore } from "../config/store" -import { BinaryRegistry } from "../config/binaries" +import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" import { EventBus } from "../events/bus" import { registerWorkspaceRoutes } from "./routes/workspaces" -import { registerConfigRoutes } from "./routes/config" +import { registerSettingsRoutes } from "./routes/settings" import { registerFilesystemRoutes } from "./routes/filesystem" import { registerMetaRoutes } from "./routes/meta" import { registerEventRoutes } from "./routes/events" @@ -37,8 +36,7 @@ interface HttpServerDeps { protocol: "http" | "https" httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } workspaceManager: WorkspaceManager - configStore: ConfigStore - binaryRegistry: BinaryRegistry + settings: SettingsService fileSystemBrowser: FileSystemBrowser eventBus: EventBus serverMeta: ServerMeta @@ -244,7 +242,7 @@ export function createHttpServer(deps: HttpServerDeps) { }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) - registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry }) + registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) diff --git a/packages/server/src/server/routes/config.ts b/packages/server/src/server/routes/config.ts deleted file mode 100644 index ea4e03c5..00000000 --- a/packages/server/src/server/routes/config.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { FastifyInstance } from "fastify" -import { z } from "zod" -import { ConfigStore } from "../../config/store" -import { BinaryRegistry } from "../../config/binaries" - -interface RouteDeps { - configStore: ConfigStore - binaryRegistry: BinaryRegistry -} - -const BinaryCreateSchema = z.object({ - path: z.string(), - label: z.string().optional(), - makeDefault: z.boolean().optional(), -}) - -const BinaryUpdateSchema = z.object({ - label: z.string().optional(), - makeDefault: z.boolean().optional(), -}) - -const BinaryValidateSchema = z.object({ - path: z.string(), -}) - -export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) { - app.get("/api/config/app", async () => deps.configStore.get()) - - app.put("/api/config/app", async (request, reply) => { - // Backwards compatible: treat PUT as a merge-patch update. - try { - deps.configStore.mergePatch(request.body ?? {}) - return deps.configStore.get() - } catch (error) { - reply.code(400) - return { error: error instanceof Error ? error.message : "Invalid config patch" } - } - }) - - app.patch("/api/config/app", async (request, reply) => { - try { - deps.configStore.mergePatch(request.body ?? {}) - return deps.configStore.get() - } catch (error) { - reply.code(400) - return { error: error instanceof Error ? error.message : "Invalid config patch" } - } - }) - - app.get("/api/config/binaries", async () => { - return { binaries: deps.binaryRegistry.list() } - }) - - app.post("/api/config/binaries", async (request, reply) => { - const body = BinaryCreateSchema.parse(request.body ?? {}) - const binary = deps.binaryRegistry.create(body) - reply.code(201) - return { binary } - }) - - app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => { - const body = BinaryUpdateSchema.parse(request.body ?? {}) - const binary = deps.binaryRegistry.update(request.params.id, body) - return { binary } - }) - - app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => { - deps.binaryRegistry.remove(request.params.id) - reply.code(204) - }) - - app.post("/api/config/binaries/validate", async (request) => { - const body = BinaryValidateSchema.parse(request.body ?? {}) - return deps.binaryRegistry.validatePath(body.path) - }) -} diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts new file mode 100644 index 00000000..4f5a70eb --- /dev/null +++ b/packages/server/src/server/routes/settings.ts @@ -0,0 +1,110 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import { spawnSync } from "child_process" +import { buildSpawnSpec } from "../../workspaces/runtime" +import type { SettingsService } from "../../settings/service" +import type { Logger } from "../../logger" + +interface RouteDeps { + settings: SettingsService + logger: Logger +} + +const ValidateBinarySchema = z.object({ + path: z.string(), +}) + +function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } { + if (!binaryPath) { + return { valid: false, error: "Missing binary path" } + } + + const spec = buildSpawnSpec(binaryPath, ["--version"]) + try { + const result = spawnSync(spec.command, spec.args, { + encoding: "utf8", + windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments), + }) + + if (result.error) { + return { valid: false, error: result.error.message } + } + if (result.status !== 0) { + const stderr = result.stderr?.trim() + const stdout = result.stdout?.trim() + const combined = stderr || stdout + const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` + return { valid: false, error } + } + + const stdout = (result.stdout ?? "").trim() + const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0) + const normalized = firstLine?.trim() + const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) + const version = versionMatch?.[1] + return { valid: true, version } + } catch (error) { + return { valid: false, error: error instanceof Error ? error.message : String(error) } + } +} + +export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { + // Full-document access + app.get("/api/storage/config", async () => deps.settings.getDoc("config")) + app.patch("/api/storage/config", async (request, reply) => { + try { + return deps.settings.mergePatchDoc("config", request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => { + return deps.settings.getOwner("config", request.params.owner) + }) + + app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => { + try { + return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + app.get("/api/storage/state", async () => deps.settings.getDoc("state")) + app.patch("/api/storage/state", async (request, reply) => { + try { + return deps.settings.mergePatchDoc("state", request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => { + return deps.settings.getOwner("state", request.params.owner) + }) + + app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => { + try { + return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + // Binary validation helper (used by UI when adding binaries) + app.post("/api/storage/binaries/validate", async (request, reply) => { + try { + const body = ValidateBinarySchema.parse(request.body ?? {}) + return validateBinaryPath(body.path) + } catch (error) { + deps.logger.warn({ err: error }, "Failed to validate binary") + reply.code(400) + return { valid: false, error: error instanceof Error ? error.message : "Invalid request" } + } + }) +} diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts new file mode 100644 index 00000000..e4b25960 --- /dev/null +++ b/packages/server/src/settings/binaries.ts @@ -0,0 +1,55 @@ +import type { SettingsService } from "./service" + +export interface OpenCodeBinaryEntry { + path: string + version?: string + lastUsed?: number + label?: string +} + +export interface ResolvedBinary { + path: string + label: string + version?: string +} + +function prettyLabel(p: string): string { + const parts = p.split(/[\\/]/) + const last = parts[parts.length - 1] || p + return last || p +} + +function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] { + const ui = settings.getOwner("state", "ui") + const list = (ui as any)?.opencodeBinaries + if (!Array.isArray(list)) return [] + return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any +} + +function readDefaultBinaryPath(settings: SettingsService): string | undefined { + const server = settings.getOwner("config", "server") + const value = (server as any)?.opencodeBinary + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined +} + +export class BinaryResolver { + constructor(private readonly settings: SettingsService) {} + + list(): OpenCodeBinaryEntry[] { + return readUiBinaries(this.settings) + } + + resolveDefault(): ResolvedBinary { + const binaries = this.list() + const configuredDefault = readDefaultBinaryPath(this.settings) + const fallback = binaries[0]?.path + const path = configuredDefault ?? fallback ?? "opencode" + + const entry = binaries.find((b) => b.path === path) + return { + path, + label: entry?.label ?? prettyLabel(path), + version: entry?.version, + } + } +} diff --git a/packages/server/src/settings/merge-patch.ts b/packages/server/src/settings/merge-patch.ts new file mode 100644 index 00000000..f5008f46 --- /dev/null +++ b/packages/server/src/settings/merge-patch.ts @@ -0,0 +1,39 @@ +type PlainObject = Record + +export function isPlainObject(value: unknown): value is PlainObject { + if (!value || typeof value !== "object") return false + if (Array.isArray(value)) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +/** + * RFC 7396-ish merge patch with explicit null deletes. + * - Objects merge recursively + * - Arrays/scalars replace + * - null deletes keys + */ +export function applyMergePatch(current: unknown, patch: unknown): unknown { + if (!isPlainObject(patch)) { + return patch + } + + const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {} + + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete base[key] + continue + } + + const existing = base[key] + if (isPlainObject(value) && isPlainObject(existing)) { + base[key] = applyMergePatch(existing, value) + continue + } + + base[key] = value + } + + return base +} diff --git a/packages/server/src/settings/migrate.ts b/packages/server/src/settings/migrate.ts new file mode 100644 index 00000000..e693a96d --- /dev/null +++ b/packages/server/src/settings/migrate.ts @@ -0,0 +1,269 @@ +import fs from "fs" +import path from "path" +import { parse as parseYaml, stringify as stringifyYaml } from "yaml" +import type { Logger } from "../logger" +import type { ConfigLocation } from "../config/location" +import { isPlainObject } from "./merge-patch" + +type Doc = Record + +function ensureTrailingNewline(content: string): string { + if (!content) return "\n" + return content.endsWith("\n") ? content : `${content}\n` +} + +function safeReadYaml(filePath: string, logger: Logger): unknown { + try { + const content = fs.readFileSync(filePath, "utf-8") + return parseYaml(content) + } catch (error) { + logger.warn({ err: error, filePath }, "Failed to read YAML file during migration") + return null + } +} + +function safeReadJson(filePath: string, logger: Logger): unknown { + try { + const content = fs.readFileSync(filePath, "utf-8") + return JSON.parse(content) + } catch (error) { + logger.warn({ err: error, filePath }, "Failed to read JSON file during migration") + return null + } +} + +function writeYaml(filePath: string, doc: Doc, logger: Logger) { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const yaml = stringifyYaml(doc as any) + fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8") + } catch (error) { + logger.warn({ err: error, filePath }, "Failed to write YAML file during migration") + } +} + +function pickBackupPath(filePath: string): string { + const preferred = `${filePath}.bak` + if (!fs.existsSync(preferred)) { + return preferred + } + return `${filePath}.bak.${Date.now()}` +} + +function normalizeDoc(value: unknown): Doc { + return isPlainObject(value) ? (value as Doc) : {} +} + +function looksLikeNewOwnerDoc(value: unknown): boolean { + const doc = normalizeDoc(value) + // Heuristic: owner-bucket docs have at least one of these roots. + return Boolean(doc.ui || doc.server || doc.app || doc.legacy) +} + +function looksLikeLegacyConfig(value: unknown): boolean { + const doc = normalizeDoc(value) + return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders) +} + +function looksLikeLegacyState(value: unknown): boolean { + const doc = normalizeDoc(value) + return Boolean(doc.recentFolders) +} + +function omitKeys(source: Doc, keys: Set): Doc { + const out: Doc = {} + for (const [k, v] of Object.entries(source)) { + if (keys.has(k)) continue + out[k] = v + } + return out +} + +function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } { + const cfg = normalizeDoc(legacyConfig) + const st = normalizeDoc(legacyState) + + const outConfig: Doc = {} + const outState: Doc = {} + + const uiConfig: Doc = {} + const uiSettings: Doc = {} + const serverConfig: Doc = {} + const uiState: Doc = {} + + // theme -> config.ui.theme + if (typeof cfg.theme === "string") { + uiConfig.theme = cfg.theme + } + + const preferences = normalizeDoc(cfg.preferences) + if (Object.keys(preferences).length > 0) { + // Server-owned stable keys + const envVars = preferences.environmentVariables + if (isPlainObject(envVars)) { + serverConfig.environmentVariables = envVars + } + const listeningMode = preferences.listeningMode + if (typeof listeningMode === "string") { + serverConfig.listeningMode = listeningMode + } + const lastUsedBinary = preferences.lastUsedBinary + if (typeof lastUsedBinary === "string") { + serverConfig.opencodeBinary = lastUsedBinary + } + + // UI-owned state keys (drop preferences) + const modelRecents = preferences.modelRecents + const modelFavorites = preferences.modelFavorites + const modelThinkingSelections = preferences.modelThinkingSelections + + const models: Doc = {} + if (Array.isArray(modelRecents)) { + models.recents = modelRecents + } + if (Array.isArray(modelFavorites)) { + models.favorites = modelFavorites + } + if (isPlainObject(modelThinkingSelections)) { + models.thinkingSelections = modelThinkingSelections + } + if (Object.keys(models).length > 0) { + uiState.models = models + } + + // Remaining preferences are treated as stable UI settings. + const moved = new Set([ + "environmentVariables", + "listeningMode", + "lastUsedBinary", + "modelRecents", + "modelFavorites", + "modelThinkingSelections", + ]) + Object.assign(uiSettings, omitKeys(preferences, moved)) + } + + // recentFolders lives in legacy state (yaml) or legacy config.json + const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown + if (Array.isArray(recentFolders)) { + uiState.recentFolders = recentFolders + } + + // opencodeBinaries -> state.ui.opencodeBinaries + if (Array.isArray(cfg.opencodeBinaries)) { + uiState.opencodeBinaries = cfg.opencodeBinaries + } + + if (Object.keys(uiSettings).length > 0) { + uiConfig.settings = uiSettings + } + + if (Object.keys(uiConfig).length > 0) { + outConfig.ui = uiConfig + } + if (Object.keys(serverConfig).length > 0) { + outConfig.server = serverConfig + } + if (Object.keys(uiState).length > 0) { + outState.ui = uiState + } + + // Unknown top-level keys -> legacy.unknown + const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"]) + const unknownConfig = omitKeys(cfg, knownConfigKeys) + if (Object.keys(unknownConfig).length > 0) { + outConfig.legacy = { unknown: unknownConfig } + } + + const knownStateKeys = new Set(["recentFolders"]) + const unknownState = omitKeys(st, knownStateKeys) + if (Object.keys(unknownState).length > 0) { + outState.legacy = { unknown: unknownState } + } + + return { config: outConfig, state: outState } +} + +/** + * Migrate older config/state layouts into owner-bucket YAML docs. + * + * Legacy inputs supported: + * - config.yaml with { preferences, opencodeBinaries, theme } + * - state.yaml with { recentFolders } + * - legacy config.json with full ConfigFile schema + */ +export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) { + const configYamlPath = location.configYamlPath + const stateYamlPath = location.stateYamlPath + const legacyJsonPath = location.legacyJsonPath + + const configExists = fs.existsSync(configYamlPath) + const stateExists = fs.existsSync(stateYamlPath) + + const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null + const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null + + const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc) + const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc) + + if (configIsNew && stateIsNew) { + return + } + + const legacyJsonExists = fs.existsSync(legacyJsonPath) + + const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc)) + const shouldMigrateFromJson = !configExists && legacyJsonExists + + if (!hasLegacyYaml && !shouldMigrateFromJson) { + // Either fresh install or partially written docs; let stores create on first write. + return + } + + const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc + const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc + + const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState) + + try { + fs.mkdirSync(location.baseDir, { recursive: true }) + } catch (error) { + logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration") + } + + // Backup legacy files before rewriting. + if (configExists) { + try { + const bak = pickBackupPath(configYamlPath) + fs.renameSync(configYamlPath, bak) + logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml") + } catch (error) { + logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml") + } + } + + if (stateExists) { + try { + const bak = pickBackupPath(stateYamlPath) + fs.renameSync(stateYamlPath, bak) + logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml") + } catch (error) { + logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml") + } + } + + if (shouldMigrateFromJson) { + try { + const bak = pickBackupPath(legacyJsonPath) + fs.renameSync(legacyJsonPath, bak) + logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup") + } catch (error) { + logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup") + } + } + + writeYaml(configYamlPath, config, logger) + writeYaml(stateYamlPath, state, logger) + + logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout") +} diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts new file mode 100644 index 00000000..02a18422 --- /dev/null +++ b/packages/server/src/settings/service.ts @@ -0,0 +1,55 @@ +import type { Logger } from "../logger" +import type { EventBus } from "../events/bus" +import type { ConfigLocation } from "../config/location" +import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store" +import { migrateSettingsLayout } from "./migrate" +import type { WorkspaceEventPayload } from "../api-types" + +export type DocKind = "config" | "state" + +export class SettingsService { + private readonly configStore: YamlDocStore + private readonly stateStore: YamlDocStore + + constructor( + private readonly location: ConfigLocation, + private readonly eventBus: EventBus | undefined, + private readonly logger: Logger, + ) { + migrateSettingsLayout(location, logger) + this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" })) + this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" })) + } + + getDoc(kind: DocKind): SettingsDoc { + return kind === "config" ? this.configStore.get() : this.stateStore.get() + } + + mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc { + const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch) + this.publish(kind, "*") + return updated + } + + getOwner(kind: DocKind, owner: string): SettingsDoc { + return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner) + } + + mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc { + const updated = + kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch) + this.publish(kind, owner, updated) + return updated + } + + private publish(kind: DocKind, owner: string, value?: SettingsDoc) { + if (!this.eventBus) return + const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged" + const payload: WorkspaceEventPayload = { + type, + owner, + value: value ?? this.getOwner(kind, owner), + } as any + this.eventBus.publish(payload) + } +} diff --git a/packages/server/src/settings/yaml-doc-store.ts b/packages/server/src/settings/yaml-doc-store.ts new file mode 100644 index 00000000..91c5540c --- /dev/null +++ b/packages/server/src/settings/yaml-doc-store.ts @@ -0,0 +1,110 @@ +import fs from "fs" +import path from "path" +import { parse as parseYaml, stringify as stringifyYaml } from "yaml" +import type { Logger } from "../logger" +import { applyMergePatch, isPlainObject } from "./merge-patch" + +export type SettingsDoc = Record + +function ensureTrailingNewline(content: string): string { + if (!content) return "\n" + return content.endsWith("\n") ? content : `${content}\n` +} + +function normalizeDoc(input: unknown): SettingsDoc { + if (!isPlainObject(input)) { + return {} + } + return input +} + +export class YamlDocStore { + private cache: SettingsDoc = {} + private loaded = false + + constructor( + private readonly filePath: string, + private readonly logger: Logger, + ) {} + + load(): SettingsDoc { + if (this.loaded) { + return this.cache + } + + try { + if (!fs.existsSync(this.filePath)) { + this.cache = {} + this.loaded = true + return this.cache + } + + const content = fs.readFileSync(this.filePath, "utf-8") + const parsed = parseYaml(content) + this.cache = normalizeDoc(parsed) + this.loaded = true + return this.cache + } catch (error) { + this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object") + this.cache = {} + this.loaded = true + return this.cache + } + } + + get(): SettingsDoc { + return this.load() + } + + replace(next: unknown): SettingsDoc { + const normalized = normalizeDoc(next) + this.cache = normalized + this.loaded = true + this.persist() + return this.cache + } + + mergePatch(patch: unknown): SettingsDoc { + if (!isPlainObject(patch)) { + throw new Error("Patch must be a JSON object") + } + const current = this.get() + const next = applyMergePatch(current, patch) + return this.replace(next) + } + + getOwner(owner: string): SettingsDoc { + const doc = this.get() + const value = (doc as any)?.[owner] + return normalizeDoc(value) + } + + replaceOwner(owner: string, value: unknown): SettingsDoc { + const doc = this.get() + const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) } + this.replace(nextDoc) + return nextDoc[owner] as SettingsDoc + } + + mergePatchOwner(owner: string, patch: unknown): SettingsDoc { + if (!isPlainObject(patch)) { + throw new Error("Patch must be a JSON object") + } + const doc = this.get() + const currentOwner = normalizeDoc((doc as any)?.[owner]) + const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch)) + const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner } + this.replace(nextDoc) + return nextOwner + } + + private persist() { + try { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }) + const yaml = stringifyYaml(this.cache as any) + fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8") + } catch (error) { + this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc") + } + } +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 8afca60b..a4b50e06 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -2,8 +2,8 @@ import path from "path" import { spawnSync } from "child_process" import { connect } from "net" import { EventBus } from "../events/bus" -import { ConfigStore } from "../config/store" -import { BinaryRegistry } from "../config/binaries" +import type { SettingsService } from "../settings/service" +import type { BinaryResolver } from "../settings/binaries" import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" @@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500 interface WorkspaceManagerOptions { rootDir: string - configStore: ConfigStore - binaryRegistry: BinaryRegistry + settings: SettingsService + binaryResolver: BinaryResolver eventBus: EventBus logger: Logger getServerBaseUrl: () => string @@ -86,7 +86,7 @@ export class WorkspaceManager { async create(folder: string, name?: string): Promise { const id = `${Date.now().toString(36)}` - const binary = this.options.binaryRegistry.resolveDefault() + const binary = this.options.binaryResolver.resolveDefault() const resolvedBinaryPath = this.resolveBinaryPath(binary.path) const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) clearWorkspaceSearchCache(workspacePath) @@ -118,8 +118,9 @@ export class WorkspaceManager { this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) - const preferences = this.options.configStore.get().preferences ?? {} - const userEnvironment = preferences.environmentVariables ?? {} + const serverConfig = this.options.settings.getOwner("config", "server") + const envVars = (serverConfig as any)?.environmentVariables + const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} const opencodeUsername = DEFAULT_OPENCODE_USERNAME const opencodePassword = generateOpencodeServerPassword() diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index ad0860f9..6b55b945 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -140,9 +140,16 @@ struct PreferencesConfig { listening_mode: Option, } +#[derive(Debug, Deserialize)] +struct ServerConfig { + #[serde(rename = "listeningMode")] + listening_mode: Option, +} + #[derive(Debug, Deserialize)] struct AppConfig { preferences: Option, + server: Option, } fn resolve_config_locations() -> (PathBuf, PathBuf) { @@ -188,11 +195,18 @@ fn resolve_listening_mode() -> String { if let Ok(content) = fs::read_to_string(&yaml_path) { if let Ok(config) = serde_yaml::from_str::(&content) { - if let Some(mode) = config - .preferences + let mode = config + .server .as_ref() - .and_then(|prefs| prefs.listening_mode.as_ref()) - { + .and_then(|srv| srv.listening_mode.as_ref()) + .or_else(|| { + config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + }); + + if let Some(mode) = mode { if mode == "local" { return "local".to_string(); } @@ -206,11 +220,17 @@ fn resolve_listening_mode() -> String { // Legacy fallback. if let Ok(content) = fs::read_to_string(&json_path) { if let Ok(config) = serde_json::from_str::(&content) { - if let Some(mode) = config - .preferences + let mode = config + .server .as_ref() - .and_then(|prefs| prefs.listening_mode.as_ref()) - { + .and_then(|srv| srv.listening_mode.as_ref()) + .or_else(|| { + config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + }); + if let Some(mode) = mode { if mode == "local" { return "local".to_string(); } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index f4092ad3..c4e747c8 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -58,6 +58,7 @@ const App: Component = () => { const { t } = useI18n() const { preferences, + serverSettings, recordWorkspaceLaunch, toggleShowThinkingBlocks, toggleShowTimelineTools, @@ -177,7 +178,7 @@ const App: Component = () => { return } setIsSelectingFolder(true) - const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode" + const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode" try { recordWorkspaceLaunch(folderPath, selectedBinary) clearLaunchError() diff --git a/packages/ui/src/components/environment-variables-editor.tsx b/packages/ui/src/components/environment-variables-editor.tsx index 07e3738d..e8653ba0 100644 --- a/packages/ui/src/components/environment-variables-editor.tsx +++ b/packages/ui/src/components/environment-variables-editor.tsx @@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps { const EnvironmentVariablesEditor: Component = (props) => { const { t } = useI18n() const { - preferences, + serverSettings, addEnvironmentVariable, removeEnvironmentVariable, updateEnvironmentVariables, } = useConfig() - const [envVars, setEnvVars] = createSignal>(preferences().environmentVariables || {}) + const [envVars, setEnvVars] = createSignal>(serverSettings().environmentVariables || {}) const [newKey, setNewKey] = createSignal("") const [newValue, setNewValue] = createSignal("") diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 281ea084..7de3fe43 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -26,11 +26,11 @@ interface FolderSelectionViewProps { } const FolderSelectionView: Component = (props) => { - const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig() + const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig() const { t, locale } = useI18n() const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") - const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") + const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) const nativeDialogsAvailable = supportsNativeDialogs() let recentListRef: HTMLDivElement | undefined @@ -53,7 +53,7 @@ const FolderSelectionView: Component = (props) => { // Update selected binary when preferences change createEffect(() => { - const lastUsed = preferences().lastUsedBinary + const lastUsed = serverSettings().opencodeBinary if (!lastUsed) return setSelectedBinary((current) => (current === lastUsed ? current : lastUsed)) }) diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index c7960633..005af3ad 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -5,7 +5,7 @@ import { ChevronDown, Star } from "lucide-solid" import type { Model } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" -import { preferences, toggleFavoriteModelPreference } from "../stores/preferences" +import { uiState, toggleFavoriteModelPreference } from "../stores/preferences" const log = getLogger("session") @@ -59,7 +59,7 @@ export default function ModelSelector(props: ModelSelectorProps) { const favoriteKeySet = createMemo(() => { const result = new Set() - for (const item of preferences().modelFavorites ?? []) { + for (const item of uiState().models.favorites ?? []) { if (item.providerId && item.modelId) { result.add(`${item.providerId}/${item.modelId}`) } diff --git a/packages/ui/src/components/opencode-binary-selector.tsx b/packages/ui/src/components/opencode-binary-selector.tsx index dc6cdc0d..9518c24b 100644 --- a/packages/ui/src/components/opencode-binary-selector.tsx +++ b/packages/ui/src/components/opencode-binary-selector.tsx @@ -29,8 +29,8 @@ const OpenCodeBinarySelector: Component = (props) = opencodeBinaries, addOpenCodeBinary, removeOpenCodeBinary, - preferences, - updatePreferences, + serverSettings, + updateLastUsedBinary, } = useConfig() const [customPath, setCustomPath] = createSignal("") const [validating, setValidating] = createSignal(false) @@ -42,7 +42,7 @@ const OpenCodeBinarySelector: Component = (props) = const binaries = () => opencodeBinaries() - const lastUsedBinary = () => preferences().lastUsedBinary + const lastUsedBinary = () => serverSettings().opencodeBinary const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode")) @@ -158,7 +158,7 @@ const OpenCodeBinarySelector: Component = (props) = if (validation.valid) { addOpenCodeBinary(path, validation.version) props.onBinaryChange(path) - updatePreferences({ lastUsedBinary: path }) + updateLastUsedBinary(path) setCustomPath("") setValidationError(null) } else { @@ -183,7 +183,7 @@ const OpenCodeBinarySelector: Component = (props) = if (props.disabled) return if (path === props.selectedBinary) return props.onBinaryChange(path) - updatePreferences({ lastUsedBinary: path }) + updateLastUsedBinary(path) } function handleRemoveBinary(path: string, event: Event) { @@ -193,7 +193,7 @@ const OpenCodeBinarySelector: Component = (props) = if (props.selectedBinary === path) { props.onBinaryChange("opencode") - updatePreferences({ lastUsedBinary: "opencode" }) + updateLastUsedBinary("opencode") } } diff --git a/packages/ui/src/components/remote-access-overlay.tsx b/packages/ui/src/components/remote-access-overlay.tsx index e6589b5c..08815a15 100644 --- a/packages/ui/src/components/remote-access-overlay.tsx +++ b/packages/ui/src/components/remote-access-overlay.tsx @@ -6,7 +6,7 @@ import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-so import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" import { restartCli } from "../lib/native/cli" -import { preferences, setListeningMode } from "../stores/preferences" +import { serverSettings, setListeningMode } from "../stores/preferences" import { showConfirmDialog } from "../stores/alerts" import { getLogger } from "../lib/logger" import { useI18n } from "../lib/i18n" @@ -33,7 +33,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { const [savingPassword, setSavingPassword] = createSignal(false) const addresses = createMemo(() => meta()?.addresses ?? []) - const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode) + const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) const allowExternalConnections = createMemo(() => currentMode() === "all") const displayAddresses = createMemo(() => { const list = addresses() diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 74898c3f..96971e00 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -1,11 +1,7 @@ import type { - AppConfig, BackgroundProcess, BackgroundProcessListResponse, BackgroundProcessOutputResponse, - BinaryCreateRequest, - BinaryListResponse, - BinaryUpdateRequest, BinaryValidationResult, FileSystemEntry, FileSystemCreateFolderResponse, @@ -214,37 +210,27 @@ export const serverApi = { ) }, - fetchConfig(): Promise { - return request("/api/config/app") + fetchConfigOwner = Record>(owner: string): Promise { + return request(`/api/storage/config/${encodeURIComponent(owner)}`) }, - updateConfig(payload: AppConfig): Promise { - return request("/api/config/app", { - method: "PUT", - body: JSON.stringify(payload), - }) - }, - listBinaries(): Promise { - return request("/api/config/binaries") - }, - createBinary(payload: BinaryCreateRequest) { - return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", { - method: "POST", - body: JSON.stringify(payload), - }) - }, - - updateBinary(id: string, updates: BinaryUpdateRequest) { - return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, { + patchConfigOwner = Record>(owner: string, patch: unknown): Promise { + return request(`/api/storage/config/${encodeURIComponent(owner)}`, { method: "PATCH", - body: JSON.stringify(updates), + body: JSON.stringify(patch ?? {}), + }) + }, + fetchStateOwner = Record>(owner: string): Promise { + return request(`/api/storage/state/${encodeURIComponent(owner)}`) + }, + patchStateOwner = Record>(owner: string, patch: unknown): Promise { + return request(`/api/storage/state/${encodeURIComponent(owner)}`, { + method: "PATCH", + body: JSON.stringify(patch ?? {}), }) }, - deleteBinary(id: string): Promise { - return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" }) - }, validateBinary(path: string): Promise { - return request("/api/config/binaries/validate", { + return request("/api/storage/binaries/validate", { method: "POST", body: JSON.stringify({ path }), }) diff --git a/packages/ui/src/lib/storage.ts b/packages/ui/src/lib/storage.ts index a5f1afa6..4616bc94 100644 --- a/packages/ui/src/lib/storage.ts +++ b/packages/ui/src/lib/storage.ts @@ -1,11 +1,11 @@ -import type { AppConfig, InstanceData } from "../../../server/src/api-types" +import type { InstanceData, WorkspaceEventPayload } from "../../../server/src/api-types" import { serverApi } from "./api-client" import { serverEvents } from "./server-events" import { getLogger } from "./logger" const log = getLogger("actions") -export type ConfigData = AppConfig +export type OwnerBucket = Record const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], @@ -30,17 +30,25 @@ function isDeepEqual(a: unknown, b: unknown): boolean { } export class ServerStorage { - private configChangeListeners: Set<(config: ConfigData) => void> = new Set() - private configCache: ConfigData | null = null - private loadPromise: Promise | null = null + private configOwnerCache = new Map() + private stateOwnerCache = new Map() + private configOwnerLoadPromises = new Map>() + private stateOwnerLoadPromises = new Map>() + private configOwnerListeners = new Map void>>() + private stateOwnerListeners = new Map void>>() private instanceDataCache = new Map() private instanceDataListeners = new Map void>>() private instanceLoadPromises = new Map>() constructor() { - serverEvents.on("config.appChanged", (event) => { - if (event.type !== "config.appChanged") return - this.setConfigCache(event.config) + serverEvents.on("storage.configChanged", (event: WorkspaceEventPayload) => { + if (event.type !== "storage.configChanged") return + this.setOwnerCache("config", event.owner, event.value) + }) + + serverEvents.on("storage.stateChanged", (event: WorkspaceEventPayload) => { + if (event.type !== "storage.stateChanged") return + this.setOwnerCache("state", event.owner, event.value) }) serverEvents.on("instance.dataChanged", (event) => { @@ -49,30 +57,56 @@ export class ServerStorage { }) } - async loadConfig(): Promise { - if (this.configCache) { - return this.configCache - } + async loadConfigOwner(owner: string): Promise { + const cached = this.configOwnerCache.get(owner) + if (cached) return cached - if (!this.loadPromise) { - this.loadPromise = serverApi - .fetchConfig() - .then((config) => { - this.setConfigCache(config) - return config + if (!this.configOwnerLoadPromises.has(owner)) { + const promise = serverApi + .fetchConfigOwner(owner) + .then((value) => { + this.setOwnerCache("config", owner, value) + return value }) .finally(() => { - this.loadPromise = null + this.configOwnerLoadPromises.delete(owner) }) + this.configOwnerLoadPromises.set(owner, promise) } - return this.loadPromise + return this.configOwnerLoadPromises.get(owner)! } - async updateConfig(next: ConfigData): Promise { - const nextConfig = await serverApi.updateConfig(next) - this.setConfigCache(nextConfig) - return nextConfig + async patchConfigOwner(owner: string, patch: unknown): Promise { + const updated = await serverApi.patchConfigOwner(owner, patch) + this.setOwnerCache("config", owner, updated) + return updated + } + + async loadStateOwner(owner: string): Promise { + const cached = this.stateOwnerCache.get(owner) + if (cached) return cached + + if (!this.stateOwnerLoadPromises.has(owner)) { + const promise = serverApi + .fetchStateOwner(owner) + .then((value) => { + this.setOwnerCache("state", owner, value) + return value + }) + .finally(() => { + this.stateOwnerLoadPromises.delete(owner) + }) + this.stateOwnerLoadPromises.set(owner, promise) + } + + return this.stateOwnerLoadPromises.get(owner)! + } + + async patchStateOwner(owner: string, patch: unknown): Promise { + const updated = await serverApi.patchStateOwner(owner, patch) + this.setOwnerCache("state", owner, updated) + return updated } async loadInstanceData(instanceId: string): Promise { @@ -110,12 +144,40 @@ export class ServerStorage { this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA) } - onConfigChanged(listener: (config: ConfigData) => void): () => void { - this.configChangeListeners.add(listener) - if (this.configCache) { - listener(this.configCache) + onConfigOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void { + if (!this.configOwnerListeners.has(owner)) { + this.configOwnerListeners.set(owner, new Set()) + } + const bucket = this.configOwnerListeners.get(owner)! + bucket.add(listener) + const cached = this.configOwnerCache.get(owner) + if (cached) { + listener(cached) + } + return () => { + bucket.delete(listener) + if (bucket.size === 0) { + this.configOwnerListeners.delete(owner) + } + } + } + + onStateOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void { + if (!this.stateOwnerListeners.has(owner)) { + this.stateOwnerListeners.set(owner, new Set()) + } + const bucket = this.stateOwnerListeners.get(owner)! + bucket.add(listener) + const cached = this.stateOwnerCache.get(owner) + if (cached) { + listener(cached) + } + return () => { + bucket.delete(listener) + if (bucket.size === 0) { + this.stateOwnerListeners.delete(owner) + } } - return () => this.configChangeListeners.delete(listener) } onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void { @@ -136,18 +198,30 @@ export class ServerStorage { } } - private setConfigCache(config: ConfigData) { - if (this.configCache && isDeepEqual(this.configCache, config)) { - this.configCache = config + private setOwnerCache(kind: "config" | "state", owner: string, value: OwnerBucket) { + if (owner === "*") { + // Full-doc updates are not tracked owner-by-owner; invalidate caches. + if (kind === "config") { + this.configOwnerCache.clear() + } else { + this.stateOwnerCache.clear() + } return } - this.configCache = config - this.notifyConfigChanged(config) - } - private notifyConfigChanged(config: ConfigData) { - for (const listener of this.configChangeListeners) { - listener(config) + const cache = kind === "config" ? this.configOwnerCache : this.stateOwnerCache + const listeners = kind === "config" ? this.configOwnerListeners : this.stateOwnerListeners + + const previous = cache.get(owner) + if (previous && isDeepEqual(previous, value)) { + cache.set(owner, value) + return + } + cache.set(owner, value) + const bucket = listeners.get(owner) + if (!bucket) return + for (const listener of bucket) { + listener(value) } } diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 4ab54714..c0a1b4ff 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -30,8 +30,8 @@ async function bootstrap() { document.documentElement.removeAttribute("data-theme") try { - const config = await storage.loadConfig() - const theme = config?.theme ?? "system" + const uiConfig = await storage.loadConfigOwner("ui") + const theme = (uiConfig as any)?.theme ?? "system" if (theme === "system") { document.documentElement.removeAttribute("data-theme") diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index f3e5c56e..f2a39bda 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -20,7 +20,7 @@ import { } from "./sessions" import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" import { fetchCommands, clearCommands } from "./commands" -import { preferences } from "./preferences" +import { serverSettings } from "./preferences" import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" @@ -91,7 +91,7 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath, binaryLabel: descriptor.binaryLabel, binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion, - environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {}, + environmentVariables: existing?.environmentVariables ?? serverSettings().environmentVariables ?? {}, } } diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 5907e78e..e66af3e9 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,6 +1,6 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" -import { storage, type ConfigData } from "../lib/storage" +import { storage, type OwnerBucket } from "../lib/storage" import { ensureInstanceConfigLoaded, getInstanceConfig, @@ -23,32 +23,21 @@ export interface ModelPreference { modelId: string } -export interface AgentModelSelections { - [instanceId: string]: Record -} - export type DiffViewMode = "split" | "unified" export type ExpansionPreference = "expanded" | "collapsed" - export type ListeningMode = "local" | "all" -export interface Preferences { +export interface UiSettings { showThinkingBlocks: boolean thinkingBlocksExpansion: ExpansionPreference showTimelineTools: boolean promptSubmitOnEnter: boolean - lastUsedBinary?: string locale?: string - environmentVariables: Record - modelRecents: ModelPreference[] - modelFavorites: ModelPreference[] - modelThinkingSelections: Record diffViewMode: DiffViewMode toolOutputExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference showUsageMetrics: boolean autoCleanupBlankSessions: boolean - listeningMode: ListeningMode // OS notifications osNotificationsEnabled: boolean @@ -57,12 +46,14 @@ export interface Preferences { notifyOnIdle: boolean } +// Backwards-compatible alias for older imports. +export type Preferences = UiSettings export interface OpenCodeBinary { - path: string version?: string lastUsed: number + label?: string } export interface RecentFolder { @@ -70,27 +61,53 @@ export interface RecentFolder { lastAccessed: number } -export type ThemePreference = NonNullable +export type ThemePreference = "light" | "dark" | "system" + +interface UiConfigBucket { + theme?: ThemePreference + settings?: Partial +} + +interface ServerConfigBucket { + listeningMode?: ListeningMode + environmentVariables?: Record + opencodeBinary?: string +} + +interface UiStateBucket { + recentFolders?: RecentFolder[] + opencodeBinaries?: OpenCodeBinary[] + models?: { + recents?: ModelPreference[] + favorites?: ModelPreference[] + thinkingSelections?: Record + } +} + +interface NormalizedUiState { + recentFolders: RecentFolder[] + opencodeBinaries: OpenCodeBinary[] + models: { + recents: ModelPreference[] + favorites: ModelPreference[] + thinkingSelections: Record + } +} const MAX_RECENT_FOLDERS = 20 const MAX_RECENT_MODELS = 5 const MAX_FAVORITE_MODELS = 50 -const defaultPreferences: Preferences = { +const defaultUiSettings: UiSettings = { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded", showTimelineTools: true, promptSubmitOnEnter: false, - environmentVariables: {}, - modelRecents: [], - modelFavorites: [], - modelThinkingSelections: {}, diffViewMode: "split", toolOutputExpansion: "expanded", diagnosticsExpansion: "expanded", showUsageMetrics: true, autoCleanupBlankSessions: true, - listeningMode: "local", osNotificationsEnabled: false, osNotificationsAllowWhenVisible: false, @@ -98,382 +115,351 @@ const defaultPreferences: Preferences = { notifyOnIdle: true, } - -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true - if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) { - try { - return JSON.stringify(a) === JSON.stringify(b) - } catch (error) { - log.warn("Failed to compare preference values", error) - } +function normalizeUiSettings(input?: Partial | null): UiSettings { + const sanitized = input ?? {} + return { + showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultUiSettings.showThinkingBlocks, + thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion, + showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools, + promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter, + locale: sanitized.locale ?? defaultUiSettings.locale, + diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode, + toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion, + diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultUiSettings.diagnosticsExpansion, + showUsageMetrics: sanitized.showUsageMetrics ?? defaultUiSettings.showUsageMetrics, + autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, + osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled, + osNotificationsAllowWhenVisible: + sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible, + notifyOnNeedsInput: sanitized.notifyOnNeedsInput ?? defaultUiSettings.notifyOnNeedsInput, + notifyOnIdle: sanitized.notifyOnIdle ?? defaultUiSettings.notifyOnIdle, } - return false } -function normalizePreferences(pref?: Partial & { agentModelSelections?: unknown }): Preferences { - const sanitized = pref ?? {} - const environmentVariables = { - ...defaultPreferences.environmentVariables, - ...(sanitized.environmentVariables ?? {}), +function normalizeRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + if (typeof v === "string") out[k] = v } + return out +} - const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents - const modelRecents = sourceModelRecents.map((item) => ({ ...item })) - - const sourceModelFavorites = sanitized.modelFavorites ?? defaultPreferences.modelFavorites - const modelFavorites = sourceModelFavorites.map((item) => ({ ...item })) - - const modelThinkingSelections = { - ...defaultPreferences.modelThinkingSelections, - ...(sanitized.modelThinkingSelections ?? {}), +function cloneArray(value: unknown, mapper: (item: any) => T | null): T[] { + if (!Array.isArray(value)) return [] + const out: T[] = [] + for (const item of value) { + const mapped = mapper(item) + if (mapped) out.push(mapped) } + return out +} +function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { + const source = input ?? {} return { - showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, - thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion, - showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools, - promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter, - lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary, - locale: sanitized.locale ?? defaultPreferences.locale, - environmentVariables, - modelRecents, - modelFavorites, - modelThinkingSelections, - diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode, - toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion, - diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, - showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics, - autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions, - listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode, - - osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultPreferences.osNotificationsEnabled, - osNotificationsAllowWhenVisible: - sanitized.osNotificationsAllowWhenVisible ?? defaultPreferences.osNotificationsAllowWhenVisible, - notifyOnNeedsInput: sanitized.notifyOnNeedsInput ?? defaultPreferences.notifyOnNeedsInput, - notifyOnIdle: sanitized.notifyOnIdle ?? defaultPreferences.notifyOnIdle, + recentFolders: cloneArray(source.recentFolders, (f) => { + if (!f || typeof f !== "object") return null + const p = (f as any).path + const lastAccessed = (f as any).lastAccessed + if (typeof p !== "string") return null + const ts = typeof lastAccessed === "number" ? lastAccessed : Date.now() + return { path: p, lastAccessed: ts } + }), + opencodeBinaries: cloneArray(source.opencodeBinaries, (b) => { + if (!b || typeof b !== "object") return null + const p = (b as any).path + if (typeof p !== "string") return null + const lastUsed = typeof (b as any).lastUsed === "number" ? (b as any).lastUsed : Date.now() + const version = typeof (b as any).version === "string" ? (b as any).version : undefined + const label = typeof (b as any).label === "string" ? (b as any).label : undefined + return { path: p, version, label, lastUsed } + }), + models: { + recents: cloneArray((source.models as any)?.recents, (m) => { + if (!m || typeof m !== "object") return null + const providerId = (m as any).providerId + const modelId = (m as any).modelId + if (typeof providerId !== "string" || typeof modelId !== "string") return null + return { providerId, modelId } + }), + favorites: cloneArray((source.models as any)?.favorites, (m) => { + if (!m || typeof m !== "object") return null + const providerId = (m as any).providerId + const modelId = (m as any).modelId + if (typeof providerId !== "string" || typeof modelId !== "string") return null + return { providerId, modelId } + }), + thinkingSelections: normalizeRecord((source.models as any)?.thinkingSelections), + }, } } +function normalizeServerConfig(input?: ServerConfigBucket | null): Required> { + const source = input ?? {} + const listeningMode = source.listeningMode === "all" ? "all" : "local" + const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode" + const environmentVariables = normalizeRecord(source.environmentVariables) + return { listeningMode, opencodeBinary, environmentVariables } +} + function getModelKey(model: { providerId: string; modelId: string }): string { return `${model.providerId}/${model.modelId}` } +function buildRecentFolderList(folderPath: string, source: RecentFolder[]): RecentFolder[] { + const folders = source.filter((f) => f.path !== folderPath) + folders.unshift({ path: folderPath, lastAccessed: Date.now() }) + return folders.slice(0, MAX_RECENT_FOLDERS) +} + +function buildBinaryList(binaryPath: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] { + const timestamp = Date.now() + const existing = source.find((b) => b.path === binaryPath) + if (existing) { + const updatedEntry: OpenCodeBinary = { ...existing, lastUsed: timestamp, version: version ?? existing.version } + const remaining = source.filter((b) => b.path !== binaryPath) + return [updatedEntry, ...remaining] + } + const nextEntry: OpenCodeBinary = version + ? { path: binaryPath, version, lastUsed: timestamp } + : { path: binaryPath, lastUsed: timestamp } + return [nextEntry, ...source].slice(0, 10) +} + +const [uiConfigBucket, setUiConfigBucket] = createSignal({}) +const [serverConfigBucket, setServerConfigBucket] = createSignal({}) +const [uiStateBucket, setUiStateBucket] = createSignal({}) +const [isLoaded, setIsLoaded] = createSignal(false) + +const uiSettings = createMemo(() => normalizeUiSettings(uiConfigBucket().settings)) +const themePreference = createMemo(() => uiConfigBucket().theme ?? "system") +const serverSettings = createMemo(() => normalizeServerConfig(serverConfigBucket())) +const uiState = createMemo(() => normalizeUiState(uiStateBucket())) + +const preferences = uiSettings +const recentFolders = createMemo(() => uiState().recentFolders) +const opencodeBinaries = createMemo(() => uiState().opencodeBinaries) + +let loadPromise: Promise | null = null + +async function ensureLoaded(): Promise { + if (isLoaded()) return + if (!loadPromise) { + loadPromise = Promise.all([ + storage.loadConfigOwner("ui"), + storage.loadConfigOwner("server"), + storage.loadStateOwner("ui"), + ]) + .then(([uiCfg, srvCfg, uiSt]) => { + setUiConfigBucket(uiCfg as any) + setServerConfigBucket(srvCfg as any) + setUiStateBucket(uiSt as any) + setIsLoaded(true) + }) + .catch((error) => { + log.error("Failed to load settings", error) + setUiConfigBucket({}) + setServerConfigBucket({}) + setUiStateBucket({}) + setIsLoaded(true) + }) + .finally(() => { + loadPromise = null + }) + } + await loadPromise +} + +async function patchConfigOwner(owner: string, patch: unknown) { + await ensureLoaded() + const updated = await storage.patchConfigOwner(owner, patch) + if (owner === "ui") setUiConfigBucket(updated as any) + if (owner === "server") setServerConfigBucket(updated as any) +} + +async function patchStateOwner(owner: string, patch: unknown) { + await ensureLoaded() + const updated = await storage.patchStateOwner(owner, patch) + if (owner === "ui") setUiStateBucket(updated as any) +} + +function updateUiSettings(updates: Partial) { + const current = uiConfigBucket() + const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates }) + const patch = { settings: nextSettings } + void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error)) +} + +function updatePreferences(updates: Partial): void { + updateUiSettings(updates) +} + +function setThemePreference(preference: ThemePreference): void { + if (themePreference() === preference) return + void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error)) +} + +function setListeningMode(mode: ListeningMode): void { + if (serverSettings().listeningMode === mode) return + void patchConfigOwner("server", { listeningMode: mode }).catch((error) => log.error("Failed to set listening mode", error)) +} + +function updateEnvironmentVariables(envVars: Record): void { + void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) => + log.error("Failed to update environment variables", error), + ) +} + +function addEnvironmentVariable(key: string, value: string): void { + const current = serverSettings().environmentVariables + updateEnvironmentVariables({ ...current, [key]: value }) +} + +function removeEnvironmentVariable(key: string): void { + const current = serverSettings().environmentVariables + const { [key]: removed, ...rest } = current + updateEnvironmentVariables(rest) +} + +function updateLastUsedBinary(path: string): void { + const target = path && path.trim().length > 0 ? path : "opencode" + void patchConfigOwner("server", { opencodeBinary: target }).catch((error) => log.error("Failed to set default binary", error)) + + // also bump lastUsed in state ui.opencodeBinaries + const nextList = buildBinaryList(target, undefined, opencodeBinaries()) + void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error)) +} + +function addOpenCodeBinary(path: string, version?: string): void { + const nextList = buildBinaryList(path, version, opencodeBinaries()) + void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error)) +} + +function removeOpenCodeBinary(path: string): void { + const nextList = opencodeBinaries().filter((b) => b.path !== path) + void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to remove binary", error)) + + if (serverSettings().opencodeBinary === path) { + void patchConfigOwner("server", { opencodeBinary: "opencode" }).catch((error) => + log.error("Failed to reset default binary", error), + ) + } +} + +function addRecentFolder(folderPath: string): void { + const next = buildRecentFolderList(folderPath, recentFolders()) + void patchStateOwner("ui", { recentFolders: next }).catch((error) => log.error("Failed to add recent folder", error)) +} + +function removeRecentFolder(folderPath: string): void { + const next = recentFolders().filter((f) => f.path !== folderPath) + void patchStateOwner("ui", { recentFolders: next }).catch((error) => log.error("Failed to remove recent folder", error)) +} + +function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void { + const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : serverSettings().opencodeBinary + const nextFolders = buildRecentFolderList(folderPath, recentFolders()) + const nextBinaries = buildBinaryList(targetBinary, undefined, opencodeBinaries()) + + void patchStateOwner("ui", { recentFolders: nextFolders, opencodeBinaries: nextBinaries }).catch((error) => + log.error("Failed to update ui state on launch", error), + ) + void patchConfigOwner("server", { opencodeBinary: targetBinary }).catch((error) => + log.error("Failed to persist selected binary", error), + ) +} + +function addRecentModelPreference(model: ModelPreference): void { + if (!model.providerId || !model.modelId) return + const recents = uiState().models.recents + const filtered = recents.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) + const updated = [model, ...filtered].slice(0, MAX_RECENT_MODELS) + void patchStateOwner("ui", { models: { recents: updated } }).catch((error) => log.error("Failed to update model recents", error)) +} + function isFavoriteModelPreference(model: ModelPreference): boolean { if (!model.providerId || !model.modelId) return false - return (preferences().modelFavorites ?? []).some( - (item) => item.providerId === model.providerId && item.modelId === model.modelId, - ) + return uiState().models.favorites.some((item) => item.providerId === model.providerId && item.modelId === model.modelId) } function toggleFavoriteModelPreference(model: ModelPreference): void { if (!model.providerId || !model.modelId) return - const favorites = preferences().modelFavorites ?? [] + const favorites = uiState().models.favorites const exists = favorites.some((item) => item.providerId === model.providerId && item.modelId === model.modelId) - if (exists) { - const updated = favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) - updatePreferences({ modelFavorites: updated }) - return - } + const updated = exists + ? favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) + : [model, ...favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId)].slice( + 0, + MAX_FAVORITE_MODELS, + ) - const filtered = favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) - const updated = [model, ...filtered].slice(0, MAX_FAVORITE_MODELS) - updatePreferences({ modelFavorites: updated }) + void patchStateOwner("ui", { models: { favorites: updated } }).catch((error) => log.error("Failed to update model favorites", error)) } function getModelThinkingSelection(model: { providerId: string; modelId: string }): string | undefined { if (!model.providerId || !model.modelId) return undefined - return preferences().modelThinkingSelections?.[getModelKey(model)] + return uiState().models.thinkingSelections[getModelKey(model)] } function setModelThinkingSelection(model: { providerId: string; modelId: string }, value: string | undefined): void { if (!model.providerId || !model.modelId) return const key = getModelKey(model) - const current = preferences().modelThinkingSelections?.[key] + const current = uiState().models.thinkingSelections[key] if (current === value) return - updateConfig((draft) => { - const selections = { ...(draft.preferences.modelThinkingSelections ?? {}) } - if (!value) { - delete selections[key] - } else { - selections[key] = value - } - draft.preferences = normalizePreferences({ - ...draft.preferences, - modelThinkingSelections: selections, - }) - }) -} - -const [internalConfig, setInternalConfig] = createSignal(buildFallbackConfig()) - -const config = createMemo>(() => internalConfig()) -const [isConfigLoaded, setIsConfigLoaded] = createSignal(false) -const preferences = createMemo(() => internalConfig().preferences) -const recentFolders = createMemo(() => internalConfig().recentFolders ?? []) -const opencodeBinaries = createMemo(() => internalConfig().opencodeBinaries ?? []) -const themePreference = createMemo(() => internalConfig().theme ?? "system") -let loadPromise: Promise | null = null - -function normalizeConfig(config?: ConfigData | null): ConfigData { - return { - preferences: normalizePreferences(config?.preferences), - recentFolders: (config?.recentFolders ?? []).map((folder) => ({ ...folder })), - opencodeBinaries: (config?.opencodeBinaries ?? []).map((binary) => ({ ...binary })), - theme: config?.theme ?? "system", + const selections = { ...uiState().models.thinkingSelections } + if (!value) { + delete selections[key] + } else { + selections[key] = value } -} - -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 loaded = source ?? (await storage.loadConfig()) - const { cleaned, migrated } = removeLegacyAgentSelections(loaded) - applyConfig(cleaned) - if (migrated) { - void storage.updateConfig(cleaned).catch((error: unknown) => { - log.error("Failed to persist legacy config cleanup", error) - }) - } - } catch (error) { - log.error("Failed to load config", error) - applyConfig(buildFallbackConfig()) - } -} - -function applyConfig(next: ConfigData) { - setInternalConfig(normalizeConfig(next)) - setIsConfigLoaded(true) -} - -function cloneConfigForUpdate(): ConfigData { - return normalizeConfig(internalConfig()) -} - -function logConfigDiff(previous: ConfigData, next: ConfigData) { - if (deepEqual(previous, next)) { - return - } - const changes = diffObjects(previous, next) - if (changes.length > 0) { - log.info("[Config] Changes", changes) - } -} - -function diffObjects(previous: unknown, next: unknown, path: string[] = []): string[] { - if (previous === next) { - return [] - } - - if (typeof previous !== "object" || previous === null || typeof next !== "object" || next === null) { - return [path.join(".")] - } - - const prevKeys = Object.keys(previous as Record) - const nextKeys = Object.keys(next as Record) - const allKeys = new Set([...prevKeys, ...nextKeys]) - const changes: string[] = [] - - for (const key of allKeys) { - const childPath = [...path, key] - const prevValue = (previous as Record)[key] - const nextValue = (next as Record)[key] - changes.push(...diffObjects(prevValue, nextValue, childPath)) - } - - return changes -} - -function updateConfig(mutator: (draft: ConfigData) => void): void { - const previous = internalConfig() - const draft = cloneConfigForUpdate() - mutator(draft) - logConfigDiff(previous, draft) - applyConfig(draft) - void persistFullConfig(draft) -} - -async function persistFullConfig(next: ConfigData): Promise { - try { - await ensureConfigLoaded() - await storage.updateConfig(next) - } catch (error) { - log.error("Failed to save config", error) - void syncConfig().catch((syncError: unknown) => { - log.error("Failed to refresh config", syncError) - }) - } -} - -function setThemePreference(preference: ThemePreference): void { - if (themePreference() === preference) { - return - } - updateConfig((draft) => { - draft.theme = preference - }) -} - -async function ensureConfigLoaded(): Promise { - if (isConfigLoaded()) return - if (!loadPromise) { - loadPromise = syncConfig().finally(() => { - loadPromise = null - }) - } - await loadPromise -} - -function buildRecentFolderList(path: string, source: RecentFolder[]): RecentFolder[] { - const folders = source.filter((f) => f.path !== path) - folders.unshift({ path, lastAccessed: Date.now() }) - return folders.slice(0, MAX_RECENT_FOLDERS) -} - -function buildBinaryList(path: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] { - const timestamp = Date.now() - const existing = source.find((b) => b.path === path) - if (existing) { - const updatedEntry: OpenCodeBinary = { ...existing, lastUsed: timestamp } - const remaining = source.filter((b) => b.path !== path) - return [updatedEntry, ...remaining] - } - const nextEntry: OpenCodeBinary = version ? { path, version, lastUsed: timestamp } : { path, lastUsed: timestamp } - return [nextEntry, ...source].slice(0, 10) -} - -function updatePreferences(updates: Partial): void { - const current = internalConfig().preferences - const merged = normalizePreferences({ ...current, ...updates }) - if (deepEqual(current, merged)) { - return - } - updateConfig((draft) => { - draft.preferences = merged - }) -} - -function setListeningMode(mode: ListeningMode): void { - if (preferences().listeningMode === mode) return - updatePreferences({ listeningMode: mode }) + void patchStateOwner("ui", { models: { thinkingSelections: selections } }).catch((error) => + log.error("Failed to update thinking selection", error), + ) } function setDiffViewMode(mode: DiffViewMode): void { if (preferences().diffViewMode === mode) return - updatePreferences({ diffViewMode: mode }) + updateUiSettings({ diffViewMode: mode }) } function setToolOutputExpansion(mode: ExpansionPreference): void { if (preferences().toolOutputExpansion === mode) return - updatePreferences({ toolOutputExpansion: mode }) + updateUiSettings({ toolOutputExpansion: mode }) } function setDiagnosticsExpansion(mode: ExpansionPreference): void { if (preferences().diagnosticsExpansion === mode) return - updatePreferences({ diagnosticsExpansion: mode }) + updateUiSettings({ diagnosticsExpansion: mode }) } function setThinkingBlocksExpansion(mode: ExpansionPreference): void { if (preferences().thinkingBlocksExpansion === mode) return - updatePreferences({ thinkingBlocksExpansion: mode }) + updateUiSettings({ thinkingBlocksExpansion: mode }) } function toggleShowThinkingBlocks(): void { - updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks }) + updateUiSettings({ showThinkingBlocks: !preferences().showThinkingBlocks }) } function toggleShowTimelineTools(): void { - updatePreferences({ showTimelineTools: !preferences().showTimelineTools }) + updateUiSettings({ showTimelineTools: !preferences().showTimelineTools }) } function toggleUsageMetrics(): void { - updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics }) + updateUiSettings({ showUsageMetrics: !preferences().showUsageMetrics }) } function togglePromptSubmitOnEnter(): void { - updatePreferences({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter }) + updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter }) } function toggleAutoCleanupBlankSessions(): void { const nextValue = !preferences().autoCleanupBlankSessions log.info("toggle auto cleanup", { value: nextValue }) - updatePreferences({ autoCleanupBlankSessions: nextValue }) -} - -function addRecentFolder(path: string): void { - updateConfig((draft) => { - draft.recentFolders = buildRecentFolderList(path, draft.recentFolders) - }) -} - -function removeRecentFolder(path: string): void { - updateConfig((draft) => { - draft.recentFolders = draft.recentFolders.filter((f) => f.path !== path) - }) -} - -function addOpenCodeBinary(path: string, version?: string): void { - updateConfig((draft) => { - draft.opencodeBinaries = buildBinaryList(path, version, draft.opencodeBinaries) - }) -} - -function removeOpenCodeBinary(path: string): void { - updateConfig((draft) => { - draft.opencodeBinaries = draft.opencodeBinaries.filter((b) => b.path !== path) - }) -} - -function updateLastUsedBinary(path: string): void { - const target = path || preferences().lastUsedBinary || "opencode" - updateConfig((draft) => { - draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: target }) - draft.opencodeBinaries = buildBinaryList(target, undefined, draft.opencodeBinaries) - }) -} - -function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void { - updateConfig((draft) => { - const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : draft.preferences.lastUsedBinary || "opencode" - draft.recentFolders = buildRecentFolderList(folderPath, draft.recentFolders) - draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: targetBinary }) - draft.opencodeBinaries = buildBinaryList(targetBinary, undefined, draft.opencodeBinaries) - }) -} - -function updateEnvironmentVariables(envVars: Record): void { - updatePreferences({ environmentVariables: envVars }) -} - -function addEnvironmentVariable(key: string, value: string): void { - const current = preferences().environmentVariables || {} - const updated = { ...current, [key]: value } - updateEnvironmentVariables(updated) -} - -function removeEnvironmentVariable(key: string): void { - const current = preferences().environmentVariables || {} - const { [key]: removed, ...rest } = current - updateEnvironmentVariables(rest) -} - -function addRecentModelPreference(model: ModelPreference): void { - if (!model.providerId || !model.modelId) return - const recents = preferences().modelRecents ?? [] - const filtered = recents.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) - const updated = [model, ...filtered].slice(0, MAX_RECENT_MODELS) - updatePreferences({ modelRecents: updated }) + updateUiSettings({ autoCleanupBlankSessions: nextValue }) } async function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): Promise { @@ -497,41 +483,52 @@ async function getAgentModelPreference(instanceId: string, agent: string): Promi return selections[agent] } -void ensureConfigLoaded().catch((error: unknown) => { - log.error("Failed to initialize config", error) +void ensureLoaded().catch((error: unknown) => { + log.error("Failed to initialize settings", error) }) interface ConfigContextValue { isLoaded: Accessor - config: typeof config preferences: typeof preferences - recentFolders: typeof recentFolders - opencodeBinaries: typeof opencodeBinaries + updatePreferences: typeof updatePreferences themePreference: typeof themePreference setThemePreference: typeof setThemePreference - updateConfig: typeof updateConfig + + // server-owned stable config + serverSettings: typeof serverSettings + setListeningMode: typeof setListeningMode + updateEnvironmentVariables: typeof updateEnvironmentVariables + addEnvironmentVariable: typeof addEnvironmentVariable + removeEnvironmentVariable: typeof removeEnvironmentVariable + updateLastUsedBinary: typeof updateLastUsedBinary + + // ui-owned state + recentFolders: typeof recentFolders + opencodeBinaries: typeof opencodeBinaries + uiState: typeof uiState + addRecentFolder: typeof addRecentFolder + removeRecentFolder: typeof removeRecentFolder + addOpenCodeBinary: typeof addOpenCodeBinary + removeOpenCodeBinary: typeof removeOpenCodeBinary + recordWorkspaceLaunch: typeof recordWorkspaceLaunch + addRecentModelPreference: typeof addRecentModelPreference + isFavoriteModelPreference: typeof isFavoriteModelPreference + toggleFavoriteModelPreference: typeof toggleFavoriteModelPreference + getModelThinkingSelection: typeof getModelThinkingSelection + setModelThinkingSelection: typeof setModelThinkingSelection + + // ui settings helpers toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks toggleShowTimelineTools: typeof toggleShowTimelineTools toggleUsageMetrics: typeof toggleUsageMetrics toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter - setDiffViewMode: typeof setDiffViewMode setToolOutputExpansion: typeof setToolOutputExpansion setDiagnosticsExpansion: typeof setDiagnosticsExpansion setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion - setListeningMode: typeof setListeningMode - addRecentFolder: typeof addRecentFolder - removeRecentFolder: typeof removeRecentFolder - addOpenCodeBinary: typeof addOpenCodeBinary - removeOpenCodeBinary: typeof removeOpenCodeBinary - updateLastUsedBinary: typeof updateLastUsedBinary - recordWorkspaceLaunch: typeof recordWorkspaceLaunch - updatePreferences: typeof updatePreferences - updateEnvironmentVariables: typeof updateEnvironmentVariables - addEnvironmentVariable: typeof addEnvironmentVariable - removeEnvironmentVariable: typeof removeEnvironmentVariable - addRecentModelPreference: typeof addRecentModelPreference + + // instance scoped setAgentModelPreference: typeof setAgentModelPreference getAgentModelPreference: typeof getAgentModelPreference } @@ -539,14 +536,30 @@ interface ConfigContextValue { const ConfigContext = createContext() const configContextValue: ConfigContextValue = { - isLoaded: isConfigLoaded, - config, + isLoaded, preferences, - recentFolders, - opencodeBinaries, + updatePreferences, themePreference, setThemePreference, - updateConfig, + serverSettings, + setListeningMode, + updateEnvironmentVariables, + addEnvironmentVariable, + removeEnvironmentVariable, + updateLastUsedBinary, + recentFolders, + opencodeBinaries, + uiState, + addRecentFolder, + removeRecentFolder, + addOpenCodeBinary, + removeOpenCodeBinary, + recordWorkspaceLaunch, + addRecentModelPreference, + isFavoriteModelPreference, + toggleFavoriteModelPreference, + getModelThinkingSelection, + setModelThinkingSelection, toggleShowThinkingBlocks, toggleShowTimelineTools, toggleUsageMetrics, @@ -556,43 +569,40 @@ const configContextValue: ConfigContextValue = { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, - setListeningMode, - addRecentFolder, - removeRecentFolder, - addOpenCodeBinary, - removeOpenCodeBinary, - updateLastUsedBinary, - recordWorkspaceLaunch, - updatePreferences, - updateEnvironmentVariables, - addEnvironmentVariable, - removeEnvironmentVariable, - addRecentModelPreference, setAgentModelPreference, getAgentModelPreference, } -const ConfigProvider: ParentComponent = (props) => { +export const ConfigProvider: ParentComponent = (props) => { onMount(() => { - ensureConfigLoaded().catch((error: unknown) => { - log.error("Failed to initialize config", error) + ensureLoaded().catch((error: unknown) => { + log.error("Failed to initialize settings", error) }) - const unsubscribe = storage.onConfigChanged((config) => { - syncConfig(config).catch((error: unknown) => { - log.error("Failed to refresh config", error) - }) + const unsubUi = storage.onConfigOwnerChanged("ui", (bucket) => { + setUiConfigBucket(bucket as any) + setIsLoaded(true) + }) + const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => { + setServerConfigBucket(bucket as any) + setIsLoaded(true) + }) + const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => { + setUiStateBucket(bucket as any) + setIsLoaded(true) }) return () => { - unsubscribe() + unsubUi() + unsubServer() + unsubStateUi() } }) return {props.children} } -function useConfig(): ConfigContextValue { +export function useConfig(): ConfigContextValue { const context = useContext(ConfigContext) if (!context) { throw new Error("useConfig must be used within ConfigProvider") @@ -601,41 +611,38 @@ function useConfig(): ConfigContextValue { } export { - ConfigProvider, - useConfig, - config, preferences, - updateConfig, - updatePreferences, - toggleShowThinkingBlocks, - toggleShowTimelineTools, - toggleAutoCleanupBlankSessions, - toggleUsageMetrics, - togglePromptSubmitOnEnter, + uiState, + serverSettings, recentFolders, - addRecentFolder, - removeRecentFolder, opencodeBinaries, - addOpenCodeBinary, - removeOpenCodeBinary, - updateLastUsedBinary, + themePreference, + setThemePreference, + updatePreferences, + setListeningMode, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable, + updateLastUsedBinary, + addRecentFolder, + removeRecentFolder, + addOpenCodeBinary, + removeOpenCodeBinary, + recordWorkspaceLaunch, addRecentModelPreference, isFavoriteModelPreference, toggleFavoriteModelPreference, getModelThinkingSelection, setModelThinkingSelection, - setAgentModelPreference, - getAgentModelPreference, + toggleShowThinkingBlocks, + toggleShowTimelineTools, + toggleUsageMetrics, + toggleAutoCleanupBlankSessions, + togglePromptSubmitOnEnter, setDiffViewMode, setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, - setListeningMode, - themePreference, - setThemePreference, - recordWorkspaceLaunch, - } - + setAgentModelPreference, + getAgentModelPreference, +} diff --git a/packages/ui/src/stores/session-models.ts b/packages/ui/src/stores/session-models.ts index d03fd423..919e8a9e 100644 --- a/packages/ui/src/stores/session-models.ts +++ b/packages/ui/src/stores/session-models.ts @@ -1,5 +1,5 @@ import { agents, providers } from "./session-state" -import { preferences, getAgentModelPreference } from "./preferences" +import { uiState, getAgentModelPreference } from "./preferences" const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000 @@ -17,7 +17,7 @@ function isModelValid( function getRecentModelPreferenceForInstance( instanceId: string, ): { providerId: string; modelId: string } | undefined { - const recents = preferences().modelRecents ?? [] + const recents = uiState().models.recents ?? [] for (const item of recents) { if (isModelValid(instanceId, item)) { return item