Просмотр исходного кода

chore(app): rework storage approach

Adam 1 месяц назад
Родитель
Сommit
761863ae35

+ 6 - 6
packages/app/src/components/prompt-input.tsx

@@ -42,7 +42,7 @@ import { ModelSelectorPopover } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
 import { useProviders } from "@/hooks/use-providers"
 import { useCommand } from "@/context/command"
 import { useCommand } from "@/context/command"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
 import { Identifier } from "@/utils/id"
 import { Identifier } from "@/utils/id"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { usePermission } from "@/context/permission"
 import { usePermission } from "@/context/permission"
@@ -189,7 +189,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
 
   const MAX_HISTORY = 100
   const MAX_HISTORY = 100
   const [history, setHistory] = persisted(
   const [history, setHistory] = persisted(
-    "prompt-history.v1",
+    Persist.global("prompt-history", ["prompt-history.v1"]),
     createStore<{
     createStore<{
       entries: Prompt[]
       entries: Prompt[]
     }>({
     }>({
@@ -197,7 +197,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }),
     }),
   )
   )
   const [shellHistory, setShellHistory] = persisted(
   const [shellHistory, setShellHistory] = persisted(
-    "prompt-history-shell.v1",
+    Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
     createStore<{
     createStore<{
       entries: Prompt[]
       entries: Prompt[]
     }>({
     }>({
@@ -1593,14 +1593,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
                       onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
                       classList={{
                       classList={{
                         "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
                         "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
-                        "text-text-base": !permission.isAutoAccepting(params.id!),
-                        "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
+                        "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
+                        "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
                       }}
                       }}
                     >
                     >
                       <Icon
                       <Icon
                         name="chevron-double-right"
                         name="chevron-double-right"
                         size="small"
                         size="small"
-                        classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
+                        classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
                       />
                       />
                     </Button>
                     </Button>
                   </TooltipKeybind>
                   </TooltipKeybind>

+ 33 - 4
packages/app/src/context/file.tsx

@@ -1,4 +1,4 @@
-import { createMemo, onCleanup } from "solid-js"
+import { createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import type { FileContent } from "@opencode-ai/sdk/v2"
 import type { FileContent } from "@opencode-ai/sdk/v2"
@@ -7,7 +7,7 @@ import { useParams } from "@solidjs/router"
 import { getFilename } from "@opencode-ai/util/path"
 import { getFilename } from "@opencode-ai/util/path"
 import { useSDK } from "./sdk"
 import { useSDK } from "./sdk"
 import { useSync } from "./sync"
 import { useSync } from "./sync"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
 
 
 export type FileSelection = {
 export type FileSelection = {
   startLine: number
   startLine: number
@@ -134,10 +134,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       file: {},
       file: {},
     })
     })
 
 
-    const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
+    const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
 
 
     const [view, setView, _, ready] = persisted(
     const [view, setView, _, ready] = persisted(
-      viewKey(),
+      Persist.scoped(params.dir, params.id, "file-view", [legacyViewKey()]),
       createStore<{
       createStore<{
         file: Record<string, FileViewState>
         file: Record<string, FileViewState>
       }>({
       }>({
@@ -145,6 +145,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       }),
       }),
     )
     )
 
 
+    const MAX_VIEW_FILES = 500
+    const viewMeta = { pruned: false }
+
+    const pruneView = (keep?: string) => {
+      const keys = Object.keys(view.file)
+      if (keys.length <= MAX_VIEW_FILES) return
+
+      const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
+      if (drop.length === 0) return
+
+      setView(
+        produce((draft) => {
+          for (const key of drop) {
+            delete draft.file[key]
+          }
+        }),
+      )
+    }
+
+    createEffect(() => {
+      if (!ready()) return
+      if (viewMeta.pruned) return
+      viewMeta.pruned = true
+      pruneView()
+    })
+
     function ensure(path: string) {
     function ensure(path: string) {
       if (!path) return
       if (!path) return
       if (store.file[path]) return
       if (store.file[path]) return
@@ -233,6 +259,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
           scrollTop: top,
           scrollTop: top,
         }
         }
       })
       })
+      pruneView(path)
     }
     }
 
 
     const setScrollLeft = (input: string, left: number) => {
     const setScrollLeft = (input: string, left: number) => {
@@ -244,6 +271,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
           scrollLeft: left,
           scrollLeft: left,
         }
         }
       })
       })
+      pruneView(path)
     }
     }
 
 
     const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
     const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
@@ -256,6 +284,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
           selectedLines: next,
           selectedLines: next,
         }
         }
       })
       })
+      pruneView(path)
     }
     }
 
 
     onCleanup(() => stop())
     onCleanup(() => stop())

+ 26 - 2
packages/app/src/context/layout.tsx

@@ -5,7 +5,7 @@ import { useGlobalSync } from "./global-sync"
 import { useGlobalSDK } from "./global-sdk"
 import { useGlobalSDK } from "./global-sdk"
 import { useServer } from "./server"
 import { useServer } from "./server"
 import { Project } from "@opencode-ai/sdk/v2"
 import { Project } from "@opencode-ai/sdk/v2"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted, removePersisted } from "@/utils/persist"
 import { same } from "@/utils/same"
 import { same } from "@/utils/same"
 import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
 import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
 
 
@@ -46,7 +46,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     const globalSync = useGlobalSync()
     const globalSync = useGlobalSync()
     const server = useServer()
     const server = useServer()
     const [store, setStore, _, ready] = persisted(
     const [store, setStore, _, ready] = persisted(
-      "layout.v6",
+      Persist.global("layout", ["layout.v6"]),
       createStore({
       createStore({
         sidebar: {
         sidebar: {
           opened: false,
           opened: false,
@@ -75,6 +75,29 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     const meta = { active: undefined as string | undefined, pruned: false }
     const meta = { active: undefined as string | undefined, pruned: false }
     const used = new Map<string, number>()
     const used = new Map<string, number>()
 
 
+    const SESSION_STATE_KEYS = [
+      { key: "prompt", legacy: "prompt", version: "v2" },
+      { key: "terminal", legacy: "terminal", version: "v1" },
+      { key: "file-view", legacy: "file", version: "v1" },
+    ] as const
+
+    const dropSessionState = (keys: string[]) => {
+      for (const key of keys) {
+        const parts = key.split("/")
+        const dir = parts[0]
+        const session = parts[1]
+        if (!dir) continue
+
+        for (const entry of SESSION_STATE_KEYS) {
+          const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
+          void removePersisted(target)
+
+          const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
+          void removePersisted({ key: legacyKey })
+        }
+      }
+    }
+
     function prune(keep?: string) {
     function prune(keep?: string) {
       if (!keep) return
       if (!keep) return
 
 
@@ -102,6 +125,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       )
       )
 
 
       scroll.drop(drop)
       scroll.drop(drop)
+      dropSessionState(drop)
 
 
       for (const key of drop) {
       for (const key of drop) {
         used.delete(key)
         used.delete(key)

+ 2 - 2
packages/app/src/context/local.tsx

@@ -8,7 +8,7 @@ import { useSync } from "./sync"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { useProviders } from "@/hooks/use-providers"
 import { useProviders } from "@/hooks/use-providers"
 import { DateTime } from "luxon"
 import { DateTime } from "luxon"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
 import { showToast } from "@opencode-ai/ui/toast"
 import { showToast } from "@opencode-ai/ui/toast"
 
 
 export type LocalFile = FileNode &
 export type LocalFile = FileNode &
@@ -111,7 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
 
 
     const model = (() => {
     const model = (() => {
       const [store, setStore, _, modelReady] = persisted(
       const [store, setStore, _, modelReady] = persisted(
-        "model.v1",
+        Persist.global("model", ["model.v1"]),
         createStore<{
         createStore<{
           user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
           user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
           recent: ModelKey[]
           recent: ModelKey[]

+ 28 - 5
packages/app/src/context/notification.tsx

@@ -1,5 +1,5 @@
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
-import { onCleanup } from "solid-js"
+import { createEffect, onCleanup } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSDK } from "./global-sdk"
 import { useGlobalSDK } from "./global-sdk"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSync } from "./global-sync"
@@ -10,7 +10,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { makeAudioPlayer } from "@solid-primitives/audio"
 import { makeAudioPlayer } from "@solid-primitives/audio"
 import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
 import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
 import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
 import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
 
 
 type NotificationBase = {
 type NotificationBase = {
   directory?: string
   directory?: string
@@ -31,6 +31,16 @@ type ErrorNotification = NotificationBase & {
 
 
 export type Notification = TurnCompleteNotification | ErrorNotification
 export type Notification = TurnCompleteNotification | ErrorNotification
 
 
+const MAX_NOTIFICATIONS = 500
+const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
+
+function pruneNotifications(list: Notification[]) {
+  const cutoff = Date.now() - NOTIFICATION_TTL_MS
+  const pruned = list.filter((n) => n.time >= cutoff)
+  if (pruned.length <= MAX_NOTIFICATIONS) return pruned
+  return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
+}
+
 export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
 export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
   name: "Notification",
   name: "Notification",
   init: () => {
   init: () => {
@@ -49,12 +59,25 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
     const platform = usePlatform()
     const platform = usePlatform()
 
 
     const [store, setStore, _, ready] = persisted(
     const [store, setStore, _, ready] = persisted(
-      "notification.v1",
+      Persist.global("notification", ["notification.v1"]),
       createStore({
       createStore({
         list: [] as Notification[],
         list: [] as Notification[],
       }),
       }),
     )
     )
 
 
+    const meta = { pruned: false }
+
+    createEffect(() => {
+      if (!ready()) return
+      if (meta.pruned) return
+      meta.pruned = true
+      setStore("list", pruneNotifications(store.list))
+    })
+
+    const append = (notification: Notification) => {
+      setStore("list", (list) => pruneNotifications([...list, notification]))
+    }
+
     const unsub = globalSDK.event.listen((e) => {
     const unsub = globalSDK.event.listen((e) => {
       const directory = e.name
       const directory = e.name
       const event = e.details
       const event = e.details
@@ -73,7 +96,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
           try {
           try {
             idlePlayer?.play()
             idlePlayer?.play()
           } catch {}
           } catch {}
-          setStore("list", store.list.length, {
+          append({
             ...base,
             ...base,
             type: "turn-complete",
             type: "turn-complete",
             session: sessionID,
             session: sessionID,
@@ -92,7 +115,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
             errorPlayer?.play()
             errorPlayer?.play()
           } catch {}
           } catch {}
           const error = "error" in event.properties ? event.properties.error : undefined
           const error = "error" in event.properties ? event.properties.error : undefined
-          setStore("list", store.list.length, {
+          append({
             ...base,
             ...base,
             type: "error",
             type: "error",
             session: sessionID ?? "global",
             session: sessionID ?? "global",

+ 35 - 17
packages/app/src/context/permission.tsx

@@ -1,12 +1,12 @@
 import { createMemo, onCleanup } from "solid-js"
 import { createMemo, onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
 import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSync } from "./global-sync"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
-import { base64Decode } from "@opencode-ai/util/encode"
+import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
 
 
 type PermissionRespondFn = (input: {
 type PermissionRespondFn = (input: {
   sessionID: string
   sessionID: string
@@ -60,7 +60,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     })
     })
 
 
     const [store, setStore, _, ready] = persisted(
     const [store, setStore, _, ready] = persisted(
-      "permission.v3",
+      Persist.global("permission", ["permission.v3"]),
       createStore({
       createStore({
         autoAcceptEdits: {} as Record<string, boolean>,
         autoAcceptEdits: {} as Record<string, boolean>,
       }),
       }),
@@ -85,8 +85,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       })
       })
     }
     }
 
 
-    function isAutoAccepting(sessionID: string) {
-      return store.autoAcceptEdits[sessionID] ?? false
+    function acceptKey(sessionID: string, directory?: string) {
+      if (!directory) return sessionID
+      return `${base64Encode(directory)}/${sessionID}`
+    }
+
+    function isAutoAccepting(sessionID: string, directory?: string) {
+      const key = acceptKey(sessionID, directory)
+      return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
     }
     }
 
 
     const unsubscribe = globalSDK.event.listen((e) => {
     const unsubscribe = globalSDK.event.listen((e) => {
@@ -94,7 +100,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       if (event?.type !== "permission.asked") return
       if (event?.type !== "permission.asked") return
 
 
       const perm = event.properties
       const perm = event.properties
-      if (!isAutoAccepting(perm.sessionID)) return
+      if (!isAutoAccepting(perm.sessionID, e.name)) return
       if (!shouldAutoAccept(perm)) return
       if (!shouldAutoAccept(perm)) return
 
 
       respondOnce(perm, e.name)
       respondOnce(perm, e.name)
@@ -102,7 +108,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     onCleanup(unsubscribe)
     onCleanup(unsubscribe)
 
 
     function enable(sessionID: string, directory: string) {
     function enable(sessionID: string, directory: string) {
-      setStore("autoAcceptEdits", sessionID, true)
+      const key = acceptKey(sessionID, directory)
+      setStore(
+        produce((draft) => {
+          draft.autoAcceptEdits[key] = true
+          delete draft.autoAcceptEdits[sessionID]
+        }),
+      )
 
 
       globalSDK.client.permission
       globalSDK.client.permission
         .list({ directory })
         .list({ directory })
@@ -117,31 +129,37 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
         .catch(() => undefined)
         .catch(() => undefined)
     }
     }
 
 
-    function disable(sessionID: string) {
-      setStore("autoAcceptEdits", sessionID, false)
+    function disable(sessionID: string, directory?: string) {
+      const key = directory ? acceptKey(sessionID, directory) : undefined
+      setStore(
+        produce((draft) => {
+          if (key) delete draft.autoAcceptEdits[key]
+          delete draft.autoAcceptEdits[sessionID]
+        }),
+      )
     }
     }
 
 
     return {
     return {
       ready,
       ready,
       respond,
       respond,
-      autoResponds(permission: PermissionRequest) {
-        return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
+      autoResponds(permission: PermissionRequest, directory?: string) {
+        return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
       },
       },
       isAutoAccepting,
       isAutoAccepting,
       toggleAutoAccept(sessionID: string, directory: string) {
       toggleAutoAccept(sessionID: string, directory: string) {
-        if (isAutoAccepting(sessionID)) {
-          disable(sessionID)
+        if (isAutoAccepting(sessionID, directory)) {
+          disable(sessionID, directory)
           return
           return
         }
         }
 
 
         enable(sessionID, directory)
         enable(sessionID, directory)
       },
       },
       enableAutoAccept(sessionID: string, directory: string) {
       enableAutoAccept(sessionID: string, directory: string) {
-        if (isAutoAccepting(sessionID)) return
+        if (isAutoAccepting(sessionID, directory)) return
         enable(sessionID, directory)
         enable(sessionID, directory)
       },
       },
-      disableAutoAccept(sessionID: string) {
-        disable(sessionID)
+      disableAutoAccept(sessionID: string, directory?: string) {
+        disable(sessionID, directory)
       },
       },
       permissionsEnabled,
       permissionsEnabled,
     }
     }

+ 3 - 3
packages/app/src/context/prompt.tsx

@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { batch, createMemo } from "solid-js"
 import { batch, createMemo } from "solid-js"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
 import type { FileSelection } from "@/context/file"
 import type { FileSelection } from "@/context/file"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
 
 
 interface PartBase {
 interface PartBase {
   content: string
   content: string
@@ -103,10 +103,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
   name: "Prompt",
   name: "Prompt",
   init: () => {
   init: () => {
     const params = useParams()
     const params = useParams()
-    const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
+    const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
 
 
     const [store, setStore, _, ready] = persisted(
     const [store, setStore, _, ready] = persisted(
-      name(),
+      Persist.scoped(params.dir, params.id, "prompt", [legacy()]),
       createStore<{
       createStore<{
         prompt: Prompt
         prompt: Prompt
         cursor?: number
         cursor?: number

+ 2 - 2
packages/app/src/context/server.tsx

@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
 import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
 
 
 type StoredProject = { worktree: string; expanded: boolean }
 type StoredProject = { worktree: string; expanded: boolean }
 
 
@@ -35,7 +35,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
     const platform = usePlatform()
     const platform = usePlatform()
 
 
     const [store, setStore, _, ready] = persisted(
     const [store, setStore, _, ready] = persisted(
-      "server.v3",
+      Persist.global("server", ["server.v3"]),
       createStore({
       createStore({
         list: [] as string[],
         list: [] as string[],
         projects: {} as Record<string, StoredProject[]>,
         projects: {} as Record<string, StoredProject[]>,

+ 3 - 3
packages/app/src/context/terminal.tsx

@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { batch, createMemo } from "solid-js"
 import { batch, createMemo } from "solid-js"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
 import { useSDK } from "./sdk"
 import { useSDK } from "./sdk"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
 
 
 export type LocalPTY = {
 export type LocalPTY = {
   id: string
   id: string
@@ -19,10 +19,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
   init: () => {
   init: () => {
     const sdk = useSDK()
     const sdk = useSDK()
     const params = useParams()
     const params = useParams()
-    const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
+    const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
 
 
     const [store, setStore, _, ready] = persisted(
     const [store, setStore, _, ready] = persisted(
-      name(),
+      Persist.scoped(params.dir, params.id, "terminal", [legacy()]),
       createStore<{
       createStore<{
         active?: string
         active?: string
         all: LocalPTY[]
         all: LocalPTY[]

+ 1 - 1
packages/app/src/pages/layout.tsx

@@ -170,7 +170,7 @@ export default function Layout(props: ParentProps) {
       if (e.details?.type !== "permission.asked") return
       if (e.details?.type !== "permission.asked") return
       const directory = e.name
       const directory = e.name
       const perm = e.details.properties
       const perm = e.details.properties
-      if (permission.autoResponds(perm)) return
+      if (permission.autoResponds(perm, directory)) return
 
 
       const [store] = globalSync.child(directory)
       const [store] = globalSync.child(directory)
       const session = store.session.find((s) => s.id === perm.sessionID)
       const session = store.session.find((s) => s.id === perm.sessionID)

+ 8 - 3
packages/app/src/pages/session.tsx

@@ -467,7 +467,10 @@ export default function Page() {
     },
     },
     {
     {
       id: "permissions.autoaccept",
       id: "permissions.autoaccept",
-      title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
+      title:
+        params.id && permission.isAutoAccepting(params.id, sdk.directory)
+          ? "Stop auto-accepting edits"
+          : "Auto-accept edits",
       category: "Permissions",
       category: "Permissions",
       keybind: "mod+shift+a",
       keybind: "mod+shift+a",
       disabled: !params.id || !permission.permissionsEnabled(),
       disabled: !params.id || !permission.permissionsEnabled(),
@@ -476,8 +479,10 @@ export default function Page() {
         if (!sessionID) return
         if (!sessionID) return
         permission.toggleAutoAccept(sessionID, sdk.directory)
         permission.toggleAutoAccept(sessionID, sdk.directory)
         showToast({
         showToast({
-          title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
-          description: permission.isAutoAccepting(sessionID)
+          title: permission.isAutoAccepting(sessionID, sdk.directory)
+            ? "Auto-accepting edits"
+            : "Stopped auto-accepting edits",
+          description: permission.isAutoAccepting(sessionID, sdk.directory)
             ? "Edit and write permissions will be automatically approved"
             ? "Edit and write permissions will be automatically approved"
             : "Edit and write permissions will require approval",
             : "Edit and write permissions will require approval",
         })
         })

+ 223 - 5
packages/app/src/utils/persist.ts

@@ -1,17 +1,235 @@
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
-import { makePersisted } from "@solid-primitives/storage"
+import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
+import { checksum } from "@opencode-ai/util/encode"
 import { createResource, type Accessor } from "solid-js"
 import { createResource, type Accessor } from "solid-js"
 import type { SetStoreFunction, Store } from "solid-js/store"
 import type { SetStoreFunction, Store } from "solid-js/store"
 
 
 type InitType = Promise<string> | string | null
 type InitType = Promise<string> | string | null
 type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
 type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
 
 
-export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
+type PersistTarget = {
+  storage?: string
+  key: string
+  legacy?: string[]
+  migrate?: (value: unknown) => unknown
+}
+
+const LEGACY_STORAGE = "default.dat"
+const GLOBAL_STORAGE = "opencode.global.dat"
+
+function snapshot(value: unknown) {
+  return JSON.parse(JSON.stringify(value)) as unknown
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+function merge(defaults: unknown, value: unknown): unknown {
+  if (value === undefined) return defaults
+  if (value === null) return value
+
+  if (Array.isArray(defaults)) {
+    if (Array.isArray(value)) return value
+    return defaults
+  }
+
+  if (isRecord(defaults)) {
+    if (!isRecord(value)) return defaults
+
+    const result: Record<string, unknown> = { ...defaults }
+    for (const key of Object.keys(value)) {
+      if (key in defaults) {
+        result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
+      } else {
+        result[key] = (value as Record<string, unknown>)[key]
+      }
+    }
+    return result
+  }
+
+  return value
+}
+
+function parse(value: string) {
+  try {
+    return JSON.parse(value) as unknown
+  } catch {
+    return undefined
+  }
+}
+
+function workspaceStorage(dir: string) {
+  const head = dir.slice(0, 12) || "workspace"
+  const sum = checksum(dir) ?? "0"
+  return `opencode.workspace.${head}.${sum}.dat`
+}
+
+function localStorageWithPrefix(prefix: string): SyncStorage {
+  const base = `${prefix}:`
+  return {
+    getItem: (key) => localStorage.getItem(base + key),
+    setItem: (key, value) => localStorage.setItem(base + key, value),
+    removeItem: (key) => localStorage.removeItem(base + key),
+  }
+}
+
+export const Persist = {
+  global(key: string, legacy?: string[]): PersistTarget {
+    return { storage: GLOBAL_STORAGE, key, legacy }
+  },
+  workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
+    return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
+  },
+  session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
+    return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
+  },
+  scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
+    if (session) return Persist.session(dir, session, key, legacy)
+    return Persist.workspace(dir, key, legacy)
+  },
+}
+
+export function removePersisted(target: { storage?: string; key: string }) {
   const platform = usePlatform()
   const platform = usePlatform()
-  const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
+  const isDesktop = platform.platform === "desktop" && !!platform.storage
+
+  if (isDesktop) {
+    return platform.storage?.(target.storage)?.removeItem(target.key)
+  }
+
+  if (!target.storage) {
+    localStorage.removeItem(target.key)
+    return
+  }
+
+  localStorageWithPrefix(target.storage).removeItem(target.key)
+}
+
+export function persisted<T>(
+  target: string | PersistTarget,
+  store: [Store<T>, SetStoreFunction<T>],
+): PersistedWithReady<T> {
+  const platform = usePlatform()
+  const config: PersistTarget = typeof target === "string" ? { key: target } : target
+
+  const defaults = snapshot(store[0])
+  const legacy = config.legacy ?? []
+
+  const isDesktop = platform.platform === "desktop" && !!platform.storage
+
+  const currentStorage = (() => {
+    if (isDesktop) return platform.storage?.(config.storage)
+    if (!config.storage) return localStorage
+    return localStorageWithPrefix(config.storage)
+  })()
+
+  const legacyStorage = (() => {
+    if (!isDesktop) return localStorage
+    if (!config.storage) return platform.storage?.()
+    return platform.storage?.(LEGACY_STORAGE)
+  })()
+
+  const storage = (() => {
+    if (!isDesktop) {
+      const current = currentStorage as SyncStorage
+      const legacyStore = legacyStorage as SyncStorage
+
+      const api: SyncStorage = {
+        getItem: (key) => {
+          const raw = current.getItem(key)
+          if (raw !== null) {
+            const parsed = parse(raw)
+            if (parsed === undefined) return raw
+
+            const migrated = config.migrate ? config.migrate(parsed) : parsed
+            const merged = merge(defaults, migrated)
+            const next = JSON.stringify(merged)
+            if (raw !== next) current.setItem(key, next)
+            return next
+          }
+
+          for (const legacyKey of legacy) {
+            const legacyRaw = legacyStore.getItem(legacyKey)
+            if (legacyRaw === null) continue
+
+            current.setItem(key, legacyRaw)
+            legacyStore.removeItem(legacyKey)
+
+            const parsed = parse(legacyRaw)
+            if (parsed === undefined) return legacyRaw
+
+            const migrated = config.migrate ? config.migrate(parsed) : parsed
+            const merged = merge(defaults, migrated)
+            const next = JSON.stringify(merged)
+            if (legacyRaw !== next) current.setItem(key, next)
+            return next
+          }
+
+          return null
+        },
+        setItem: (key, value) => {
+          current.setItem(key, value)
+        },
+        removeItem: (key) => {
+          current.removeItem(key)
+        },
+      }
+
+      return api
+    }
+
+    const current = currentStorage as AsyncStorage
+    const legacyStore = legacyStorage as AsyncStorage | undefined
+
+    const api: AsyncStorage = {
+      getItem: async (key) => {
+        const raw = await current.getItem(key)
+        if (raw !== null) {
+          const parsed = parse(raw)
+          if (parsed === undefined) return raw
+
+          const migrated = config.migrate ? config.migrate(parsed) : parsed
+          const merged = merge(defaults, migrated)
+          const next = JSON.stringify(merged)
+          if (raw !== next) await current.setItem(key, next)
+          return next
+        }
+
+        if (!legacyStore) return null
+
+        for (const legacyKey of legacy) {
+          const legacyRaw = await legacyStore.getItem(legacyKey)
+          if (legacyRaw === null) continue
+
+          await current.setItem(key, legacyRaw)
+          await legacyStore.removeItem(legacyKey)
+
+          const parsed = parse(legacyRaw)
+          if (parsed === undefined) return legacyRaw
+
+          const migrated = config.migrate ? config.migrate(parsed) : parsed
+          const merged = merge(defaults, migrated)
+          const next = JSON.stringify(merged)
+          if (legacyRaw !== next) await current.setItem(key, next)
+          return next
+        }
+
+        return null
+      },
+      setItem: async (key, value) => {
+        await current.setItem(key, value)
+      },
+      removeItem: async (key) => {
+        await current.removeItem(key)
+      },
+    }
+
+    return api
+  })()
+
+  const [state, setState, init] = makePersisted(store, { name: config.key, storage })
 
 
-  // Create a resource that resolves when the store is initialized
-  // This integrates with Suspense and provides a ready signal
   const isAsync = init instanceof Promise
   const isAsync = init instanceof Promise
   const [ready] = createResource(
   const [ready] = createResource(
     () => init,
     () => init,

+ 109 - 40
packages/desktop/src/index.tsx

@@ -60,7 +60,7 @@ const platform: Platform = {
     void shellOpen(url).catch(() => undefined)
     void shellOpen(url).catch(() => undefined)
   },
   },
 
 
-  storage: (name = "default.dat") => {
+  storage: (() => {
     type StoreLike = {
     type StoreLike = {
       get(key: string): Promise<string | null | undefined>
       get(key: string): Promise<string | null | undefined>
       set(key: string, value: string): Promise<unknown>
       set(key: string, value: string): Promise<unknown>
@@ -70,7 +70,13 @@ const platform: Platform = {
       length(): Promise<number>
       length(): Promise<number>
     }
     }
 
 
-    const memory = () => {
+    const WRITE_DEBOUNCE_MS = 250
+
+    const storeCache = new Map<string, Promise<StoreLike>>()
+    const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
+    const memoryCache = new Map<string, StoreLike>()
+
+    const createMemoryStore = () => {
       const data = new Map<string, string>()
       const data = new Map<string, string>()
       const store: StoreLike = {
       const store: StoreLike = {
         get: async (key) => data.get(key),
         get: async (key) => data.get(key),
@@ -89,45 +95,108 @@ const platform: Platform = {
       return store
       return store
     }
     }
 
 
-    const api: AsyncStorage & { _store: Promise<StoreLike> | null; _getStore: () => Promise<StoreLike> } = {
-      _store: null,
-      _getStore: async () => {
-        if (api._store) return api._store
-        api._store = Store.load(name).catch(() => memory())
-        return api._store
-      },
-      getItem: async (key: string) => {
-        const store = await api._getStore()
-        const value = await store.get(key).catch(() => null)
-        if (value === undefined) return null
-        return value
-      },
-      setItem: async (key: string, value: string) => {
-        const store = await api._getStore()
-        await store.set(key, value).catch(() => undefined)
-      },
-      removeItem: async (key: string) => {
-        const store = await api._getStore()
-        await store.delete(key).catch(() => undefined)
-      },
-      clear: async () => {
-        const store = await api._getStore()
-        await store.clear().catch(() => undefined)
-      },
-      key: async (index: number) => {
-        const store = await api._getStore()
-        return (await store.keys().catch(() => []))[index]
-      },
-      getLength: async () => {
-        const store = await api._getStore()
-        return await store.length().catch(() => 0)
-      },
-      get length() {
-        return api.getLength()
-      },
+    const getStore = (name: string) => {
+      const cached = storeCache.get(name)
+      if (cached) return cached
+
+      const store = Store.load(name).catch(() => {
+        const cached = memoryCache.get(name)
+        if (cached) return cached
+
+        const memory = createMemoryStore()
+        memoryCache.set(name, memory)
+        return memory
+      })
+
+      storeCache.set(name, store)
+      return store
     }
     }
-    return api
-  },
+
+    const createStorage = (name: string) => {
+      const pending = new Map<string, string | null>()
+      let timer: ReturnType<typeof setTimeout> | undefined
+      let flushing: Promise<void> | undefined
+
+      const flush = async () => {
+        if (flushing) return flushing
+
+        flushing = (async () => {
+          const store = await getStore(name)
+          while (pending.size > 0) {
+            const batch = Array.from(pending.entries())
+            pending.clear()
+            for (const [key, value] of batch) {
+              if (value === null) {
+                await store.delete(key).catch(() => undefined)
+              } else {
+                await store.set(key, value).catch(() => undefined)
+              }
+            }
+          }
+        })().finally(() => {
+          flushing = undefined
+        })
+
+        return flushing
+      }
+
+      const schedule = () => {
+        if (timer) return
+        timer = setTimeout(() => {
+          timer = undefined
+          void flush()
+        }, WRITE_DEBOUNCE_MS)
+      }
+
+      const api: AsyncStorage & { flush: () => Promise<void> } = {
+        flush,
+        getItem: async (key: string) => {
+          const next = pending.get(key)
+          if (next !== undefined) return next
+
+          const store = await getStore(name)
+          const value = await store.get(key).catch(() => null)
+          if (value === undefined) return null
+          return value
+        },
+        setItem: async (key: string, value: string) => {
+          pending.set(key, value)
+          schedule()
+        },
+        removeItem: async (key: string) => {
+          pending.set(key, null)
+          schedule()
+        },
+        clear: async () => {
+          pending.clear()
+          const store = await getStore(name)
+          await store.clear().catch(() => undefined)
+        },
+        key: async (index: number) => {
+          const store = await getStore(name)
+          return (await store.keys().catch(() => []))[index]
+        },
+        getLength: async () => {
+          const store = await getStore(name)
+          return await store.length().catch(() => 0)
+        },
+        get length() {
+          return api.getLength()
+        },
+      }
+
+      return api
+    }
+
+    return (name = "default.dat") => {
+      const cached = apiCache.get(name)
+      if (cached) return cached
+
+      const api = createStorage(name)
+      apiCache.set(name, api)
+      return api
+    }
+  })(),
 
 
   checkUpdate: async () => {
   checkUpdate: async () => {
     if (!UPDATER_ENABLED) return { updateAvailable: false }
     if (!UPDATER_ENABLED) return { updateAvailable: false }