From 6aaaa4c807bf4f9c7f56852427dc7278243c2a46 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 25 Mar 2026 14:46:10 -0400 Subject: [PATCH 1/3] fix(app): normalize workspace keys for session restore --- packages/app/src/context/local.tsx | 5 +++-- packages/app/src/pages/layout/helpers.ts | 10 ++-------- packages/app/src/utils/persist.test.ts | 7 +++++++ packages/app/src/utils/persist.ts | 6 ++++-- packages/app/src/utils/workspace.ts | 7 +++++++ 5 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 packages/app/src/utils/workspace.ts diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 76d337c82b..715c84daf8 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -7,6 +7,7 @@ import { useModels } from "@/context/models" import { useProviders } from "@/hooks/use-providers" import { modelEnabled, modelProbe } from "@/testing/model-selection" import { Persist, persisted } from "@/utils/persist" +import { workspaceKey } from "@/utils/workspace" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { useSDK } from "./sdk" import { useSync } from "./sync" @@ -26,7 +27,7 @@ type Saved = { const WORKSPACE_KEY = "__workspace__" const handoff = new Map() -const handoffKey = (dir: string, id: string) => `${dir}\n${id}` +const handoffKey = (dir: string, id: string) => `${workspaceKey(dir)}\n${id}` const migrate = (value: unknown) => { if (!value || typeof value !== "object") return { session: {} } @@ -364,7 +365,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const next = clone(snapshot()) if (!next) return - if (dir === sdk.directory) { + if (workspaceKey(dir) === workspaceKey(sdk.directory)) { setSaved("session", session, next) setStore("draft", undefined) return diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 226098c1cd..fc2141c404 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,19 +1,13 @@ import { getFilename } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" +export { workspaceKey } from "@/utils/workspace" +import { workspaceKey } from "@/utils/workspace" 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) => { diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index 673acd224d..983a3c135c 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -112,4 +112,11 @@ describe("persist localStorage resilience", () => { expect(result.endsWith(".dat")).toBeTrue() expect(/[:\\/]/.test(result)).toBeFalse() }) + + test("workspace storage treats slash variants as the same workspace", () => { + const a = persistTesting.workspaceStorage("C:\\Users\\foo\\bar\\") + const b = persistTesting.workspaceStorage("C:/Users/foo/bar") + + expect(a).toBe(b) + }) }) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 3dcbeb7d36..0472feb894 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -1,4 +1,5 @@ import { Platform, usePlatform } from "@/context/platform" +import { workspaceKey } from "@/utils/workspace" import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" import { checksum } from "@opencode-ai/util/encode" import { createResource, type Accessor } from "solid-js" @@ -209,8 +210,9 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => } function workspaceStorage(dir: string) { - const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") - const sum = checksum(dir) ?? "0" + const key = workspaceKey(dir) + const head = (key.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") + const sum = checksum(key) ?? "0" return `opencode.workspace.${head}.${sum}.dat` } diff --git a/packages/app/src/utils/workspace.ts b/packages/app/src/utils/workspace.ts new file mode 100644 index 0000000000..df8a7f5f2a --- /dev/null +++ b/packages/app/src/utils/workspace.ts @@ -0,0 +1,7 @@ +export const workspaceKey = (dir: string) => { + const value = dir.replaceAll("\\", "/") + const drive = value.match(/^([A-Za-z]:)\/+$/) + if (drive) return `${drive[1]}/` + if (/^\/+$/i.test(value)) return "/" + return value.replace(/\/+$/, "") +} From 31e92f1454af22e5b902164005ff845895942716 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 25 Mar 2026 15:01:43 -0400 Subject: [PATCH 2/3] fix(app): normalize e2e workspace path checks --- packages/app/e2e/actions.ts | 18 ++++++++++-------- packages/app/e2e/utils.ts | 13 +++++++++++-- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 90af177ed1..e798797c7c 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -5,7 +5,7 @@ import os from "node:os" import path from "node:path" import { execSync } from "node:child_process" import { terminalAttr, type E2EWindow } from "../src/testing/terminal" -import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" +import { createSdk, modKey, resolveDirectory, serverUrl, workspaceKey } from "./utils" import { dropdownMenuTriggerSelector, dropdownMenuContentSelector, @@ -438,7 +438,7 @@ export async function resolveSlug(slug: string) { } export async function waitDir(page: Page, directory: string) { - const target = await resolveDirectory(directory) + const target = workspaceKey(await resolveDirectory(directory)) await expect .poll( async () => { @@ -446,7 +446,7 @@ export async function waitDir(page: Page, directory: string) { const slug = slugFromUrl(page.url()) if (!slug) return "" return resolveSlug(slug) - .then((item) => item.directory) + .then((item) => workspaceKey(item.directory)) .catch(() => "") }, { timeout: 45_000 }, @@ -456,7 +456,7 @@ export async function waitDir(page: Page, directory: string) { } export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) { - const target = await resolveDirectory(input.directory) + const target = workspaceKey(await resolveDirectory(input.directory)) await expect .poll( async () => { @@ -464,14 +464,14 @@ export async function waitSession(page: Page, input: { directory: string; sessio const slug = slugFromUrl(page.url()) if (!slug) return false const resolved = await resolveSlug(slug).catch(() => undefined) - if (!resolved || resolved.directory !== target) return false + if (!resolved || workspaceKey(resolved.directory) !== target) return false if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false const state = await probeSession(page) if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false if (state?.dir) { const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "") - if (dir !== target) return false + if (workspaceKey(dir) !== target) return false } return page @@ -488,7 +488,7 @@ export async function waitSession(page: Page, input: { directory: string; sessio export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) { const sdk = createSdk(directory) - const target = await resolveDirectory(directory) + const target = workspaceKey(await resolveDirectory(directory)) await expect .poll( @@ -498,7 +498,9 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim .then((x) => x.data) .catch(() => undefined) if (!data?.directory) return "" - return resolveDirectory(data.directory).catch(() => data.directory) + return resolveDirectory(data.directory) + .then(workspaceKey) + .catch(() => workspaceKey(data.directory)) }, { timeout }, ) diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index f07a8d3f11..0cedf93e93 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -36,6 +36,14 @@ export async function resolveDirectory(directory: string) { .then((x) => x.data?.directory ?? directory) } +export function workspaceKey(dir: string) { + const value = dir.replaceAll("\\", "/") + const drive = value.match(/^([A-Za-z]:)\/+$/) + if (drive) return `${drive[1]}/` + if (/^\/+$/i.test(value)) return "/" + return value.replace(/\/+$/, "") +} + export async function getWorktree() { const sdk = createSdk() const result = await sdk.path.get() @@ -57,7 +65,8 @@ export function sessionPath(directory: string, sessionID?: string) { } export function workspacePersistKey(directory: string, key: string) { - const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") - const sum = checksum(directory) ?? "0" + const dir = workspaceKey(directory) + const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") + const sum = checksum(dir) ?? "0" return `opencode.workspace.${head}.${sum}.dat:workspace:${key}` } From 638a025ab42fcb6e39476981397ddcf1ae31da50 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 25 Mar 2026 15:24:15 -0400 Subject: [PATCH 3/3] fix(app): resync session model on session change --- packages/app/src/pages/session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7a3b476e8d..17a5add089 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -484,7 +484,7 @@ export default function Page() { createEffect( on( - () => lastUserMessage()?.id, + () => [params.id, lastUserMessage()?.id] as const, () => { const msg = lastUserMessage() if (!msg) return