From eb26d40850321f5ebddc98c71735f0aa8290292f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:57:41 +1000 Subject: [PATCH 1/7] test(app): fail on unexpected error toasts --- packages/app/e2e/fixtures.ts | 211 ++++++++++++++++++ packages/app/e2e/performance/benchmark.ts | 11 +- .../timeline-stability/adverse.spec.ts | 2 +- .../timeline-stability/context-matrix.spec.ts | 2 +- .../environment-matrix.spec.ts | 2 +- .../timeline-stability/file-matrix.spec.ts | 2 +- .../timeline-stability/file-mutation.spec.ts | 2 +- .../timeline-stability/interaction.spec.ts | 2 +- .../timeline-stability/lifecycle.spec.ts | 2 +- .../timeline-stability/oracle-browser.spec.ts | 2 +- .../scroll-interaction.spec.ts | 2 +- .../timeline-stability/shell-matrix.spec.ts | 2 +- .../timeline-stability/tool-mutation.spec.ts | 2 +- .../timeline-stability/tools.spec.ts | 2 +- .../transition-matrix.spec.ts | 2 +- .../regression/cross-server-tab-close.spec.ts | 9 +- .../e2e/regression/error-toast-guard.spec.ts | 50 +++++ .../regression/prompt-thinking-level.spec.ts | 2 +- .../e2e/regression/remote-tab-busy.spec.ts | 2 +- .../e2e/regression/review-image-flash.spec.ts | 2 +- .../regression/review-line-comment.spec.ts | 2 +- .../e2e/regression/review-tab-switch.spec.ts | 2 +- .../session-list-path-loading.spec.ts | 2 +- .../session-timeline-accessibility.spec.ts | 2 +- .../session-timeline-collapse-state.spec.ts | 2 +- .../session-timeline-context-resize.spec.ts | 2 +- .../session-timeline-context-state.spec.ts | 2 +- .../session-timeline-file-projection.spec.ts | 2 +- .../session-timeline-file-state.spec.ts | 2 +- .../session-timeline-history-root.spec.ts | 2 +- .../session-timeline-lifecycle-state.spec.ts | 2 +- ...session-timeline-locale-projection.spec.ts | 2 +- .../session-timeline-projection.spec.ts | 2 +- ...sion-timeline-reasoning-projection.spec.ts | 2 +- ...ession-timeline-reducer-projection.spec.ts | 2 +- .../session-timeline-tool-projection.spec.ts | 2 +- .../session-timeline-tool-state.spec.ts | 2 +- .../session-timeline-transport.spec.ts | 2 +- .../session-todo-dock-navigation.spec.ts | 2 +- .../subagent-child-navigation.spec.ts | 2 +- .../regression/tab-navigate-mousedown.spec.ts | 3 +- .../terminal-composer-focus.spec.ts | 2 +- .../e2e/regression/terminal-hidden.spec.ts | 2 +- .../regression/terminal-tab-switch.spec.ts | 2 +- .../app/e2e/smoke/session-timeline.spec.ts | 2 +- packages/app/e2e/tsconfig.json | 3 + packages/app/e2e/utils/mock-server.ts | 16 +- packages/app/e2e/utils/sse-transport.ts | 2 +- .../src/components/dialog-custom-provider.tsx | 2 +- packages/app/src/components/dialog-fork.tsx | 4 +- .../app/src/components/prompt-input/submit.ts | 7 + .../app/src/components/settings-providers.tsx | 4 +- .../src/components/settings-v2/providers.tsx | 4 +- .../app/src/components/titlebar-tab-nav.tsx | 1 + packages/app/src/components/updater-action.ts | 2 +- packages/app/src/pages/home.tsx | 1 + packages/app/src/pages/layout.tsx | 3 + .../composer/session-composer-state.ts | 2 +- .../composer/session-question-dock.tsx | 2 +- .../session/timeline/message-timeline.tsx | 11 +- packages/ui/src/v2/components/toast-v2.tsx | 8 +- 61 files changed, 374 insertions(+), 62 deletions(-) create mode 100644 packages/app/e2e/fixtures.ts create mode 100644 packages/app/e2e/regression/error-toast-guard.spec.ts diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts new file mode 100644 index 00000000000..a8fa0110916 --- /dev/null +++ b/packages/app/e2e/fixtures.ts @@ -0,0 +1,211 @@ +import { expect, test as base, type Page, type TestInfo } from "@playwright/test" + +const marker = "__OPENCODE_E2E_ERROR_TOAST__" + +type ErrorToastObservation = { + component: "legacy" | "v2" + title: string + description: string + text: string + url: string + timestamp: number +} + +type ErrorToastExpectation = { + pattern: string | RegExp + seen: boolean +} + +export type ErrorToastControl = { + expect: (pattern: string | RegExp) => void + allow: (pattern?: string | RegExp) => void +} + +type ErrorToastState = ErrorToastControl & { + allowed: boolean + allowedPatterns: (string | RegExp)[] + expected: ErrorToastExpectation[] +} + +type Fixtures = { + errorToasts: ErrorToastControl +} + +export const test = base.extend({ + errorToasts: async ({}, use) => use(createErrorToastState()), + page: async ({ page, errorToasts }, use, testInfo) => { + const guard = await guardPage(page, testInfo, errorToasts as ErrorToastState) + try { + await use(page) + } finally { + await guard.finish() + } + }, +}) + +export { expect } +export type { Browser, CDPSession, Locator, Page, Route, TestInfo } from "@playwright/test" + +export async function guardPage(page: Page, testInfo: TestInfo, control = createErrorToastState()) { + const observations: ErrorToastObservation[] = [] + const unexpected: ErrorToastObservation[] = [] + let closing = false + const onConsole = (message: { text: () => string }) => { + const text = message.text() + if (!text.startsWith(marker)) return + + const observation = JSON.parse(text.slice(marker.length)) as ErrorToastObservation + observations.push(observation) + console.error(`E2E_ERROR_TOAST ${JSON.stringify(observation)}`) + + const expected = control.expected.find((item) => !item.seen && matches(item.pattern, observation.text)) + if (expected) { + expected.seen = true + return + } + if (control.allowed || control.allowedPatterns.some((pattern) => matches(pattern, observation.text))) return + + unexpected.push(observation) + if (closing) return + closing = true + void page.close().catch(() => {}) + } + + page.on("console", onConsole) + await page.addInitScript(installErrorToastObserver, marker) + await page.evaluate(installErrorToastObserver, marker) + + return { + async finish() { + if (!page.isClosed()) { + await page + .evaluate(() => { + ;(window as Window & { __flushErrorToastObserver?: () => void }).__flushErrorToastObserver?.() + }) + .catch(() => {}) + } + page.off("console", onConsole) + if (observations.length > 0) { + await testInfo.attach("error-toasts", { + body: JSON.stringify(observations, null, 2), + contentType: "application/json", + }) + } + + const missing = control.expected.filter((item) => !item.seen) + if (unexpected.length === 0 && missing.length === 0) return + + const messages = [ + ...unexpected.map((item) => `Unexpected error toast: ${item.text}`), + ...missing.map((item) => `Expected error toast was not shown: ${String(item.pattern)}`), + ] + throw new Error(messages.join("\n")) + }, + } +} + +function createErrorToastState(): ErrorToastState { + const state: ErrorToastState = { + allowed: false, + allowedPatterns: [], + expected: [], + expect(pattern) { + state.expected.push({ pattern, seen: false }) + }, + allow(pattern) { + if (pattern !== undefined) { + state.allowedPatterns.push(pattern) + return + } + state.allowed = true + }, + } + return state +} + +function matches(pattern: string | RegExp, value: string) { + if (typeof pattern === "string") return value.includes(pattern) + pattern.lastIndex = 0 + return pattern.test(value) +} + +function installErrorToastObserver(marker: string) { + const owner = window as Window & { + __errorToastObserverInstalled?: boolean + __flushErrorToastObserver?: () => void + } + if (owner.__errorToastObserverInstalled) return + owner.__errorToastObserverInstalled = true + + const selector = '[data-component="toast"][data-variant="error"], [data-component="toast-v2"][data-variant="error"]' + const seen = new WeakSet() + const pending = new Set() + let scheduled = false + const inspect = (node: Node) => { + const element = node instanceof HTMLElement ? node : node.parentElement + if (!element) return + const candidates = [ + ...(element.matches(selector) ? [element] : []), + ...element.querySelectorAll(selector), + ...(element.closest(selector) ? [element.closest(selector)!] : []), + ] + candidates.forEach((toast) => { + if (seen.has(toast)) return + const text = toast.textContent?.replace(/\s+/g, " ").trim() + if (!text) return + seen.add(toast) + const component = toast.dataset.component === "toast-v2" ? "v2" : "legacy" + const title = toast.querySelector(`[data-slot="toast${component === "v2" ? "-v2" : ""}-title"]`) + const description = toast.querySelector( + `[data-slot="toast${component === "v2" ? "-v2" : ""}-description"]`, + ) + console.debug( + marker + + JSON.stringify({ + component, + title: title?.textContent?.trim() ?? "", + description: description?.textContent?.trim() ?? "", + text, + url: location.href, + timestamp: Date.now(), + }), + ) + }) + } + const scan = () => { + scheduled = false + pending.forEach(inspect) + pending.clear() + } + const schedule = (node: Node) => { + pending.add(node) + if (scheduled) return + scheduled = true + queueMicrotask(scan) + } + const start = () => { + const root = document.documentElement + if (!root) return + new MutationObserver((records) => { + records.forEach((record) => { + if (record.type !== "childList") schedule(record.target) + record.addedNodes.forEach(schedule) + }) + }).observe(root, { + attributes: true, + attributeFilter: ["data-variant"], + childList: true, + characterData: true, + subtree: true, + }) + schedule(root) + } + owner.__flushErrorToastObserver = () => { + const root = document.documentElement + if (root) pending.add(root) + scan() + } + + if (document.documentElement) start() + else document.addEventListener("readystatechange", start, { once: true }) +} diff --git a/packages/app/e2e/performance/benchmark.ts b/packages/app/e2e/performance/benchmark.ts index b9f8ea43411..1e4b82834ce 100644 --- a/packages/app/e2e/performance/benchmark.ts +++ b/packages/app/e2e/performance/benchmark.ts @@ -1,4 +1,4 @@ -import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test" +import { expect, guardPage, test as base, type Browser, type Page, type TestInfo } from "../fixtures" import { startChromeTrace } from "./chrome-trace" type BenchmarkFixtures = { @@ -101,16 +101,21 @@ export async function withBenchmarkPage( browser: Browser, name: string, run: (page: Page) => Promise, - testInfo?: TestInfo, + testInfo: TestInfo, ) { const context = await browser.newContext() try { const page = await context.newPage() + const toastGuard = await guardPage(page, testInfo) const diagnostics = await observePerformancePage(page, name) try { return await run(page) } finally { - await reportPerformancePage(name, diagnostics, testInfo) + try { + await reportPerformancePage(name, diagnostics, testInfo) + } finally { + await toastGuard?.finish() + } } } finally { await context.close() diff --git a/packages/app/e2e/performance/timeline-stability/adverse.spec.ts b/packages/app/e2e/performance/timeline-stability/adverse.spec.ts index 7ce1be90645..bfd5929157f 100644 --- a/packages/app/e2e/performance/timeline-stability/adverse.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/adverse.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts index b3438b77c06..799eeb44546 100644 --- a/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts index 3455438a736..7381250a4c2 100644 --- a/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts @@ -1,4 +1,4 @@ -import { test } from "@playwright/test" +import { test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts index e0f0d72233b..4445d26c75b 100644 --- a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts @@ -1,4 +1,4 @@ -import { test } from "@playwright/test" +import { test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts index 798bf0df3b2..5dde1eb1375 100644 --- a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/interaction.spec.ts index 8cdf4fa8cfa..f1f845bc8d9 100644 --- a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/interaction.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts b/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts index 40688429a33..27732039828 100644 --- a/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../../fixtures" import { defineVisualRegions, mapVisualRegions, diff --git a/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts b/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts index 7891eba5bd9..d2ed1db746d 100644 --- a/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../../fixtures" import { analyzeVisualObservations, defineVisualRegions, diff --git a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts index e67545104d4..2d3a19ba3c0 100644 --- a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts index bab1ca23b4f..b0c1991c4c8 100644 --- a/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts @@ -1,4 +1,4 @@ -import { test } from "@playwright/test" +import { test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts index 03690ec9214..37bbbb61fed 100644 --- a/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/tools.spec.ts b/packages/app/e2e/performance/timeline-stability/tools.spec.ts index d28fdaa65f5..c723f9f2011 100644 --- a/packages/app/e2e/performance/timeline-stability/tools.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/tools.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts index e999e8c50a6..52b82a84204 100644 --- a/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../../fixtures" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index 159b5a50676..32f318b7bc4 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page, type Route } from "@playwright/test" +import { expect, test, type Page, type Route } from "../fixtures" import { base64Encode } from "@opencode-ai/core/util/encode" const serverA = "http://127.0.0.1:4096" @@ -45,7 +45,9 @@ test("closing the active server's last tab opens the remaining server tab", asyn ).toBe(true) }) -test("legacy session routes preserve an existing tab's server", async ({ page }) => { +test("legacy session routes preserve an existing tab's server", async ({ page, errorToasts }) => { + // The legacy route bootstraps against the default server before redirecting to the persisted tab server. + errorToasts.allow(/server-b.*InvalidDirectory/) await mockServers(page, []) await page.addInitScript( ({ serverB, sessionB }) => { @@ -81,11 +83,12 @@ async function mockServers(page: Page, requests: string[]) { const url = new URL(route.request().url()) if (url.origin !== serverA && url.origin !== serverB) return route.fallback() requests.push(url.toString()) - const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") + const current = url.origin === serverA ? sessionA : sessionB if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/session/status") return json(route, {}) if (url.pathname === "/session") return json(route, [current]) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) diff --git a/packages/app/e2e/regression/error-toast-guard.spec.ts b/packages/app/e2e/regression/error-toast-guard.spec.ts new file mode 100644 index 00000000000..c4f8d648132 --- /dev/null +++ b/packages/app/e2e/regression/error-toast-guard.spec.ts @@ -0,0 +1,50 @@ +import { glob, readFile } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { expect, test } from "../fixtures" + +test("requires every browser spec to use the error toast fixture", async () => { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") + const files = await Array.fromAsync(glob("**/*.spec.{ts,tsx}", { cwd: root })) + const unguarded = ( + await Promise.all( + files.map(async (file) => { + const source = await readFile(path.join(root, file), "utf8") + if (/from\s+["'](?:\.\.\/)+fixtures["']/.test(source)) return + if (/from\s+["']\.\.\/benchmark["']/.test(source)) return + return file + }), + ) + ).filter((file): file is string => !!file) + + expect(unguarded).toEqual([]) +}) + +test("allows an explicitly expected error toast", async ({ page, errorToasts }) => { + errorToasts.expect("Expected request failure") + + await page.goto( + `data:text/html,${encodeURIComponent(` +
+
Request failed
+
Expected request failure
+
+ `)}`, + ) + + await expect(page.locator('[data-component="toast-v2"]')).toBeVisible() +}) + +test("fails immediately on an unexpected error toast", async ({ page }) => { + test.fail() + + await page.goto( + `data:text/html,${encodeURIComponent(` +
+
Request failed
+
Unexpected request failure
+
+ `)}`, + ) + await page.waitForTimeout(10_000) +}) diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts index 4219699f28c..011dcb89c9a 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { base64Encode } from "@opencode-ai/core/util/encode" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible } from "../utils/waits" diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 119fc7ee2da..7e44d9d69d6 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page, type Route } from "@playwright/test" +import { expect, test, type Page, type Route } from "../fixtures" import { base64Encode } from "@opencode-ai/core/util/encode" const serverA = "http://127.0.0.1:4096" diff --git a/packages/app/e2e/regression/review-image-flash.spec.ts b/packages/app/e2e/regression/review-image-flash.spec.ts index dd200384d49..e76b5c3f8c0 100644 --- a/packages/app/e2e/regression/review-image-flash.spec.ts +++ b/packages/app/e2e/regression/review-image-flash.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { base64Encode } from "@opencode-ai/core/util/encode" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 042f926c537..41116131596 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { base64Encode } from "@opencode-ai/core/util/encode" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index c2ea406c5ab..9993b2698bf 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts index 4a3855122a4..77375a5c55e 100644 --- a/packages/app/e2e/regression/session-list-path-loading.spec.ts +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -1,4 +1,4 @@ -import { test } from "@playwright/test" +import { test } from "../fixtures" import { fixture, pageMessages } from "../smoke/session-timeline.fixture" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible } from "../utils/waits" diff --git a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts index 598763c0225..1aa276079d4 100644 --- a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts +++ b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture" test("space activates a focused timeline button instead of scrolling", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 5b6e0b127b1..f84042199bd 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Locator, type Page } from "@playwright/test" +import { expect, test, type Locator, type Page } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index a9a4738da92..99db4b779e5 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { diff --git a/packages/app/e2e/regression/session-timeline-context-state.spec.ts b/packages/app/e2e/regression/session-timeline-context-state.spec.ts index 37878325e14..96ed413327d 100644 --- a/packages/app/e2e/regression/session-timeline-context-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, partUpdated, diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index f07da121c66..55e35d4f21d 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" test("renders completed write content", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts index cb228c13c7a..8ae8e2b2bcf 100644 --- a/packages/app/e2e/regression/session-timeline-file-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, partUpdated, diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index 15375cafed5..1d6c8aaa9c9 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { assistantMessage, directory, diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index 3e2b171bca0..b55941fcb67 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, completedAssistantInfo, diff --git a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts index 3901f8865e3..d9d250ef88d 100644 --- a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" for (const profile of [ diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 9fd2ca8d0b6..4c7a584c6c3 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, setupTimeline, diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts index 7c0864e5845..483e65faf64 100644 --- a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, reasoningPart, diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index ad35eef601c..5410b2767b6 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, completedAssistantInfo, diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index 99f1acf270b..e5813123ed8 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, partUpdated, diff --git a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts b/packages/app/e2e/regression/session-timeline-tool-state.spec.ts index 63646f4454c..8d8af6f37da 100644 --- a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, partUpdated, diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 850e966d0b0..2b05db85f98 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { assistantMessage, partUpdated, diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 603c411d551..1e574de9616 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 19d2c29af02..388b3bf0e1c 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index 94afbc9a9d6..309d11610a8 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page, type Route } from "@playwright/test" +import { expect, test, type Page, type Route } from "../fixtures" import { base64Encode } from "@opencode-ai/core/util/encode" const server = "http://127.0.0.1:4096" @@ -58,6 +58,7 @@ async function mockServer(page: Page) { if (url.origin !== server) return route.fallback() if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/session/status") return json(route, {}) if (url.pathname === "/session") return json(route, sessions) const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) if (byId) return json(route, byId) diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index 2c2801d4b5b..1015dc0f75a 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 73821580af0..31482da478d 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index cbb72958ad3..a84dcca9a90 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index a73cc0ccdd7..e44b25ae823 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { base64Encode } from "@opencode-ai/core/util/encode" import { fixture, pageMessages } from "./session-timeline.fixture" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 53aacbda02e..191295258cd 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -6,11 +6,14 @@ "types": ["node", "bun"] }, "include": [ + "./fixtures.ts", "./performance/timeline-stability/**/*.spec.ts", "./performance/timeline-stability/fixture.test.ts", "./performance/timeline-stability/fixture.ts", "./performance/unit/visual-stability.test.ts", "./regression/new-session-panel-corner.spec.ts", + "./regression/error-toast-guard.spec.ts", + "./regression/session-request-docks.spec.ts", "./regression/session-timeline-context-resize.spec.ts", "./utils/**/*.ts" ] diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index e1946b8fba0..26e5a48302c 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -20,6 +20,7 @@ export interface MockServerConfig { todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) + onQuestionReply?: (input: { requestID: string; answers?: string[][] }) => unknown | Promise fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown @@ -63,8 +64,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) if (path === "/session/status") return json(route, config.sessionStatus ?? {}) if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) - if (path === "/file" && config.fileList) - return json(route, await config.fileList(url.searchParams.get("path") ?? "")) + if (path === "/file") return json(route, (await config.fileList?.(url.searchParams.get("path") ?? "")) ?? []) if (path === "/file/content" && config.fileContent) return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) if (path === "/find/file" && config.findFiles) @@ -110,6 +110,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) + const permissionRespondMatch = path.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) + if (permissionRespondMatch && route.request().method() === "POST") return json(route, true) + + const questionReplyMatch = path.match(/^\/question\/([^/]+)\/reply$/) + if (questionReplyMatch && route.request().method() === "POST") { + const body = route.request().postDataJSON() as { answers?: string[][] } + return json( + route, + (await config.onQuestionReply?.({ requestID: questionReplyMatch[1]!, answers: body.answers })) ?? true, + ) + } + const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { const token = url.searchParams.get("before") ?? undefined diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 186962998d1..55420485f39 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -162,7 +162,7 @@ export async function installSseTransport( const request = new Request(input, init) const url = new URL(request.url) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) - return originalFetch(input, init) + return originalFetch(request) const id = ++nextConnectionID const record = { diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 363a2e390a4..7bc8576739c 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -161,7 +161,7 @@ export function CustomProviderForm() { }, onError: (err) => { const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) + showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) }, })) diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 601f03084ce..4d50832840f 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -72,7 +72,7 @@ export const DialogFork: Component = () => { .client.session.fork({ sessionID, messageID: item.id }) .then((forked) => { if (!forked.data) { - showToast({ title: language.t("common.requestFailed") }) + showToast({ variant: "error", title: language.t("common.requestFailed") }) return } dialog.close() @@ -81,7 +81,7 @@ export const DialogFork: Component = () => { }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) + showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) }) } diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05d978a947c..38c3f6b1fa2 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -327,6 +327,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .then((x) => x.data) .catch((err) => { showToast({ + variant: "error", title: language.t("prompt.toast.worktreeCreateFailed.title"), description: errorMessage(err), }) @@ -335,6 +336,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (!createdWorktree?.directory) { showToast({ + variant: "error", title: language.t("prompt.toast.worktreeCreateFailed.title"), description: language.t("common.requestFailed"), }) @@ -366,6 +368,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .then((x) => x.data ?? undefined) .catch((err) => { showToast({ + variant: "error", title: language.t("prompt.toast.sessionCreateFailed.title"), description: errorMessage(err), }) @@ -385,6 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (!session) { showToast({ + variant: "error", title: language.t("prompt.toast.promptSendFailed.title"), description: language.t("prompt.toast.promptSendFailed.description"), }) @@ -449,6 +453,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) .catch((err) => { showToast({ + variant: "error", title: language.t("prompt.toast.shellSendFailed.title"), description: errorMessage(err), }) @@ -481,6 +486,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) .catch((err) => { showToast({ + variant: "error", title: language.t("prompt.toast.commandSendFailed.title"), description: formatServerError(err, language.t, language.t("common.requestFailed")), }) @@ -576,6 +582,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { sync().set("session_status", session.id, { type: "idle" }) } showToast({ + variant: "error", title: language.t("prompt.toast.promptSendFailed.title"), description: errorMessage(err), }) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index bcd30edbc7d..5bc90434ae4 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -113,7 +113,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => .catch((err: unknown) => { serverSync().set("config", "disabled_providers", before) const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) + showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) }) } @@ -138,7 +138,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) + showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) }) } diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index f945fa33c64..0d6b17981ca 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -107,7 +107,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = .catch((err: unknown) => { serverSync().set("config", "disabled_providers", before) const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) + showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) }) } @@ -132,7 +132,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) + showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) }) } diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index a397046f9b3..98985f019f0 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -145,6 +145,7 @@ export function TabNavItem(props: { } catch (err) { props.onTitleChangeFailed?.(original) showToast({ + variant: "error", title: language.t("common.requestFailed"), description: err instanceof Error ? err.message : undefined, }) diff --git a/packages/app/src/components/updater-action.ts b/packages/app/src/components/updater-action.ts index 9c13c542b41..25be6167b2d 100644 --- a/packages/app/src/components/updater-action.ts +++ b/packages/app/src/components/updater-action.ts @@ -44,7 +44,7 @@ export function useUpdaterAction() { }) } if (state?.status === "error") { - showToast({ title: language.t("common.requestFailed"), description: state.message }) + showToast({ variant: "error", title: language.t("common.requestFailed"), description: state.message }) } }, } diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index c8a89672dfc..8b53e0746b7 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -518,6 +518,7 @@ export function NewHome() { ), onError: (error) => showToast({ + variant: "error", title: language.t("common.requestFailed"), description: errorMessage(error, language.t("common.requestFailed")), }), diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index fd9d16b90ad..ec32964e287 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1391,6 +1391,7 @@ export default function LegacyLayout(props: ParentProps) { .then((x) => x.data) .catch((err) => { showToast({ + variant: "error", title: language.t("workspace.delete.failed.title"), description: errorMessage(err, language.t("common.requestFailed")), }) @@ -1464,6 +1465,7 @@ export default function LegacyLayout(props: ParentProps) { .then((x) => x.data) .catch((err) => { showToast({ + variant: "error", title: language.t("workspace.reset.failed.title"), description: errorMessage(err, language.t("common.requestFailed")), }) @@ -1826,6 +1828,7 @@ export default function LegacyLayout(props: ParentProps) { .then((x) => x.data) .catch((err) => { showToast({ + variant: "error", title: language.t("workspace.create.failed.title"), description: errorMessage(err, language.t("common.requestFailed")), }) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 45f5e4cb26f..7284f41bd84 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -85,7 +85,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) .catch((err: unknown) => { const description = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description }) + showToast({ variant: "error", title: language.t("common.requestFailed"), description }) }) .finally(() => { setStore("responding", (id) => (id === perm.id ? undefined : id)) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 445a9f47a08..b9ca648b521 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -219,7 +219,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const fail = (err: unknown) => { const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) + showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) } const replyMutation = useMutation(() => ({ diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index d6cc7be972d..fc5efec6e85 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -15,7 +15,12 @@ import { createStore, produce } from "solid-js/store" import { Dynamic } from "solid-js/web" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" -import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual" +import { + createVirtualizer, + defaultRangeExtractor, + elementScroll, + type VirtualItem, +} from "@tanstack/solid-virtual" import { Accordion } from "@opencode-ai/ui/accordion" import { Button } from "@opencode-ai/ui/button" import { Card } from "@opencode-ai/ui/card" @@ -674,6 +679,7 @@ export function MessageTimeline(props: { }, onError: (err) => { showToast({ + variant: "error", title: language.t("common.requestFailed"), description: errorMessage(err), }) @@ -708,6 +714,7 @@ export function MessageTimeline(props: { ) .catch((err: unknown) => showToast({ + variant: "error", title: language.t("common.requestFailed"), description: errorMessage(err), }), @@ -820,6 +827,7 @@ export function MessageTimeline(props: { }) .catch((err) => { showToast({ + variant: "error", title: language.t("common.requestFailed"), description: errorMessage(err), }) @@ -839,6 +847,7 @@ export function MessageTimeline(props: { .then((x) => x.data) .catch((err) => { showToast({ + variant: "error", title: language.t("session.delete.failed.title"), description: errorMessage(err), }) diff --git a/packages/ui/src/v2/components/toast-v2.tsx b/packages/ui/src/v2/components/toast-v2.tsx index d2dc1b3605f..03a96a7ea6b 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -90,6 +90,7 @@ export interface ToastV2Options { title?: string description?: string icon?: JSX.Element + variant?: "default" | "success" | "error" | "loading" duration?: number persistent?: boolean actions?: ToastV2Action[] @@ -100,7 +101,12 @@ export function showToastV2(options: ToastV2Options | string) { return toaster.show((props) => { const resolvedIcon = children(() => opts.icon) return ( - +
{resolvedIcon()} From 9a2131852dc5262975af16d9f61977def8ad5de5 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:58:33 +1000 Subject: [PATCH 2/7] fix(app): preserve timeline bottom anchoring --- bun.lock | 25 +- package.json | 7 +- .../regression/session-request-docks.spec.ts | 379 +++++++++++++++++- .../app/e2e/utils/timeline-cdp-diagnostics.ts | 226 +++++++++++ .../app/test-browser/solid-virtual.test.ts | 54 ++- .../@tanstack%2Fsolid-virtual@3.13.28.patch | 45 --- patches/@tanstack%2Fvirtual-core@3.17.0.patch | 105 ----- patches/@tanstack%2Fvirtual-core@3.17.3.patch | 54 +++ 8 files changed, 708 insertions(+), 187 deletions(-) create mode 100644 packages/app/e2e/utils/timeline-cdp-diagnostics.ts delete mode 100644 patches/@tanstack%2Fsolid-virtual@3.13.28.patch delete mode 100644 patches/@tanstack%2Fvirtual-core@3.17.0.patch create mode 100644 patches/@tanstack%2Fvirtual-core@3.17.3.patch diff --git a/bun.lock b/bun.lock index ba18f4635aa..72f852daf6a 100644 --- a/bun.lock +++ b/bun.lock @@ -1056,27 +1056,26 @@ }, }, "trustedDependencies": [ - "esbuild", "tree-sitter-powershell", - "protobufjs", - "electron", "web-tree-sitter", "tree-sitter-bash", + "esbuild", + "electron", + "protobufjs", ], "patchedDependencies": { + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "pacote@21.5.0": "patches/pacote@21.5.0.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", - "pacote@21.5.0": "patches/pacote@21.5.0.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -1110,7 +1109,7 @@ "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", - "@tanstack/solid-virtual": "3.13.28", + "@tanstack/solid-virtual": "3.13.32", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -2772,9 +2771,9 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], - "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], diff --git a/package.json b/package.json index 0d394011966..4168fc00301 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@opentui/core": "0.4.3", "@opentui/keymap": "0.4.3", "@opentui/solid": "0.4.3", - "@tanstack/solid-virtual": "3.13.28", + "@tanstack/solid-virtual": "3.13.32", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", @@ -151,10 +151,9 @@ "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", - "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch" + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } } diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 036eaaef422..3004f46360f 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -1,12 +1,79 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" +import { startTimelineDiagnostics, type TimelineDiagnostics } from "../utils/timeline-cdp-diagnostics" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/RequestDocks" const projectID = "proj_request_docks" const sessionID = "ses_request_docks" const title = "Request dock regression" +const activeAssistantID = "msg_0079_b13_request_assistant" +const activeToolID = "prt_0079_request_question" +const activeCallID = "call_0079_request_question" +const questionID = "question-focus-return" +const questionPrompts = [ + { + header: "Focus path", + question: "How was focus changing immediately before the timeline jumped?", + options: [ + { label: "Already in app", description: "The app stayed focused before answering." }, + { label: "Returned to app", description: "The app regained focus before answering." }, + ], + }, +] + +type RequestTimelineEvent = { + directory: string + payload: { + id: string + } & ( + | { + type: "question.asked" + properties: { + id: string + sessionID: string + questions: typeof questionPrompts + tool: { messageID: string; callID: string } + } + } + | { + type: "question.replied" + properties: { sessionID: string; requestID: string; answers: string[][] } + } + | { + type: "message.part.updated" + properties: { sessionID: string; part: Record; time: number } + } + | { + type: "message.updated" + properties: { sessionID: string; info: Record } + } + ) +} + +type QuestionTimelineProbe = Window & { + __questionTimelineProbe?: { + blank: boolean + samples: number + stop: boolean + submitted: boolean + transitionSamples: number + } +} + +const timelineDiagnostics = new WeakMap() + +test.beforeEach(async ({ page }, testInfo) => { + if (process.env.TIMELINE_CDP_TRACE !== "1") return + timelineDiagnostics.set(page, await startTimelineDiagnostics(page, testInfo)) +}) + +test.afterEach(async ({ page }) => { + await timelineDiagnostics.get(page)?.stop() + timelineDiagnostics.delete(page) +}) test("shows a pending question dock", async ({ page }) => { await mockServer(page, { @@ -100,11 +167,197 @@ test("shows a pending permission dock", async ({ page }) => { expect(request.postDataJSON()).toEqual({ response: "once" }) }) +test("keeps an active split timeline visible when a focused question closes", async ({ page }) => { + test.setTimeout(180_000) + // Match the observed long session, assistant chain, and large review history before the focused dock handoff. + const messages = timelineMessages(80) + const activeMessage = messages.at(-1)! + const activeTool = activeMessage.parts[0]! + const responses: { requestID: string; answers?: string[][] }[] = [] + const transport = await installSseTransport(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: 20, + }) + await mockServer(page, { + messages, + questions: [], + sessionStatus: { [sessionID]: { type: "busy" } }, + vcsDiff: reviewDiffs(2_754), + onQuestionReply: async (input) => { + responses.push(input) + await transport.send({ + directory, + payload: { + id: "evt_question_replied", + type: "question.replied", + properties: { sessionID, requestID: questionID, answers: [["Already in app"]] }, + }, + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + await transport.send({ + directory, + payload: { + id: "evt_tool_completed", + type: "message.part.updated", + properties: { + sessionID, + part: { + ...activeTool, + state: { + status: "completed", + input: { questions: questionPrompts }, + output: "Questions answered", + title: "Questions answered", + metadata: { answers: [["Already in app"]] }, + time: { start: 1700000791000, end: 1700000795000 }, + }, + }, + time: 1700000795000, + }, + }, + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + await transport.burst([ + { + directory, + payload: { + id: "evt_assistant_completed", + type: "message.updated", + properties: { + sessionID, + info: { + ...activeMessage.info, + time: { ...activeMessage.info.time, completed: 1700000796000 }, + }, + }, + }, + }, + { + directory, + payload: { + id: "evt_continued_assistant", + type: "message.updated", + properties: { + sessionID, + info: { + ...activeMessage.info, + id: "msg_0079_b14_request_assistant", + time: { created: 1700000797000 }, + }, + }, + }, + }, + ]) + return true + }, + }) + + await page.setViewportSize({ width: 1700, height: 1220 }) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + const reviewToggle = page.getByRole("button", { name: "Toggle review" }) + const review = page.locator('#review-panel [data-component="session-review-v2"]') + await reviewToggle.click() + await expect(review).toBeVisible() + await reviewToggle.click() + await expect(review).toHaveCount(0) + + const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const firstPart = page.locator(`[data-timeline-part-id="${messages[0]!.parts[0]!.id}"]`) + const lastTextPart = page.locator('[data-timeline-part-id="prt_0079_12_request_assistant"]') + const lastPart = page.locator(`[data-timeline-part-id="${activeToolID}"]`) + await expect(lastTextPart).toBeInViewport() + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeInViewport() + await expect(firstPart).toHaveCount(0) + await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3) + await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3) + + const composer = page.locator('[data-component="prompt-input"]') + await composer.click() + await expect(composer).toBeFocused() + await transport.send({ + directory, + payload: { + id: "evt_question_asked", + type: "question.asked", + properties: { + id: questionID, + sessionID, + questions: questionPrompts, + tool: { messageID: activeAssistantID, callID: activeCallID }, + }, + }, + }) + await expect(question).toBeVisible() + await expect(question.getByRole("radio", { name: /Already in app/ })).toBeFocused() + await expect(lastTextPart).toBeInViewport() + await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3) + await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3) + await page.evaluate(() => { + const state = { blank: false, samples: 0, stop: false, submitted: false, transitionSamples: 0 } + ;(window as QuestionTimelineProbe).__questionTimelineProbe = state + const submit = [...document.querySelectorAll('[data-component="dock-prompt"] button')].find( + (button) => button.textContent?.trim() === "Submit", + ) + submit?.addEventListener( + "click", + () => { + state.submitted = true + state.transitionSamples = 0 + }, + { capture: true, once: true }, + ) + const sample = () => { + const viewport = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-virtual-content]"), + ) + const view = viewport?.getBoundingClientRect() + const visible = [...(viewport?.querySelectorAll("[data-timeline-part-id]") ?? [])].some((part) => { + const rect = part.getBoundingClientRect() + return !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + }) + if (view && view.width > 0 && view.height > 0 && !visible) state.blank = true + state.samples++ + if (state.submitted) state.transitionSamples++ + if (!state.stop) requestAnimationFrame(sample) + } + requestAnimationFrame(sample) + }) + await page.waitForFunction(() => ((window as QuestionTimelineProbe).__questionTimelineProbe?.samples ?? 0) >= 2) + await question.getByRole("radio", { name: /Already in app/ }).click() + await question.getByRole("button", { name: "Submit" }).click() + + await expect.poll(() => responses).toEqual([{ requestID: questionID, answers: [["Already in app"]] }]) + await expect(question).toHaveCount(0) + await expect(page.locator('[data-component="session-composer"]')).toBeVisible() + await expect(page.locator('[data-component="toast-v2"]')).toHaveCount(0) + await expect(lastPart).toBeInViewport() + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeInViewport() + await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() + await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3) + await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3) + expect(await visibleTimelineRows(scroller)).toBeGreaterThan(0) + const probe = await page.evaluate(() => { + const state = (window as QuestionTimelineProbe).__questionTimelineProbe! + state.stop = true + return state + }) + expect(probe).toMatchObject({ blank: false }) + expect(probe.submitted).toBe(true) + expect(probe.transitionSamples).toBeGreaterThan(0) +}) + async function mockServer( page: Page, requests: { permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) + messages?: { info: { id: string }; parts: { id: string }[] }[] + sessionStatus?: unknown + vcsDiff?: unknown[] + onQuestionReply?: (input: { requestID: string; answers?: string[][] }) => unknown | Promise }, ) { await mockOpenCodeServer(page, { @@ -145,11 +398,131 @@ async function mockServer( time: { created: 1700000000000, updated: 1700000000000 }, }, ], - pageMessages: () => ({ items: [] }), + pageMessages: (_, limit, before) => { + const messages = requests.messages ?? [] + const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length + const start = Math.max(0, end - limit) + return { + items: messages.slice(start, end), + cursor: start > 0 ? messages[start]!.info.id : undefined, + } + }, + message: (_, messageID) => requests.messages?.find((message) => message.info.id === messageID), permissions: requests.permissions, questions: requests.questions, + sessionStatus: requests.sessionStatus, + onQuestionReply: requests.onQuestionReply, + vcsDiff: requests.vcsDiff, + fileList: () => [], }) await page.addInitScript(() => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "settings.v3", + JSON.stringify({ general: { newLayoutDesigns: true, shellToolPartsExpanded: true } }), + ) + }) +} + +function timelineMessages(turns: number) { + return Array.from({ length: turns }, (_, index) => { + const key = String(index).padStart(4, "0") + const userID = `msg_${key}_a_request_user` + const active = index === turns - 1 + const user = { + info: { + id: userID, + sessionID, + role: "user", + time: { created: 1700000000000 + index * 10_000 }, + summary: { diffs: [] }, + agent: "build", + model: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + parts: [ + { + id: `prt_${key}_request_user`, + sessionID, + messageID: userID, + type: "text", + text: `Request turn ${index}`, + }, + ], + } + const assistants = Array.from({ length: active ? 14 : 1 }, (_, assistantIndex) => { + const suffix = active ? `b${String(assistantIndex).padStart(2, "0")}` : "b" + const assistantID = `msg_${key}_${suffix}_request_assistant` + const current = active && assistantIndex === 13 + const created = 1700000001000 + index * 10_000 + assistantIndex * 100 + return { + info: { + id: assistantID, + sessionID, + role: "assistant", + time: { created, ...(current ? {} : { completed: created + 50 }) }, + parentID: userID, + modelID: "claude-opus-4-6", + providerID: "opencode", + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0, + tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: current + ? [ + { + id: activeToolID, + sessionID, + messageID: assistantID, + type: "tool", + callID: activeCallID, + tool: "question", + state: { + status: "running", + input: { questions: questionPrompts }, + metadata: {}, + time: { start: 1700000791000 }, + }, + }, + ] + : [ + { + id: `prt_${key}_${assistantIndex}_request_assistant`, + sessionID, + messageID: assistantID, + type: "text", + text: `Assistant response ${index}.${assistantIndex}. ${"Long timeline content. ".repeat(12)}`, + }, + ], + } + }) + return [user, ...assistants] + }).flat() +} + +function distanceFromBottom(scroller: ReturnType) { + return scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop) +} + +function reviewDiffs(count: number) { + return Array.from({ length: count }, (_, index) => { + const file = `src/focus-${String(index).padStart(4, "0")}.ts` + return { + file, + additions: 1, + deletions: 1, + status: "modified", + patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const focused = false\n+export const focused = true\n`, + } + }) +} + +function visibleTimelineRows(scroller: ReturnType) { + return scroller.evaluate((element) => { + const view = element.getBoundingClientRect() + return [...element.querySelectorAll("[data-timeline-key]")].filter((row) => { + const rect = row.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }).length }) } diff --git a/packages/app/e2e/utils/timeline-cdp-diagnostics.ts b/packages/app/e2e/utils/timeline-cdp-diagnostics.ts new file mode 100644 index 00000000000..60003cb3f8b --- /dev/null +++ b/packages/app/e2e/utils/timeline-cdp-diagnostics.ts @@ -0,0 +1,226 @@ +import type { CDPSession, Page, TestInfo } from "@playwright/test" + +export async function startTimelineDiagnostics(page: Page, testInfo: TestInfo) { + const cdp = await page.context().newCDPSession(page) + const pauses: unknown[] = [] + const scripts: { url: string; scriptID: string }[] = [] + const breakpoints = new Map() + const setup: Promise[] = [] + let handling = Promise.resolve() + + await cdp.send("Debugger.enable") + + cdp.on("Debugger.scriptParsed", (event) => { + if (!/tanstack|solid-virtual|virtual-core/i.test(event.url)) return + scripts.push({ url: event.url, scriptID: event.scriptId }) + setup.push( + installSourceBreakpoints(cdp, event.scriptId, breakpoints).catch((error) => { + pauses.push({ type: "breakpoint-install-error", url: event.url, error: String(error) }) + }), + ) + }) + + cdp.on("Debugger.paused", (event) => { + handling = handling.then(async () => { + const frame = event.callFrames[0] + const state = frame + ? await cdp + .send("Debugger.evaluateOnCallFrame", { + callFrameId: frame.callFrameId, + expression: `(() => { + const root = [...document.querySelectorAll('.scroll-view__viewport')].find((element) => + element.querySelector('[data-timeline-virtual-content]') + ) + const indexes = root ? [...root.querySelectorAll('[data-index]')].map((item) => Number(item.getAttribute('data-index'))) : [] + const owner = this && typeof this === 'object' && 'scrollOffset' in this ? this : undefined + return { + now: performance.now(), + dom: root ? { + scrollTop: root.scrollTop, + scrollHeight: root.scrollHeight, + clientHeight: root.clientHeight, + indexes, + } : null, + virtualizer: owner ? { + scrollOffset: owner.scrollOffset, + scrollAdjustments: owner.scrollAdjustments, + intendedScrollOffset: owner._intendedScrollOffset, + isScrolling: owner.isScrolling, + range: owner.range, + totalSize: typeof owner.getTotalSize === 'function' ? owner.getTotalSize() : undefined, + } : null, + active: document.activeElement ? { + tag: document.activeElement.tagName, + component: document.activeElement.getAttribute('data-component'), + slot: document.activeElement.getAttribute('data-slot'), + text: document.activeElement.textContent?.trim().slice(0, 80), + } : null, + } + })()`, + returnByValue: true, + }) + .then((result) => result.result.value) + .catch((error) => ({ error: String(error) })) + : undefined + pauses.push({ + type: "pause", + reason: event.reason, + data: event.data, + hitBreakpoints: event.hitBreakpoints?.map((id) => breakpoints.get(id) ?? id), + state, + stack: event.callFrames.slice(0, 20).map((item) => ({ + functionName: item.functionName, + url: item.url, + line: item.location.lineNumber + 1, + column: (item.location.columnNumber ?? 0) + 1, + })), + }) + await cdp.send("Debugger.resume").catch(() => {}) + }) + }) + + await page.addInitScript(() => { + type TraceWindow = Window & { __timelineDomTrace?: unknown[] } + const output: unknown[] = [] + ;(window as TraceWindow).__timelineDomTrace = output + let last = "" + let observed: HTMLElement | undefined + const active = () => { + const element = document.activeElement + if (!(element instanceof HTMLElement)) return null + return { + tag: element.tagName, + component: element.dataset.component, + slot: element.dataset.slot, + text: element.textContent?.trim().slice(0, 80), + } + } + const root = () => + [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-virtual-content]"), + ) + const record = (type: string, detail: Record = {}) => { + const element = root() + const indexes = element + ? [...element.querySelectorAll("[data-index]")].map((item) => Number(item.dataset.index)) + : [] + output.push({ + type, + at: performance.now(), + scrollTop: element?.scrollTop, + scrollHeight: element?.scrollHeight, + clientHeight: element?.clientHeight, + indexes, + active: active(), + question: !!document.querySelector('[data-component="dock-prompt"][data-kind="question"]'), + review: !!document.querySelector('#review-panel [data-component="session-review-v2"]'), + ...detail, + }) + } + const watch = () => { + const element = root() + if (element && element !== observed) { + observed = element + new ResizeObserver(() => record("resize")).observe(element) + } + requestAnimationFrame(watch) + } + const sample = () => { + const element = root() + const indexes = element + ? [...element.querySelectorAll("[data-index]")].map((item) => Number(item.dataset.index)) + : [] + const next = JSON.stringify([ + element?.scrollTop, + element?.scrollHeight, + element?.clientHeight, + indexes[0], + indexes.at(-1), + document.activeElement?.getAttribute("data-slot"), + !!document.querySelector('[data-component="dock-prompt"][data-kind="question"]'), + ]) + if (next !== last) { + last = next + record("frame") + } + requestAnimationFrame(sample) + } + document.addEventListener("scroll", (event) => record("scroll", { trusted: event.isTrusted }), true) + document.addEventListener("focusin", (event) => record("focusin", { trusted: event.isTrusted }), true) + document.addEventListener("focusout", (event) => record("focusout", { trusted: event.isTrusted }), true) + requestAnimationFrame(watch) + requestAnimationFrame(sample) + }) + + await cdp.send("Tracing.start", { + categories: "devtools.timeline,blink.user_timing,v8.execute,disabled-by-default-devtools.timeline.stack", + options: "sampling-frequency=10000", + transferMode: "ReturnAsStream", + }) + + return { + async stop() { + await Promise.allSettled(setup) + await handling + const complete = new Promise((resolve, reject) => { + cdp.once("Tracing.tracingComplete", (event) => { + if (!event.stream) { + reject(new Error("CDP tracing completed without a stream")) + return + } + resolve(event.stream) + }) + }) + await cdp.send("Tracing.end") + const stream = await complete + const trace = await readCdpStream(cdp, stream) + const dom = await page + .evaluate(() => (window as Window & { __timelineDomTrace?: unknown[] }).__timelineDomTrace ?? []) + .catch(() => []) + const { writeFile } = await import("node:fs/promises") + const tracePath = "C:\\tmp\\opencode\\timeline-devtools-trace.json" + const debuggerPath = "C:\\tmp\\opencode\\timeline-cdp-debugger.json" + const domPath = "C:\\tmp\\opencode\\timeline-dom-events.json" + await Promise.all([ + writeFile(tracePath, trace), + writeFile(debuggerPath, JSON.stringify({ test: testInfo.title, scripts, pauses }, null, 2)), + writeFile(domPath, JSON.stringify(dom, null, 2)), + ]) + await cdp.send("Debugger.disable").catch(() => {}) + await cdp.detach().catch(() => {}) + console.log(`TIMELINE_TRACE ${JSON.stringify({ tracePath, debuggerPath, domPath, pauses: pauses.length })}`) + }, + } +} + +export type TimelineDiagnostics = Awaited> + +async function installSourceBreakpoints(cdp: CDPSession, scriptID: string, labels: Map) { + const targets = [ + ["offset-observer", "this.scrollOffset = offset"], + ["resize-item", "this.resizeItem = (index, size)"], + ["apply-scroll-adjustment", "applyScrollAdjustment(delta, behavior)"], + ["scroll-to-offset", "this._scrollToOffset = (offset"], + ["scroll-to-end", "this.scrollToEnd ="], + ] as const + for (const [label, query] of targets) { + const matches = await cdp.send("Debugger.searchInContent", { scriptId: scriptID, query }) + for (const match of matches.result) { + const result = await cdp.send("Debugger.setBreakpoint", { + location: { scriptId: scriptID, lineNumber: match.lineNumber }, + }) + labels.set(result.breakpointId, label) + } + } +} + +async function readCdpStream(cdp: CDPSession, handle: string) { + const chunks: Buffer[] = [] + while (true) { + const chunk = await cdp.send("IO.read", { handle }) + chunks.push(Buffer.from(chunk.data, chunk.base64Encoded ? "base64" : "utf8")) + if (chunk.eof) break + } + await cdp.send("IO.close", { handle }) + return Buffer.concat(chunks) +} diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index 716fa7fa9ea..2a9d98ca683 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -1,8 +1,32 @@ import { expect, test } from "bun:test" -import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" +import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual" import { createRoot, createSignal } from "solid-js" import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items" +test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => { + const writes: { offset: number; adjustments?: number }[] = [] + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + initialOffset: 50, + initialRect: { width: 400, height: 200 }, + anchorTo: "end", + scrollEndThreshold: 1, + getScrollElement: () => null, + scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }), + observeElementRect: () => {}, + observeElementOffset: () => {}, + }) + + virtualizer.getTotalSize() + virtualizer.resizeItem(4, 120) + expect(writes).toEqual([{ offset: 50, adjustments: 70 }]) + writes.length = 0 + + virtualizer.resizeItem(4, 200) + expect(writes).toEqual([{ offset: 120, adjustments: 80 }]) +}) + test("reactive count updates preserve measured row sizes", () => { createRoot((dispose) => { const [count, setCount] = createSignal(2) @@ -42,23 +66,19 @@ test("initial rect projects rows before a scroll element connects", () => { }) }) -test("logical scroll offset includes pending measurement adjustments", () => { - createRoot((dispose) => { - const virtualizer = createVirtualizer({ - count: 2, - getScrollElement: () => null, - estimateSize: () => 60, - initialOffset: 100, - initialRect: { width: 800, height: 60 }, - }) - - virtualizer.getTotalSize() - virtualizer.resizeItem(0, 100) - - expect(virtualizer.scrollOffset).toBe(100) - expect(virtualizer.getLogicalScrollOffset()).toBe(140) - dispose() +test("clamps an oversized initial offset to a full final viewport range", () => { + const virtualizer = new Virtualizer({ + count: 20, + estimateSize: () => 60, + initialOffset: Number.MAX_SAFE_INTEGER, + initialRect: { width: 800, height: 600 }, + getScrollElement: () => null, + scrollToFn: () => {}, + observeElementRect: () => {}, + observeElementOffset: () => {}, }) + + expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]) }) test("stale pinned indexes do not produce missing virtual items after count shrinks", () => { diff --git a/patches/@tanstack%2Fsolid-virtual@3.13.28.patch b/patches/@tanstack%2Fsolid-virtual@3.13.28.patch deleted file mode 100644 index 3b1cda91e1c..00000000000 --- a/patches/@tanstack%2Fsolid-virtual@3.13.28.patch +++ /dev/null @@ -1,45 +0,0 @@ -diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs -index 7e97823ea769398ccd9cf449b178c77675ed252c..d75183f11421af0e20e4e8a996af99c300ad936d 100644 ---- a/dist/cjs/index.cjs -+++ b/dist/cjs/index.cjs -@@ -39,7 +39,9 @@ function createVirtualizerBase(options) { - (_a = options.onChange) == null ? void 0 : _a.call(options, instance2, sync); - } - })); -- virtualizer.measure(); -+ virtualizer._willUpdate(); -+ setVirtualItems(store.reconcile(instance.getVirtualItems(), { key: "index" })); -+ setTotalSize(instance.getTotalSize()); - }); - return virtualizer; - } -diff --git a/dist/esm/index.js b/dist/esm/index.js -index 1d525463775fef3e8ece6ab191061ef9d0a36d73..14c680a2088c49a33959d8118cf32ee599ab83c2 100644 ---- a/dist/esm/index.js -+++ b/dist/esm/index.js -@@ -38,7 +38,9 @@ function createVirtualizerBase(options) { - (_a = options.onChange) == null ? void 0 : _a.call(options, instance2, sync); - } - })); -- virtualizer.measure(); -+ virtualizer._willUpdate(); -+ setVirtualItems(reconcile(instance.getVirtualItems(), { key: "index" })); -+ setTotalSize(instance.getTotalSize()); - }); - return virtualizer; - } -diff --git a/src/index.tsx b/src/index.tsx -index 69ac34fd70753b9bd00683c2540be7f62630f8f2..9f16672aa0f4a044aa2b35754d385d7d8031f743 100644 ---- a/src/index.tsx -+++ b/src/index.tsx -@@ -81,7 +81,9 @@ function createVirtualizerBase< - }, - }), - ) -- virtualizer.measure() -+ virtualizer._willUpdate() -+ setVirtualItems(reconcile(instance.getVirtualItems(), { key: 'index' })) -+ setTotalSize(instance.getTotalSize()) - }) - - return virtualizer diff --git a/patches/@tanstack%2Fvirtual-core@3.17.0.patch b/patches/@tanstack%2Fvirtual-core@3.17.0.patch deleted file mode 100644 index 58616e31e6f..00000000000 --- a/patches/@tanstack%2Fvirtual-core@3.17.0.patch +++ /dev/null @@ -1,105 +0,0 @@ -diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs -index df75d0cf0347b62906e04e454d4f4ef062ed5c48..e5110715b049c6c8b992bd1375a7432df7c0182e 100644 ---- a/dist/cjs/index.cjs -+++ b/dist/cjs/index.cjs -@@ -526,6 +526,7 @@ class Virtualizer { - this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset); - return this.scrollOffset; - }; -+ this.getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments; - this.getFurthestMeasurement = (measurements, index) => { - const furthestMeasurementsFound = /* @__PURE__ */ new Map(); - const furthestMeasurements = /* @__PURE__ */ new Map(); -@@ -715,10 +716,12 @@ class Virtualizer { - this.options.lanes - ], - (measurements, outerSize, scrollOffset, lanes) => { -+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0); -+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); - return this.range = measurements.length > 0 && outerSize > 0 ? calculateRange({ - measurements, - outerSize, -- scrollOffset, -+ scrollOffset: effectiveScrollOffset, - lanes, - // Pass the typed array so binary search + forward-walk can - // read start/end directly from Float64Array, skipping the -diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts -index c61ee17752565253f795c7fc7d57e86237ecbb52..705bb7e3a121b040fb1a3e7890179eaa3e9b219e 100644 ---- a/dist/cjs/index.d.cts -+++ b/dist/cjs/index.d.cts -@@ -108,6 +108,7 @@ export declare class Virtualizer number; - private scrollAdjustments; - private _iosDeferredAdjustment; - private _iosTouching; -diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts -index b03abab604eb6578f6f56ff92c489259cfaf8f19..0495f372ea000dffc416c4f56809946f7ba73099 100644 ---- a/dist/esm/index.d.ts -+++ b/dist/esm/index.d.ts -@@ -108,6 +108,7 @@ export declare class Virtualizer number; - private scrollAdjustments; - private _iosDeferredAdjustment; - private _iosTouching; -diff --git a/dist/esm/index.js b/dist/esm/index.js -index e384cf7541978a2782b9dca68146e869b16ac3f2..77af22006325377bf9ca0052a3554f881f9e75fe 100644 ---- a/dist/esm/index.js -+++ b/dist/esm/index.js -@@ -524,6 +524,7 @@ class Virtualizer { - this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset); - return this.scrollOffset; - }; -+ this.getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments; - this.getFurthestMeasurement = (measurements, index) => { - const furthestMeasurementsFound = /* @__PURE__ */ new Map(); - const furthestMeasurements = /* @__PURE__ */ new Map(); -@@ -713,10 +714,12 @@ class Virtualizer { - this.options.lanes - ], - (measurements, outerSize, scrollOffset, lanes) => { -+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0); -+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); - return this.range = measurements.length > 0 && outerSize > 0 ? calculateRange({ - measurements, - outerSize, -- scrollOffset, -+ scrollOffset: effectiveScrollOffset, - lanes, - // Pass the typed array so binary search + forward-walk can - // read start/end directly from Float64Array, skipping the -diff --git a/src/index.ts b/src/index.ts -index d35b3e0695a9c85b261bc1a4fbe23c0a60d5b204..c504e630a60a3a7791345622bb02754b1308fd86 100644 ---- a/src/index.ts -+++ b/src/index.ts -@@ -1047,6 +1047,8 @@ export class Virtualizer< - return this.scrollOffset - } - -+ getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments -+ - private getFurthestMeasurement = ( - measurements: Array, - index: number, -@@ -1334,12 +1336,14 @@ export class Virtualizer< - this.options.lanes, - ], - (measurements, outerSize, scrollOffset, lanes) => { -+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0) -+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset) - return (this.range = - measurements.length > 0 && outerSize > 0 - ? calculateRange({ - measurements, - outerSize, -- scrollOffset, -+ scrollOffset: effectiveScrollOffset, - lanes, - // Pass the typed array so binary search + forward-walk can - // read start/end directly from Float64Array, skipping the diff --git a/patches/@tanstack%2Fvirtual-core@3.17.3.patch b/patches/@tanstack%2Fvirtual-core@3.17.3.patch new file mode 100644 index 00000000000..e5201888b9e --- /dev/null +++ b/patches/@tanstack%2Fvirtual-core@3.17.3.patch @@ -0,0 +1,54 @@ +diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs +index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..bcee09df7377c37ffb220606b741c9ff434b3470 100644 +--- a/dist/cjs/index.cjs ++++ b/dist/cjs/index.cjs +@@ -723,10 +723,12 @@ class Virtualizer { + this.range = null; + return null; + } ++ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0); ++ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); + this.range = calculateRangeImpl( + measurements, + outerSize, +- scrollOffset, ++ effectiveScrollOffset, + lanes, + // Pass the typed array so binary search + forward-walk can read + // start/end directly from Float64Array, skipping the Proxy traps. +diff --git a/dist/esm/index.js b/dist/esm/index.js +index 3032c0ca457582be3f47923cba1f7d92c848745c..90b574881a073aabac99c075f7eab0a8f363fff6 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -721,10 +721,12 @@ class Virtualizer { + this.range = null; + return null; + } ++ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0); ++ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); + this.range = calculateRangeImpl( + measurements, + outerSize, +- scrollOffset, ++ effectiveScrollOffset, + lanes, + // Pass the typed array so binary search + forward-walk can read + // start/end directly from Float64Array, skipping the Proxy traps. +diff --git a/src/index.ts b/src/index.ts +index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..faef81e8e567424146143242408eec35a634cf04 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -1373,10 +1373,12 @@ export class Virtualizer< + this.range = null + return null + } ++ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0) ++ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset) + this.range = calculateRangeImpl( + measurements, + outerSize, +- scrollOffset, ++ effectiveScrollOffset, + lanes, + // Pass the typed array so binary search + forward-walk can read + // start/end directly from Float64Array, skipping the Proxy traps. From 7243a33e03364e1824f478200296eccac4dac512 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:12:19 +1000 Subject: [PATCH 3/7] test(app): guard new e2e specs --- packages/app/e2e/regression/legacy-new-session.spec.ts | 2 +- packages/app/e2e/regression/new-session-panel-corner.spec.ts | 2 +- packages/app/e2e/regression/review-open-file.spec.ts | 2 +- packages/app/e2e/regression/review-state-persistence.spec.ts | 2 +- packages/app/e2e/regression/review-terminal-stacked.spec.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/regression/legacy-new-session.spec.ts b/packages/app/e2e/regression/legacy-new-session.spec.ts index 30233a5aae1..8bf669c386e 100644 --- a/packages/app/e2e/regression/legacy-new-session.spec.ts +++ b/packages/app/e2e/regression/legacy-new-session.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { base64Encode } from "@opencode-ai/core/util/encode" import { mockOpenCodeServer } from "../utils/mock-server" diff --git a/packages/app/e2e/regression/new-session-panel-corner.spec.ts b/packages/app/e2e/regression/new-session-panel-corner.spec.ts index a17b8c08170..b4a1200007e 100644 --- a/packages/app/e2e/regression/new-session-panel-corner.spec.ts +++ b/packages/app/e2e/regression/new-session-panel-corner.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible } from "../utils/waits" diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index f69591f608a..ef5ada216d3 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "@playwright/test" +import { expect, test } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index aa42f1bb516..57e6c54baaa 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index e3ba607b3d8..a2c5e90a3b7 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Page } from "../fixtures" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" From 555a1299de16163a3d6e32149260ddef93529a4f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:32:55 +1000 Subject: [PATCH 4/7] Revert "test(app): guard new e2e specs" This reverts commit 7243a33e03364e1824f478200296eccac4dac512. --- packages/app/e2e/regression/legacy-new-session.spec.ts | 2 +- packages/app/e2e/regression/new-session-panel-corner.spec.ts | 2 +- packages/app/e2e/regression/review-open-file.spec.ts | 2 +- packages/app/e2e/regression/review-state-persistence.spec.ts | 2 +- packages/app/e2e/regression/review-terminal-stacked.spec.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/regression/legacy-new-session.spec.ts b/packages/app/e2e/regression/legacy-new-session.spec.ts index 8bf669c386e..30233a5aae1 100644 --- a/packages/app/e2e/regression/legacy-new-session.spec.ts +++ b/packages/app/e2e/regression/legacy-new-session.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { mockOpenCodeServer } from "../utils/mock-server" diff --git a/packages/app/e2e/regression/new-session-panel-corner.spec.ts b/packages/app/e2e/regression/new-session-panel-corner.spec.ts index b4a1200007e..a17b8c08170 100644 --- a/packages/app/e2e/regression/new-session-panel-corner.spec.ts +++ b/packages/app/e2e/regression/new-session-panel-corner.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible } from "../utils/waits" diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index ef5ada216d3..f69591f608a 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 57e6c54baaa..aa42f1bb516 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index a2c5e90a3b7..e3ba607b3d8 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" From e8e5f7b8a00aaf87b82e8a08abdd636550500686 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:32:56 +1000 Subject: [PATCH 5/7] Revert "test(app): fail on unexpected error toasts" This reverts commit eb26d40850321f5ebddc98c71735f0aa8290292f. --- packages/app/e2e/fixtures.ts | 211 ------------------ packages/app/e2e/performance/benchmark.ts | 11 +- .../timeline-stability/adverse.spec.ts | 2 +- .../timeline-stability/context-matrix.spec.ts | 2 +- .../environment-matrix.spec.ts | 2 +- .../timeline-stability/file-matrix.spec.ts | 2 +- .../timeline-stability/file-mutation.spec.ts | 2 +- .../timeline-stability/interaction.spec.ts | 2 +- .../timeline-stability/lifecycle.spec.ts | 2 +- .../timeline-stability/oracle-browser.spec.ts | 2 +- .../scroll-interaction.spec.ts | 2 +- .../timeline-stability/shell-matrix.spec.ts | 2 +- .../timeline-stability/tool-mutation.spec.ts | 2 +- .../timeline-stability/tools.spec.ts | 2 +- .../transition-matrix.spec.ts | 2 +- .../regression/cross-server-tab-close.spec.ts | 9 +- .../e2e/regression/error-toast-guard.spec.ts | 50 ----- .../regression/prompt-thinking-level.spec.ts | 2 +- .../e2e/regression/remote-tab-busy.spec.ts | 2 +- .../e2e/regression/review-image-flash.spec.ts | 2 +- .../regression/review-line-comment.spec.ts | 2 +- .../e2e/regression/review-tab-switch.spec.ts | 2 +- .../session-list-path-loading.spec.ts | 2 +- .../session-timeline-accessibility.spec.ts | 2 +- .../session-timeline-collapse-state.spec.ts | 2 +- .../session-timeline-context-resize.spec.ts | 2 +- .../session-timeline-context-state.spec.ts | 2 +- .../session-timeline-file-projection.spec.ts | 2 +- .../session-timeline-file-state.spec.ts | 2 +- .../session-timeline-history-root.spec.ts | 2 +- .../session-timeline-lifecycle-state.spec.ts | 2 +- ...session-timeline-locale-projection.spec.ts | 2 +- .../session-timeline-projection.spec.ts | 2 +- ...sion-timeline-reasoning-projection.spec.ts | 2 +- ...ession-timeline-reducer-projection.spec.ts | 2 +- .../session-timeline-tool-projection.spec.ts | 2 +- .../session-timeline-tool-state.spec.ts | 2 +- .../session-timeline-transport.spec.ts | 2 +- .../session-todo-dock-navigation.spec.ts | 2 +- .../subagent-child-navigation.spec.ts | 2 +- .../regression/tab-navigate-mousedown.spec.ts | 3 +- .../terminal-composer-focus.spec.ts | 2 +- .../e2e/regression/terminal-hidden.spec.ts | 2 +- .../regression/terminal-tab-switch.spec.ts | 2 +- .../app/e2e/smoke/session-timeline.spec.ts | 2 +- packages/app/e2e/tsconfig.json | 3 - packages/app/e2e/utils/mock-server.ts | 16 +- packages/app/e2e/utils/sse-transport.ts | 2 +- .../src/components/dialog-custom-provider.tsx | 2 +- packages/app/src/components/dialog-fork.tsx | 4 +- .../app/src/components/prompt-input/submit.ts | 7 - .../app/src/components/settings-providers.tsx | 4 +- .../src/components/settings-v2/providers.tsx | 4 +- .../app/src/components/titlebar-tab-nav.tsx | 1 - packages/app/src/components/updater-action.ts | 2 +- packages/app/src/pages/home.tsx | 1 - packages/app/src/pages/layout.tsx | 3 - .../composer/session-composer-state.ts | 2 +- .../composer/session-question-dock.tsx | 2 +- .../session/timeline/message-timeline.tsx | 11 +- packages/ui/src/v2/components/toast-v2.tsx | 8 +- 61 files changed, 62 insertions(+), 374 deletions(-) delete mode 100644 packages/app/e2e/fixtures.ts delete mode 100644 packages/app/e2e/regression/error-toast-guard.spec.ts diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts deleted file mode 100644 index a8fa0110916..00000000000 --- a/packages/app/e2e/fixtures.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { expect, test as base, type Page, type TestInfo } from "@playwright/test" - -const marker = "__OPENCODE_E2E_ERROR_TOAST__" - -type ErrorToastObservation = { - component: "legacy" | "v2" - title: string - description: string - text: string - url: string - timestamp: number -} - -type ErrorToastExpectation = { - pattern: string | RegExp - seen: boolean -} - -export type ErrorToastControl = { - expect: (pattern: string | RegExp) => void - allow: (pattern?: string | RegExp) => void -} - -type ErrorToastState = ErrorToastControl & { - allowed: boolean - allowedPatterns: (string | RegExp)[] - expected: ErrorToastExpectation[] -} - -type Fixtures = { - errorToasts: ErrorToastControl -} - -export const test = base.extend({ - errorToasts: async ({}, use) => use(createErrorToastState()), - page: async ({ page, errorToasts }, use, testInfo) => { - const guard = await guardPage(page, testInfo, errorToasts as ErrorToastState) - try { - await use(page) - } finally { - await guard.finish() - } - }, -}) - -export { expect } -export type { Browser, CDPSession, Locator, Page, Route, TestInfo } from "@playwright/test" - -export async function guardPage(page: Page, testInfo: TestInfo, control = createErrorToastState()) { - const observations: ErrorToastObservation[] = [] - const unexpected: ErrorToastObservation[] = [] - let closing = false - const onConsole = (message: { text: () => string }) => { - const text = message.text() - if (!text.startsWith(marker)) return - - const observation = JSON.parse(text.slice(marker.length)) as ErrorToastObservation - observations.push(observation) - console.error(`E2E_ERROR_TOAST ${JSON.stringify(observation)}`) - - const expected = control.expected.find((item) => !item.seen && matches(item.pattern, observation.text)) - if (expected) { - expected.seen = true - return - } - if (control.allowed || control.allowedPatterns.some((pattern) => matches(pattern, observation.text))) return - - unexpected.push(observation) - if (closing) return - closing = true - void page.close().catch(() => {}) - } - - page.on("console", onConsole) - await page.addInitScript(installErrorToastObserver, marker) - await page.evaluate(installErrorToastObserver, marker) - - return { - async finish() { - if (!page.isClosed()) { - await page - .evaluate(() => { - ;(window as Window & { __flushErrorToastObserver?: () => void }).__flushErrorToastObserver?.() - }) - .catch(() => {}) - } - page.off("console", onConsole) - if (observations.length > 0) { - await testInfo.attach("error-toasts", { - body: JSON.stringify(observations, null, 2), - contentType: "application/json", - }) - } - - const missing = control.expected.filter((item) => !item.seen) - if (unexpected.length === 0 && missing.length === 0) return - - const messages = [ - ...unexpected.map((item) => `Unexpected error toast: ${item.text}`), - ...missing.map((item) => `Expected error toast was not shown: ${String(item.pattern)}`), - ] - throw new Error(messages.join("\n")) - }, - } -} - -function createErrorToastState(): ErrorToastState { - const state: ErrorToastState = { - allowed: false, - allowedPatterns: [], - expected: [], - expect(pattern) { - state.expected.push({ pattern, seen: false }) - }, - allow(pattern) { - if (pattern !== undefined) { - state.allowedPatterns.push(pattern) - return - } - state.allowed = true - }, - } - return state -} - -function matches(pattern: string | RegExp, value: string) { - if (typeof pattern === "string") return value.includes(pattern) - pattern.lastIndex = 0 - return pattern.test(value) -} - -function installErrorToastObserver(marker: string) { - const owner = window as Window & { - __errorToastObserverInstalled?: boolean - __flushErrorToastObserver?: () => void - } - if (owner.__errorToastObserverInstalled) return - owner.__errorToastObserverInstalled = true - - const selector = '[data-component="toast"][data-variant="error"], [data-component="toast-v2"][data-variant="error"]' - const seen = new WeakSet() - const pending = new Set() - let scheduled = false - const inspect = (node: Node) => { - const element = node instanceof HTMLElement ? node : node.parentElement - if (!element) return - const candidates = [ - ...(element.matches(selector) ? [element] : []), - ...element.querySelectorAll(selector), - ...(element.closest(selector) ? [element.closest(selector)!] : []), - ] - candidates.forEach((toast) => { - if (seen.has(toast)) return - const text = toast.textContent?.replace(/\s+/g, " ").trim() - if (!text) return - seen.add(toast) - const component = toast.dataset.component === "toast-v2" ? "v2" : "legacy" - const title = toast.querySelector(`[data-slot="toast${component === "v2" ? "-v2" : ""}-title"]`) - const description = toast.querySelector( - `[data-slot="toast${component === "v2" ? "-v2" : ""}-description"]`, - ) - console.debug( - marker + - JSON.stringify({ - component, - title: title?.textContent?.trim() ?? "", - description: description?.textContent?.trim() ?? "", - text, - url: location.href, - timestamp: Date.now(), - }), - ) - }) - } - const scan = () => { - scheduled = false - pending.forEach(inspect) - pending.clear() - } - const schedule = (node: Node) => { - pending.add(node) - if (scheduled) return - scheduled = true - queueMicrotask(scan) - } - const start = () => { - const root = document.documentElement - if (!root) return - new MutationObserver((records) => { - records.forEach((record) => { - if (record.type !== "childList") schedule(record.target) - record.addedNodes.forEach(schedule) - }) - }).observe(root, { - attributes: true, - attributeFilter: ["data-variant"], - childList: true, - characterData: true, - subtree: true, - }) - schedule(root) - } - owner.__flushErrorToastObserver = () => { - const root = document.documentElement - if (root) pending.add(root) - scan() - } - - if (document.documentElement) start() - else document.addEventListener("readystatechange", start, { once: true }) -} diff --git a/packages/app/e2e/performance/benchmark.ts b/packages/app/e2e/performance/benchmark.ts index 1e4b82834ce..b9f8ea43411 100644 --- a/packages/app/e2e/performance/benchmark.ts +++ b/packages/app/e2e/performance/benchmark.ts @@ -1,4 +1,4 @@ -import { expect, guardPage, test as base, type Browser, type Page, type TestInfo } from "../fixtures" +import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test" import { startChromeTrace } from "./chrome-trace" type BenchmarkFixtures = { @@ -101,21 +101,16 @@ export async function withBenchmarkPage( browser: Browser, name: string, run: (page: Page) => Promise, - testInfo: TestInfo, + testInfo?: TestInfo, ) { const context = await browser.newContext() try { const page = await context.newPage() - const toastGuard = await guardPage(page, testInfo) const diagnostics = await observePerformancePage(page, name) try { return await run(page) } finally { - try { - await reportPerformancePage(name, diagnostics, testInfo) - } finally { - await toastGuard?.finish() - } + await reportPerformancePage(name, diagnostics, testInfo) } } finally { await context.close() diff --git a/packages/app/e2e/performance/timeline-stability/adverse.spec.ts b/packages/app/e2e/performance/timeline-stability/adverse.spec.ts index bfd5929157f..7ce1be90645 100644 --- a/packages/app/e2e/performance/timeline-stability/adverse.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/adverse.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../../fixtures" +import { expect, test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts index 799eeb44546..b3438b77c06 100644 --- a/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../../fixtures" +import { expect, test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts index 7381250a4c2..3455438a736 100644 --- a/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts @@ -1,4 +1,4 @@ -import { test } from "../../fixtures" +import { test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts index 4445d26c75b..e0f0d72233b 100644 --- a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts @@ -1,4 +1,4 @@ -import { test } from "../../fixtures" +import { test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts index 5dde1eb1375..798bf0df3b2 100644 --- a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../../fixtures" +import { expect, test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/interaction.spec.ts index f1f845bc8d9..8cdf4fa8cfa 100644 --- a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/interaction.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../../fixtures" +import { expect, test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts b/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts index 27732039828..40688429a33 100644 --- a/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../../fixtures" +import { expect, test } from "@playwright/test" import { defineVisualRegions, mapVisualRegions, diff --git a/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts b/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts index d2ed1db746d..7891eba5bd9 100644 --- a/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../../fixtures" +import { expect, test } from "@playwright/test" import { analyzeVisualObservations, defineVisualRegions, diff --git a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts index 2d3a19ba3c0..e67545104d4 100644 --- a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../../fixtures" +import { expect, test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts index b0c1991c4c8..bab1ca23b4f 100644 --- a/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts @@ -1,4 +1,4 @@ -import { test } from "../../fixtures" +import { test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts index 37bbbb61fed..03690ec9214 100644 --- a/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../../fixtures" +import { expect, test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/tools.spec.ts b/packages/app/e2e/performance/timeline-stability/tools.spec.ts index c723f9f2011..d28fdaa65f5 100644 --- a/packages/app/e2e/performance/timeline-stability/tools.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/tools.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../../fixtures" +import { expect, test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts index 52b82a84204..e999e8c50a6 100644 --- a/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../../fixtures" +import { expect, test } from "@playwright/test" import { defineVisualRegions, reportVisualStability, diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index 32f318b7bc4..159b5a50676 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page, type Route } from "../fixtures" +import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" const serverA = "http://127.0.0.1:4096" @@ -45,9 +45,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn ).toBe(true) }) -test("legacy session routes preserve an existing tab's server", async ({ page, errorToasts }) => { - // The legacy route bootstraps against the default server before redirecting to the persisted tab server. - errorToasts.allow(/server-b.*InvalidDirectory/) +test("legacy session routes preserve an existing tab's server", async ({ page }) => { await mockServers(page, []) await page.addInitScript( ({ serverB, sessionB }) => { @@ -83,12 +81,11 @@ async function mockServers(page: Page, requests: string[]) { const url = new URL(route.request().url()) if (url.origin !== serverA && url.origin !== serverB) return route.fallback() requests.push(url.toString()) - const directory = url.searchParams.get("directory") const current = url.origin === serverA ? sessionA : sessionB + const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session/status") return json(route, {}) if (url.pathname === "/session") return json(route, [current]) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) diff --git a/packages/app/e2e/regression/error-toast-guard.spec.ts b/packages/app/e2e/regression/error-toast-guard.spec.ts deleted file mode 100644 index c4f8d648132..00000000000 --- a/packages/app/e2e/regression/error-toast-guard.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { glob, readFile } from "node:fs/promises" -import path from "node:path" -import { fileURLToPath } from "node:url" -import { expect, test } from "../fixtures" - -test("requires every browser spec to use the error toast fixture", async () => { - const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") - const files = await Array.fromAsync(glob("**/*.spec.{ts,tsx}", { cwd: root })) - const unguarded = ( - await Promise.all( - files.map(async (file) => { - const source = await readFile(path.join(root, file), "utf8") - if (/from\s+["'](?:\.\.\/)+fixtures["']/.test(source)) return - if (/from\s+["']\.\.\/benchmark["']/.test(source)) return - return file - }), - ) - ).filter((file): file is string => !!file) - - expect(unguarded).toEqual([]) -}) - -test("allows an explicitly expected error toast", async ({ page, errorToasts }) => { - errorToasts.expect("Expected request failure") - - await page.goto( - `data:text/html,${encodeURIComponent(` -
-
Request failed
-
Expected request failure
-
- `)}`, - ) - - await expect(page.locator('[data-component="toast-v2"]')).toBeVisible() -}) - -test("fails immediately on an unexpected error toast", async ({ page }) => { - test.fail() - - await page.goto( - `data:text/html,${encodeURIComponent(` -
-
Request failed
-
Unexpected request failure
-
- `)}`, - ) - await page.waitForTimeout(10_000) -}) diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts index 011dcb89c9a..4219699f28c 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible } from "../utils/waits" diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 7e44d9d69d6..119fc7ee2da 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page, type Route } from "../fixtures" +import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" const serverA = "http://127.0.0.1:4096" diff --git a/packages/app/e2e/regression/review-image-flash.spec.ts b/packages/app/e2e/regression/review-image-flash.spec.ts index e76b5c3f8c0..dd200384d49 100644 --- a/packages/app/e2e/regression/review-image-flash.spec.ts +++ b/packages/app/e2e/regression/review-image-flash.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 41116131596..042f926c537 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index 9993b2698bf..c2ea406c5ab 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts index 77375a5c55e..4a3855122a4 100644 --- a/packages/app/e2e/regression/session-list-path-loading.spec.ts +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -1,4 +1,4 @@ -import { test } from "../fixtures" +import { test } from "@playwright/test" import { fixture, pageMessages } from "../smoke/session-timeline.fixture" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible } from "../utils/waits" diff --git a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts index 1aa276079d4..598763c0225 100644 --- a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts +++ b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture" test("space activates a focused timeline button instead of scrolling", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index f84042199bd..5b6e0b127b1 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Locator, type Page } from "../fixtures" +import { expect, test, type Locator, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index 99db4b779e5..a9a4738da92 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { diff --git a/packages/app/e2e/regression/session-timeline-context-state.spec.ts b/packages/app/e2e/regression/session-timeline-context-state.spec.ts index 96ed413327d..37878325e14 100644 --- a/packages/app/e2e/regression/session-timeline-context-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, partUpdated, diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index 55e35d4f21d..f07da121c66 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" test("renders completed write content", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts index 8ae8e2b2bcf..cb228c13c7a 100644 --- a/packages/app/e2e/regression/session-timeline-file-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, partUpdated, diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index 1d6c8aaa9c9..15375cafed5 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { assistantMessage, directory, diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index b55941fcb67..3e2b171bca0 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, completedAssistantInfo, diff --git a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts index d9d250ef88d..3901f8865e3 100644 --- a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" for (const profile of [ diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 4c7a584c6c3..9fd2ca8d0b6 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, setupTimeline, diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts index 483e65faf64..7c0864e5845 100644 --- a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, reasoningPart, diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index 5410b2767b6..ad35eef601c 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, completedAssistantInfo, diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index e5813123ed8..99f1acf270b 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, partUpdated, diff --git a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts b/packages/app/e2e/regression/session-timeline-tool-state.spec.ts index 8d8af6f37da..63646f4454c 100644 --- a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, partUpdated, diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 2b05db85f98..850e966d0b0 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { assistantMessage, partUpdated, diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 1e574de9616..603c411d551 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 388b3bf0e1c..19d2c29af02 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index 309d11610a8..94afbc9a9d6 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page, type Route } from "../fixtures" +import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" const server = "http://127.0.0.1:4096" @@ -58,7 +58,6 @@ async function mockServer(page: Page) { if (url.origin !== server) return route.fallback() if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session/status") return json(route, {}) if (url.pathname === "/session") return json(route, sessions) const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) if (byId) return json(route, byId) diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index 1015dc0f75a..2c2801d4b5b 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 31482da478d..73821580af0 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../fixtures" +import { expect, test } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index a84dcca9a90..cbb72958ad3 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index e44b25ae823..a73cc0ccdd7 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { fixture, pageMessages } from "./session-timeline.fixture" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 191295258cd..53aacbda02e 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -6,14 +6,11 @@ "types": ["node", "bun"] }, "include": [ - "./fixtures.ts", "./performance/timeline-stability/**/*.spec.ts", "./performance/timeline-stability/fixture.test.ts", "./performance/timeline-stability/fixture.ts", "./performance/unit/visual-stability.test.ts", "./regression/new-session-panel-corner.spec.ts", - "./regression/error-toast-guard.spec.ts", - "./regression/session-request-docks.spec.ts", "./regression/session-timeline-context-resize.spec.ts", "./utils/**/*.ts" ] diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 26e5a48302c..e1946b8fba0 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -20,7 +20,6 @@ export interface MockServerConfig { todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) - onQuestionReply?: (input: { requestID: string; answers?: string[][] }) => unknown | Promise fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown @@ -64,7 +63,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) if (path === "/session/status") return json(route, config.sessionStatus ?? {}) if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) - if (path === "/file") return json(route, (await config.fileList?.(url.searchParams.get("path") ?? "")) ?? []) + if (path === "/file" && config.fileList) + return json(route, await config.fileList(url.searchParams.get("path") ?? "")) if (path === "/file/content" && config.fileContent) return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) if (path === "/find/file" && config.findFiles) @@ -110,18 +110,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) - const permissionRespondMatch = path.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) - if (permissionRespondMatch && route.request().method() === "POST") return json(route, true) - - const questionReplyMatch = path.match(/^\/question\/([^/]+)\/reply$/) - if (questionReplyMatch && route.request().method() === "POST") { - const body = route.request().postDataJSON() as { answers?: string[][] } - return json( - route, - (await config.onQuestionReply?.({ requestID: questionReplyMatch[1]!, answers: body.answers })) ?? true, - ) - } - const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { const token = url.searchParams.get("before") ?? undefined diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 55420485f39..186962998d1 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -162,7 +162,7 @@ export async function installSseTransport( const request = new Request(input, init) const url = new URL(request.url) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) - return originalFetch(request) + return originalFetch(input, init) const id = ++nextConnectionID const record = { diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 7bc8576739c..363a2e390a4 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -161,7 +161,7 @@ export function CustomProviderForm() { }, onError: (err) => { const message = err instanceof Error ? err.message : String(err) - showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) + showToast({ title: language.t("common.requestFailed"), description: message }) }, })) diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 4d50832840f..601f03084ce 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -72,7 +72,7 @@ export const DialogFork: Component = () => { .client.session.fork({ sessionID, messageID: item.id }) .then((forked) => { if (!forked.data) { - showToast({ variant: "error", title: language.t("common.requestFailed") }) + showToast({ title: language.t("common.requestFailed") }) return } dialog.close() @@ -81,7 +81,7 @@ export const DialogFork: Component = () => { }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) - showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) + showToast({ title: language.t("common.requestFailed"), description: message }) }) } diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 38c3f6b1fa2..05d978a947c 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -327,7 +327,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { .then((x) => x.data) .catch((err) => { showToast({ - variant: "error", title: language.t("prompt.toast.worktreeCreateFailed.title"), description: errorMessage(err), }) @@ -336,7 +335,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (!createdWorktree?.directory) { showToast({ - variant: "error", title: language.t("prompt.toast.worktreeCreateFailed.title"), description: language.t("common.requestFailed"), }) @@ -368,7 +366,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { .then((x) => x.data ?? undefined) .catch((err) => { showToast({ - variant: "error", title: language.t("prompt.toast.sessionCreateFailed.title"), description: errorMessage(err), }) @@ -388,7 +385,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (!session) { showToast({ - variant: "error", title: language.t("prompt.toast.promptSendFailed.title"), description: language.t("prompt.toast.promptSendFailed.description"), }) @@ -453,7 +449,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) .catch((err) => { showToast({ - variant: "error", title: language.t("prompt.toast.shellSendFailed.title"), description: errorMessage(err), }) @@ -486,7 +481,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) .catch((err) => { showToast({ - variant: "error", title: language.t("prompt.toast.commandSendFailed.title"), description: formatServerError(err, language.t, language.t("common.requestFailed")), }) @@ -582,7 +576,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { sync().set("session_status", session.id, { type: "idle" }) } showToast({ - variant: "error", title: language.t("prompt.toast.promptSendFailed.title"), description: errorMessage(err), }) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 5bc90434ae4..bcd30edbc7d 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -113,7 +113,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => .catch((err: unknown) => { serverSync().set("config", "disabled_providers", before) const message = err instanceof Error ? err.message : String(err) - showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) + showToast({ title: language.t("common.requestFailed"), description: message }) }) } @@ -138,7 +138,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) - showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) + showToast({ title: language.t("common.requestFailed"), description: message }) }) } diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index 0d6b17981ca..f945fa33c64 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -107,7 +107,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = .catch((err: unknown) => { serverSync().set("config", "disabled_providers", before) const message = err instanceof Error ? err.message : String(err) - showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) + showToast({ title: language.t("common.requestFailed"), description: message }) }) } @@ -132,7 +132,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) - showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) + showToast({ title: language.t("common.requestFailed"), description: message }) }) } diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 98985f019f0..a397046f9b3 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -145,7 +145,6 @@ export function TabNavItem(props: { } catch (err) { props.onTitleChangeFailed?.(original) showToast({ - variant: "error", title: language.t("common.requestFailed"), description: err instanceof Error ? err.message : undefined, }) diff --git a/packages/app/src/components/updater-action.ts b/packages/app/src/components/updater-action.ts index 25be6167b2d..9c13c542b41 100644 --- a/packages/app/src/components/updater-action.ts +++ b/packages/app/src/components/updater-action.ts @@ -44,7 +44,7 @@ export function useUpdaterAction() { }) } if (state?.status === "error") { - showToast({ variant: "error", title: language.t("common.requestFailed"), description: state.message }) + showToast({ title: language.t("common.requestFailed"), description: state.message }) } }, } diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 8b53e0746b7..c8a89672dfc 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -518,7 +518,6 @@ export function NewHome() { ), onError: (error) => showToast({ - variant: "error", title: language.t("common.requestFailed"), description: errorMessage(error, language.t("common.requestFailed")), }), diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index ec32964e287..fd9d16b90ad 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1391,7 +1391,6 @@ export default function LegacyLayout(props: ParentProps) { .then((x) => x.data) .catch((err) => { showToast({ - variant: "error", title: language.t("workspace.delete.failed.title"), description: errorMessage(err, language.t("common.requestFailed")), }) @@ -1465,7 +1464,6 @@ export default function LegacyLayout(props: ParentProps) { .then((x) => x.data) .catch((err) => { showToast({ - variant: "error", title: language.t("workspace.reset.failed.title"), description: errorMessage(err, language.t("common.requestFailed")), }) @@ -1828,7 +1826,6 @@ export default function LegacyLayout(props: ParentProps) { .then((x) => x.data) .catch((err) => { showToast({ - variant: "error", title: language.t("workspace.create.failed.title"), description: errorMessage(err, language.t("common.requestFailed")), }) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 7284f41bd84..45f5e4cb26f 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -85,7 +85,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) .catch((err: unknown) => { const description = err instanceof Error ? err.message : String(err) - showToast({ variant: "error", title: language.t("common.requestFailed"), description }) + showToast({ title: language.t("common.requestFailed"), description }) }) .finally(() => { setStore("responding", (id) => (id === perm.id ? undefined : id)) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index b9ca648b521..445a9f47a08 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -219,7 +219,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const fail = (err: unknown) => { const message = err instanceof Error ? err.message : String(err) - showToast({ variant: "error", title: language.t("common.requestFailed"), description: message }) + showToast({ title: language.t("common.requestFailed"), description: message }) } const replyMutation = useMutation(() => ({ diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index fc5efec6e85..d6cc7be972d 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -15,12 +15,7 @@ import { createStore, produce } from "solid-js/store" import { Dynamic } from "solid-js/web" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" -import { - createVirtualizer, - defaultRangeExtractor, - elementScroll, - type VirtualItem, -} from "@tanstack/solid-virtual" +import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual" import { Accordion } from "@opencode-ai/ui/accordion" import { Button } from "@opencode-ai/ui/button" import { Card } from "@opencode-ai/ui/card" @@ -679,7 +674,6 @@ export function MessageTimeline(props: { }, onError: (err) => { showToast({ - variant: "error", title: language.t("common.requestFailed"), description: errorMessage(err), }) @@ -714,7 +708,6 @@ export function MessageTimeline(props: { ) .catch((err: unknown) => showToast({ - variant: "error", title: language.t("common.requestFailed"), description: errorMessage(err), }), @@ -827,7 +820,6 @@ export function MessageTimeline(props: { }) .catch((err) => { showToast({ - variant: "error", title: language.t("common.requestFailed"), description: errorMessage(err), }) @@ -847,7 +839,6 @@ export function MessageTimeline(props: { .then((x) => x.data) .catch((err) => { showToast({ - variant: "error", title: language.t("session.delete.failed.title"), description: errorMessage(err), }) diff --git a/packages/ui/src/v2/components/toast-v2.tsx b/packages/ui/src/v2/components/toast-v2.tsx index 03a96a7ea6b..d2dc1b3605f 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -90,7 +90,6 @@ export interface ToastV2Options { title?: string description?: string icon?: JSX.Element - variant?: "default" | "success" | "error" | "loading" duration?: number persistent?: boolean actions?: ToastV2Action[] @@ -101,12 +100,7 @@ export function showToastV2(options: ToastV2Options | string) { return toaster.show((props) => { const resolvedIcon = children(() => opts.icon) return ( - +
{resolvedIcon()} From 686a3cf26a73f998f97c0e0b9e0c23277cea35b6 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:35:03 +1000 Subject: [PATCH 6/7] test(app): restore request dock mocks --- .../e2e/regression/session-request-docks.spec.ts | 2 +- packages/app/e2e/utils/mock-server.ts | 13 +++++++++++++ packages/app/e2e/utils/sse-transport.ts | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 3004f46360f..f186444f226 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "../fixtures" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { installSseTransport } from "../utils/sse-transport" import { startTimelineDiagnostics, type TimelineDiagnostics } from "../utils/timeline-cdp-diagnostics" diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index e1946b8fba0..503fe45a6a1 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -20,6 +20,7 @@ export interface MockServerConfig { todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) + onQuestionReply?: (input: { requestID: string; answers?: string[][] }) => unknown | Promise fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown @@ -110,6 +111,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) + const permissionRespondMatch = path.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) + if (permissionRespondMatch && route.request().method() === "POST") return json(route, true) + + const questionReplyMatch = path.match(/^\/question\/([^/]+)\/reply$/) + if (questionReplyMatch && route.request().method() === "POST") { + const body = route.request().postDataJSON() as { answers?: string[][] } + return json( + route, + (await config.onQuestionReply?.({ requestID: questionReplyMatch[1]!, answers: body.answers })) ?? true, + ) + } + const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { const token = url.searchParams.get("before") ?? undefined diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 186962998d1..55420485f39 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -162,7 +162,7 @@ export async function installSseTransport( const request = new Request(input, init) const url = new URL(request.url) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) - return originalFetch(input, init) + return originalFetch(request) const id = ++nextConnectionID const record = { From 036c12d921f8014e6012b890572daf679a687bcd Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:03:46 +1000 Subject: [PATCH 7/7] fix(app): correct virtual range clamping --- bun.lock | 2 +- .../regression/session-request-docks.spec.ts | 377 +----------------- packages/app/e2e/utils/mock-server.ts | 13 - packages/app/e2e/utils/sse-transport.ts | 2 +- .../app/e2e/utils/timeline-cdp-diagnostics.ts | 226 ----------- .../app/test-browser/solid-virtual.test.ts | 13 +- patches/@tanstack%2Fvirtual-core@3.17.3.patch | 72 +++- 7 files changed, 77 insertions(+), 628 deletions(-) delete mode 100644 packages/app/e2e/utils/timeline-cdp-diagnostics.ts diff --git a/bun.lock b/bun.lock index 72f852daf6a..228d57cd0c7 100644 --- a/bun.lock +++ b/bun.lock @@ -1066,7 +1066,6 @@ "patchedDependencies": { "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", @@ -1076,6 +1075,7 @@ "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", }, "overrides": { "@opentui/core": "catalog:", diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index f186444f226..036eaaef422 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -1,79 +1,12 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" -import { installSseTransport } from "../utils/sse-transport" -import { startTimelineDiagnostics, type TimelineDiagnostics } from "../utils/timeline-cdp-diagnostics" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/RequestDocks" const projectID = "proj_request_docks" const sessionID = "ses_request_docks" const title = "Request dock regression" -const activeAssistantID = "msg_0079_b13_request_assistant" -const activeToolID = "prt_0079_request_question" -const activeCallID = "call_0079_request_question" -const questionID = "question-focus-return" -const questionPrompts = [ - { - header: "Focus path", - question: "How was focus changing immediately before the timeline jumped?", - options: [ - { label: "Already in app", description: "The app stayed focused before answering." }, - { label: "Returned to app", description: "The app regained focus before answering." }, - ], - }, -] - -type RequestTimelineEvent = { - directory: string - payload: { - id: string - } & ( - | { - type: "question.asked" - properties: { - id: string - sessionID: string - questions: typeof questionPrompts - tool: { messageID: string; callID: string } - } - } - | { - type: "question.replied" - properties: { sessionID: string; requestID: string; answers: string[][] } - } - | { - type: "message.part.updated" - properties: { sessionID: string; part: Record; time: number } - } - | { - type: "message.updated" - properties: { sessionID: string; info: Record } - } - ) -} - -type QuestionTimelineProbe = Window & { - __questionTimelineProbe?: { - blank: boolean - samples: number - stop: boolean - submitted: boolean - transitionSamples: number - } -} - -const timelineDiagnostics = new WeakMap() - -test.beforeEach(async ({ page }, testInfo) => { - if (process.env.TIMELINE_CDP_TRACE !== "1") return - timelineDiagnostics.set(page, await startTimelineDiagnostics(page, testInfo)) -}) - -test.afterEach(async ({ page }) => { - await timelineDiagnostics.get(page)?.stop() - timelineDiagnostics.delete(page) -}) test("shows a pending question dock", async ({ page }) => { await mockServer(page, { @@ -167,197 +100,11 @@ test("shows a pending permission dock", async ({ page }) => { expect(request.postDataJSON()).toEqual({ response: "once" }) }) -test("keeps an active split timeline visible when a focused question closes", async ({ page }) => { - test.setTimeout(180_000) - // Match the observed long session, assistant chain, and large review history before the focused dock handoff. - const messages = timelineMessages(80) - const activeMessage = messages.at(-1)! - const activeTool = activeMessage.parts[0]! - const responses: { requestID: string; answers?: string[][] }[] = [] - const transport = await installSseTransport(page, { - server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, - retry: 20, - }) - await mockServer(page, { - messages, - questions: [], - sessionStatus: { [sessionID]: { type: "busy" } }, - vcsDiff: reviewDiffs(2_754), - onQuestionReply: async (input) => { - responses.push(input) - await transport.send({ - directory, - payload: { - id: "evt_question_replied", - type: "question.replied", - properties: { sessionID, requestID: questionID, answers: [["Already in app"]] }, - }, - }) - await new Promise((resolve) => setTimeout(resolve, 50)) - await transport.send({ - directory, - payload: { - id: "evt_tool_completed", - type: "message.part.updated", - properties: { - sessionID, - part: { - ...activeTool, - state: { - status: "completed", - input: { questions: questionPrompts }, - output: "Questions answered", - title: "Questions answered", - metadata: { answers: [["Already in app"]] }, - time: { start: 1700000791000, end: 1700000795000 }, - }, - }, - time: 1700000795000, - }, - }, - }) - await new Promise((resolve) => setTimeout(resolve, 50)) - await transport.burst([ - { - directory, - payload: { - id: "evt_assistant_completed", - type: "message.updated", - properties: { - sessionID, - info: { - ...activeMessage.info, - time: { ...activeMessage.info.time, completed: 1700000796000 }, - }, - }, - }, - }, - { - directory, - payload: { - id: "evt_continued_assistant", - type: "message.updated", - properties: { - sessionID, - info: { - ...activeMessage.info, - id: "msg_0079_b14_request_assistant", - time: { created: 1700000797000 }, - }, - }, - }, - }, - ]) - return true - }, - }) - - await page.setViewportSize({ width: 1700, height: 1220 }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await transport.waitForConnection() - await expectSessionTitle(page, title) - const reviewToggle = page.getByRole("button", { name: "Toggle review" }) - const review = page.locator('#review-panel [data-component="session-review-v2"]') - await reviewToggle.click() - await expect(review).toBeVisible() - await reviewToggle.click() - await expect(review).toHaveCount(0) - - const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const firstPart = page.locator(`[data-timeline-part-id="${messages[0]!.parts[0]!.id}"]`) - const lastTextPart = page.locator('[data-timeline-part-id="prt_0079_12_request_assistant"]') - const lastPart = page.locator(`[data-timeline-part-id="${activeToolID}"]`) - await expect(lastTextPart).toBeInViewport() - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeInViewport() - await expect(firstPart).toHaveCount(0) - await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3) - await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3) - - const composer = page.locator('[data-component="prompt-input"]') - await composer.click() - await expect(composer).toBeFocused() - await transport.send({ - directory, - payload: { - id: "evt_question_asked", - type: "question.asked", - properties: { - id: questionID, - sessionID, - questions: questionPrompts, - tool: { messageID: activeAssistantID, callID: activeCallID }, - }, - }, - }) - await expect(question).toBeVisible() - await expect(question.getByRole("radio", { name: /Already in app/ })).toBeFocused() - await expect(lastTextPart).toBeInViewport() - await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3) - await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3) - await page.evaluate(() => { - const state = { blank: false, samples: 0, stop: false, submitted: false, transitionSamples: 0 } - ;(window as QuestionTimelineProbe).__questionTimelineProbe = state - const submit = [...document.querySelectorAll('[data-component="dock-prompt"] button')].find( - (button) => button.textContent?.trim() === "Submit", - ) - submit?.addEventListener( - "click", - () => { - state.submitted = true - state.transitionSamples = 0 - }, - { capture: true, once: true }, - ) - const sample = () => { - const viewport = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => - element.querySelector("[data-timeline-virtual-content]"), - ) - const view = viewport?.getBoundingClientRect() - const visible = [...(viewport?.querySelectorAll("[data-timeline-part-id]") ?? [])].some((part) => { - const rect = part.getBoundingClientRect() - return !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom - }) - if (view && view.width > 0 && view.height > 0 && !visible) state.blank = true - state.samples++ - if (state.submitted) state.transitionSamples++ - if (!state.stop) requestAnimationFrame(sample) - } - requestAnimationFrame(sample) - }) - await page.waitForFunction(() => ((window as QuestionTimelineProbe).__questionTimelineProbe?.samples ?? 0) >= 2) - await question.getByRole("radio", { name: /Already in app/ }).click() - await question.getByRole("button", { name: "Submit" }).click() - - await expect.poll(() => responses).toEqual([{ requestID: questionID, answers: [["Already in app"]] }]) - await expect(question).toHaveCount(0) - await expect(page.locator('[data-component="session-composer"]')).toBeVisible() - await expect(page.locator('[data-component="toast-v2"]')).toHaveCount(0) - await expect(lastPart).toBeInViewport() - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeInViewport() - await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() - await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3) - await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3) - expect(await visibleTimelineRows(scroller)).toBeGreaterThan(0) - const probe = await page.evaluate(() => { - const state = (window as QuestionTimelineProbe).__questionTimelineProbe! - state.stop = true - return state - }) - expect(probe).toMatchObject({ blank: false }) - expect(probe.submitted).toBe(true) - expect(probe.transitionSamples).toBeGreaterThan(0) -}) - async function mockServer( page: Page, requests: { permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) - messages?: { info: { id: string }; parts: { id: string }[] }[] - sessionStatus?: unknown - vcsDiff?: unknown[] - onQuestionReply?: (input: { requestID: string; answers?: string[][] }) => unknown | Promise }, ) { await mockOpenCodeServer(page, { @@ -398,131 +145,11 @@ async function mockServer( time: { created: 1700000000000, updated: 1700000000000 }, }, ], - pageMessages: (_, limit, before) => { - const messages = requests.messages ?? [] - const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length - const start = Math.max(0, end - limit) - return { - items: messages.slice(start, end), - cursor: start > 0 ? messages[start]!.info.id : undefined, - } - }, - message: (_, messageID) => requests.messages?.find((message) => message.info.id === messageID), + pageMessages: () => ({ items: [] }), permissions: requests.permissions, questions: requests.questions, - sessionStatus: requests.sessionStatus, - onQuestionReply: requests.onQuestionReply, - vcsDiff: requests.vcsDiff, - fileList: () => [], }) await page.addInitScript(() => { - localStorage.setItem( - "settings.v3", - JSON.stringify({ general: { newLayoutDesigns: true, shellToolPartsExpanded: true } }), - ) - }) -} - -function timelineMessages(turns: number) { - return Array.from({ length: turns }, (_, index) => { - const key = String(index).padStart(4, "0") - const userID = `msg_${key}_a_request_user` - const active = index === turns - 1 - const user = { - info: { - id: userID, - sessionID, - role: "user", - time: { created: 1700000000000 + index * 10_000 }, - summary: { diffs: [] }, - agent: "build", - model: { providerID: "opencode", modelID: "claude-opus-4-6" }, - }, - parts: [ - { - id: `prt_${key}_request_user`, - sessionID, - messageID: userID, - type: "text", - text: `Request turn ${index}`, - }, - ], - } - const assistants = Array.from({ length: active ? 14 : 1 }, (_, assistantIndex) => { - const suffix = active ? `b${String(assistantIndex).padStart(2, "0")}` : "b" - const assistantID = `msg_${key}_${suffix}_request_assistant` - const current = active && assistantIndex === 13 - const created = 1700000001000 + index * 10_000 + assistantIndex * 100 - return { - info: { - id: assistantID, - sessionID, - role: "assistant", - time: { created, ...(current ? {} : { completed: created + 50 }) }, - parentID: userID, - modelID: "claude-opus-4-6", - providerID: "opencode", - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0, - tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, - }, - parts: current - ? [ - { - id: activeToolID, - sessionID, - messageID: assistantID, - type: "tool", - callID: activeCallID, - tool: "question", - state: { - status: "running", - input: { questions: questionPrompts }, - metadata: {}, - time: { start: 1700000791000 }, - }, - }, - ] - : [ - { - id: `prt_${key}_${assistantIndex}_request_assistant`, - sessionID, - messageID: assistantID, - type: "text", - text: `Assistant response ${index}.${assistantIndex}. ${"Long timeline content. ".repeat(12)}`, - }, - ], - } - }) - return [user, ...assistants] - }).flat() -} - -function distanceFromBottom(scroller: ReturnType) { - return scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop) -} - -function reviewDiffs(count: number) { - return Array.from({ length: count }, (_, index) => { - const file = `src/focus-${String(index).padStart(4, "0")}.ts` - return { - file, - additions: 1, - deletions: 1, - status: "modified", - patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const focused = false\n+export const focused = true\n`, - } - }) -} - -function visibleTimelineRows(scroller: ReturnType) { - return scroller.evaluate((element) => { - const view = element.getBoundingClientRect() - return [...element.querySelectorAll("[data-timeline-key]")].filter((row) => { - const rect = row.getBoundingClientRect() - return rect.bottom > view.top && rect.top < view.bottom - }).length + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) }) } diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 503fe45a6a1..e1946b8fba0 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -20,7 +20,6 @@ export interface MockServerConfig { todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) - onQuestionReply?: (input: { requestID: string; answers?: string[][] }) => unknown | Promise fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown @@ -111,18 +110,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) - const permissionRespondMatch = path.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) - if (permissionRespondMatch && route.request().method() === "POST") return json(route, true) - - const questionReplyMatch = path.match(/^\/question\/([^/]+)\/reply$/) - if (questionReplyMatch && route.request().method() === "POST") { - const body = route.request().postDataJSON() as { answers?: string[][] } - return json( - route, - (await config.onQuestionReply?.({ requestID: questionReplyMatch[1]!, answers: body.answers })) ?? true, - ) - } - const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { const token = url.searchParams.get("before") ?? undefined diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 55420485f39..186962998d1 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -162,7 +162,7 @@ export async function installSseTransport( const request = new Request(input, init) const url = new URL(request.url) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) - return originalFetch(request) + return originalFetch(input, init) const id = ++nextConnectionID const record = { diff --git a/packages/app/e2e/utils/timeline-cdp-diagnostics.ts b/packages/app/e2e/utils/timeline-cdp-diagnostics.ts deleted file mode 100644 index 60003cb3f8b..00000000000 --- a/packages/app/e2e/utils/timeline-cdp-diagnostics.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { CDPSession, Page, TestInfo } from "@playwright/test" - -export async function startTimelineDiagnostics(page: Page, testInfo: TestInfo) { - const cdp = await page.context().newCDPSession(page) - const pauses: unknown[] = [] - const scripts: { url: string; scriptID: string }[] = [] - const breakpoints = new Map() - const setup: Promise[] = [] - let handling = Promise.resolve() - - await cdp.send("Debugger.enable") - - cdp.on("Debugger.scriptParsed", (event) => { - if (!/tanstack|solid-virtual|virtual-core/i.test(event.url)) return - scripts.push({ url: event.url, scriptID: event.scriptId }) - setup.push( - installSourceBreakpoints(cdp, event.scriptId, breakpoints).catch((error) => { - pauses.push({ type: "breakpoint-install-error", url: event.url, error: String(error) }) - }), - ) - }) - - cdp.on("Debugger.paused", (event) => { - handling = handling.then(async () => { - const frame = event.callFrames[0] - const state = frame - ? await cdp - .send("Debugger.evaluateOnCallFrame", { - callFrameId: frame.callFrameId, - expression: `(() => { - const root = [...document.querySelectorAll('.scroll-view__viewport')].find((element) => - element.querySelector('[data-timeline-virtual-content]') - ) - const indexes = root ? [...root.querySelectorAll('[data-index]')].map((item) => Number(item.getAttribute('data-index'))) : [] - const owner = this && typeof this === 'object' && 'scrollOffset' in this ? this : undefined - return { - now: performance.now(), - dom: root ? { - scrollTop: root.scrollTop, - scrollHeight: root.scrollHeight, - clientHeight: root.clientHeight, - indexes, - } : null, - virtualizer: owner ? { - scrollOffset: owner.scrollOffset, - scrollAdjustments: owner.scrollAdjustments, - intendedScrollOffset: owner._intendedScrollOffset, - isScrolling: owner.isScrolling, - range: owner.range, - totalSize: typeof owner.getTotalSize === 'function' ? owner.getTotalSize() : undefined, - } : null, - active: document.activeElement ? { - tag: document.activeElement.tagName, - component: document.activeElement.getAttribute('data-component'), - slot: document.activeElement.getAttribute('data-slot'), - text: document.activeElement.textContent?.trim().slice(0, 80), - } : null, - } - })()`, - returnByValue: true, - }) - .then((result) => result.result.value) - .catch((error) => ({ error: String(error) })) - : undefined - pauses.push({ - type: "pause", - reason: event.reason, - data: event.data, - hitBreakpoints: event.hitBreakpoints?.map((id) => breakpoints.get(id) ?? id), - state, - stack: event.callFrames.slice(0, 20).map((item) => ({ - functionName: item.functionName, - url: item.url, - line: item.location.lineNumber + 1, - column: (item.location.columnNumber ?? 0) + 1, - })), - }) - await cdp.send("Debugger.resume").catch(() => {}) - }) - }) - - await page.addInitScript(() => { - type TraceWindow = Window & { __timelineDomTrace?: unknown[] } - const output: unknown[] = [] - ;(window as TraceWindow).__timelineDomTrace = output - let last = "" - let observed: HTMLElement | undefined - const active = () => { - const element = document.activeElement - if (!(element instanceof HTMLElement)) return null - return { - tag: element.tagName, - component: element.dataset.component, - slot: element.dataset.slot, - text: element.textContent?.trim().slice(0, 80), - } - } - const root = () => - [...document.querySelectorAll(".scroll-view__viewport")].find((element) => - element.querySelector("[data-timeline-virtual-content]"), - ) - const record = (type: string, detail: Record = {}) => { - const element = root() - const indexes = element - ? [...element.querySelectorAll("[data-index]")].map((item) => Number(item.dataset.index)) - : [] - output.push({ - type, - at: performance.now(), - scrollTop: element?.scrollTop, - scrollHeight: element?.scrollHeight, - clientHeight: element?.clientHeight, - indexes, - active: active(), - question: !!document.querySelector('[data-component="dock-prompt"][data-kind="question"]'), - review: !!document.querySelector('#review-panel [data-component="session-review-v2"]'), - ...detail, - }) - } - const watch = () => { - const element = root() - if (element && element !== observed) { - observed = element - new ResizeObserver(() => record("resize")).observe(element) - } - requestAnimationFrame(watch) - } - const sample = () => { - const element = root() - const indexes = element - ? [...element.querySelectorAll("[data-index]")].map((item) => Number(item.dataset.index)) - : [] - const next = JSON.stringify([ - element?.scrollTop, - element?.scrollHeight, - element?.clientHeight, - indexes[0], - indexes.at(-1), - document.activeElement?.getAttribute("data-slot"), - !!document.querySelector('[data-component="dock-prompt"][data-kind="question"]'), - ]) - if (next !== last) { - last = next - record("frame") - } - requestAnimationFrame(sample) - } - document.addEventListener("scroll", (event) => record("scroll", { trusted: event.isTrusted }), true) - document.addEventListener("focusin", (event) => record("focusin", { trusted: event.isTrusted }), true) - document.addEventListener("focusout", (event) => record("focusout", { trusted: event.isTrusted }), true) - requestAnimationFrame(watch) - requestAnimationFrame(sample) - }) - - await cdp.send("Tracing.start", { - categories: "devtools.timeline,blink.user_timing,v8.execute,disabled-by-default-devtools.timeline.stack", - options: "sampling-frequency=10000", - transferMode: "ReturnAsStream", - }) - - return { - async stop() { - await Promise.allSettled(setup) - await handling - const complete = new Promise((resolve, reject) => { - cdp.once("Tracing.tracingComplete", (event) => { - if (!event.stream) { - reject(new Error("CDP tracing completed without a stream")) - return - } - resolve(event.stream) - }) - }) - await cdp.send("Tracing.end") - const stream = await complete - const trace = await readCdpStream(cdp, stream) - const dom = await page - .evaluate(() => (window as Window & { __timelineDomTrace?: unknown[] }).__timelineDomTrace ?? []) - .catch(() => []) - const { writeFile } = await import("node:fs/promises") - const tracePath = "C:\\tmp\\opencode\\timeline-devtools-trace.json" - const debuggerPath = "C:\\tmp\\opencode\\timeline-cdp-debugger.json" - const domPath = "C:\\tmp\\opencode\\timeline-dom-events.json" - await Promise.all([ - writeFile(tracePath, trace), - writeFile(debuggerPath, JSON.stringify({ test: testInfo.title, scripts, pauses }, null, 2)), - writeFile(domPath, JSON.stringify(dom, null, 2)), - ]) - await cdp.send("Debugger.disable").catch(() => {}) - await cdp.detach().catch(() => {}) - console.log(`TIMELINE_TRACE ${JSON.stringify({ tracePath, debuggerPath, domPath, pauses: pauses.length })}`) - }, - } -} - -export type TimelineDiagnostics = Awaited> - -async function installSourceBreakpoints(cdp: CDPSession, scriptID: string, labels: Map) { - const targets = [ - ["offset-observer", "this.scrollOffset = offset"], - ["resize-item", "this.resizeItem = (index, size)"], - ["apply-scroll-adjustment", "applyScrollAdjustment(delta, behavior)"], - ["scroll-to-offset", "this._scrollToOffset = (offset"], - ["scroll-to-end", "this.scrollToEnd ="], - ] as const - for (const [label, query] of targets) { - const matches = await cdp.send("Debugger.searchInContent", { scriptId: scriptID, query }) - for (const match of matches.result) { - const result = await cdp.send("Debugger.setBreakpoint", { - location: { scriptId: scriptID, lineNumber: match.lineNumber }, - }) - labels.set(result.breakpointId, label) - } - } -} - -async function readCdpStream(cdp: CDPSession, handle: string) { - const chunks: Buffer[] = [] - while (true) { - const chunk = await cdp.send("IO.read", { handle }) - chunks.push(Buffer.from(chunk.data, chunk.base64Encoded ? "base64" : "utf8")) - if (chunk.eof) break - } - await cdp.send("IO.close", { handle }) - return Buffer.concat(chunks) -} diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index 2a9d98ca683..9327eacf426 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -66,19 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => { }) }) -test("clamps an oversized initial offset to a full final viewport range", () => { - const virtualizer = new Virtualizer({ +test("clamps oversized offsets with scroll margin and padding changes", () => { + const options = (paddingEnd: number) => ({ count: 20, estimateSize: () => 60, initialOffset: Number.MAX_SAFE_INTEGER, initialRect: { width: 800, height: 600 }, + scrollMargin: 64, + paddingEnd, + overscan: 1, getScrollElement: () => null, scrollToFn: () => {}, observeElementRect: () => {}, observeElementOffset: () => {}, }) + const virtualizer = new Virtualizer(options(64)) - expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]) + expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]) + + virtualizer.setOptions(options(600)) + expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19]) }) test("stale pinned indexes do not produce missing virtual items after count shrinks", () => { diff --git a/patches/@tanstack%2Fvirtual-core@3.17.3.patch b/patches/@tanstack%2Fvirtual-core@3.17.3.patch index e5201888b9e..2450d4234b4 100644 --- a/patches/@tanstack%2Fvirtual-core@3.17.3.patch +++ b/patches/@tanstack%2Fvirtual-core@3.17.3.patch @@ -1,12 +1,22 @@ diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs -index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..bcee09df7377c37ffb220606b741c9ff434b3470 100644 +index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..830f646f5bd80f4e00f442291ad6e4c6c508630d 100644 --- a/dist/cjs/index.cjs +++ b/dist/cjs/index.cjs -@@ -723,10 +723,12 @@ class Virtualizer { +@@ -716,17 +716,20 @@ class Virtualizer { + this.getMeasurements(), + this.getSize(), + this.getScrollOffset(), +- this.options.lanes ++ this.options.lanes, ++ this.options.paddingEnd + ], +- (measurements, outerSize, scrollOffset, lanes) => { ++ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => { + if (measurements.length === 0 || outerSize === 0) { this.range = null; return null; } -+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0); ++ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0); + const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); this.range = calculateRangeImpl( measurements, @@ -16,15 +26,51 @@ index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..bcee09df7377c37ffb220606b741c9ff lanes, // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps. +diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts +index c61ee17752565253f795c7fc7d57e86237ecbb52..430c622a18c69407df9f1ca82ee9440e9be287b7 100644 +--- a/dist/cjs/index.d.cts ++++ b/dist/cjs/index.d.cts +@@ -144,7 +144,7 @@ export declare class Virtualizer { ++ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => { + if (measurements.length === 0 || outerSize === 0) { this.range = null; return null; } -+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0); ++ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0); + const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); this.range = calculateRangeImpl( measurements, @@ -35,14 +81,22 @@ index 3032c0ca457582be3f47923cba1f7d92c848745c..90b574881a073aabac99c075f7eab0a8 // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps. diff --git a/src/index.ts b/src/index.ts -index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..faef81e8e567424146143242408eec35a634cf04 100644 +index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..201406779ee85d2f2e5b01a8ea284aa9a5f694e2 100644 --- a/src/index.ts +++ b/src/index.ts -@@ -1373,10 +1373,12 @@ export class Virtualizer< +@@ -1367,16 +1367,19 @@ export class Virtualizer< + this.getSize(), + this.getScrollOffset(), + this.options.lanes, ++ this.options.paddingEnd, + ], +- (measurements, outerSize, scrollOffset, lanes) => { ++ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => { + if (measurements.length === 0 || outerSize === 0) { this.range = null return null } -+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0) ++ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0) + const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset) this.range = calculateRangeImpl( measurements,