Browse Source

Merge branch 'dev' into refactor/hono-server

Dax 1 month ago
parent
commit
37ff5aaa5c

+ 21 - 5
.github/workflows/test.yml

@@ -50,20 +50,17 @@ jobs:
 
   e2e:
     name: e2e (${{ matrix.settings.name }})
-    needs: unit
     strategy:
       fail-fast: false
       matrix:
         settings:
           - name: linux
             host: blacksmith-4vcpu-ubuntu-2404
-            playwright: bunx playwright install --with-deps
           - name: windows
             host: blacksmith-4vcpu-windows-2025
-            playwright: bunx playwright install
     runs-on: ${{ matrix.settings.host }}
     env:
-      PLAYWRIGHT_BROWSERS_PATH: 0
+      PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
     defaults:
       run:
         shell: bash
@@ -76,9 +73,28 @@ jobs:
       - name: Setup Bun
         uses: ./.github/actions/setup-bun
 
+      - name: Read Playwright version
+        id: playwright-version
+        run: |
+          version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
+          echo "version=$version" >> "$GITHUB_OUTPUT"
+
+      - name: Cache Playwright browsers
+        id: playwright-cache
+        uses: actions/cache@v4
+        with:
+          path: ${{ github.workspace }}/.playwright-browsers
+          key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
+
+      - name: Install Playwright system dependencies
+        if: runner.os == 'Linux'
+        working-directory: packages/app
+        run: bunx playwright install-deps chromium
+
       - name: Install Playwright browsers
+        if: steps.playwright-cache.outputs.cache-hit != 'true'
         working-directory: packages/app
-        run: ${{ matrix.settings.playwright }}
+        run: bunx playwright install chromium
 
       - name: Run app e2e tests
         run: bun --cwd packages/app test:e2e:local

+ 1 - 0
bun.lock

@@ -588,6 +588,7 @@
   ],
   "patchedDependencies": {
     "@openrouter/[email protected]": "patches/@openrouter%[email protected]",
+    "[email protected]": "patches/[email protected]",
     "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
     "@standard-community/[email protected]": "patches/@standard-community%[email protected]",
   },

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-xq0W2Ym0AzANLXnLyAL+IUwrFm0MKXwkJVdENowoPyY=",
-    "aarch64-linux": "sha256-RtpiGZXk+BboD9MjBetq5sInIbH/OPkLVNSFgN/0ehY=",
-    "aarch64-darwin": "sha256-cX6y262OzqRicH4m0/u1DCsMkpJfzCUOOBFQqtQLvls=",
-    "x86_64-darwin": "sha256-K4UmRKiEfKkvVeKUB85XjHJ1jf0ZUnjL0dWvx9TD4pI="
+    "x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=",
+    "aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=",
+    "aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=",
+    "x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA="
   }
 }

+ 2 - 1
package.json

@@ -113,6 +113,7 @@
   "patchedDependencies": {
     "@standard-community/[email protected]": "patches/@standard-community%[email protected]",
     "@openrouter/[email protected]": "patches/@openrouter%[email protected]",
-    "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]"
+    "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
+    "[email protected]": "patches/[email protected]"
   }
 }

+ 145 - 7
packages/app/e2e/actions.ts

@@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
 import {
   dropdownMenuTriggerSelector,
   dropdownMenuContentSelector,
+  projectSwitchSelector,
   projectMenuTriggerSelector,
   projectCloseMenuSelector,
   projectWorkspacesToggleSelector,
@@ -23,6 +24,16 @@ import {
   workspaceMenuTriggerSelector,
 } from "./selectors"
 
+const phase = new WeakMap<Page, "test" | "cleanup">()
+
+export function setHealthPhase(page: Page, value: "test" | "cleanup") {
+  phase.set(page, value)
+}
+
+export function healthPhase(page: Page) {
+  return phase.get(page) ?? "test"
+}
+
 export async function defocus(page: Page) {
   await page
     .evaluate(() => {
@@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
 }
 
 export async function isSidebarClosed(page: Page) {
-  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
-  await expect(button).toBeVisible()
+  const button = await waitSidebarButton(page, "isSidebarClosed")
   return (await button.getAttribute("aria-expanded")) !== "true"
 }
 
+async function errorBoundaryText(page: Page) {
+  const title = page.getByRole("heading", { name: /something went wrong/i }).first()
+  if (!(await title.isVisible().catch(() => false))) return
+
+  const description = await page
+    .getByText(/an error occurred while loading the application\./i)
+    .first()
+    .textContent()
+    .catch(() => "")
+  const detail = await page
+    .getByRole("textbox", { name: /error details/i })
+    .first()
+    .inputValue()
+    .catch(async () =>
+      (
+        (await page
+          .getByRole("textbox", { name: /error details/i })
+          .first()
+          .textContent()
+          .catch(() => "")) ?? ""
+      ).trim(),
+    )
+
+  return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
+}
+
+export async function assertHealthy(page: Page, context: string) {
+  const text = await errorBoundaryText(page)
+  if (!text) return
+  console.log(`[e2e:error-boundary][${context}]\n${text}`)
+  throw new Error(`Error boundary during ${context}\n${text}`)
+}
+
+async function waitSidebarButton(page: Page, context: string) {
+  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
+  await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
+  await assertHealthy(page, context)
+  return button
+}
+
 export async function toggleSidebar(page: Page) {
   await defocus(page)
   await page.keyboard.press(`${modKey}+B`)
@@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) {
 export async function openSidebar(page: Page) {
   if (!(await isSidebarClosed(page))) return
 
-  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  const button = await waitSidebarButton(page, "openSidebar")
   await button.click()
 
   const opened = await expect(button)
@@ -226,7 +277,7 @@ export async function openSidebar(page: Page) {
 export async function closeSidebar(page: Page) {
   if (await isSidebarClosed(page)) return
 
-  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  const button = await waitSidebarButton(page, "closeSidebar")
   await button.click()
 
   const closed = await expect(button)
@@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) {
 }
 
 export async function openSettings(page: Page) {
+  await assertHealthy(page, "openSettings")
   await defocus(page)
 
   const dialog = page.getByRole("dialog")
@@ -253,6 +305,8 @@ export async function openSettings(page: Page) {
 
   if (opened) return dialog
 
+  await assertHealthy(page, "openSettings")
+
   await page.getByRole("button", { name: "Settings" }).first().click()
   await expect(dialog).toBeVisible()
   return dialog
@@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
 
 export async function createTestProject() {
   const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
+  const id = `e2e-${path.basename(root)}`
 
-  await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
+  await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
 
   execSync("git init", { cwd: root, stdio: "ignore" })
+  await fs.writeFile(path.join(root, ".git", "opencode"), id)
   execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
   execSync("git add -A", { cwd: root, stdio: "ignore" })
   execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
@@ -339,12 +395,24 @@ export function slugFromUrl(url: string) {
   return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
 }
 
+async function probeSession(page: Page) {
+  return page
+    .evaluate(() => {
+      const win = window as E2EWindow
+      const current = win.__opencode_e2e?.model?.current
+      if (!current) return null
+      return { dir: current.dir, sessionID: current.sessionID }
+    })
+    .catch(() => null as { dir?: string; sessionID?: string } | null)
+}
+
 export async function waitSlug(page: Page, skip: string[] = []) {
   let prev = ""
   let next = ""
   await expect
     .poll(
-      () => {
+      async () => {
+        await assertHealthy(page, "waitSlug")
         const slug = slugFromUrl(page.url())
         if (!slug) return ""
         if (skip.includes(slug)) return ""
@@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) {
   await expect
     .poll(
       async () => {
+        await assertHealthy(page, "waitDir")
         const slug = slugFromUrl(page.url())
         if (!slug) return ""
         return resolveSlug(slug)
@@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) {
   return { directory: target, slug: base64Encode(target) }
 }
 
+export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
+  const target = await resolveDirectory(input.directory)
+  await expect
+    .poll(
+      async () => {
+        await assertHealthy(page, "waitSession")
+        const slug = slugFromUrl(page.url())
+        if (!slug) return false
+        const resolved = await resolveSlug(slug).catch(() => undefined)
+        if (!resolved || resolved.directory !== target) return false
+        if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
+
+        const state = await probeSession(page)
+        if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
+        if (state?.dir) {
+          const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
+          if (dir !== target) return false
+        }
+
+        return page
+          .locator(promptSelector)
+          .first()
+          .isVisible()
+          .catch(() => false)
+      },
+      { timeout: 45_000 },
+    )
+    .toBe(true)
+  return { directory: target, slug: base64Encode(target) }
+}
+
+export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
+  const sdk = createSdk(directory)
+  const target = await resolveDirectory(directory)
+
+  await expect
+    .poll(
+      async () => {
+        const data = await sdk.session
+          .get({ sessionID })
+          .then((x) => x.data)
+          .catch(() => undefined)
+        if (!data?.directory) return ""
+        return resolveDirectory(data.directory).catch(() => data.directory)
+      },
+      { timeout },
+    )
+    .toBe(target)
+
+  await expect
+    .poll(
+      async () => {
+        const items = await sdk.session
+          .messages({ sessionID, limit: 20 })
+          .then((x) => x.data ?? [])
+          .catch(() => [])
+        return items.some((item) => item.info.role === "user")
+      },
+      { timeout },
+    )
+    .toBe(true)
+}
+
 export function sessionIDFromUrl(url: string) {
   const match = /\/session\/([^/?#]+)/.exec(url)
   return match?.[1]
@@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) {
 }
 
 export async function openProjectMenu(page: Page, projectSlug: string) {
+  await openSidebar(page)
+  const item = page.locator(projectSwitchSelector(projectSlug)).first()
+  await expect(item).toBeVisible()
+  await item.hover()
+
   const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
   await expect(trigger).toHaveCount(1)
+  await expect(trigger).toBeVisible()
 
   const menu = page
     .locator(dropdownMenuContentSelector)
@@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
   const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
 
   const clicked = await trigger
-    .click({ timeout: 1500 })
+    .click({ force: true, timeout: 1500 })
     .then(() => true)
     .catch(() => false)
 

+ 39 - 5
packages/app/e2e/fixtures.ts

@@ -1,7 +1,16 @@
 import { test as base, expect, type Page } from "@playwright/test"
 import type { E2EWindow } from "../src/testing/terminal"
-import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
-import { promptSelector } from "./selectors"
+import {
+  healthPhase,
+  cleanupSession,
+  cleanupTestProject,
+  createTestProject,
+  setHealthPhase,
+  seedProjects,
+  sessionIDFromUrl,
+  waitSlug,
+  waitSession,
+} from "./actions"
 import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
 
 export const settingsKey = "settings.v3"
@@ -27,6 +36,29 @@ type WorkerFixtures = {
 }
 
 export const test = base.extend<TestFixtures, WorkerFixtures>({
+  page: async ({ page }, use) => {
+    let boundary: string | undefined
+    setHealthPhase(page, "test")
+    const consoleHandler = (msg: { text(): string }) => {
+      const text = msg.text()
+      if (!text.includes("[e2e:error-boundary]")) return
+      if (healthPhase(page) === "cleanup") {
+        console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
+        return
+      }
+      boundary ||= text
+      console.log(text)
+    }
+    const pageErrorHandler = (err: Error) => {
+      console.log(`[e2e:pageerror] ${err.stack || err.message}`)
+    }
+    page.on("console", consoleHandler)
+    page.on("pageerror", pageErrorHandler)
+    await use(page)
+    page.off("console", consoleHandler)
+    page.off("pageerror", pageErrorHandler)
+    if (boundary) throw new Error(boundary)
+  },
   directory: [
     async ({}, use) => {
       const directory = await getWorktree()
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
 
     const gotoSession = async (sessionID?: string) => {
       await page.goto(sessionPath(directory, sessionID))
-      await expect(page.locator(promptSelector)).toBeVisible()
+      await waitSession(page, { directory, sessionID })
     }
     await use(gotoSession)
   },
   withProject: async ({ page }, use) => {
     await use(async (callback, options) => {
       const root = await createTestProject()
-      const slug = dirSlug(root)
       const sessions = new Map<string, string>()
       const dirs = new Set<string>()
       await seedStorage(page, { directory: root, extra: options?.extra })
 
       const gotoSession = async (sessionID?: string) => {
         await page.goto(sessionPath(root, sessionID))
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await waitSession(page, { directory: root, sessionID })
         const current = sessionIDFromUrl(page.url())
         if (current) trackSession(current)
       }
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
 
       try {
         await gotoSession()
+        const slug = await waitSlug(page)
         return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
       } finally {
+        setHealthPhase(page, "cleanup")
         await Promise.allSettled(
           Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
         )
         await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
         await cleanupTestProject(root)
+        setHealthPhase(page, "test")
       }
     })
   },

+ 10 - 43
packages/app/e2e/projects/projects-switch.spec.ts

@@ -1,5 +1,4 @@
 import { base64Decode } from "@opencode-ai/util/encode"
-import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
 import {
   defocus,
@@ -7,43 +6,14 @@ import {
   cleanupTestProject,
   openSidebar,
   sessionIDFromUrl,
-  waitDir,
+  setWorkspacesEnabled,
+  waitSession,
+  waitSessionSaved,
   waitSlug,
 } from "../actions"
 import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 import { dirSlug, resolveDirectory } from "../utils"
 
-async function workspaces(page: Page, directory: string, enabled: boolean) {
-  await page.evaluate(
-    ({ directory, enabled }: { directory: string; enabled: boolean }) => {
-      const key = "opencode.global.dat:layout"
-      const raw = localStorage.getItem(key)
-      const data = raw ? JSON.parse(raw) : {}
-      const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
-      const current =
-        sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
-          ? sidebar.workspaces
-          : {}
-      const next = { ...current }
-
-      if (enabled) next[directory] = true
-      if (!enabled) delete next[directory]
-
-      localStorage.setItem(
-        key,
-        JSON.stringify({
-          ...data,
-          sidebar: {
-            ...sidebar,
-            workspaces: next,
-          },
-        }),
-      )
-    },
-    { directory, enabled },
-  )
-}
-
 test("can switch between projects from sidebar", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
@@ -84,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
     await withProject(
       async ({ directory, slug, trackSession, trackDirectory }) => {
         await defocus(page)
-        await workspaces(page, directory, true)
-        await page.reload()
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await setWorkspacesEnabled(page, slug, true)
         await openSidebar(page)
         await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
 
@@ -108,8 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
         await expect(btn).toBeVisible()
         await btn.click({ force: true })
 
-        await waitSlug(page)
-        await waitDir(page, space)
+        await waitSession(page, { directory: space })
 
         // Create a session by sending a prompt
         const prompt = page.locator(promptSelector)
@@ -123,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
         const created = sessionIDFromUrl(page.url())
         if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
         trackSession(created, space)
+        await waitSessionSaved(space, created)
 
         await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
 
@@ -130,15 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
 
         const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
         await expect(otherButton).toBeVisible()
-        await otherButton.click()
-        await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+        await otherButton.click({ force: true })
+        await waitSession(page, { directory: other })
 
         const rootButton = page.locator(projectSwitchSelector(slug)).first()
         await expect(rootButton).toBeVisible()
-        await rootButton.click()
+        await rootButton.click({ force: true })
 
-        await waitDir(page, space)
-        await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
+        await waitSession(page, { directory: space, sessionID: created })
         await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
       },
       { extra: [other] },

+ 24 - 47
packages/app/e2e/projects/workspace-new-session.spec.ts

@@ -1,6 +1,15 @@
 import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
-import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
+import {
+  openSidebar,
+  resolveSlug,
+  sessionIDFromUrl,
+  setWorkspacesEnabled,
+  waitDir,
+  waitSession,
+  waitSessionSaved,
+  waitSlug,
+} from "../actions"
 import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 import { createSdk } from "../utils"
 
@@ -14,20 +23,7 @@ function button(space: { slug: string; raw: string }) {
 
 async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
   await openSidebar(page)
-  await expect
-    .poll(
-      async () => {
-        const row = page.locator(item(space)).first()
-        try {
-          await row.hover({ timeout: 500 })
-          return true
-        } catch {
-          return false
-        }
-      },
-      { timeout: 60_000 },
-    )
-    .toBe(true)
+  await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
 }
 
 async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -49,7 +45,8 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
   await expect(next).toBeVisible()
   await next.click({ force: true })
 
-  return waitDir(page, space.directory)
+  await waitSession(page, { directory: space.directory })
+  await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
 }
 
 async function createSessionFromWorkspace(
@@ -57,39 +54,28 @@ async function createSessionFromWorkspace(
   space: { slug: string; raw: string; directory: string },
   text: string,
 ) {
-  const next = await openWorkspaceNewSession(page, space)
+  await openWorkspaceNewSession(page, space)
 
   const prompt = page.locator(promptSelector)
   await expect(prompt).toBeVisible()
-  await expect(prompt).toBeEditable()
-  await prompt.click()
-  await expect(prompt).toBeFocused()
   await prompt.fill(text)
-  await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
-  await prompt.press("Enter")
-
-  await waitDir(page, next.directory)
-  await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
+  await page.keyboard.press("Enter")
 
+  await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_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(`/session/${sessionID}(?:[/?#]|$)`))
-  return { sessionID, slug: next.slug }
-}
 
-async function sessionDirectory(directory: string, sessionID: string) {
-  const info = await createSdk(directory)
-    .session.get({ sessionID })
-    .then((x) => x.data)
+  await waitSessionSaved(space.directory, sessionID)
+  await createSdk(space.directory)
+    .session.abort({ sessionID })
     .catch(() => undefined)
-  if (!info) return ""
-  return info.directory
+  return sessionID
 }
 
 test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
-  await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
+  await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
     await openSidebar(page)
     await setWorkspacesEnabled(page, root, true)
 
@@ -101,17 +87,8 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
     trackDirectory(second.directory)
     await waitWorkspaceReady(page, second)
 
-    const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
-    trackSession(firstSession.sessionID, first.directory)
-
-    const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
-    trackSession(secondSession.sessionID, second.directory)
-
-    const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
-    trackSession(thirdSession.sessionID, first.directory)
-
-    await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
-    await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
-    await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
+    trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
+    trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
+    trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
   })
 })

+ 13 - 25
packages/app/e2e/session/session-model-persistence.spec.ts

@@ -1,6 +1,14 @@
 import type { Locator, Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
-import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
+import {
+  openSidebar,
+  resolveSlug,
+  sessionIDFromUrl,
+  setWorkspacesEnabled,
+  waitSession,
+  waitSessionIdle,
+  waitSlug,
+} from "../actions"
 import {
   promptAgentSelector,
   promptModelSelector,
@@ -29,8 +37,6 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
 
 const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
 
-const dirKey = (state: Probe | null) => state?.dir ?? ""
-
 async function probe(page: Page): Promise<Probe | null> {
   return page.evaluate(() => {
     const win = window as Window & {
@@ -44,21 +50,6 @@ async function probe(page: Page): Promise<Probe | null> {
   })
 }
 
-async function currentDir(page: Page) {
-  let hit = ""
-  await expect
-    .poll(
-      async () => {
-        const next = dirKey(await probe(page))
-        if (next) hit = next
-        return next
-      },
-      { timeout: 30_000 },
-    )
-    .not.toBe("")
-  return hit
-}
-
 async function read(page: Page): Promise<Footer> {
   return {
     agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
@@ -187,8 +178,7 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
 
 async function goto(page: Page, directory: string, sessionID?: string) {
   await page.goto(sessionPath(directory, sessionID))
-  await expect(page.locator(promptSelector)).toBeVisible()
-  await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
+  await waitSession(page, { directory, sessionID })
 }
 
 async function submit(page: Page, value: string) {
@@ -224,7 +214,7 @@ 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 waitSession(page, { directory: next.directory })
   return next
 }
 
@@ -256,9 +246,7 @@ async function newWorkspaceSession(page: Page, slug: string) {
   await button.click({ force: true })
 
   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)
+  return waitSession(page, { directory: next.directory }).then((item) => item.directory)
 }
 
 test("session model and variant restore per session without leaking into new sessions", async ({
@@ -277,7 +265,7 @@ test("session model and variant restore per session without leaking into new ses
     await waitUser(directory, first)
 
     await page.reload()
-    await expect(page.locator(promptSelector)).toBeVisible()
+    await waitSession(page, { directory, sessionID: first })
     await waitFooter(page, firstState)
 
     await gotoSession()

+ 1 - 0
packages/app/src/context/global-sync.tsx

@@ -378,6 +378,7 @@ function createGlobalSync() {
       return globalStore.error
     },
     child: children.child,
+    peek: children.peek,
     bootstrap,
     updateConfig,
     project: projectApi,

+ 10 - 0
packages/app/src/context/global-sync/child-store.ts

@@ -226,6 +226,15 @@ export function createChildStoreManager(input: {
     return childStore
   }
 
+  function peek(directory: string, options: ChildOptions = {}) {
+    const childStore = ensureChild(directory)
+    const shouldBootstrap = options.bootstrap ?? true
+    if (shouldBootstrap && childStore[0].status === "loading") {
+      input.onBootstrap(directory)
+    }
+    return childStore
+  }
+
   function projectMeta(directory: string, patch: ProjectMeta) {
     const [store, setStore] = ensureChild(directory)
     const cached = metaCache.get(directory)
@@ -256,6 +265,7 @@ export function createChildStoreManager(input: {
     children,
     ensureChild,
     child,
+    peek,
     projectMeta,
     projectIcon,
     mark,

+ 9 - 1
packages/app/src/pages/error.tsx

@@ -1,11 +1,12 @@
 import { TextField } from "@opencode-ai/ui/text-field"
 import { Logo } from "@opencode-ai/ui/logo"
 import { Button } from "@opencode-ai/ui/button"
-import { Component, Show } from "solid-js"
+import { Component, Show, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
 import { usePlatform } from "@/context/platform"
 import { useLanguage } from "@/context/language"
 import { Icon } from "@opencode-ai/ui/icon"
+import type { E2EWindow } from "@/testing/terminal"
 
 export type InitError = {
   name: string
@@ -226,6 +227,13 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
     actionError: undefined as string | undefined,
   })
 
+  onMount(() => {
+    const win = window as E2EWindow
+    if (!win.__opencode_e2e) return
+    const detail = formatError(props.error, language.t)
+    console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
+  })
+
   async function checkForUpdates() {
     if (!platform.checkUpdate) return
     setStore("checking", true)

+ 27 - 20
packages/app/src/pages/layout.tsx

@@ -129,6 +129,16 @@ export default function Layout(props: ParentProps) {
   const theme = useTheme()
   const language = useLanguage()
   const initialDirectory = decode64(params.dir)
+  const route = createMemo(() => {
+    const slug = params.dir
+    if (!slug) return { slug, dir: "" }
+    const dir = decode64(slug)
+    if (!dir) return { slug, dir: "" }
+    return {
+      slug,
+      dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
+    }
+  })
   const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
   const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
   const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@@ -137,7 +147,7 @@ export default function Layout(props: ParentProps) {
     dark: "theme.scheme.dark",
   }
   const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
-  const currentDir = createMemo(() => decode64(params.dir) ?? "")
+  const currentDir = createMemo(() => route().dir)
 
   const [state, setState] = createStore({
     autoselect: !initialDirectory,
@@ -484,8 +494,8 @@ export default function Layout(props: ParentProps) {
         }
 
         const currentSession = params.id
-        if (directory === currentDir() && props.sessionID === currentSession) return
-        if (directory === currentDir() && session?.parentID === currentSession) return
+        if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
+        if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
 
         dismissSessionAlert(sessionKey)
 
@@ -620,7 +630,7 @@ export default function Layout(props: ParentProps) {
     const activeDir = currentDir()
     return workspaceIds(project).filter((directory) => {
       const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
-      const active = directory === activeDir
+      const active = workspaceKey(directory) === workspaceKey(activeDir)
       return expanded || active
     })
   })
@@ -687,7 +697,7 @@ export default function Layout(props: ParentProps) {
       seen: lru,
       keep: sessionID,
       limit: PREFETCH_MAX_SESSIONS_PER_DIR,
-      preserve: directory === params.dir && params.id ? [params.id] : undefined,
+      preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
     })
   }
 
@@ -700,7 +710,7 @@ export default function Layout(props: ParentProps) {
   })
 
   createEffect(() => {
-    params.dir
+    route()
     globalSDK.url
 
     prefetchToken.value += 1
@@ -1692,13 +1702,10 @@ export default function Layout(props: ParentProps) {
   createEffect(
     on(
       () => {
-        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
+        return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
       },
-      ([ready, dir, id, root, directory, resolved]) => {
-        if (!ready || !dir || !directory) {
+      ([ready, slug, id, root, dir]) => {
+        if (!ready || !slug || !dir) {
           activeRoute.session = ""
           activeRoute.sessionProject = ""
           activeRoute.directory = ""
@@ -1712,29 +1719,28 @@ export default function Layout(props: ParentProps) {
           return
         }
 
-        const next = resolved || directory
-        const session = `${dir}/${id}`
+        const session = `${slug}/${id}`
 
         if (!root) {
           activeRoute.session = session
-          activeRoute.directory = next
+          activeRoute.directory = dir
           activeRoute.sessionProject = ""
           return
         }
 
         if (server.projects.last() !== root) server.projects.touch(root)
 
-        const changed = session !== activeRoute.session || next !== activeRoute.directory
+        const changed = session !== activeRoute.session || dir !== activeRoute.directory
         if (changed) {
           activeRoute.session = session
-          activeRoute.directory = next
-          activeRoute.sessionProject = syncSessionRoute(next, id, root)
+          activeRoute.directory = dir
+          activeRoute.sessionProject = syncSessionRoute(dir, id, root)
           return
         }
 
         if (root === activeRoute.sessionProject) return
-        activeRoute.directory = next
-        activeRoute.sessionProject = rememberSessionRoute(next, id, root)
+        activeRoute.directory = dir
+        activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
       },
     ),
   )
@@ -1927,6 +1933,7 @@ export default function Layout(props: ParentProps) {
 
   const projectSidebarCtx: ProjectSidebarContext = {
     currentDir,
+    currentProject,
     sidebarOpened: () => layout.sidebar.opened(),
     sidebarHovering,
     hoverProject: () => state.hoverProject,

+ 2 - 2
packages/app/src/pages/layout/helpers.ts

@@ -40,10 +40,10 @@ export const latestRootSession = (stores: SessionStore[], now: number) =>
   stores.flatMap(roots).sort(sortSessions(now))[0]
 
 export function hasProjectPermissions<T>(
-  request: Record<string, T[] | undefined>,
+  request: Record<string, T[] | undefined> | undefined,
   include: (item: T) => boolean = () => true,
 ) {
-  return Object.values(request).some((list) => list?.some(include))
+  return Object.values(request ?? {}).some((list) => list?.some(include))
 }
 
 export const childMapByParent = (sessions: Session[] | undefined) => {

+ 2 - 5
packages/app/src/pages/layout/sidebar-project.tsx

@@ -15,6 +15,7 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
 
 export type ProjectSidebarContext = {
   currentDir: Accessor<string>
+  currentProject: Accessor<LocalProject | undefined>
   sidebarOpened: Accessor<boolean>
   sidebarHovering: Accessor<boolean>
   hoverProject: Accessor<string | undefined>
@@ -278,11 +279,7 @@ export const SortableProject = (props: {
   const globalSync = useGlobalSync()
   const language = useLanguage()
   const sortable = createSortable(props.project.worktree)
-  const selected = createMemo(
-    () =>
-      props.project.worktree === props.ctx.currentDir() ||
-      props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
-  )
+  const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
   const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
   const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
   const dirs = createMemo(() => props.ctx.workspaceIds(props.project))

+ 2 - 2
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
 import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
-import { childMapByParent, sortedRootSessions } from "./helpers"
+import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
 
 type InlineEditorComponent = (props: {
   id: string
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
   const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
   const children = createMemo(() => childMapByParent(workspaceStore.session))
   const local = createMemo(() => props.directory === props.project.worktree)
-  const active = createMemo(() => props.ctx.currentDir() === props.directory)
+  const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
   const workspaceValue = createMemo(() => {
     const branch = workspaceStore.vcs?.branch
     const name = branch ?? getFilename(props.directory)

+ 5 - 1
packages/opencode/src/session/prompt.ts

@@ -28,6 +28,7 @@ import { MCP } from "../mcp"
 import { LSP } from "../lsp"
 import { ReadTool } from "../tool/read"
 import { FileTime } from "../file/time"
+import { NotFoundError } from "@/storage/db"
 import { Flag } from "../flag/flag"
 import { ulid } from "ulid"
 import { spawn } from "child_process"
@@ -1988,7 +1989,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       if (!cleaned) return
 
       const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
-      return Session.setTitle({ sessionID: input.session.id, title })
+      return Session.setTitle({ sessionID: input.session.id, title }).catch((err) => {
+        if (NotFoundError.isInstance(err)) return
+        throw err
+      })
     }
   }
 }

+ 14 - 6
packages/opencode/src/session/summary.ts

@@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot"
 
 import { Storage } from "@/storage/storage"
 import { Bus } from "@/bus"
+import { NotFoundError } from "@/storage/db"
 
 export namespace SessionSummary {
   function unquoteGitPath(input: string) {
@@ -73,11 +74,17 @@ export namespace SessionSummary {
       messageID: MessageID.zod,
     }),
     async (input) => {
-      const all = await Session.messages({ sessionID: input.sessionID })
-      await Promise.all([
-        summarizeSession({ sessionID: input.sessionID, messages: all }),
-        summarizeMessage({ messageID: input.messageID, messages: all }),
-      ])
+      await Session.messages({ sessionID: input.sessionID })
+        .then((all) =>
+          Promise.all([
+            summarizeSession({ sessionID: input.sessionID, messages: all }),
+            summarizeMessage({ messageID: input.messageID, messages: all }),
+          ]),
+        )
+        .catch((err) => {
+          if (NotFoundError.isInstance(err)) return
+          throw err
+        })
     },
   )
 
@@ -102,7 +109,8 @@ export namespace SessionSummary {
     const messages = input.messages.filter(
       (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
     )
-    const msgWithParts = messages.find((m) => m.info.id === input.messageID)!
+    const msgWithParts = messages.find((m) => m.info.id === input.messageID)
+    if (!msgWithParts) return
     const userMsg = msgWithParts.info as MessageV2.User
     const diffs = await computeDiff({ messages })
     userMsg.summary = {

+ 58 - 0
patches/[email protected]

@@ -0,0 +1,58 @@
+diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-6fcb6b48d6947d2c b/.bun-tag-6fcb6b48d6947d2c
+new file mode 100644
+index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
+diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-b272f631c12927b0 b/.bun-tag-b272f631c12927b0
+new file mode 100644
+index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
+diff --git a/dist/dev.cjs b/dist/dev.cjs
+index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db42502b976 100644
+--- a/dist/dev.cjs
++++ b/dist/dev.cjs
+@@ -764,6 +764,8 @@ function runComputation(node, value, time) {
+     if (node.updatedAt != null && "observers" in node) {
+       writeSignal(node, nextValue, true);
+     } else if (Transition && Transition.running && node.pure) {
++      // On first computation during transition, also set committed value #2046
++      if (!Transition.sources.has(node)) node.value = nextValue;
+       Transition.sources.add(node);
+       node.tValue = nextValue;
+     } else node.value = nextValue;
+diff --git a/dist/dev.js b/dist/dev.js
+index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d3a78cca3 100644
+--- a/dist/dev.js
++++ b/dist/dev.js
+@@ -762,6 +762,8 @@ function runComputation(node, value, time) {
+     if (node.updatedAt != null && "observers" in node) {
+       writeSignal(node, nextValue, true);
+     } else if (Transition && Transition.running && node.pure) {
++      // On first computation during transition, also set committed value #2046
++      if (!Transition.sources.has(node)) node.value = nextValue;
+       Transition.sources.add(node);
+       node.tValue = nextValue;
+     } else node.value = nextValue;
+diff --git a/dist/solid.cjs b/dist/solid.cjs
+index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd3beb2881 100644
+--- a/dist/solid.cjs
++++ b/dist/solid.cjs
+@@ -717,6 +717,8 @@ function runComputation(node, value, time) {
+     if (node.updatedAt != null && "observers" in node) {
+       writeSignal(node, nextValue, true);
+     } else if (Transition && Transition.running && node.pure) {
++      // On first computation during transition, also set committed value #2046
++      if (!Transition.sources.has(node)) node.value = nextValue;
+       Transition.sources.add(node);
+       node.tValue = nextValue;
+     } else node.value = nextValue;
+diff --git a/dist/solid.js b/dist/solid.js
+index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991335a80be 100644
+--- a/dist/solid.js
++++ b/dist/solid.js
+@@ -715,6 +715,8 @@ function runComputation(node, value, time) {
+     if (node.updatedAt != null && "observers" in node) {
+       writeSignal(node, nextValue, true);
+     } else if (Transition && Transition.running && node.pure) {
++      // On first computation during transition, also set committed value #2046
++      if (!Transition.sources.has(node)) node.value = nextValue;
+       Transition.sources.add(node);
+       node.tValue = nextValue;
+     } else node.value = nextValue;