summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-03-20 14:12:06 +1000
committerGitHub <[email protected]>2026-03-20 00:12:06 -0400
commitd460614cd7ad9e047a2792139ea67e16caa82ea7 (patch)
treeff415e8719c7b6edd73bc824da379308e4e62589
parent7866dbcfcc36a60d22ad466eddf54c54b21fabe3 (diff)
downloadopencode-d460614cd7ad9e047a2792139ea67e16caa82ea7.tar.gz
opencode-d460614cd7ad9e047a2792139ea67e16caa82ea7.zip
fix: lots of desktop stability, better e2e error logging (#18300)
-rw-r--r--.github/workflows/test.yml26
-rw-r--r--packages/app/e2e/actions.ts152
-rw-r--r--packages/app/e2e/fixtures.ts44
-rw-r--r--packages/app/e2e/projects/projects-switch.spec.ts53
-rw-r--r--packages/app/e2e/projects/workspace-new-session.spec.ts71
-rw-r--r--packages/app/e2e/session/session-model-persistence.spec.ts38
-rw-r--r--packages/app/src/context/global-sync.tsx1
-rw-r--r--packages/app/src/context/global-sync/child-store.ts10
-rw-r--r--packages/app/src/pages/error.tsx10
-rw-r--r--packages/app/src/pages/layout.tsx47
-rw-r--r--packages/app/src/pages/layout/helpers.ts4
-rw-r--r--packages/app/src/pages/layout/sidebar-project.tsx7
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx4
-rw-r--r--packages/opencode/src/session/prompt.ts6
-rw-r--r--packages/opencode/src/session/summary.ts20
-rw-r--r--packages/ui/src/components/message-part.tsx277
16 files changed, 457 insertions, 313 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c928e8223..9c58be30a 100644
--- a/.github/workflows/test.yml
+++ b/.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
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index 88d71f94c..aced0756c 100644
--- a/packages/app/e2e/actions.ts
+++ b/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)
diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts
index 7bc994e50..7232df687 100644
--- a/packages/app/e2e/fixtures.ts
+++ b/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")
}
})
},
diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts
index e9cbf868d..b46c1b407 100644
--- a/packages/app/e2e/projects/projects-switch.spec.ts
+++ b/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] },
diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts
index 0858f2627..3a7a6bbc2 100644
--- a/packages/app/e2e/projects/workspace-new-session.spec.ts
+++ b/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)
})
})
diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts
index 2c2e4e886..b758a3b3d 100644
--- a/packages/app/e2e/session/session-model-persistence.spec.ts
+++ b/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()
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index c84098869..2d1e50135 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/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,
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index d5904c609..70668350e 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/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,
diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx
index 11284b3d2..1cdc06116 100644
--- a/packages/app/src/pages/error.tsx
+++ b/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)
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 52ac7c5f3..8e2248469 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/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,
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts
index 209cff8a7..226098c1c 100644
--- a/packages/app/src/pages/layout/helpers.ts
+++ b/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) => {
diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx
index a26bc1831..252826456 100644
--- a/packages/app/src/pages/layout/sidebar-project.tsx
+++ b/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))
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index 127626feb..3bf00ea42 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/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)
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 1cc144c8d..bac958ec1 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/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
+ })
}
}
}
diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts
index 678a00851..898b93f3f 100644
--- a/packages/opencode/src/session/summary.ts
+++ b/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 = {
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index e8c9dcf95..68170b061 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/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()