diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index aa047fb287a..88d71f94cfd 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,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]
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 18fa46d3299..0858f26273c 100644
--- a/packages/app/e2e/projects/workspace-new-session.spec.ts
+++ b/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)
diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts
index aeeccb9bba9..8ee899f18e6 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,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)
}
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)
}
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 857892123bc..9a282bbb708 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..cd5e079a69a 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/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 (
-
+
{(resolved) => (
resolved}>
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 4c3a00dfb5d..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]
@@ -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])
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 be4ce9f5742..886ffd26a1a 100644
--- a/packages/app/src/pages/layout/helpers.ts
+++ b/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(
request: Record,
@@ -40,9 +44,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) {
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)