Skip to content
Merged
25 changes: 25 additions & 0 deletions packages/app/e2e/actions.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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]
Expand Down
8 changes: 3 additions & 5 deletions packages/app/e2e/projects/projects-switch.spec.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}(?:[/?#]|$)`))
},
Expand Down
60 changes: 34 additions & 26 deletions packages/app/e2e/projects/workspace-new-session.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand All @@ -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) {
Expand All @@ -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)
Expand Down
29 changes: 15 additions & 14 deletions packages/app/e2e/projects/workspaces.spec.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,8 +12,10 @@ import {
confirmDialog,
openSidebar,
openWorkspaceMenu,
resolveSlug,
setWorkspacesEnabled,
slugFromUrl,
waitDir,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
Expand All @@ -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
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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
Expand All @@ -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)
})
})

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
14 changes: 6 additions & 8 deletions packages/app/e2e/session/session-model-persistence.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down
18 changes: 6 additions & 12 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)

Expand Down Expand Up @@ -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>
)
}
Expand Down
Loading
Loading