From f1e40fffdf646dc949b7b723c22de43527f65a56 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 19 Mar 2026 15:45:08 +0800 Subject: [PATCH 1/7] fix workspace flicker when switching directories --- packages/app/src/app.tsx | 18 +-- packages/app/src/pages/directory-layout.tsx | 136 +++++++++++--------- 2 files changed, 83 insertions(+), 71 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e370862212b..6f30c4f6058 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -46,21 +46,13 @@ import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -const Home = lazy(() => import("@/pages/home")) +const HomeRoute = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) const Loading = () =>
-const HomeRoute = () => ( - }> - - -) - const SessionRoute = () => ( - }> - - + ) @@ -124,8 +116,10 @@ function SessionProviders(props: ParentProps) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { return ( - {props.appChildren} - {props.children} + }> + {props.appChildren} + {props.children} + ) } diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f993ffcd890..d29e71c6903 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,16 +1,26 @@ -import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" +import { DataProvider } from "@opencode-ai/ui/context" +import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode } from "@opencode-ai/util/encode" +import { Navigate, useLocation, useNavigate, useParams } from "@solidjs/router" +import { + batch, + createEffect, + createMemo, + createResource, + Match, + type ParentProps, + Show, + Switch, + startTransition, +} from "solid-js" import { createStore } from "solid-js/store" -import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" -import { LocalProvider } from "@/context/local" -import { useGlobalSDK } from "@/context/global-sdk" - -import { DataProvider } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" -import { showToast } from "@opencode-ai/ui/toast" -import { useLanguage } from "@/context/language" + function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const navigate = useNavigate() const sync = useSync() @@ -30,64 +40,72 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { export default function Layout(props: ParentProps) { const params = useParams() - const navigate = useNavigate() const location = useLocation() const language = useLanguage() const globalSDK = useGlobalSDK() - const directory = createMemo(() => decode64(params.dir) ?? "") - const [state, setState] = createStore({ invalid: "", resolved: "" }) + let invalid = "" - createEffect(() => { - if (!params.dir) return - const raw = directory() - if (!raw) { - if (state.invalid === params.dir) return - setState("invalid", params.dir) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } + const [resolved] = createResource( + () => params.dir, + async (b64Dir) => { + const directory = decode64(b64Dir) - const current = params.dir - globalSDK - .createClient({ - directory: raw, - throwOnError: true, - }) - .path.get() - .then((x) => { - if (params.dir !== current) return - const next = x.data?.directory ?? raw - batch(() => { - setState("invalid", "") - setState("resolved", next) + if (!directory) { + if (invalid === params.dir) return + invalid = b64Dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), }) - if (next === raw) return - const path = location.pathname.slice(current.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - if (params.dir !== current) return - batch(() => { - setState("invalid", "") - setState("resolved", raw) + return { type: "redirect" as const, href: "/" } + } + + return await globalSDK + .createClient({ + directory, + throwOnError: true, + }) + .path.get() + .then((x) => { + const next = x.data?.directory ?? directory + invalid = "" + if (next === directory) return { type: "resolved" as const, resolved: next } + const path = location.pathname.slice(b64Dir.length + 1) + return { type: "redirect" as const, href: `/${base64Encode(next)}${path}${location.search}${location.hash}` } }) - }) - }) + .catch(() => { + invalid = "" + return { type: "resolved" as const, resolved: directory } + }) + }, + ) return ( - - {(resolved) => ( - resolved}> - - {props.children} - - - )} - + + { + const r = resolved() + if (r?.type === "redirect") return r.href + })()} + > + {(href) => } + + { + const r = resolved() + if (r?.type === "resolved") return r.resolved + })()} + keyed + > + {(resolved) => ( + resolved}> + + {props.children} + + + )} + + ) } From d30f60ac098b8062786ba82f2e52e581f5955b8d Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 19 Mar 2026 20:13:45 +0800 Subject: [PATCH 2/7] capture location.pathname in resource pure phase --- packages/app/src/pages/directory-layout.tsx | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index d29e71c6903..784412a8ed6 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,18 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { Navigate, useLocation, useNavigate, useParams } from "@solidjs/router" -import { - batch, - createEffect, - createMemo, - createResource, - Match, - type ParentProps, - Show, - Switch, - startTransition, -} from "solid-js" -import { createStore } from "solid-js/store" +import { createMemo, createResource, Match, type ParentProps, Switch } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" @@ -46,8 +35,10 @@ export default function Layout(props: ParentProps) { let invalid = "" const [resolved] = createResource( - () => params.dir, - async (b64Dir) => { + () => { + if (params.dir) return [location.pathname, params.dir] as const + }, + async ([pathname, b64Dir]) => { const directory = decode64(b64Dir) if (!directory) { @@ -71,7 +62,7 @@ export default function Layout(props: ParentProps) { const next = x.data?.directory ?? directory invalid = "" if (next === directory) return { type: "resolved" as const, resolved: next } - const path = location.pathname.slice(b64Dir.length + 1) + const path = pathname.slice(b64Dir.length + 1) return { type: "redirect" as const, href: `/${base64Encode(next)}${path}${location.search}${location.hash}` } }) .catch(() => { From 7580c9f742bc92ce22af013a02ba3fe3baafd002 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 19 Mar 2026 17:55:01 +0530 Subject: [PATCH 3/7] fix(app): preserve canonical workspace session state --- packages/app/src/pages/layout.tsx | 46 ++++++++++++++---------- packages/app/src/pages/layout/helpers.ts | 19 +++++----- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4c3a00dfb5d..4baa8ca874d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1177,13 +1177,6 @@ export default function Layout(props: ParentProps) { return currentProject()?.worktree ?? projectRoot(directory) } - function touchProjectRoute() { - const root = currentProject()?.worktree - if (!root) return - if (server.projects.last() !== root) server.projects.touch(root) - return root - } - function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { setStore("lastProjectSession", root, { directory, id, at: Date.now() }) return root @@ -1683,38 +1676,55 @@ export default function Layout(props: ParentProps) { const activeRoute = { session: "", sessionProject: "", + directory: "", } createEffect( on( - () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const, - ([ready, dir, id]) => { - if (!ready || !dir) { + () => { + const dir = params.dir + const directory = dir ? decode64(dir) : undefined + const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : "" + return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const + }, + ([ready, dir, id, root, directory, resolved]) => { + if (!ready || !dir || !directory) { activeRoute.session = "" activeRoute.sessionProject = "" + activeRoute.directory = "" return } - const directory = decode64(dir) - if (!directory) return - - const root = touchProjectRoute() ?? activeProjectRoot(directory) - if (!id) { activeRoute.session = "" activeRoute.sessionProject = "" + activeRoute.directory = "" return } + const next = resolved || directory const session = `${dir}/${id}` - if (session !== activeRoute.session) { + + if (!root) { + activeRoute.session = session + activeRoute.directory = next + activeRoute.sessionProject = "" + return + } + + if (server.projects.last() !== root) server.projects.touch(root) + + const changed = session !== activeRoute.session || next !== activeRoute.directory + if (changed) { activeRoute.session = session - activeRoute.sessionProject = syncSessionRoute(directory, id, root) + activeRoute.directory = next + activeRoute.sessionProject = syncSessionRoute(next, id, root) return } if (root === activeRoute.sessionProject) return - activeRoute.sessionProject = rememberSessionRoute(directory, id, root) + activeRoute.directory = next + activeRoute.sessionProject = rememberSessionRoute(next, id, root) }, ), ) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index be4ce9f5742..8736b062cba 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,6 +1,11 @@ import { getFilename } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" +type SessionStore = { + session?: Session[] + path: { directory: string } +} + export const workspaceKey = (directory: string) => { const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` @@ -25,13 +30,11 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => - store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) +const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) + +export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) -export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => - stores - .flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) - .sort(sortSessions(now))[0] +export const latestRootSession = (stores: SessionStore[], now: number) => stores.flatMap(roots).sort(sortSessions(now))[0] export function hasProjectPermissions( request: Record, @@ -40,9 +43,9 @@ export function hasProjectPermissions( return Object.values(request).some((list) => list?.some(include)) } -export const childMapByParent = (sessions: Session[]) => { +export const childMapByParent = (sessions: Session[] | undefined) => { const map = new Map() - for (const session of sessions) { + for (const session of sessions ?? []) { if (!session.parentID) continue const existing = map.get(session.parentID) if (existing) { From eba2db3d3eea45929706e057de640ae1a4723f39 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 19 Mar 2026 17:55:17 +0530 Subject: [PATCH 4/7] test(app): resolve canonical workspace slugs in e2e --- packages/app/e2e/actions.ts | 8 ++++++ .../projects/workspace-new-session.spec.ts | 16 +++++------- packages/app/e2e/projects/workspaces.spec.ts | 26 +++++++++---------- .../session/session-model-persistence.spec.ts | 14 +++++----- 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index aa047fb287a..cf201493b5a 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -1,3 +1,4 @@ +import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { expect, type Locator, type Page } from "@playwright/test" import fs from "node:fs/promises" import os from "node:os" @@ -361,6 +362,13 @@ export async function waitSlug(page: Page, skip: string[] = []) { return next } +export async function resolveSlug(slug: string) { + const directory = base64Decode(slug) + if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) + const resolved = await resolveDirectory(directory) + return { directory: resolved, slug: base64Encode(resolved) } +} + export function sessionIDFromUrl(url: string) { const match = /\/session\/([^/?#]+)/.exec(url) return match?.[1] diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index 18fa46d3299..8dc8bb1c327 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -1,7 +1,6 @@ -import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" +import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { createSdk } from "../utils" @@ -27,10 +26,9 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [root, ...seen]) - const directory = base64Decode(slug) - if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) - return { slug, directory } + const next = await resolveSlug(await waitSlug(page, [root, ...seen])) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + return next } async function openWorkspaceNewSession(page: Page, slug: string) { @@ -43,9 +41,9 @@ async function openWorkspaceNewSession(page: Page, slug: string) { await expect(button).toBeVisible() await button.click({ force: true }) - const next = await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) - return next + const next = await resolveSlug(await waitSlug(page)) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + return next.slug } async function createSessionFromWorkspace(page: Page, slug: string, text: string) { diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index aeeccb9bba9..d23851e0b47 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -1,4 +1,3 @@ -import { base64Decode } from "@opencode-ai/util/encode" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -13,6 +12,7 @@ import { confirmDialog, openSidebar, openWorkspaceMenu, + resolveSlug, setWorkspacesEnabled, slugFromUrl, waitSlug, @@ -27,15 +27,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { await setWorkspacesEnabled(page, rootSlug, true) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [rootSlug]) - const dir = base64Decode(slug) + const next = await resolveSlug(await waitSlug(page, [rootSlug])) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(slug)).first() + const item = page.locator(workspaceItemSelector(next.slug)).first() try { await item.hover({ timeout: 500 }) return true @@ -47,7 +47,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { ) .toBe(true) - return { rootSlug, slug, directory: dir } + return { rootSlug, slug: next.slug, directory: next.directory } } test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { @@ -79,15 +79,15 @@ test("can create a workspace", async ({ page, withProject }) => { await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await page.getByRole("button", { name: "New workspace" }).first().click() - const workspaceSlug = await waitSlug(page, [slug]) - const workspaceDir = base64Decode(workspaceSlug) + const next = await resolveSlug(await waitSlug(page, [slug])) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(workspaceSlug)).first() + const item = page.locator(workspaceItemSelector(next.slug)).first() try { await item.hover({ timeout: 500 }) return true @@ -99,9 +99,9 @@ test("can create a workspace", async ({ page, withProject }) => { ) .toBe(true) - await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() - await cleanupTestProject(workspaceDir) + await cleanupTestProject(next.directory) }) }) @@ -331,9 +331,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) => for (const _ of [0, 1]) { const prev = slugFromUrl(page.url()) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [rootSlug, prev]) - const dir = base64Decode(slug) - workspaces.push({ slug, directory: dir }) + const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + workspaces.push(next) await openSidebar(page) } diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts index 933d5e6f96d..2c2e4e886da 100644 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -1,7 +1,6 @@ -import { base64Decode } from "@opencode-ai/util/encode" import type { Locator, Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" +import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" import { promptAgentSelector, promptModelSelector, @@ -224,10 +223,9 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [root, ...seen]) - const directory = base64Decode(slug) - if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) - return { slug, directory } + const next = await resolveSlug(await waitSlug(page, [root, ...seen])) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + return next } async function waitWorkspace(page: Page, slug: string) { @@ -257,8 +255,8 @@ async function newWorkspaceSession(page: Page, slug: string) { await expect(button).toBeVisible() await button.click({ force: true }) - const next = await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + const next = await resolveSlug(await waitSlug(page)) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) await expect(page.locator(promptSelector)).toBeVisible() return currentDir(page) } From 949e4352a14df7c06ccb0d6cf83360d22769a858 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 19 Mar 2026 20:34:16 +0800 Subject: [PATCH 5/7] call navigate in resource fetcher --- packages/app/src/pages/directory-layout.tsx | 48 ++++++++------------- 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 784412a8ed6..cd5e079a69a 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,8 +1,8 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" -import { Navigate, useLocation, useNavigate, useParams } from "@solidjs/router" -import { createMemo, createResource, Match, type ParentProps, Switch } from "solid-js" +import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { createMemo, createResource, type ParentProps, Show } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" @@ -32,6 +32,7 @@ export default function Layout(props: ParentProps) { const location = useLocation() const language = useLanguage() const globalSDK = useGlobalSDK() + const navigate = useNavigate() let invalid = "" const [resolved] = createResource( @@ -49,7 +50,8 @@ export default function Layout(props: ParentProps) { title: language.t("common.requestFailed"), description: language.t("directory.error.invalidUrl"), }) - return { type: "redirect" as const, href: "/" } + navigate("/", { replace: true }) + return } return await globalSDK @@ -61,42 +63,26 @@ export default function Layout(props: ParentProps) { .then((x) => { const next = x.data?.directory ?? directory invalid = "" - if (next === directory) return { type: "resolved" as const, resolved: next } + if (next === directory) return next const path = pathname.slice(b64Dir.length + 1) - return { type: "redirect" as const, href: `/${base64Encode(next)}${path}${location.search}${location.hash}` } + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) .catch(() => { invalid = "" - return { type: "resolved" as const, resolved: directory } + return directory }) }, ) return ( - - { - const r = resolved() - if (r?.type === "redirect") return r.href - })()} - > - {(href) => } - - { - const r = resolved() - if (r?.type === "resolved") return r.resolved - })()} - keyed - > - {(resolved) => ( - resolved}> - - {props.children} - - - )} - - + + {(resolved) => ( + resolved}> + + {props.children} + + + )} + ) } From 4f807455b8df437569e428ea7cc444a662d04a46 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 19 Mar 2026 19:04:48 +0530 Subject: [PATCH 6/7] fix(app): normalize workspace path identity --- packages/app/src/pages/layout.tsx | 31 +++++++++++++------ packages/app/src/pages/layout/helpers.test.ts | 6 ++-- packages/app/src/pages/layout/helpers.ts | 9 +++--- .../src/pages/layout/sidebar-workspace.tsx | 12 ++++--- 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4baa8ca874d..cca14fd50c9 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -543,13 +543,14 @@ export default function Layout(props: ParentProps) { const currentProject = createMemo(() => { const directory = currentDir() if (!directory) return + const key = workspaceKey(directory) const projects = layout.projects.list() - const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) + const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key)) if (sandbox) return sandbox - const direct = projects.find((p) => p.worktree === directory) + const direct = projects.find((p) => workspaceKey(p.worktree) === key) if (direct) return direct const [child] = globalSync.child(directory, { bootstrap: false }) @@ -630,7 +631,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 project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + const key = workspaceKey(directory) + const project = projects.find( + (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + ) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue setStore("workspaceExpanded", directory, false) @@ -1155,13 +1159,16 @@ export default function Layout(props: ParentProps) { } function projectRoot(directory: string) { + const key = workspaceKey(directory) const project = layout.projects .list() - .find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + .find( + (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + ) if (project) return project.worktree const known = Object.entries(store.workspaceOrder).find( - ([root, dirs]) => root === directory || dirs.includes(directory), + ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key), ) if (known) return known[0] @@ -1340,8 +1347,9 @@ export default function Layout(props: ParentProps) { function closeProject(directory: string) { const list = layout.projects.list() - const index = list.findIndex((x) => x.worktree === directory) - const active = currentProject()?.worktree === directory + const key = workspaceKey(directory) + const index = list.findIndex((x) => workspaceKey(x.worktree) === key) + const active = workspaceKey(currentProject()?.worktree ?? "") === key if (index === -1) return const next = list[index + 1] @@ -1788,8 +1796,13 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree ? currentDir() : undefined - const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined + const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined + const extra = + directory && + workspaceKey(directory) !== workspaceKey(local) && + !dirs.some((item) => workspaceKey(item) === workspaceKey(directory)) + ? directory + : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 9dbc6c72d2f..1fe52d47a0a 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -104,14 +104,14 @@ 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(workspaceKey("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(workspaceKey("C://")).toBe("C:/") expect(workspaceKey("C:///")).toBe("C:/") }) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 8736b062cba..886ffd26a1a 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -7,10 +7,11 @@ type SessionStore = { } export const workspaceKey = (directory: string) => { - const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) - if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` - if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" - return directory.replace(/[\\/]+$/, "") + 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) { diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 86ede774e63..127626febef 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -332,12 +332,13 @@ export const SortableWorkspace = (props: { const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const boot = createMemo(() => open() || active()) const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) - const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) + const count = createMemo(() => sessions()?.length ?? 0) + const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) const busy = createMemo(() => props.ctx.isBusy(props.directory)) const wasBusy = createMemo((prev) => prev || busy(), false) - const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) + const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy()) const touch = createMediaQuery("(hover: none)") - const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id))) + const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id))) const loadMore = async () => { setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.directory) @@ -472,8 +473,9 @@ export const LocalWorkspace = (props: { const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) - const loading = createMemo(() => !booted() && sessions().length === 0) - const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) + const count = createMemo(() => sessions()?.length ?? 0) + const loading = createMemo(() => !booted() && count() === 0) + const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const loadMore = async () => { workspace().setStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.project.worktree) From 22f51da2950593b330c7a5d972dbd7c2da788368 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 19 Mar 2026 19:05:02 +0530 Subject: [PATCH 7/7] test(app): wait for resolved workspace directories --- packages/app/e2e/actions.ts | 19 ++++++- .../app/e2e/projects/projects-switch.spec.ts | 8 ++- .../projects/workspace-new-session.spec.ts | 54 +++++++++++-------- packages/app/e2e/projects/workspaces.spec.ts | 9 ++-- 4 files changed, 58 insertions(+), 32 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index cf201493b5a..88d71f94cfd 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -366,7 +366,24 @@ export async function resolveSlug(slug: string) { const directory = base64Decode(slug) if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) const resolved = await resolveDirectory(directory) - return { directory: resolved, slug: base64Encode(resolved) } + return { directory: resolved, slug: base64Encode(resolved), raw: slug } +} + +export async function waitDir(page: Page, directory: string) { + const target = await resolveDirectory(directory) + await expect + .poll( + async () => { + const slug = slugFromUrl(page.url()) + if (!slug) return "" + return resolveSlug(slug) + .then((item) => item.directory) + .catch(() => "") + }, + { timeout: 45_000 }, + ) + .toBe(target) + return { directory: target, slug: base64Encode(target) } } export function sessionIDFromUrl(url: string) { diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 6ad64f59278..1416aec7267 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,7 +1,7 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" +import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitDir, waitSlug } from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" @@ -100,11 +100,8 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(btn).toBeVisible() await btn.click({ force: true }) - // A new workspace can be discovered via a transient slug before the route and sidebar - // settle to the canonical workspace path on Windows, so interact with either and assert - // against the resolved workspace slug. await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + await waitDir(page, space) // Create a session by sending a prompt const prompt = page.locator(promptSelector) @@ -132,6 +129,7 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(rootButton).toBeVisible() await rootButton.click() + await waitDir(page, space) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) }, diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index 8dc8bb1c327..0858f26273c 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -1,17 +1,25 @@ import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" +import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { createSdk } from "../utils" -async function waitWorkspaceReady(page: Page, slug: string) { +function item(space: { slug: string; raw: string }) { + return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}` +} + +function button(space: { slug: string; raw: string }) { + return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}` +} + +async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) { await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(slug)).first() + const row = page.locator(item(space)).first() try { - await item.hover({ timeout: 500 }) + await row.hover({ timeout: 500 }) return true } catch { return false @@ -27,27 +35,29 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await page.getByRole("button", { name: "New workspace" }).first().click() const next = await resolveSlug(await waitSlug(page, [root, ...seen])) - await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + await waitDir(page, next.directory) return next } -async function openWorkspaceNewSession(page: Page, slug: string) { - await waitWorkspaceReady(page, slug) +async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) { + await waitWorkspaceReady(page, space) - const item = page.locator(workspaceItemSelector(slug)).first() - await item.hover() + const row = page.locator(item(space)).first() + await row.hover() - const button = page.locator(workspaceNewSessionSelector(slug)).first() - await expect(button).toBeVisible() - await button.click({ force: true }) + const next = page.locator(button(space)).first() + await expect(next).toBeVisible() + await next.click({ force: true }) - const next = await resolveSlug(await waitSlug(page)) - await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) - return next.slug + return waitDir(page, space.directory) } -async function createSessionFromWorkspace(page: Page, slug: string, text: string) { - const next = await openWorkspaceNewSession(page, slug) +async function createSessionFromWorkspace( + page: Page, + space: { slug: string; raw: string; directory: string }, + text: string, +) { + const next = await openWorkspaceNewSession(page, space) const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() @@ -58,13 +68,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await prompt.press("Enter") - await expect.poll(() => slugFromUrl(page.url())).toBe(next) + await waitDir(page, next.directory) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) - await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`)) - return { sessionID, slug: next } + await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) + return { sessionID, slug: next.slug } } async function sessionDirectory(directory: string, sessionID: string) { @@ -85,11 +95,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a const first = await createWorkspace(page, root, []) trackDirectory(first.directory) - await waitWorkspaceReady(page, first.slug) + await waitWorkspaceReady(page, first) const second = await createWorkspace(page, root, [first.slug]) trackDirectory(second.directory) - await waitWorkspaceReady(page, second.slug) + await waitWorkspaceReady(page, second) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) trackSession(firstSession.sessionID, first.directory) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index d23851e0b47..8ee899f18e6 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -15,6 +15,7 @@ import { resolveSlug, setWorkspacesEnabled, slugFromUrl, + waitDir, waitSlug, } from "../actions" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" @@ -28,7 +29,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { await page.getByRole("button", { name: "New workspace" }).first().click() const next = await resolveSlug(await waitSlug(page, [rootSlug])) - await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + await waitDir(page, next.directory) await openSidebar(page) @@ -80,7 +81,7 @@ test("can create a workspace", async ({ page, withProject }) => { await page.getByRole("button", { name: "New workspace" }).first().click() const next = await resolveSlug(await waitSlug(page, [slug])) - await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + await waitDir(page, next.directory) await openSidebar(page) @@ -119,7 +120,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") - const activeDir = base64Decode(slugFromUrl(page.url())) + const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory) expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") await openSidebar(page) @@ -332,7 +333,7 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) => const prev = slugFromUrl(page.url()) await page.getByRole("button", { name: "New workspace" }).first().click() const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) - await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + await waitDir(page, next.directory) workspaces.push(next) await openSidebar(page)