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

app: fix workspace flicker when switching directories (#18207)

Co-authored-by: Shoubhit Dash <[email protected]>
Brendan Allan 4 недель назад
Родитель
Сommit
84f60d97a0

+ 25 - 0
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,30 @@ 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), 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) {
   const match = /\/session\/([^/?#]+)/.exec(url)
   return match?.[1]

+ 3 - 5
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}(?:[/?#]|$)`))
       },

+ 34 - 26
packages/app/e2e/projects/workspace-new-session.spec.ts

@@ -1,18 +1,25 @@
-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, 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,29 +34,30 @@ 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 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 waitSlug(page)
-  await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
-  return next
+  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()
@@ -60,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) {
@@ -87,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)

+ 15 - 14
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,8 +12,10 @@ import {
   confirmDialog,
   openSidebar,
   openWorkspaceMenu,
+  resolveSlug,
   setWorkspacesEnabled,
   slugFromUrl,
+  waitDir,
   waitSlug,
 } from "../actions"
 import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
@@ -27,15 +28,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 waitDir(page, next.directory)
 
   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 +48,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 +80,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 waitDir(page, next.directory)
 
     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 +100,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)
   })
 })
 
@@ -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)
@@ -331,9 +332,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 waitDir(page, next.directory)
+        workspaces.push(next)
 
         await openSidebar(page)
       }

+ 6 - 8
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)
 }

+ 6 - 12
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 = () => <div class="size-full" />
 
-const HomeRoute = () => (
-  <Suspense fallback={<Loading />}>
-    <Home />
-  </Suspense>
-)
-
 const SessionRoute = () => (
   <SessionProviders>
-    <Suspense fallback={<Loading />}>
-      <Session />
-    </Suspense>
+    <Session />
   </SessionProviders>
 )
 
@@ -124,8 +116,10 @@ function SessionProviders(props: ParentProps) {
 function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
   return (
     <AppShellProviders>
-      {props.appChildren}
-      {props.children}
+      <Suspense fallback={<Loading />}>
+        {props.appChildren}
+        {props.children}
+      </Suspense>
     </AppShellProviders>
   )
 }

+ 46 - 51
packages/app/src/pages/directory-layout.tsx

@@ -1,16 +1,15 @@
-import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
-import { createStore } from "solid-js/store"
+import { DataProvider } from "@opencode-ai/ui/context"
+import { showToast } from "@opencode-ai/ui/toast"
+import { base64Encode } from "@opencode-ai/util/encode"
 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"
 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,57 +29,53 @@ 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: "" })
+  const navigate = useNavigate()
+  let invalid = ""
+
+  const [resolved] = createResource(
+    () => {
+      if (params.dir) return [location.pathname, params.dir] as const
+    },
+    async ([pathname, b64Dir]) => {
+      const directory = decode64(b64Dir)
 
-  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
-    }
+      if (!directory) {
+        if (invalid === params.dir) return
+        invalid = b64Dir
+        showToast({
+          variant: "error",
+          title: language.t("common.requestFailed"),
+          description: language.t("directory.error.invalidUrl"),
+        })
+        navigate("/", { replace: true })
+        return
+      }
 
-    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)
+      return await globalSDK
+        .createClient({
+          directory,
+          throwOnError: true,
         })
-        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)
+        .path.get()
+        .then((x) => {
+          const next = x.data?.directory ?? directory
+          invalid = ""
+          if (next === directory) return next
+          const path = pathname.slice(b64Dir.length + 1)
+          navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
         })
-      })
-  })
+        .catch(() => {
+          invalid = ""
+          return directory
+        })
+    },
+  )
 
   return (
-    <Show when={state.resolved} keyed>
+    <Show when={resolved()} keyed>
       {(resolved) => (
         <SDKProvider directory={() => resolved}>
           <SyncProvider>

+ 50 - 27
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]
 
@@ -1177,13 +1184,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
@@ -1347,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]
 
@@ -1683,38 +1684,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)
       },
     ),
   )
@@ -1778,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])

+ 3 - 3
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:/")
   })
 

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

@@ -1,11 +1,17 @@
 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("\\") ? "\\" : "/"}`
-  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) {
@@ -25,13 +31,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<T>(
   request: Record<string, T[] | undefined>,
@@ -40,9 +44,9 @@ export function hasProjectPermissions<T>(
   return Object.values(request).some((list) => list?.some(include))
 }
 
-export const childMapByParent = (sessions: Session[]) => {
+export const childMapByParent = (sessions: Session[] | undefined) => {
   const map = new Map<string, string[]>()
-  for (const session of sessions) {
+  for (const session of sessions ?? []) {
     if (!session.parentID) continue
     const existing = map.get(session.parentID)
     if (existing) {

+ 7 - 5
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)