Преглед изворни кода

fix: lots of desktop stability, better e2e error logging (#18300)

Luke Parker пре 3 недеља
родитељ
комит
d460614cd7

+ 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

+ 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 = {

+ 133 - 144
packages/ui/src/components/message-part.tsx

@@ -4,15 +4,15 @@ import {
   createMemo,
   createSignal,
   For,
+  Index,
   Match,
   onMount,
   Show,
   Switch,
   onCleanup,
-  Index,
   type JSX,
 } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createStore, unwrap } from "solid-js/store"
 import stripAnsi from "strip-ansi"
 import { Dynamic } from "solid-js/web"
 import {
@@ -481,6 +481,15 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) {
   return toolDefaultOpen(part.tool, shell, edit)
 }
 
+function bindMessage<T extends MessageType>(input: T) {
+  const data = useData()
+  const base = structuredClone(unwrap(input)) as T
+  return createMemo(() => {
+    const next = data.store.message?.[base.sessionID]?.find((item) => item.id === base.id)
+    return (next as T | undefined) ?? base
+  })
+}
+
 export function AssistantParts(props: {
   messages: AssistantMessage[]
   showAssistantCopyPartID?: string | null
@@ -521,62 +530,55 @@ export function AssistantParts(props: {
 
   return (
     <Index each={grouped()}>
-      {(entryAccessor) => {
-        const entryType = createMemo(() => entryAccessor().type)
+      {(entry) => {
+        const kind = createMemo(() => entry().type)
+        const parts = createMemo(
+          () => {
+            const value = entry()
+            if (value.type !== "context") return emptyTools
+            return value.refs
+              .map((ref) => part().get(ref.messageID)?.get(ref.partID))
+              .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
+          },
+          emptyTools,
+          { equals: same },
+        )
+        const busy = createMemo(() => props.working && last() === entry().key)
+        const message = createMemo(() => {
+          const value = entry()
+          if (value.type !== "part") return
+          return msgs().get(value.ref.messageID)
+        })
+        const item = createMemo(() => {
+          const value = entry()
+          if (value.type !== "part") return
+          return part().get(value.ref.messageID)?.get(value.ref.partID)
+        })
+        const ready = createMemo(() => {
+          if (kind() !== "part") return
+          const msg = message()
+          const value = item()
+          if (!msg || !value) return
+          return { msg, value }
+        })
 
         return (
-          <Switch>
-            <Match when={entryType() === "context"}>
-              {(() => {
-                const parts = createMemo(
-                  () => {
-                    const entry = entryAccessor()
-                    if (entry.type !== "context") return emptyTools
-                    return entry.refs
-                      .map((ref) => part().get(ref.messageID)?.get(ref.partID))
-                      .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
-                  },
-                  emptyTools,
-                  { equals: same },
-                )
-                const busy = createMemo(() => props.working && last() === entryAccessor().key)
-
-                return (
-                  <Show when={parts().length > 0}>
-                    <ContextToolGroup parts={parts()} busy={busy()} />
-                  </Show>
-                )
-              })()}
-            </Match>
-            <Match when={entryType() === "part"}>
-              {(() => {
-                const message = createMemo(() => {
-                  const entry = entryAccessor()
-                  if (entry.type !== "part") return
-                  return msgs().get(entry.ref.messageID)
-                })
-                const item = createMemo(() => {
-                  const entry = entryAccessor()
-                  if (entry.type !== "part") return
-                  return part().get(entry.ref.messageID)?.get(entry.ref.partID)
-                })
-
-                return (
-                  <Show when={message()}>
-                    <Show when={item()}>
-                      <Part
-                        part={item()!}
-                        message={message()!}
-                        showAssistantCopyPartID={props.showAssistantCopyPartID}
-                        turnDurationMs={props.turnDurationMs}
-                        defaultOpen={partDefaultOpen(item()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
-                      />
-                    </Show>
-                  </Show>
-                )
-              })()}
-            </Match>
-          </Switch>
+          <>
+            <Show when={kind() === "context" && parts().length > 0}>
+              <ContextToolGroup parts={parts()} busy={busy()} />
+            </Show>
+            <Show when={ready()}>
+              {(ready) => (
+                <Part
+                  part={ready().value}
+                  message={ready().msg}
+                  showAssistantCopyPartID={props.showAssistantCopyPartID}
+                  turnDurationMs={props.turnDurationMs}
+                  defaultOpen={partDefaultOpen(ready().value, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
+                />
+              )}
+            </Show>
+          </>
         )
       }}
     </Index>
@@ -688,25 +690,22 @@ export function registerPartComponent(type: string, component: PartComponent) {
 }
 
 export function Message(props: MessageProps) {
-  return (
-    <Switch>
-      <Match when={props.message.role === "user" && props.message}>
-        {(userMessage) => (
-          <UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} actions={props.actions} />
-        )}
-      </Match>
-      <Match when={props.message.role === "assistant" && props.message}>
-        {(assistantMessage) => (
-          <AssistantMessageDisplay
-            message={assistantMessage() as AssistantMessage}
-            parts={props.parts}
-            showAssistantCopyPartID={props.showAssistantCopyPartID}
-            showReasoningSummaries={props.showReasoningSummaries}
-          />
-        )}
-      </Match>
-    </Switch>
-  )
+  if (props.message.role === "user") {
+    return <UserMessageDisplay message={props.message as UserMessage} parts={props.parts} actions={props.actions} />
+  }
+
+  if (props.message.role === "assistant") {
+    return (
+      <AssistantMessageDisplay
+        message={props.message as AssistantMessage}
+        parts={props.parts}
+        showAssistantCopyPartID={props.showAssistantCopyPartID}
+        showReasoningSummaries={props.showReasoningSummaries}
+      />
+    )
+  }
+
+  return undefined
 }
 
 export function AssistantMessageDisplay(props: {
@@ -733,52 +732,42 @@ export function AssistantMessageDisplay(props: {
 
   return (
     <Index each={grouped()}>
-      {(entryAccessor) => {
-        const entryType = createMemo(() => entryAccessor().type)
+      {(entry) => {
+        const kind = createMemo(() => entry().type)
+        const parts = createMemo(
+          () => {
+            const value = entry()
+            if (value.type !== "context") return emptyTools
+            return value.refs
+              .map((ref) => part().get(ref.partID))
+              .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
+          },
+          emptyTools,
+          { equals: same },
+        )
+        const item = createMemo(() => {
+          const value = entry()
+          if (value.type !== "part") return
+          return part().get(value.ref.partID)
+        })
+        const ready = createMemo(() => {
+          if (kind() !== "part") return
+          const value = item()
+          if (!value) return
+          return value
+        })
 
         return (
-          <Switch>
-            <Match when={entryType() === "context"}>
-              {(() => {
-                const parts = createMemo(
-                  () => {
-                    const entry = entryAccessor()
-                    if (entry.type !== "context") return emptyTools
-                    return entry.refs
-                      .map((ref) => part().get(ref.partID))
-                      .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
-                  },
-                  emptyTools,
-                  { equals: same },
-                )
-
-                return (
-                  <Show when={parts().length > 0}>
-                    <ContextToolGroup parts={parts()} />
-                  </Show>
-                )
-              })()}
-            </Match>
-            <Match when={entryType() === "part"}>
-              {(() => {
-                const item = createMemo(() => {
-                  const entry = entryAccessor()
-                  if (entry.type !== "part") return
-                  return part().get(entry.ref.partID)
-                })
-
-                return (
-                  <Show when={item()}>
-                    <Part
-                      part={item()!}
-                      message={props.message}
-                      showAssistantCopyPartID={props.showAssistantCopyPartID}
-                    />
-                  </Show>
-                )
-              })()}
-            </Match>
-          </Switch>
+          <>
+            <Show when={kind() === "context" && parts().length > 0}>
+              <ContextToolGroup parts={parts()} />
+            </Show>
+            <Show when={ready()}>
+              {(ready) => (
+                <Part part={ready()} message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID} />
+              )}
+            </Show>
+          </>
         )
       }}
     </Index>
@@ -845,11 +834,9 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
       <Collapsible.Content>
         <div data-component="context-tool-group-list">
           <Index each={props.parts}>
-            {(partAccessor) => {
-              const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n))
-              const running = createMemo(
-                () => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
-              )
+            {(part) => {
+              const trigger = createMemo(() => contextToolTrigger(part(), i18n))
+              const running = createMemo(() => part().state.status === "pending" || part().state.status === "running")
               return (
                 <div data-slot="context-tool-group-item">
                   <div data-component="tool-trigger">
@@ -887,6 +874,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
   const data = useData()
   const dialog = useDialog()
   const i18n = useI18n()
+  const message = bindMessage(props.message)
   const [state, setState] = createStore({
     copied: false,
     busy: undefined as "fork" | "revert" | undefined,
@@ -909,8 +897,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
   const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
 
   const model = createMemo(() => {
-    const providerID = props.message.model?.providerID
-    const modelID = props.message.model?.modelID
+    const providerID = message().model?.providerID
+    const modelID = message().model?.modelID
     if (!providerID || !modelID) return ""
     const match = data.store.provider?.all?.find((p) => p.id === providerID)
     return match?.models?.[modelID]?.name ?? modelID
@@ -918,13 +906,13 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
   const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" }))
 
   const stamp = createMemo(() => {
-    const created = props.message.time?.created
+    const created = message().time?.created
     if (typeof created !== "number") return ""
     return timefmt().format(created)
   })
 
   const metaHead = createMemo(() => {
-    const agent = props.message.agent
+    const agent = message().agent
     const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()]
     return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
   })
@@ -950,8 +938,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
     void Promise.resolve()
       .then(() =>
         act({
-          sessionID: props.message.sessionID,
-          messageID: props.message.id,
+          sessionID: message().sessionID,
+          messageID: message().id,
         }),
       )
       .finally(() => {
@@ -1310,27 +1298,27 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
   const i18n = useI18n()
   const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
   const part = () => props.part as TextPart
+  const message = bindMessage(props.message)
   const interrupted = createMemo(
-    () =>
-      props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
+    () => message().role === "assistant" && (message() as AssistantMessage).error?.name === "MessageAbortedError",
   )
 
   const model = createMemo(() => {
-    if (props.message.role !== "assistant") return ""
-    const message = props.message as AssistantMessage
-    const match = data.store.provider?.all?.find((p) => p.id === message.providerID)
-    return match?.models?.[message.modelID]?.name ?? message.modelID
+    const current = message()
+    if (current.role !== "assistant") return ""
+    const match = data.store.provider?.all?.find((p) => p.id === current.providerID)
+    return match?.models?.[current.modelID]?.name ?? current.modelID
   })
 
   const duration = createMemo(() => {
-    if (props.message.role !== "assistant") return ""
-    const message = props.message as AssistantMessage
-    const completed = message.time.completed
+    const current = message()
+    if (current.role !== "assistant") return ""
+    const completed = current.time.completed
     const ms =
       typeof props.turnDurationMs === "number"
         ? props.turnDurationMs
         : typeof completed === "number"
-          ? completed - message.time.created
+          ? completed - current.time.created
           : -1
     if (!(ms >= 0)) return ""
     const total = Math.round(ms / 1000)
@@ -1344,8 +1332,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
   })
 
   const meta = createMemo(() => {
-    if (props.message.role !== "assistant") return ""
-    const agent = (props.message as AssistantMessage).agent
+    const current = message()
+    if (current.role !== "assistant") return ""
+    const agent = current.agent
     const items = [
       agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
       model(),
@@ -1358,13 +1347,13 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
   const displayText = () => (part().text ?? "").trim()
   const throttledText = createThrottledValue(displayText)
   const isLastTextPart = createMemo(() => {
-    const last = (data.store.part?.[props.message.id] ?? [])
+    const last = (data.store.part?.[message().id] ?? [])
       .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
       .at(-1)
     return last?.id === part().id
   })
   const showCopy = createMemo(() => {
-    if (props.message.role !== "assistant") return isLastTextPart()
+    if (message().role !== "assistant") return isLastTextPart()
     if (props.showAssistantCopyPartID === null) return false
     if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
     return isLastTextPart()