From 10638d1d9fea3455ec61bdfc20cc615ae004a9f3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:41:50 +1000 Subject: [PATCH] refactor(app): port the settings store to plain codecs Migration shapes use preserving structs so undeclared stored fields survive, as Effect's onExcessProperty preserve did at every level; explicit-but-invalid legacy values still decode to null rather than absent; persisted() encodes through encodeOrThrow so an invalid in-memory value fails instead of being written. --- packages/app/src/runtime/persistence/codec.ts | 42 +- .../app/src/runtime/persistence/storage.ts | 4 +- .../app/src/runtime/server/persistence.ts | 3 +- packages/app/src/settings/model.test.ts | 18 +- packages/app/src/settings/model.tsx | 1018 ++++++++--------- packages/app/src/shell/state/layout.tsx | 3 +- 6 files changed, 548 insertions(+), 540 deletions(-) diff --git a/packages/app/src/runtime/persistence/codec.ts b/packages/app/src/runtime/persistence/codec.ts index 758a30fe747..825dea4f5f5 100644 --- a/packages/app/src/runtime/persistence/codec.ts +++ b/packages/app/src/runtime/persistence/codec.ts @@ -46,6 +46,13 @@ export function decodeOrThrow(codec: Of, input: unknown): T { return value } +/** Encodes and checks the result decodes, so an invalid in-memory value fails loudly instead of persisting. */ +export function encodeOrThrow(codec: Of, value: T): E { + const encoded = codec.encode(value) + if (codec.decode(encoded) === INVALID) throw new Error("Value does not match its codec") + return encoded +} + const identity = (value: T) => value export const string: Of = make((v) => (typeof v === "string" ? v : INVALID), identity) @@ -120,20 +127,23 @@ export interface Struct extends Of, StructEncode readonly fields: F } -export function struct(fields: F): Struct { - const entries = Object.entries(fields) - return { - ...make, StructEncoded>( - (input) => { - if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID - const record = input as Record - const out: Record = {} +// `preserve` keeps keys the struct does not declare, for migration shapes that only describe the +// fields they rewrite (Effect's `onExcessProperty: "preserve"`); the current schema then decides. +export function struct(fields: F, options?: { preserve?: boolean }): Struct { + const entries = Object.entries(fields) + return { + ...make, StructEncoded>( + (input) => { + if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID + const record = input as Record + const out: Record = options?.preserve ? { ...record } : {} for (const [key, codec] of entries) { - const present = Object.hasOwn(record, key) - if (!present && codec.optional) continue - const value = codec.decode(record[key]) - if (value === INVALID) return INVALID - if (value !== undefined || present) out[key] = value + const present = Object.hasOwn(record, key) + if (!present && codec.optional) continue + const value = codec.decode(record[key]) + if (value === INVALID) return INVALID + if (value !== undefined || present) out[key] = value + else delete out[key] } return out as StructType }, @@ -317,10 +327,8 @@ export function withInitial(definition: C | Migrated, initial: const read = isMigrated(definition) ? definition.read : unknown return make( (input) => { - const migrated = read.decode(input) - if (migrated === INVALID) return INVALID - // A migration only describes the fields it rewrites; everything else stored stays as it was. - const stored = isObject(input) && isObject(migrated) ? { ...input, ...migrated } : migrated + const stored = read.decode(input) + if (stored === INVALID) return INVALID return merge(initial, recover(codec, stored, initial)) }, (value) => codec.encode(value), diff --git a/packages/app/src/runtime/persistence/storage.ts b/packages/app/src/runtime/persistence/storage.ts index 3553f1a0299..f4e64b79b57 100644 --- a/packages/app/src/runtime/persistence/storage.ts +++ b/packages/app/src/runtime/persistence/storage.ts @@ -490,8 +490,8 @@ function serializer | Codec.An return { decode: (raw: string) => Codec.decodeOption(json, raw) as S["Type"] | undefined, deserialize: (raw: unknown) => Codec.decodeOrThrow(json, raw) as S["Type"], - serialize: (value: S["Type"]) => json.encode(value), - encode: (value: S["Type"]) => codec.encode(value), + serialize: (value: S["Type"]) => Codec.encodeOrThrow(json, value), + encode: (value: S["Type"]) => Codec.encodeOrThrow(codec, value), initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"], } } diff --git a/packages/app/src/runtime/server/persistence.ts b/packages/app/src/runtime/server/persistence.ts index 6680fdd31f0..737d93a9469 100644 --- a/packages/app/src/runtime/server/persistence.ts +++ b/packages/app/src/runtime/server/persistence.ts @@ -43,7 +43,7 @@ const State = Codec.struct({ recentlyClosed: Codec.record(Codec.lenientArray(Codec.string)), }) -const StoredState = Codec.struct({ projects: Projects, lastProject: LastProject }) +const StoredState = Codec.struct({ projects: Projects, lastProject: LastProject }, { preserve: true }) // Projects and last-opened entries recorded under the canonical local server's URL move under // "local" when that URL is known, so they survive the server changing address. @@ -124,3 +124,4 @@ export const ProjectState = Codec.struct({ export const IconState = Codec.struct({ value: Codec.optional(Codec.string), }) + diff --git a/packages/app/src/settings/model.test.ts b/packages/app/src/settings/model.test.ts index 57caec8390e..0e765f63990 100644 --- a/packages/app/src/settings/model.test.ts +++ b/packages/app/src/settings/model.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" import { timelinePresets } from "@opencode/session-ui/timeline/detail" -import { Persistence } from "@/runtime/persistence/schema" +import { Codec } from "@/runtime/persistence/codec" import { settingsSchema, settingsPersistence, @@ -13,9 +12,9 @@ import { terminalFontFamily, } from "./model" -const schema = Persistence.withInitial(settingsPersistence, defaultSettings) -const decode = Schema.decodeUnknownSync(schema) -const encode = Schema.encodeSync(schema) +const schema = Codec.withInitial(settingsPersistence, defaultSettings) +const decode = (input: unknown) => Codec.decodeOrThrow(schema, input) +const encode = (value: typeof settingsSchema.Type) => schema.encode(value) describe("settings timeline detail migration", () => { test("migrates saved switches and round trips the current settings", () => { @@ -51,14 +50,14 @@ describe("settings schema", () => { general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false }, appearance: { ...defaultSettings.appearance, fontSize: 20 }, } - const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial)) + const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(settingsPersistence, initial), input) expect(restore({})).toEqual(initial) expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial) expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({ placement: "separate", details: "expanded", }) - expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow() + expect(() => Codec.decodeOrThrow(settingsSchema, {})).toThrow() }) test("supplies the existing defaults for an empty document", () => { @@ -171,7 +170,7 @@ describe("settings schema", () => { test("does not silently repair invalid values during encoding", () => { expect(() => - Schema.encodeUnknownSync(settingsSchema)({ ...decode({}), appearance: { fontSize: "large" } }), + Codec.encodeOrThrow(settingsSchema, { ...decode({}), appearance: { fontSize: "large" } } as never), ).toThrow() }) }) @@ -203,3 +202,6 @@ describe("settings font families", () => { expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ') }) }) + + + diff --git a/packages/app/src/settings/model.tsx b/packages/app/src/settings/model.tsx index eadae5269ac..a0e0b96abd4 100644 --- a/packages/app/src/settings/model.tsx +++ b/packages/app/src/settings/model.tsx @@ -1,511 +1,507 @@ -import { reconcile, unwrap } from "solid-js/store" -import { createEffect, createMemo } from "solid-js" -import { Effect, Option, Schema, SchemaGetter } from "effect" -import { createSimpleContext } from "@opencode/ui/context" -import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@opencode/session-ui/timeline/detail" -import { persisted } from "@/runtime/persistence/storage" -import { Persistence } from "@/runtime/persistence/schema" -import { ScopedKey, type ServerScope } from "@/runtime/server/scope" - -export type Settings = typeof settingsSchema.Type -export type WorkspaceDefaultDestination = Settings["workspaces"]["defaultDestination"] -export type WorkspaceLastUsed = Settings["workspaces"]["lastUsed"][string] -export type TerminalPlacement = Settings["general"]["terminalPlacement"] -export type FollowUpBehavior = Settings["general"]["followUpBehavior"] -export type TabLayout = Settings["appearance"]["tabLayout"] -export type NotificationSettings = Settings["notifications"] -export type SoundSettings = Settings["sounds"] - -export const monoDefault = "IBM Plex Mono" -export const sansDefault = "Inter" -export const terminalDefault = "JetBrainsMono Nerd Font Mono" -const monoFallback = - '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' -const sansFallback = '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' -const terminalFallback = - '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' - -const monoBase = monoFallback -const sansBase = sansFallback -const terminalBase = terminalFallback - -function input(font: string | undefined) { - return font ?? "" -} - -function family(font: string) { - if (/^[\w-]+$/.test(font)) return font - return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` -} - -function stack(font: string | undefined, base: string) { - const value = font?.trim() ?? "" - if (!value) return base - return `${family(value)}, ${base}` -} - -export function monoInput(font: string | undefined) { - return input(font) -} - -export function sansInput(font: string | undefined) { - return input(font) -} - -export function monoFontFamily(font: string | undefined) { - return stack(font, monoBase) -} - -export function sansFontFamily(font: string | undefined) { - return stack(font, sansBase) -} - -export function terminalInput(font: string | undefined) { - return input(font) -} - -export function terminalFontFamily(font: string | undefined) { - return stack(font, terminalBase) -} - -const placementSchema = Schema.Literals(["separate", "grouped", "hidden"]) -const detailsSchema = Schema.Literals(["collapsed", "expanded"]) -const activitySchema = Persistence.struct({ placement: placementSchema, details: detailsSchema }) -const placementOnlySchema = Persistence.struct({ placement: placementSchema }) - -const generalSchema = Persistence.struct({ - autoSave: Schema.Boolean, - releaseNotes: Schema.Boolean, - showFileTree: Schema.Boolean, - showNavigation: Schema.Boolean, - showSearch: Schema.Boolean, - showProjectIcon: Schema.Boolean, - showTerminal: Schema.Boolean, - timelineDetail: Persistence.struct({ - shell: activitySchema, - edit: activitySchema, - thinking: activitySchema, - subagents: placementOnlySchema, - notices: placementOnlySchema, - tools: placementOnlySchema, - }), - showCustomAgents: Schema.Boolean, - mobileTitlebarPosition: Schema.Literals(["top", "bottom"]), - mobileDiffWrap: Schema.Boolean, - terminalPlacement: Schema.Literals(["side", "bottom"]), - followUpBehavior: Schema.Literals(["queue", "steer"]), - experimentalBrowser: Schema.Boolean, -}) - -const appearanceSchema = Persistence.struct({ - fontSize: Schema.Number, - mono: Schema.String, - sans: Schema.String, - terminal: Schema.String, - tabLayout: Schema.Literals(["horizontal", "vertical"]), - showProjectName: Schema.Boolean, -}) - -const permissionsSchema = Persistence.struct({ - autoApprove: Schema.Boolean, -}) - -const workspacesSchema = Persistence.struct({ - defaultDestination: Schema.Literals(["last-used", "local", "new"]), - lastUsed: Persistence.record( - Schema.Literals(["local", "workspace"]).pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))), - ), -}) - -const notificationsSchema = Persistence.struct({ - agent: Schema.Boolean, - permissions: Schema.Boolean, - errors: Schema.Boolean, -}) - -const soundsSchema = Persistence.struct({ - agentEnabled: Schema.Boolean, - agent: Schema.String, - permissionsEnabled: Schema.Boolean, - permissions: Schema.String, - errorsEnabled: Schema.Boolean, - errors: Schema.String, -}) - -export const settingsSchema = Persistence.struct({ - general: generalSchema, - sessionSummary: Persistence.struct({ projectExpanded: Schema.Boolean, serverExpanded: Schema.Boolean }), - appearance: appearanceSchema, - keybinds: Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))), - permissions: permissionsSchema, - workspaces: workspacesSchema, - notifications: notificationsSchema, - sounds: soundsSchema, -}) - -function storedTimelineCategory(category: TimelineCategory) { - return Persistence.optional( - Schema.Union([ - Schema.Struct({ - placement: Persistence.optional(placementSchema), - details: Persistence.optional(detailsSchema), - }), - Schema.Literals(["expanded", "collapsed", "hidden", "visible"]), - ]).pipe( - Schema.decode({ - decode: SchemaGetter.transform((value) => { - if (typeof value !== "string") return value - return { - placement: - value === "hidden" - ? "hidden" - : category === "subagents" - ? "separate" - : category === "tools" - ? "grouped" - : value === "expanded" - ? "separate" - : value === "collapsed" - ? "grouped" - : undefined, - details: value === "expanded" ? "expanded" : "collapsed", - } - }), - encode: SchemaGetter.passthrough(), - }), - ), - ) -} - -function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" | null | undefined) { - if (value === undefined || value === null) return - const expanded = value === true || value === "full" - return { - placement: value === "hidden" ? "hidden" : expanded ? "separate" : "grouped", - details: expanded ? "expanded" : "collapsed", - } as const -} - -export const settingsPersistence = Persistence.migrate( - settingsSchema, - Schema.Struct({ - general: Persistence.optional( - Schema.Struct({ - // Keep invalid explicit values distinct from absent values so legacy preferences cannot replace them. - timelineDetail: Schema.optional( - Schema.NullOr( - Schema.Struct({ - shell: storedTimelineCategory("shell"), - edit: storedTimelineCategory("edit"), - thinking: storedTimelineCategory("thinking"), - subagents: storedTimelineCategory("subagents"), - notices: storedTimelineCategory("notices"), - tools: storedTimelineCategory("tools"), - }), - ), - ).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(null)))), - reasoningMode: Schema.optional(Schema.NullOr(Schema.Literals(["hidden", "compact", "full"]))).pipe( - Schema.catchDecoding(() => Effect.succeed(Option.some(null))), - ), - showReasoningSummaries: Persistence.optional(Schema.Boolean), - shellToolPartsExpanded: Persistence.optional(Schema.Boolean), - editToolPartsExpanded: Persistence.optional(Schema.Boolean), - }), - ), - }).pipe( - Schema.decode({ - decode: SchemaGetter.transform((value) => { - const general = value.general - if (!general || general.timelineDetail !== undefined) return value - return { - ...value, - general: { - ...general, - timelineDetail: { - shell: legacyTimelineActivity(general.shellToolPartsExpanded), - edit: legacyTimelineActivity(general.editToolPartsExpanded), - thinking: legacyTimelineActivity( - general.reasoningMode === undefined ? general.showReasoningSummaries : general.reasoningMode, - ), - }, - }, - } - }), - encode: SchemaGetter.transform((value) => value), - }), - ), -) - -export const defaultSettings: Settings = { - general: { - autoSave: true, - releaseNotes: true, - showFileTree: false, - showNavigation: false, - showSearch: false, - showProjectIcon: false, - showTerminal: false, - timelineDetail: { ...timelinePresets[2].value }, - showCustomAgents: false, - mobileTitlebarPosition: "top", - mobileDiffWrap: true, - terminalPlacement: "side", - followUpBehavior: "steer", - experimentalBrowser: false, - }, - sessionSummary: { projectExpanded: true, serverExpanded: true }, - appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false }, - keybinds: {}, - permissions: { autoApprove: false }, - workspaces: { defaultDestination: "last-used", lastUsed: {} }, - notifications: { agent: true, permissions: true, errors: false }, - sounds: { - agentEnabled: true, - agent: "staplebops-01", - permissionsEnabled: true, - permissions: "staplebops-02", - errorsEnabled: true, - errors: "nope-03", - }, -} - -function withFallback(read: () => T | undefined, fallback: T) { - return createMemo(() => read() ?? fallback) -} - -export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ - name: "Settings", - gate: false, - init: () => { - const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings) - const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree) - const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch) - const showCustomAgents = withFallback( - () => store.general?.showCustomAgents, - defaultSettings.general.showCustomAgents, - ) - createEffect(() => { - if (typeof document === "undefined") return - const root = document.documentElement - const mono = monoFontFamily(store.appearance?.mono) - root.style.setProperty("--font-family-mono", mono) - root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans)) - // Inline code can first appear during history backfill. Load its selected - // face with the shell so that font discovery does not resize that mount. - void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined) - }) - - return { - ready, - get current() { - return store - }, - general: { - autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave), - setAutoSave(value: boolean) { - setStore("general", "autoSave", value) - }, - releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes), - setReleaseNotes(value: boolean) { - setStore("general", "releaseNotes", value) - }, - showFileTree, - setShowFileTree(value: boolean) { - setStore("general", "showFileTree", value) - }, - showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation), - setShowNavigation(value: boolean) { - setStore("general", "showNavigation", value) - }, - showSearch, - setShowSearch(value: boolean) { - setStore("general", "showSearch", value) - }, - showProjectIcon: withFallback(() => store.general?.showProjectIcon, defaultSettings.general.showProjectIcon), - setShowProjectIcon(value: boolean) { - setStore("general", "showProjectIcon", value) - }, - showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal), - setShowTerminal(value: boolean) { - setStore("general", "showTerminal", value) - }, - timelineDetail: withFallback(() => store.general?.timelineDetail, defaultSettings.general.timelineDetail), - setTimelineDetail(value: TimelineDetail) { - setStore("general", "timelineDetail", structuredClone(unwrap(value))) - }, - showCustomAgents, - setShowCustomAgents(value: boolean) { - setStore("general", "showCustomAgents", value) - }, - mobileTitlebarPosition: withFallback( - () => store.general?.mobileTitlebarPosition, - defaultSettings.general.mobileTitlebarPosition, - ), - setMobileTitlebarPosition(value: "top" | "bottom") { - setStore("general", "mobileTitlebarPosition", value) - }, - mobileDiffWrap: withFallback(() => store.general?.mobileDiffWrap, defaultSettings.general.mobileDiffWrap), - setMobileDiffWrap(value: boolean) { - setStore("general", "mobileDiffWrap", value) - }, - terminalPlacement: withFallback( - () => store.general?.terminalPlacement, - defaultSettings.general.terminalPlacement, - ), - setTerminalPlacement(value: TerminalPlacement) { - setStore("general", "terminalPlacement", value) - }, - followUpBehavior: withFallback(() => store.general?.followUpBehavior, defaultSettings.general.followUpBehavior), - setFollowUpBehavior(value: FollowUpBehavior) { - setStore("general", "followUpBehavior", value) - }, - experimentalBrowser: withFallback( - () => store.general?.experimentalBrowser, - defaultSettings.general.experimentalBrowser, - ), - setExperimentalBrowser(value: boolean) { - setStore("general", "experimentalBrowser", value) - }, - }, - sessionSummary: { - projectExpanded: withFallback( - () => store.sessionSummary?.projectExpanded, - defaultSettings.sessionSummary.projectExpanded, - ), - serverExpanded: withFallback( - () => store.sessionSummary?.serverExpanded, - defaultSettings.sessionSummary.serverExpanded, - ), - setProjectExpanded(value: boolean) { - setStore("sessionSummary", "projectExpanded", value) - }, - setServerExpanded(value: boolean) { - setStore("sessionSummary", "serverExpanded", value) - }, - }, - visibility: { - fileTree: showFileTree, - search: showSearch, - customAgents: showCustomAgents, - }, - appearance: { - fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize), - setFontSize(value: number) { - setStore("appearance", "fontSize", value) - }, - font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono), - setFont(value: string) { - setStore("appearance", "mono", value.trim() ? value : "") - }, - uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans), - setUIFont(value: string) { - setStore("appearance", "sans", value.trim() ? value : "") - }, - terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal), - setTerminalFont(value: string) { - setStore("appearance", "terminal", value.trim() ? value : "") - }, - tabLayout: withFallback(() => store.appearance?.tabLayout, defaultSettings.appearance.tabLayout), - setTabLayout(value: TabLayout) { - setStore("appearance", "tabLayout", value) - }, - showProjectName: withFallback( - () => store.appearance?.showProjectName, - defaultSettings.appearance.showProjectName, - ), - setShowProjectName(value: boolean) { - setStore("appearance", "showProjectName", value) - }, - }, - keybinds: { - get: (action: string) => store.keybinds?.[action], - set(action: string, keybind: string) { - setStore("keybinds", action, keybind) - }, - reset(action: string) { - setStore("keybinds", (current) => { - if (!Object.prototype.hasOwnProperty.call(current, action)) return current - const next = { ...current } - delete next[action] - return next - }) - }, - resetAll() { - setStore("keybinds", reconcile({})) - }, - }, - permissions: { - autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove), - setAutoApprove(value: boolean) { - setStore("permissions", "autoApprove", value) - }, - }, - workspaces: { - defaultDestination: withFallback( - () => store.workspaces?.defaultDestination, - defaultSettings.workspaces.defaultDestination, - ), - setDefaultDestination(value: WorkspaceDefaultDestination) { - setStore("workspaces", (current) => ({ - ...defaultSettings.workspaces, - ...current, - defaultDestination: value, - })) - }, - lastUsed(scope: ServerScope, projectID: string) { - return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)] - }, - setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) { - setStore("workspaces", (current) => ({ - ...defaultSettings.workspaces, - ...current, - lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value }, - })) - }, - }, - notifications: { - agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent), - setAgent(value: boolean) { - setStore("notifications", "agent", value) - }, - permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions), - setPermissions(value: boolean) { - setStore("notifications", "permissions", value) - }, - errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors), - setErrors(value: boolean) { - setStore("notifications", "errors", value) - }, - }, - sounds: { - agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled), - setAgentEnabled(value: boolean) { - setStore("sounds", "agentEnabled", value) - }, - agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent), - setAgent(value: string) { - setStore("sounds", "agent", value) - }, - permissionsEnabled: withFallback( - () => store.sounds?.permissionsEnabled, - defaultSettings.sounds.permissionsEnabled, - ), - setPermissionsEnabled(value: boolean) { - setStore("sounds", "permissionsEnabled", value) - }, - permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions), - setPermissions(value: string) { - setStore("sounds", "permissions", value) - }, - errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled), - setErrorsEnabled(value: boolean) { - setStore("sounds", "errorsEnabled", value) - }, - errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors), - setErrors(value: string) { - setStore("sounds", "errors", value) - }, - }, - } - }, -}) +import { reconcile, unwrap } from "solid-js/store" +import { createEffect, createMemo } from "solid-js" +import { createSimpleContext } from "@opencode/ui/context" +import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@opencode/session-ui/timeline/detail" +import { persisted } from "@/runtime/persistence/storage" +import { Codec } from "@/runtime/persistence/codec" +import { ScopedKey, type ServerScope } from "@/runtime/server/scope" + +export type Settings = typeof settingsSchema.Type +export type WorkspaceDefaultDestination = Settings["workspaces"]["defaultDestination"] +export type WorkspaceLastUsed = Settings["workspaces"]["lastUsed"][string] +export type TerminalPlacement = Settings["general"]["terminalPlacement"] +export type FollowUpBehavior = Settings["general"]["followUpBehavior"] +export type TabLayout = Settings["appearance"]["tabLayout"] +export type NotificationSettings = Settings["notifications"] +export type SoundSettings = Settings["sounds"] + +export const monoDefault = "IBM Plex Mono" +export const sansDefault = "Inter" +export const terminalDefault = "JetBrainsMono Nerd Font Mono" +const monoFallback = + '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' +const sansFallback = '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' +const terminalFallback = + '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' + +const monoBase = monoFallback +const sansBase = sansFallback +const terminalBase = terminalFallback + +function input(font: string | undefined) { + return font ?? "" +} + +function family(font: string) { + if (/^[\w-]+$/.test(font)) return font + return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` +} + +function stack(font: string | undefined, base: string) { + const value = font?.trim() ?? "" + if (!value) return base + return `${family(value)}, ${base}` +} + +export function monoInput(font: string | undefined) { + return input(font) +} + +export function sansInput(font: string | undefined) { + return input(font) +} + +export function monoFontFamily(font: string | undefined) { + return stack(font, monoBase) +} + +export function sansFontFamily(font: string | undefined) { + return stack(font, sansBase) +} + +export function terminalInput(font: string | undefined) { + return input(font) +} + +export function terminalFontFamily(font: string | undefined) { + return stack(font, terminalBase) +} + +const placementSchema = Codec.literals(["separate", "grouped", "hidden"]) +const detailsSchema = Codec.literals(["collapsed", "expanded"]) +const activitySchema = Codec.struct({ placement: placementSchema, details: detailsSchema }) +const placementOnlySchema = Codec.struct({ placement: placementSchema }) + +const generalSchema = Codec.struct({ + autoSave: Codec.boolean, + releaseNotes: Codec.boolean, + showFileTree: Codec.boolean, + showNavigation: Codec.boolean, + showSearch: Codec.boolean, + showProjectIcon: Codec.boolean, + showTerminal: Codec.boolean, + timelineDetail: Codec.struct({ + shell: activitySchema, + edit: activitySchema, + thinking: activitySchema, + subagents: placementOnlySchema, + notices: placementOnlySchema, + tools: placementOnlySchema, + }), + showCustomAgents: Codec.boolean, + mobileTitlebarPosition: Codec.literals(["top", "bottom"]), + mobileDiffWrap: Codec.boolean, + terminalPlacement: Codec.literals(["side", "bottom"]), + followUpBehavior: Codec.literals(["queue", "steer"]), + experimentalBrowser: Codec.boolean, +}) + +const appearanceSchema = Codec.struct({ + fontSize: Codec.number, + mono: Codec.string, + sans: Codec.string, + terminal: Codec.string, + tabLayout: Codec.literals(["horizontal", "vertical"]), + showProjectName: Codec.boolean, +}) + +const permissionsSchema = Codec.struct({ + autoApprove: Codec.boolean, +}) + +const workspacesSchema = Codec.struct({ + defaultDestination: Codec.literals(["last-used", "local", "new"]), + lastUsed: Codec.fallback(Codec.sparseRecord(Codec.literals(["local", "workspace"])), () => ({})), +}) + +const notificationsSchema = Codec.struct({ + agent: Codec.boolean, + permissions: Codec.boolean, + errors: Codec.boolean, +}) + +const soundsSchema = Codec.struct({ + agentEnabled: Codec.boolean, + agent: Codec.string, + permissionsEnabled: Codec.boolean, + permissions: Codec.string, + errorsEnabled: Codec.boolean, + errors: Codec.string, +}) + +export const settingsSchema = Codec.struct({ + general: generalSchema, + sessionSummary: Codec.struct({ projectExpanded: Codec.boolean, serverExpanded: Codec.boolean }), + appearance: appearanceSchema, + keybinds: Codec.fallback(Codec.sparseRecord(Codec.string), () => ({})), + permissions: permissionsSchema, + workspaces: workspacesSchema, + notifications: notificationsSchema, + sounds: soundsSchema, +}) + +const storedActivity = Codec.struct({ + placement: Codec.lenientOptional(placementSchema), + details: Codec.lenientOptional(detailsSchema), +}) +const storedActivityWord = Codec.literals(["expanded", "collapsed", "hidden", "visible"]) + +function storedTimelineCategory(category: TimelineCategory) { + return Codec.lenientOptional( + Codec.transform(Codec.union([storedActivity, storedActivityWord]), { + decode: (value) => { + if (typeof value !== "string") return value + return { + placement: + value === "hidden" + ? ("hidden" as const) + : category === "subagents" + ? ("separate" as const) + : category === "tools" + ? ("grouped" as const) + : value === "expanded" + ? ("separate" as const) + : value === "collapsed" + ? ("grouped" as const) + : undefined, + details: value === "expanded" ? ("expanded" as const) : ("collapsed" as const), + } + }, + encode: (value) => value, + }), + ) +} + +function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" | null | undefined) { + if (value === undefined || value === null) return + const expanded = value === true || value === "full" + return { + placement: value === "hidden" ? "hidden" : expanded ? "separate" : "grouped", + details: expanded ? "expanded" : "collapsed", + } as const +} + +// An explicit but invalid value decodes to null, distinct from absent, so a legacy preference +// cannot replace a setting the user did set. +const explicitOrNull = (codec: Codec.Of) => Codec.optional(Codec.fallback(Codec.nullOr(codec), () => null)) + +const storedTimelineDetail = Codec.struct({ + shell: storedTimelineCategory("shell"), + edit: storedTimelineCategory("edit"), + thinking: storedTimelineCategory("thinking"), + subagents: storedTimelineCategory("subagents"), + notices: storedTimelineCategory("notices"), + tools: storedTimelineCategory("tools"), +}) +const storedGeneral = Codec.struct( + { + timelineDetail: explicitOrNull(storedTimelineDetail), + reasoningMode: explicitOrNull(Codec.literals(["hidden", "compact", "full"])), + showReasoningSummaries: Codec.lenientOptional(Codec.boolean), + shellToolPartsExpanded: Codec.lenientOptional(Codec.boolean), + editToolPartsExpanded: Codec.lenientOptional(Codec.boolean), + }, + { preserve: true }, +) +const storedSettings = Codec.struct({ general: Codec.lenientOptional(storedGeneral) }, { preserve: true }) + +export const settingsPersistence = Codec.migrate( + settingsSchema, + Codec.transform(storedSettings, { + decode: (value) => { + const general = value.general + if (!general || general.timelineDetail !== undefined) return value + return { + ...value, + general: { + ...general, + timelineDetail: { + shell: legacyTimelineActivity(general.shellToolPartsExpanded), + edit: legacyTimelineActivity(general.editToolPartsExpanded), + thinking: legacyTimelineActivity( + general.reasoningMode === undefined ? general.showReasoningSummaries : general.reasoningMode, + ), + }, + }, + } + }, + encode: (value) => value, + }), +) + +export const defaultSettings: Settings = { + general: { + autoSave: true, + releaseNotes: true, + showFileTree: false, + showNavigation: false, + showSearch: false, + showProjectIcon: false, + showTerminal: false, + timelineDetail: { ...timelinePresets[2].value }, + showCustomAgents: false, + mobileTitlebarPosition: "top", + mobileDiffWrap: true, + terminalPlacement: "side", + followUpBehavior: "steer", + experimentalBrowser: false, + }, + sessionSummary: { projectExpanded: true, serverExpanded: true }, + appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false }, + keybinds: {}, + permissions: { autoApprove: false }, + workspaces: { defaultDestination: "last-used", lastUsed: {} }, + notifications: { agent: true, permissions: true, errors: false }, + sounds: { + agentEnabled: true, + agent: "staplebops-01", + permissionsEnabled: true, + permissions: "staplebops-02", + errorsEnabled: true, + errors: "nope-03", + }, +} + +function withFallback(read: () => T | undefined, fallback: T) { + return createMemo(() => read() ?? fallback) +} + +export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ + name: "Settings", + gate: false, + init: () => { + const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings) + const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree) + const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch) + const showCustomAgents = withFallback( + () => store.general?.showCustomAgents, + defaultSettings.general.showCustomAgents, + ) + createEffect(() => { + if (typeof document === "undefined") return + const root = document.documentElement + const mono = monoFontFamily(store.appearance?.mono) + root.style.setProperty("--font-family-mono", mono) + root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans)) + // Inline code can first appear during history backfill. Load its selected + // face with the shell so that font discovery does not resize that mount. + void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined) + }) + + return { + ready, + get current() { + return store + }, + general: { + autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave), + setAutoSave(value: boolean) { + setStore("general", "autoSave", value) + }, + releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes), + setReleaseNotes(value: boolean) { + setStore("general", "releaseNotes", value) + }, + showFileTree, + setShowFileTree(value: boolean) { + setStore("general", "showFileTree", value) + }, + showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation), + setShowNavigation(value: boolean) { + setStore("general", "showNavigation", value) + }, + showSearch, + setShowSearch(value: boolean) { + setStore("general", "showSearch", value) + }, + showProjectIcon: withFallback(() => store.general?.showProjectIcon, defaultSettings.general.showProjectIcon), + setShowProjectIcon(value: boolean) { + setStore("general", "showProjectIcon", value) + }, + showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal), + setShowTerminal(value: boolean) { + setStore("general", "showTerminal", value) + }, + timelineDetail: withFallback(() => store.general?.timelineDetail, defaultSettings.general.timelineDetail), + setTimelineDetail(value: TimelineDetail) { + setStore("general", "timelineDetail", structuredClone(unwrap(value))) + }, + showCustomAgents, + setShowCustomAgents(value: boolean) { + setStore("general", "showCustomAgents", value) + }, + mobileTitlebarPosition: withFallback( + () => store.general?.mobileTitlebarPosition, + defaultSettings.general.mobileTitlebarPosition, + ), + setMobileTitlebarPosition(value: "top" | "bottom") { + setStore("general", "mobileTitlebarPosition", value) + }, + mobileDiffWrap: withFallback(() => store.general?.mobileDiffWrap, defaultSettings.general.mobileDiffWrap), + setMobileDiffWrap(value: boolean) { + setStore("general", "mobileDiffWrap", value) + }, + terminalPlacement: withFallback( + () => store.general?.terminalPlacement, + defaultSettings.general.terminalPlacement, + ), + setTerminalPlacement(value: TerminalPlacement) { + setStore("general", "terminalPlacement", value) + }, + followUpBehavior: withFallback(() => store.general?.followUpBehavior, defaultSettings.general.followUpBehavior), + setFollowUpBehavior(value: FollowUpBehavior) { + setStore("general", "followUpBehavior", value) + }, + experimentalBrowser: withFallback( + () => store.general?.experimentalBrowser, + defaultSettings.general.experimentalBrowser, + ), + setExperimentalBrowser(value: boolean) { + setStore("general", "experimentalBrowser", value) + }, + }, + sessionSummary: { + projectExpanded: withFallback( + () => store.sessionSummary?.projectExpanded, + defaultSettings.sessionSummary.projectExpanded, + ), + serverExpanded: withFallback( + () => store.sessionSummary?.serverExpanded, + defaultSettings.sessionSummary.serverExpanded, + ), + setProjectExpanded(value: boolean) { + setStore("sessionSummary", "projectExpanded", value) + }, + setServerExpanded(value: boolean) { + setStore("sessionSummary", "serverExpanded", value) + }, + }, + visibility: { + fileTree: showFileTree, + search: showSearch, + customAgents: showCustomAgents, + }, + appearance: { + fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize), + setFontSize(value: number) { + setStore("appearance", "fontSize", value) + }, + font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono), + setFont(value: string) { + setStore("appearance", "mono", value.trim() ? value : "") + }, + uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans), + setUIFont(value: string) { + setStore("appearance", "sans", value.trim() ? value : "") + }, + terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal), + setTerminalFont(value: string) { + setStore("appearance", "terminal", value.trim() ? value : "") + }, + tabLayout: withFallback(() => store.appearance?.tabLayout, defaultSettings.appearance.tabLayout), + setTabLayout(value: TabLayout) { + setStore("appearance", "tabLayout", value) + }, + showProjectName: withFallback( + () => store.appearance?.showProjectName, + defaultSettings.appearance.showProjectName, + ), + setShowProjectName(value: boolean) { + setStore("appearance", "showProjectName", value) + }, + }, + keybinds: { + get: (action: string) => store.keybinds?.[action], + set(action: string, keybind: string) { + setStore("keybinds", action, keybind) + }, + reset(action: string) { + setStore("keybinds", (current) => { + if (!Object.prototype.hasOwnProperty.call(current, action)) return current + const next = { ...current } + delete next[action] + return next + }) + }, + resetAll() { + setStore("keybinds", reconcile({})) + }, + }, + permissions: { + autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove), + setAutoApprove(value: boolean) { + setStore("permissions", "autoApprove", value) + }, + }, + workspaces: { + defaultDestination: withFallback( + () => store.workspaces?.defaultDestination, + defaultSettings.workspaces.defaultDestination, + ), + setDefaultDestination(value: WorkspaceDefaultDestination) { + setStore("workspaces", (current) => ({ + ...defaultSettings.workspaces, + ...current, + defaultDestination: value, + })) + }, + lastUsed(scope: ServerScope, projectID: string) { + return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)] + }, + setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) { + setStore("workspaces", (current) => ({ + ...defaultSettings.workspaces, + ...current, + lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value }, + })) + }, + }, + notifications: { + agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent), + setAgent(value: boolean) { + setStore("notifications", "agent", value) + }, + permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions), + setPermissions(value: boolean) { + setStore("notifications", "permissions", value) + }, + errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors), + setErrors(value: boolean) { + setStore("notifications", "errors", value) + }, + }, + sounds: { + agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled), + setAgentEnabled(value: boolean) { + setStore("sounds", "agentEnabled", value) + }, + agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent), + setAgent(value: string) { + setStore("sounds", "agent", value) + }, + permissionsEnabled: withFallback( + () => store.sounds?.permissionsEnabled, + defaultSettings.sounds.permissionsEnabled, + ), + setPermissionsEnabled(value: boolean) { + setStore("sounds", "permissionsEnabled", value) + }, + permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions), + setPermissions(value: string) { + setStore("sounds", "permissions", value) + }, + errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled), + setErrorsEnabled(value: boolean) { + setStore("sounds", "errorsEnabled", value) + }, + errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors), + setErrors(value: string) { + setStore("sounds", "errors", value) + }, + }, + } + }, +}) + + + diff --git a/packages/app/src/shell/state/layout.tsx b/packages/app/src/shell/state/layout.tsx index bcb481771d2..5921a741b73 100644 --- a/packages/app/src/shell/state/layout.tsx +++ b/packages/app/src/shell/state/layout.tsx @@ -188,7 +188,7 @@ const storedLayout = Codec.struct({ ), sessionTabs: layoutSchema.fields.sessionTabs, sessionView: layoutSchema.fields.sessionView, -}) +}, { preserve: true }) export const layoutPersistence = Codec.migrate( layoutSchema, @@ -766,3 +766,4 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }) +