Przeglądaj źródła

fix(desktop): Path mismatches cause sessions missing + strong ID + existing data fix (#25013)

Luke Parker 4 miesięcy temu
rodzic
commit
d7b7be1909

+ 35 - 28
packages/app/src/context/global-sync.tsx

@@ -33,6 +33,7 @@ import { SESSION_RECENT_LIMIT } from "./global-sync/types"
 import { formatServerError } from "@/utils/server-errors"
 import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
 import { createRefreshQueue } from "./global-sync/queue"
+import { directoryKey } from "./global-sync/utils"
 
 type GlobalStore = {
   ready: boolean
@@ -169,18 +170,20 @@ function createGlobalSync() {
 
   const queue = createRefreshQueue({
     paused,
+    key: directoryKey,
     bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
     bootstrapInstance,
   })
 
   const sdkFor = (directory: string) => {
-    const cached = sdkCache.get(directory)
+    const key = directoryKey(directory)
+    const cached = sdkCache.get(key)
     if (cached) return cached
     const sdk = globalSDK.createClient({
       directory,
       throwOnError: true,
     })
-    sdkCache.set(directory, sdk)
+    sdkCache.set(key, sdk)
     return sdk
   }
 
@@ -192,23 +195,25 @@ function createGlobalSync() {
       void bootstrapInstance(directory)
     },
     onDispose: (directory) => {
-      queue.clear(directory)
-      sessionMeta.delete(directory)
-      sdkCache.delete(directory)
-      clearProviderRev(directory)
-      clearSessionPrefetchDirectory(directory)
+      const key = directoryKey(directory)
+      queue.clear(key)
+      sessionMeta.delete(key)
+      sdkCache.delete(key)
+      clearProviderRev(key)
+      clearSessionPrefetchDirectory(key)
     },
     translate: language.t,
     getSdk: sdkFor,
   })
 
   async function loadSessions(directory: string) {
-    const pending = sessionLoads.get(directory)
+    const key = directoryKey(directory)
+    const pending = sessionLoads.get(key)
     if (pending) return pending
 
-    children.pin(directory)
+    children.pin(key)
     const [store, setStore] = children.child(directory, { bootstrap: false })
-    const meta = sessionMeta.get(directory)
+    const meta = sessionMeta.get(key)
     if (meta && meta.limit >= store.limit) {
       const next = trimSessions(store.session, {
         limit: store.limit,
@@ -218,14 +223,14 @@ function createGlobalSync() {
         setStore("session", reconcile(next, { key: "id" }))
         cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
       }
-      children.unpin(directory)
+      children.unpin(key)
       return
     }
 
     const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
     const promise = queryClient
       .fetchQuery({
-        ...loadSessionsQuery(directory),
+        ...loadSessionsQuery(key),
         queryFn: () =>
           loadRootSessionsWithFallback({
             directory,
@@ -255,7 +260,7 @@ function createGlobalSync() {
                 setStore("session", reconcile(sessions, { key: "id" }))
                 cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
               })
-              sessionMeta.set(directory, { limit })
+              sessionMeta.set(key, { limit })
             })
             .catch((err) => {
               console.error("Failed to load sessions", err)
@@ -270,23 +275,24 @@ function createGlobalSync() {
       })
       .then(() => {})
 
-    sessionLoads.set(directory, promise)
+    sessionLoads.set(key, promise)
     void promise.finally(() => {
-      sessionLoads.delete(directory)
-      children.unpin(directory)
+      sessionLoads.delete(key)
+      children.unpin(key)
     })
     return promise
   }
 
   async function bootstrapInstance(directory: string) {
-    if (!directory) return
-    const pending = booting.get(directory)
+    const key = directoryKey(directory)
+    if (!key) return
+    const pending = booting.get(key)
     if (pending) return pending
 
-    children.pin(directory)
+    children.pin(key)
     const promise = Promise.resolve().then(async () => {
       const child = children.ensureChild(directory)
-      const cache = children.vcsCache.get(directory)
+      const cache = children.vcsCache.get(key)
       if (!cache) return
       const sdk = sdkFor(directory)
       await bootstrapDirectory({
@@ -307,16 +313,17 @@ function createGlobalSync() {
       })
     })
 
-    booting.set(directory, promise)
+    booting.set(key, promise)
     void promise.finally(() => {
-      booting.delete(directory)
-      children.unpin(directory)
+      booting.delete(key)
+      children.unpin(key)
     })
     return promise
   }
 
   const unsub = globalSDK.event.listen((e) => {
     const directory = e.name
+    const key = directoryKey(directory)
     const event = e.details
     const recent = bootingRoot || Date.now() - bootedAt < 1500
 
@@ -339,9 +346,9 @@ function createGlobalSync() {
       return
     }
 
-    const existing = children.children[directory]
+    const existing = children.children[key]
     if (!existing) return
-    children.mark(directory)
+    children.mark(key)
     const [store, setStore] = existing
     applyDirectoryEvent({
       event,
@@ -350,9 +357,9 @@ function createGlobalSync() {
       setStore,
       push: queue.push,
       setSessionTodo,
-      vcsCache: children.vcsCache.get(directory),
+      vcsCache: children.vcsCache.get(key),
       loadLsp: () => {
-        void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory)))
+        void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory)))
       },
     })
   })
@@ -363,7 +370,7 @@ function createGlobalSync() {
   })
   onCleanup(() => {
     for (const directory of Object.keys(children.children)) {
-      children.disposeDirectory(directory)
+      children.disposeDirectory(directoryKey(directory))
     }
   })
 

+ 57 - 43
packages/app/src/context/global-sync/child-store.ts

@@ -17,6 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
 import { useQueries } from "@tanstack/solid-query"
 import { loadPathQuery, loadProvidersQuery } from "./bootstrap"
 import { loadLspQuery, loadMcpQuery } from "../global-sync"
+import { directoryKey, type DirectoryKey } from "./utils"
 
 export function createChildStoreManager(input: {
   owner: Owner
@@ -36,30 +37,37 @@ export function createChildStoreManager(input: {
   const ownerPins = new WeakMap<object, Set<string>>()
   const disposers = new Map<string, () => void>()
 
+  const markKey = (key: DirectoryKey) => {
+    if (!key) return
+    lifecycle.set(key, { lastAccessAt: Date.now() })
+    runEviction(key)
+  }
+
   const mark = (directory: string) => {
-    if (!directory) return
-    lifecycle.set(directory, { lastAccessAt: Date.now() })
-    runEviction(directory)
+    const key = directoryKey(directory)
+    markKey(key)
   }
 
   const pin = (directory: string) => {
-    if (!directory) return
-    pins.set(directory, (pins.get(directory) ?? 0) + 1)
-    mark(directory)
+    const key = directoryKey(directory)
+    if (!key) return
+    pins.set(key, (pins.get(key) ?? 0) + 1)
+    markKey(key)
   }
 
   const unpin = (directory: string) => {
-    if (!directory) return
-    const next = (pins.get(directory) ?? 0) - 1
+    const key = directoryKey(directory)
+    if (!key) return
+    const next = (pins.get(key) ?? 0) - 1
     if (next > 0) {
-      pins.set(directory, next)
+      pins.set(key, next)
       return
     }
-    pins.delete(directory)
+    pins.delete(key)
     runEviction()
   }
 
-  const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
+  const pinned = (directory: string) => (pins.get(directoryKey(directory)) ?? 0) > 0
 
   const pinForOwner = (directory: string) => {
     const current = getOwner()
@@ -81,30 +89,31 @@ export function createChildStoreManager(input: {
     })
   }
 
-  function disposeDirectory(directory: string) {
+  function disposeDirectory(directory: DirectoryKey) {
+    const key = directory
     if (
       !canDisposeDirectory({
-        directory,
-        hasStore: !!children[directory],
-        pinned: pinned(directory),
-        booting: input.isBooting(directory),
-        loadingSessions: input.isLoadingSessions(directory),
+        directory: key,
+        hasStore: !!children[key],
+        pinned: pinned(key),
+        booting: input.isBooting(key),
+        loadingSessions: input.isLoadingSessions(key),
       })
     ) {
       return false
     }
 
-    vcsCache.delete(directory)
-    metaCache.delete(directory)
-    iconCache.delete(directory)
-    lifecycle.delete(directory)
-    const dispose = disposers.get(directory)
+    vcsCache.delete(key)
+    metaCache.delete(key)
+    iconCache.delete(key)
+    lifecycle.delete(key)
+    const dispose = disposers.get(key)
     if (dispose) {
       dispose()
-      disposers.delete(directory)
+      disposers.delete(key)
     }
-    delete children[directory]
-    input.onDispose(directory)
+    delete children[key]
+    input.onDispose(key)
     return true
   }
 
@@ -121,13 +130,14 @@ export function createChildStoreManager(input: {
     }).filter((directory) => directory !== skip)
     if (list.length === 0) return
     for (const directory of list) {
-      if (!disposeDirectory(directory)) continue
+      if (!disposeDirectory(directoryKey(directory))) continue
     }
   }
 
   function ensureChild(directory: string) {
-    if (!directory) console.error("No directory provided")
-    if (!children[directory]) {
+    const key = directoryKey(directory)
+    if (!key) console.error("No directory provided")
+    if (!children[key]) {
       const vcs = runWithOwner(input.owner, () =>
         persisted(
           Persist.workspace(directory, "vcs", ["vcs.v1"]),
@@ -136,7 +146,7 @@ export function createChildStoreManager(input: {
       )
       if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
       const vcsStore = vcs[0]
-      vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
+      vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
 
       const meta = runWithOwner(input.owner, () =>
         persisted(
@@ -145,7 +155,7 @@ export function createChildStoreManager(input: {
         ),
       )
       if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
-      metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
+      metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
 
       const icon = runWithOwner(input.owner, () =>
         persisted(
@@ -154,7 +164,7 @@ export function createChildStoreManager(input: {
         ),
       )
       if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
-      iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
+      iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })
 
       const init = () =>
         createRoot((dispose) => {
@@ -165,10 +175,10 @@ export function createChildStoreManager(input: {
 
           const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
             queries: [
-              loadPathQuery(directory, sdk),
-              loadMcpQuery(directory, sdk),
-              loadLspQuery(directory, sdk),
-              loadProvidersQuery(directory, sdk),
+              loadPathQuery(key, sdk),
+              loadMcpQuery(key, sdk),
+              loadLspQuery(key, sdk),
+              loadProvidersQuery(key, sdk),
             ],
           }))
 
@@ -213,13 +223,13 @@ export function createChildStoreManager(input: {
             message: {},
             part: {},
           })
-          children[directory] = child
-          disposers.set(directory, dispose)
+          children[key] = child
+          disposers.set(key, dispose)
 
           const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
             if (!(init instanceof Promise)) return
             void init.then(() => {
-              if (children[directory] !== child) return
+              if (children[key] !== child) return
               run()
             })
           }
@@ -243,15 +253,16 @@ export function createChildStoreManager(input: {
 
       runWithOwner(input.owner, init)
     }
-    mark(directory)
-    const childStore = children[directory]
+    markKey(key)
+    const childStore = children[key]
     if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
     return childStore
   }
 
   function child(directory: string, options: ChildOptions = {}) {
+    const key = directoryKey(directory)
     const childStore = ensureChild(directory)
-    pinForOwner(directory)
+    pinForOwner(key)
     const shouldBootstrap = options.bootstrap ?? true
     if (shouldBootstrap && childStore[0].status === "loading") {
       input.onBootstrap(directory)
@@ -260,6 +271,7 @@ export function createChildStoreManager(input: {
   }
 
   function peek(directory: string, options: ChildOptions = {}) {
+    const key = directoryKey(directory)
     const childStore = ensureChild(directory)
     const shouldBootstrap = options.bootstrap ?? true
     if (shouldBootstrap && childStore[0].status === "loading") {
@@ -269,8 +281,9 @@ export function createChildStoreManager(input: {
   }
 
   function projectMeta(directory: string, patch: ProjectMeta) {
+    const key = directoryKey(directory)
     const [store, setStore] = ensureChild(directory)
-    const cached = metaCache.get(directory)
+    const cached = metaCache.get(key)
     if (!cached) return
     const previous = store.projectMeta ?? {}
     const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon
@@ -286,8 +299,9 @@ export function createChildStoreManager(input: {
   }
 
   function projectIcon(directory: string, value: string | undefined) {
+    const key = directoryKey(directory)
     const [store, setStore] = ensureChild(directory)
-    const cached = iconCache.get(directory)
+    const cached = iconCache.get(key)
     if (!cached) return
     if (store.icon === value) return
     cached.setStore("value", value)

+ 46 - 0
packages/app/src/context/global-sync/queue.test.ts

@@ -0,0 +1,46 @@
+import { describe, expect, test } from "bun:test"
+import { createRefreshQueue } from "./queue"
+import { directoryKey } from "./utils"
+
+const tick = () => new Promise((resolve) => setTimeout(resolve, 10))
+
+describe("createRefreshQueue", () => {
+  test("clears queued directories by normalized key", async () => {
+    const calls: string[] = []
+    const queue = createRefreshQueue({
+      paused: () => false,
+      key: directoryKey,
+      bootstrap: async () => {},
+      bootstrapInstance: (directory) => {
+        calls.push(directory)
+      },
+    })
+
+    queue.push("C:\\tmp\\demo")
+    queue.clear("C:/tmp/demo")
+
+    await tick()
+
+    expect(calls).toEqual([])
+    queue.dispose()
+  })
+
+  test("passes the original directory to bootstrapInstance", async () => {
+    const calls: string[] = []
+    const queue = createRefreshQueue({
+      paused: () => false,
+      key: directoryKey,
+      bootstrap: async () => {},
+      bootstrapInstance: (directory) => {
+        calls.push(directory)
+      },
+    })
+
+    queue.push("C:\\tmp\\demo")
+
+    await tick()
+
+    expect(calls).toEqual(["C:\\tmp\\demo"])
+    queue.dispose()
+  })
+})

+ 9 - 6
packages/app/src/context/global-sync/queue.ts

@@ -2,22 +2,25 @@ type QueueInput = {
   paused: () => boolean
   bootstrap: () => Promise<void>
   bootstrapInstance: (directory: string) => Promise<void> | void
+  key?: (directory: string) => string
 }
 
 export function createRefreshQueue(input: QueueInput) {
-  const queued = new Set<string>()
+  const queued = new Map<string, string>()
   let root = false
   let running = false
   let timer: ReturnType<typeof setTimeout> | undefined
 
+  const key = input.key ?? ((directory: string) => directory)
+
   const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
 
   const take = (count: number) => {
     if (queued.size === 0) return [] as string[]
     const items: string[] = []
-    for (const item of queued) {
-      queued.delete(item)
-      items.push(item)
+    for (const [id, directory] of queued) {
+      queued.delete(id)
+      items.push(directory)
       if (items.length >= count) break
     }
     return items
@@ -33,7 +36,7 @@ export function createRefreshQueue(input: QueueInput) {
 
   const push = (directory: string) => {
     if (!directory) return
-    queued.add(directory)
+    queued.set(key(directory), directory)
     if (input.paused()) return
     schedule()
   }
@@ -73,7 +76,7 @@ export function createRefreshQueue(input: QueueInput) {
     push,
     refresh,
     clear(directory: string) {
-      queued.delete(directory)
+      queued.delete(key(directory))
     },
     dispose() {
       if (!timer) return

+ 18 - 1
packages/app/src/context/global-sync/utils.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { Agent } from "@opencode-ai/sdk/v2/client"
-import { normalizeAgentList } from "./utils"
+import { directoryKey, normalizeAgentList } from "./utils"
 
 const agent = (name = "build") =>
   ({
@@ -33,3 +33,20 @@ describe("normalizeAgentList", () => {
     expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
   })
 })
+
+describe("directoryKey", () => {
+  test("normalizes slashes", () => {
+    expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
+    expect(String(directoryKey("C:/Repos/sst/opencode"))).toBe("C:/Repos/sst/opencode")
+  })
+
+  test("preserves backslashes in posix paths", () => {
+    expect(String(directoryKey("/tmp/foo\\bar"))).toBe("/tmp/foo\\bar")
+  })
+
+  test("trims trailing slashes without breaking roots", () => {
+    expect(String(directoryKey("C:/Repos/sst/opencode/"))).toBe("C:/Repos/sst/opencode")
+    expect(String(directoryKey("C:/"))).toBe("C:/")
+    expect(String(directoryKey("/"))).toBe("/")
+  })
+})

+ 1 - 0
packages/app/src/context/global-sync/utils.ts

@@ -1,4 +1,5 @@
 import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
 
 export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 

+ 35 - 41
packages/app/src/pages/layout.tsx

@@ -64,14 +64,8 @@ import { DebugBar } from "@/components/debug-bar"
 import { Titlebar } from "@/components/titlebar"
 import { useServer } from "@/context/server"
 import { useLanguage, type Locale } from "@/context/language"
-import {
-  displayName,
-  effectiveWorkspaceOrder,
-  errorMessage,
-  latestRootSession,
-  sortedRootSessions,
-  workspaceKey,
-} from "./layout/helpers"
+import { pathKey } from "@/utils/path-key"
+import { displayName, effectiveWorkspaceOrder, errorMessage, latestRootSession, sortedRootSessions } from "./layout/helpers"
 import {
   collectNewSessionDeepLinks,
   collectOpenProjectDeepLinks,
@@ -164,7 +158,7 @@ export default function Layout(props: ParentProps) {
 
   const editor = createInlineEditorController()
   const setBusy = (directory: string, value: boolean) => {
-    const key = workspaceKey(directory)
+    const key = pathKey(directory)
     if (value) {
       setState("busyWorkspaces", key, true)
       return
@@ -176,7 +170,7 @@ export default function Layout(props: ParentProps) {
       }),
     )
   }
-  const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
+  const isBusy = (directory: string) => !!state.busyWorkspaces[pathKey(directory)]
   const navLeave = { current: undefined as number | undefined }
   const sortNow = () => state.sortNow
   let sizet: number | undefined
@@ -497,8 +491,8 @@ export default function Layout(props: ParentProps) {
         }
 
         const currentSession = params.id
-        if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
-        if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
+        if (pathKey(directory) === pathKey(currentDir()) && props.sessionID === currentSession) return
+        if (pathKey(directory) === pathKey(currentDir()) && session?.parentID === currentSession) return
 
         dismissSessionAlert(sessionKey)
 
@@ -556,14 +550,14 @@ export default function Layout(props: ParentProps) {
   const currentProject = createMemo(() => {
     const directory = currentDir()
     if (!directory) return
-    const key = workspaceKey(directory)
+    const key = pathKey(directory)
 
     const projects = layout.projects.list()
 
-    const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
+    const sandbox = projects.find((p) => p.sandboxes?.some((item) => pathKey(item) === key))
     if (sandbox) return sandbox
 
-    const direct = projects.find((p) => workspaceKey(p.worktree) === key)
+    const direct = projects.find((p) => pathKey(p.worktree) === key)
     if (direct) return direct
 
     const [child] = globalSync.child(directory, { bootstrap: false })
@@ -596,7 +590,7 @@ export default function Layout(props: ParentProps) {
   })
 
   const workspaceName = (directory: string, projectId?: string, branch?: string) => {
-    const key = workspaceKey(directory)
+    const key = pathKey(directory)
     const direct = store.workspaceName[key] ?? store.workspaceName[directory]
     if (direct) return direct
     if (!projectId) return
@@ -605,7 +599,7 @@ export default function Layout(props: ParentProps) {
   }
 
   const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
-    const key = workspaceKey(directory)
+    const key = pathKey(directory)
     setStore("workspaceName", key, next)
     if (!projectId) return
     if (!branch) return
@@ -633,7 +627,7 @@ export default function Layout(props: ParentProps) {
     const activeDir = currentDir()
     return workspaceIds(project).filter((directory) => {
       const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
-      const active = workspaceKey(directory) === workspaceKey(activeDir)
+      const active = pathKey(directory) === pathKey(activeDir)
       return expanded || active
     })
   })
@@ -644,10 +638,10 @@ export default function Layout(props: ParentProps) {
     const projects = layout.projects.list()
     for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
       if (!expanded) continue
-      const key = workspaceKey(directory)
+      const key = pathKey(directory)
       const project = projects.find(
         (item) =>
-          workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
+          pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
       )
       if (!project) continue
       if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
@@ -700,7 +694,7 @@ export default function Layout(props: ParentProps) {
       seen: lru,
       keep: sessionID,
       limit: PREFETCH_MAX_SESSIONS_PER_DIR,
-      preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
+      preserve: params.id && pathKey(directory) === pathKey(currentDir()) ? [params.id] : undefined,
     })
   }
 
@@ -1221,17 +1215,17 @@ export default function Layout(props: ParentProps) {
   }
 
   function projectRoot(directory: string) {
-    const key = workspaceKey(directory)
+    const key = pathKey(directory)
     const project = layout.projects
       .list()
       .find(
         (item) =>
-          workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
+          pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
       )
     if (project) return project.worktree
 
     const known = Object.entries(store.workspaceOrder).find(
-      ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
+      ([root, dirs]) => pathKey(root) === key || dirs.some((item) => pathKey(item) === key),
     )
     if (known) return known[0]
 
@@ -1283,7 +1277,7 @@ export default function Layout(props: ParentProps) {
       : [root]
     const canOpen = (value: string | undefined) => {
       if (!value) return false
-      return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
+      return dirs.some((item) => pathKey(item) === pathKey(value))
     }
     const refreshDirs = async (target?: string) => {
       if (!target || target === root || canOpen(target)) return canOpen(target)
@@ -1409,9 +1403,9 @@ export default function Layout(props: ParentProps) {
 
   function closeProject(directory: string) {
     const list = layout.projects.list()
-    const key = workspaceKey(directory)
-    const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
-    const active = workspaceKey(currentProject()?.worktree ?? "") === key
+    const key = pathKey(directory)
+    const index = list.findIndex((x) => pathKey(x.worktree) === key)
+    const active = pathKey(currentProject()?.worktree ?? "") === key
     if (index === -1) return
     const next = list[index + 1]
 
@@ -1485,8 +1479,8 @@ export default function Layout(props: ParentProps) {
     if (directory === root) return
 
     const current = currentDir()
-    const currentKey = workspaceKey(current)
-    const deletedKey = workspaceKey(directory)
+    const currentKey = pathKey(current)
+    const deletedKey = pathKey(directory)
     const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
     if (!leaveDeletedWorkspace && shouldLeave) {
       navigateWithSidebarReset(`/${base64Encode(root)}/session`)
@@ -1509,7 +1503,7 @@ export default function Layout(props: ParentProps) {
 
     if (!result) return
 
-    if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
+    if (pathKey(store.lastProjectSession[root]?.directory ?? "") === pathKey(directory)) {
       clearLastProjectSession(root)
     }
 
@@ -1529,12 +1523,12 @@ export default function Layout(props: ParentProps) {
     if (shouldLeave) return
 
     const nextCurrent = currentDir()
-    const nextKey = workspaceKey(nextCurrent)
+    const nextKey = pathKey(nextCurrent)
     const project = layout.projects.list().find((item) => item.worktree === root)
     const dirs = project
       ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
       : [root]
-    const valid = dirs.some((item) => workspaceKey(item) === nextKey)
+    const valid = dirs.some((item) => pathKey(item) === nextKey)
 
     if (params.dir && projectRoot(nextCurrent) === root && !valid) {
       navigateWithSidebarReset(`/${base64Encode(root)}/session`)
@@ -1640,7 +1634,7 @@ export default function Layout(props: ParentProps) {
     })
 
     const handleDelete = () => {
-      const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
+    const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory)
       if (leaveDeletedWorkspace) {
         navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
       }
@@ -1867,11 +1861,11 @@ export default function Layout(props: ParentProps) {
     const local = project.worktree
     const dirs = [local, ...(project.sandboxes ?? [])]
     const active = currentProject()
-    const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
+    const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined
     const extra =
       directory &&
-      workspaceKey(directory) !== workspaceKey(local) &&
-      !dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
+      pathKey(directory) !== pathKey(local) &&
+      !dirs.some((item) => pathKey(item) === pathKey(directory))
         ? directory
         : undefined
     const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
@@ -1916,7 +1910,7 @@ export default function Layout(props: ParentProps) {
     setStore(
       "workspaceOrder",
       project.worktree,
-      result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
+      result.filter((directory) => pathKey(directory) !== pathKey(project.worktree)),
     )
   }
 
@@ -1942,8 +1936,8 @@ export default function Layout(props: ParentProps) {
     setWorkspaceName(created.directory, created.branch, project.id, created.branch)
 
     const local = project.worktree
-    const key = workspaceKey(created.directory)
-    const root = workspaceKey(local)
+    const key = pathKey(created.directory)
+    const root = pathKey(local)
 
     setBusy(created.directory, true)
     WorktreeState.pending(created.directory)
@@ -1954,7 +1948,7 @@ export default function Layout(props: ParentProps) {
     setStore("workspaceOrder", project.worktree, (prev) => {
       const existing = prev ?? []
       const next = existing.filter((item) => {
-        const id = workspaceKey(item)
+        const id = pathKey(item)
         return id !== root && id !== key
       })
       return [created.directory, ...next]

+ 8 - 8
packages/app/src/pages/layout/helpers.test.ts

@@ -14,8 +14,8 @@ import {
   errorMessage,
   hasProjectPermissions,
   latestRootSession,
-  workspaceKey,
 } from "./helpers"
+import { pathKey } from "@/utils/path-key"
 
 const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
   ({
@@ -104,16 +104,16 @@ describe("layout deep links", () => {
 
 describe("layout workspace helpers", () => {
   test("normalizes trailing slash in workspace key", () => {
-    expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
-    expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
+    expect(String(pathKey("/tmp/demo///"))).toBe("/tmp/demo")
+    expect(String(pathKey("C:\\tmp\\demo\\\\"))).toBe("C:/tmp/demo")
   })
 
   test("preserves posix and drive roots in workspace key", () => {
-    expect(workspaceKey("/")).toBe("/")
-    expect(workspaceKey("///")).toBe("/")
-    expect(workspaceKey("C:\\")).toBe("C:/")
-    expect(workspaceKey("C://")).toBe("C:/")
-    expect(workspaceKey("C:///")).toBe("C:/")
+    expect(String(pathKey("/"))).toBe("/")
+    expect(String(pathKey("///"))).toBe("/")
+    expect(String(pathKey("C:\\"))).toBe("C:/")
+    expect(String(pathKey("C://"))).toBe("C:/")
+    expect(String(pathKey("C:///"))).toBe("C:/")
   })
 
   test("keeps local first while preserving known order", () => {

+ 5 - 12
packages/app/src/pages/layout/helpers.ts

@@ -1,19 +1,12 @@
 import { getFilename } from "@opencode-ai/core/util/path"
 import { type Session } from "@opencode-ai/sdk/v2/client"
+import { pathKey } from "@/utils/path-key"
 
 type SessionStore = {
   session?: Session[]
   path: { directory: string }
 }
 
-export const workspaceKey = (directory: string) => {
-  const value = directory.replaceAll("\\", "/")
-  const drive = value.match(/^([A-Za-z]:)\/+$/)
-  if (drive) return `${drive[1]}/`
-  if (/^\/+$/i.test(value)) return "/"
-  return value.replace(/\/+$/, "")
-}
-
 function sortSessions(now: number) {
   const oneMinuteAgo = now - 60 * 1000
   return (a: Session, b: Session) => {
@@ -29,7 +22,7 @@ function sortSessions(now: number) {
 }
 
 const isRootVisibleSession = (session: Session, directory: string) =>
-  workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
+  pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived
 
 export const roots = (store: SessionStore) =>
   (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
@@ -72,11 +65,11 @@ export const errorMessage = (err: unknown, fallback: string) => {
 }
 
 export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
-  const root = workspaceKey(local)
+  const root = pathKey(local)
   const live = new Map<string, string>()
 
   for (const dir of dirs) {
-    const key = workspaceKey(dir)
+    const key = pathKey(dir)
     if (key === root) continue
     if (!live.has(key)) live.set(key, dir)
   }
@@ -85,7 +78,7 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted
 
   const result = [local]
   for (const dir of persisted) {
-    const key = workspaceKey(dir)
+    const key = pathKey(dir)
     if (key === root) continue
     const match = live.get(key)
     if (!match) continue

+ 3 - 2
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -16,8 +16,9 @@ import { type Session } from "@opencode-ai/sdk/v2/client"
 import { type LocalProject } from "@/context/layout"
 import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
+import { pathKey } from "@/utils/path-key"
 import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
-import { sortedRootSessions, workspaceKey } from "./helpers"
+import { sortedRootSessions } from "./helpers"
 import { useQuery } from "@tanstack/solid-query"
 
 type InlineEditorComponent = (props: {
@@ -309,7 +310,7 @@ export const SortableWorkspace = (props: {
   const slug = createMemo(() => base64Encode(props.directory))
   const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
   const local = createMemo(() => props.directory === props.project.worktree)
-  const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
+  const active = createMemo(() => pathKey(props.ctx.currentDir()) === pathKey(props.directory))
   const workspaceValue = createMemo(() => {
     const branch = workspaceStore.vcs?.branch
     const name = branch ?? getFilename(props.directory)

+ 24 - 0
packages/app/src/utils/path-key.ts

@@ -0,0 +1,24 @@
+export type PathKey = string & { _brand: "PathKey" }
+
+const isDrive = (value: string) => {
+  if (value.length !== 2) return false
+  const code = value.charCodeAt(0)
+  return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122))
+}
+
+const trimTrailingSlashes = (value: string) => {
+  for (let i = value.length - 1; i >= 0; i--) {
+    if (value[i] !== "/") return value.slice(0, i + 1)
+  }
+  return ""
+}
+
+const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\")
+
+export const pathKey = (path: string) => {
+  const value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path
+  const trimmed = trimTrailingSlashes(value)
+  if (!trimmed && value.startsWith("/")) return "/" as PathKey
+  if (isDrive(trimmed)) return `${trimmed}/` as PathKey
+  return trimmed as PathKey
+}

+ 52 - 0
packages/app/src/utils/persist.test.ts

@@ -1,6 +1,8 @@
 import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
 
 type PersistTestingType = typeof import("./persist").PersistTesting
+type PersistType = typeof import("./persist").Persist
+type RemovePersistedType = typeof import("./persist").removePersisted
 
 class MemoryStorage implements Storage {
   private values = new Map<string, string>()
@@ -45,6 +47,8 @@ class MemoryStorage implements Storage {
 const storage = new MemoryStorage()
 
 let persistTesting: PersistTestingType
+let Persist: PersistType
+let removePersisted: RemovePersistedType
 
 beforeAll(async () => {
   mock.module("@/context/platform", () => ({
@@ -53,6 +57,8 @@ beforeAll(async () => {
 
   const mod = await import("./persist")
   persistTesting = mod.PersistTesting
+  Persist = mod.Persist
+  removePersisted = mod.removePersisted
 })
 
 beforeEach(() => {
@@ -112,4 +118,50 @@ describe("persist localStorage resilience", () => {
     expect(result.endsWith(".dat")).toBeTrue()
     expect(/[:\\/]/.test(result)).toBeFalse()
   })
+
+  test("workspace target keeps raw path storage as legacy fallback", () => {
+    const target = Persist.workspace("C:\\Users\\foo", "vcs")
+
+    expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo"))
+    expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")])
+  })
+
+  test("workspace target keeps backslash storage as fallback for normalized Windows paths", () => {
+    const target = Persist.workspace("C:/Users/foo", "vcs")
+
+    expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo"))
+    expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")])
+  })
+
+  test("migrates direct legacy keys into scoped storage", () => {
+    storage.setItem("legacy.workspace", '{"value":2}')
+    const target = Persist.workspace("C:/Users/foo", "demo", ["legacy.workspace"])
+    const current = persistTesting.localStorageWithPrefix(target.storage!)
+    const legacyStore = persistTesting.localStorageDirect()
+
+    const result = persistTesting.migrateLegacy({
+      current,
+      legacyStore,
+      stores: [],
+      keys: target.legacy!,
+      key: target.key,
+      defaults: { value: 1 },
+    })
+
+    expect(result).toBe('{"value":2}')
+    expect(storage.getItem(`${target.storage}:${target.key}`)).toBe('{"value":2}')
+    expect(legacyStore.getItem("legacy.workspace")).toBeNull()
+    expect(storage.getItem("legacy.workspace")).toBeNull()
+  })
+
+  test("removes legacy workspace storage when removing persisted target", () => {
+    const target = Persist.workspace("C:\\Users\\foo", "terminal")
+    storage.setItem(`${target.storage}:${target.key}`, '{"value":1}')
+    storage.setItem(`${target.legacyStorageNames![0]}:${target.key}`, '{"value":2}')
+
+    removePersisted(target)
+
+    expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
+    expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
+  })
 })

+ 188 - 58
packages/app/src/utils/persist.ts

@@ -3,6 +3,7 @@ import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primi
 import { checksum } from "@opencode-ai/core/util/encode"
 import { createResource, type Accessor } from "solid-js"
 import type { SetStoreFunction, Store } from "solid-js/store"
+import { pathKey } from "@/utils/path-key"
 
 type InitType = Promise<string> | string | null
 type PersistedWithReady<T> = [
@@ -14,6 +15,7 @@ type PersistedWithReady<T> = [
 
 type PersistTarget = {
   storage?: string
+  legacyStorageNames?: string[]
   key: string
   legacy?: string[]
   migrate?: (value: unknown) => unknown
@@ -208,12 +210,153 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
   return JSON.stringify(merged)
 }
 
+function readCurrent(input: {
+  storage: SyncStorage
+  key: string
+  defaults: unknown
+  migrate?: (value: unknown) => unknown
+}) {
+  const raw = input.storage.getItem(input.key)
+  if (raw === null) return
+  const next = normalize(input.defaults, raw, input.migrate)
+  if (next === undefined) {
+    input.storage.removeItem(input.key)
+    return null
+  }
+  if (raw !== next) input.storage.setItem(input.key, next)
+  return next
+}
+
+function migrateLegacy(input: {
+  current: SyncStorage
+  legacyStore?: SyncStorage
+  stores: SyncStorage[]
+  keys: string[]
+  key: string
+  defaults: unknown
+  migrate?: (value: unknown) => unknown
+}) {
+  for (const store of input.stores) {
+    const raw = store.getItem(input.key)
+    if (raw === null) continue
+
+    const next = normalize(input.defaults, raw, input.migrate)
+    if (next === undefined) {
+      store.removeItem(input.key)
+      continue
+    }
+    input.current.setItem(input.key, next)
+    store.removeItem(input.key)
+    return next
+  }
+
+  if (!input.legacyStore) return null
+
+  for (const key of input.keys) {
+    const raw = input.legacyStore.getItem(key)
+    if (raw === null) continue
+
+    const next = normalize(input.defaults, raw, input.migrate)
+    if (next === undefined) {
+      input.legacyStore.removeItem(key)
+      continue
+    }
+    input.current.setItem(input.key, next)
+    input.legacyStore.removeItem(key)
+    return next
+  }
+
+  return null
+}
+
+async function readCurrentAsync(input: {
+  storage: AsyncStorage
+  key: string
+  defaults: unknown
+  migrate?: (value: unknown) => unknown
+}) {
+  const raw = await input.storage.getItem(input.key)
+  if (raw === null) return
+  const next = normalize(input.defaults, raw, input.migrate)
+  if (next === undefined) {
+    await input.storage.removeItem(input.key).catch(() => undefined)
+    return null
+  }
+  if (raw !== next) await input.storage.setItem(input.key, next)
+  return next
+}
+
+async function removeAsync(storage: AsyncStorage, key: string) {
+  try {
+    await storage.removeItem(key)
+  } catch {}
+}
+
+async function migrateLegacyAsync(input: {
+  current: AsyncStorage
+  legacyStore?: AsyncStorage
+  stores: AsyncStorage[]
+  keys: string[]
+  key: string
+  defaults: unknown
+  migrate?: (value: unknown) => unknown
+}) {
+  for (const store of input.stores) {
+    const raw = await store.getItem(input.key)
+    if (raw === null) continue
+
+    const next = normalize(input.defaults, raw, input.migrate)
+    if (next === undefined) {
+      await removeAsync(store, input.key)
+      continue
+    }
+    await input.current.setItem(input.key, next)
+    await store.removeItem(input.key)
+    return next
+  }
+
+  if (!input.legacyStore) return null
+
+  for (const key of input.keys) {
+    const raw = await input.legacyStore.getItem(key)
+    if (raw === null) continue
+
+    const next = normalize(input.defaults, raw, input.migrate)
+    if (next === undefined) {
+      await removeAsync(input.legacyStore, key)
+      continue
+    }
+    await input.current.setItem(input.key, next)
+    await input.legacyStore.removeItem(key)
+    return next
+  }
+
+  return null
+}
+
 function workspaceStorage(dir: string) {
   const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
   const sum = checksum(dir) ?? "0"
   return `opencode.workspace.${head}.${sum}.dat`
 }
 
+function legacyWorkspaceStorage(dir: string) {
+  const storage = workspaceStorage(pathKey(dir))
+  const result = new Set<string>()
+  const raw = workspaceStorage(dir)
+  if (raw !== storage) result.add(raw)
+
+  const key = pathKey(dir)
+  const drive = key.length >= 3 && key[1] === ":" && key[2] === "/"
+  if (drive) {
+    const backslash = workspaceStorage(key.replaceAll("/", "\\"))
+    if (backslash !== storage) result.add(backslash)
+  }
+
+  if (result.size === 0) return
+  return [...result]
+}
+
 function localStorageWithPrefix(prefix: string): SyncStorage {
   const base = `${prefix}:`
   const scope = `prefix:${prefix}`
@@ -304,6 +447,7 @@ function localStorageDirect(): SyncStorage {
 export const PersistTesting = {
   localStorageDirect,
   localStorageWithPrefix,
+  migrateLegacy,
   normalize,
   workspaceStorage,
 }
@@ -313,10 +457,17 @@ export const Persist = {
     return { storage: GLOBAL_STORAGE, key, legacy }
   },
   workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
-    return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
+    const storage = workspaceStorage(pathKey(dir))
+    return { storage, legacyStorageNames: legacyWorkspaceStorage(dir), key: `workspace:${key}`, legacy }
   },
   session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
-    return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
+    const storage = workspaceStorage(pathKey(dir))
+    return {
+      storage,
+      legacyStorageNames: legacyWorkspaceStorage(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)
@@ -324,11 +475,15 @@ export const Persist = {
   },
 }
 
-export function removePersisted(target: { storage?: string; key: string }, platform?: Platform) {
+export function removePersisted(target: { storage?: string; legacyStorageNames?: string[]; key: string }, platform?: Platform) {
   const isDesktop = platform?.platform === "desktop" && !!platform.storage
 
   if (isDesktop) {
-    return platform.storage?.(target.storage)?.removeItem(target.key)
+    void platform.storage?.(target.storage)?.removeItem(target.key)
+    for (const storage of target.legacyStorageNames ?? []) {
+      void platform.storage?.(storage)?.removeItem(target.key)
+    }
+    return
   }
 
   if (!target.storage) {
@@ -337,6 +492,9 @@ export function removePersisted(target: { storage?: string; key: string }, platf
   }
 
   localStorageWithPrefix(target.storage).removeItem(target.key)
+  for (const storage of target.legacyStorageNames ?? []) {
+    localStorageWithPrefix(storage).removeItem(target.key)
+  }
 }
 
 export function persisted<T>(
@@ -363,39 +521,27 @@ export function persisted<T>(
     return platform.storage?.(LEGACY_STORAGE)
   })()
 
+  const legacyStorageNames = config.legacyStorageNames ?? []
+
   const storage = (() => {
     if (!isDesktop) {
       const current = currentStorage as SyncStorage
       const legacyStore = legacyStorage as SyncStorage
+      const legacyStores = legacyStorageNames.map(localStorageWithPrefix)
 
       const api: SyncStorage = {
         getItem: (key) => {
-          const raw = current.getItem(key)
-          if (raw !== null) {
-            const next = normalize(defaults, raw, config.migrate)
-            if (next === undefined) {
-              current.removeItem(key)
-              return null
-            }
-            if (raw !== next) current.setItem(key, next)
-            return next
-          }
-
-          for (const legacyKey of legacy) {
-            const legacyRaw = legacyStore.getItem(legacyKey)
-            if (legacyRaw === null) continue
-
-            const next = normalize(defaults, legacyRaw, config.migrate)
-            if (next === undefined) {
-              legacyStore.removeItem(legacyKey)
-              continue
-            }
-            current.setItem(key, next)
-            legacyStore.removeItem(legacyKey)
-            return next
-          }
-
-          return null
+          const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate })
+          if (value !== undefined) return value
+          return migrateLegacy({
+            current,
+            legacyStore,
+            stores: legacyStores,
+            keys: legacy,
+            key,
+            defaults,
+            migrate: config.migrate,
+          })
         },
         setItem: (key, value) => {
           current.setItem(key, value)
@@ -410,37 +556,21 @@ export function persisted<T>(
 
     const current = currentStorage as AsyncStorage
     const legacyStore = legacyStorage as AsyncStorage | undefined
+    const legacyStores = legacyStorageNames.map((name) => platform.storage?.(name) as AsyncStorage | undefined).filter((x) => !!x)
 
     const api: AsyncStorage = {
       getItem: async (key) => {
-        const raw = await current.getItem(key)
-        if (raw !== null) {
-          const next = normalize(defaults, raw, config.migrate)
-          if (next === undefined) {
-            await current.removeItem(key).catch(() => undefined)
-            return null
-          }
-          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
-
-          const next = normalize(defaults, legacyRaw, config.migrate)
-          if (next === undefined) {
-            await legacyStore.removeItem(legacyKey).catch(() => undefined)
-            continue
-          }
-          await current.setItem(key, next)
-          await legacyStore.removeItem(legacyKey)
-          return next
-        }
-
-        return null
+        const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
+        if (value !== undefined) return value
+        return migrateLegacyAsync({
+          current,
+          legacyStore,
+          stores: legacyStores,
+          keys: legacy,
+          key,
+          defaults,
+          migrate: config.migrate,
+        })
       },
       setItem: async (key, value) => {
         await current.setItem(key, value)

+ 4 - 3
packages/opencode/test/cli/tui/thread.test.ts

@@ -71,11 +71,11 @@ describe("tui thread", () => {
 
   async function check(project?: string) {
     setup()
-    await using tmp = await tmpdir({ git: true })
     const cwd = process.cwd()
     const pwd = process.env.PWD
     const worker = globalThis.Worker
     const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
+    await using tmp = await tmpdir({ git: true })
     const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
     const type = process.platform === "win32" ? "junction" : "dir"
     seen.tui.length = 0
@@ -109,11 +109,12 @@ describe("tui thread", () => {
     }
   }
 
-  test("uses the real cwd when PWD points at a symlink", async () => {
+  // serial because both modify real env vars
+  test.serial("uses the real cwd when PWD points at a symlink", async () => {
     await check()
   })
 
-  test("uses the real cwd after resolving a relative project from PWD", async () => {
+  test.serial("uses the real cwd after resolving a relative project from PWD", async () => {
     await check(".")
   })
 })