diff options
| -rw-r--r-- | .github/workflows/test.yml | 26 | ||||
| -rw-r--r-- | packages/app/e2e/actions.ts | 152 | ||||
| -rw-r--r-- | packages/app/e2e/fixtures.ts | 44 | ||||
| -rw-r--r-- | packages/app/e2e/projects/projects-switch.spec.ts | 53 | ||||
| -rw-r--r-- | packages/app/e2e/projects/workspace-new-session.spec.ts | 71 | ||||
| -rw-r--r-- | packages/app/e2e/session/session-model-persistence.spec.ts | 38 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 1 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync/child-store.ts | 10 | ||||
| -rw-r--r-- | packages/app/src/pages/error.tsx | 10 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 47 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/helpers.ts | 4 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-project.tsx | 7 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-workspace.tsx | 4 | ||||
| -rw-r--r-- | packages/opencode/src/session/prompt.ts | 6 | ||||
| -rw-r--r-- | packages/opencode/src/session/summary.ts | 20 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 277 |
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() |
