From 6575215ddf720701dfdd94666ed9675b33793f72 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:41:12 +1000 Subject: [PATCH] fix(desktop): keep command palette responsive and scoped (#47164) --- .../command-palette-benchmark.spec.ts | 47 ++++++ .../background-read-failure.spec.ts | 50 +++++++ .../e2e/regression/command-palette.spec.ts | 102 +++++++++++++ .../regression/command-registration.spec.ts | 77 ++++++++++ .../e2e/regression/remote-tab-busy.spec.ts | 113 +++++++------- packages/app/e2e/utils/command-palette.ts | 57 +++++++ .../app/src/home/sessions/command-palette.tsx | 8 +- packages/app/src/runtime/server/runtime.tsx | 11 ++ .../session/commands/use-session-commands.tsx | 5 +- .../src/session/files/session-side-panel.tsx | 2 +- packages/app/src/session/files/tab.tsx | 2 +- packages/app/src/shell/commands/command.tsx | 4 +- packages/app/src/shell/commands/dialog.tsx | 70 +++++---- packages/app/src/shell/commands/search.ts | 41 +++++ .../app/src/shell/notifications/toast.tsx | 6 +- .../command-palette-search.test.ts | 97 ++++++++++++ packages/client/src/solid/data.ts | 141 ++++++++++-------- packages/client/test/solid-refresh.test.ts | 117 +++++++++++++++ packages/desktop/electron.vite.config.ts | 4 +- .../scripts/fixtures/picker/index.html | 10 ++ .../desktop/scripts/fixtures/picker/main.ts | 1 + packages/desktop/scripts/picker.test.ts | 76 ++++++++++ packages/desktop/scripts/picker.ts | 26 ++++ 23 files changed, 901 insertions(+), 166 deletions(-) create mode 100644 packages/app/e2e/performance/command-palette-benchmark.spec.ts create mode 100644 packages/app/e2e/regression/background-read-failure.spec.ts create mode 100644 packages/app/e2e/regression/command-palette.spec.ts create mode 100644 packages/app/e2e/regression/command-registration.spec.ts create mode 100644 packages/app/e2e/utils/command-palette.ts create mode 100644 packages/app/src/shell/commands/search.ts create mode 100644 packages/app/test-browser/command-palette-search.test.ts create mode 100644 packages/client/test/solid-refresh.test.ts create mode 100644 packages/desktop/scripts/fixtures/picker/index.html create mode 100644 packages/desktop/scripts/fixtures/picker/main.ts create mode 100644 packages/desktop/scripts/picker.test.ts create mode 100644 packages/desktop/scripts/picker.ts diff --git a/packages/app/e2e/performance/command-palette-benchmark.spec.ts b/packages/app/e2e/performance/command-palette-benchmark.spec.ts new file mode 100644 index 00000000000..cadd90cf9ca --- /dev/null +++ b/packages/app/e2e/performance/command-palette-benchmark.spec.ts @@ -0,0 +1,47 @@ +import { benchmark, expect } from "./benchmark" +import { openCommandPalette } from "../utils/command-palette" + +benchmark.use({ + viewport: { width: 1440, height: 900 }, + serviceWorkers: "block", + traceScope: "interaction", + trace: "off", + video: "off", +}) + +for (const home of [false, true]) { + benchmark(`command lookup from ${home ? "home" : "session"}`, async ({ page, report }) => { + const { dialog, input } = await openCommandPalette(page, home) + const title = home ? "Open settings" : "Copy Session ID" + const query = home ? "open settings" : "copy session" + // Measure input-to-selected-result in the renderer, without assertion polling overhead. + await input.evaluate((element, title) => { + element.addEventListener( + "input", + () => { + performance.mark("palette-input") + const observer = new MutationObserver(() => { + if (document.querySelectorAll('[role="dialog"] [role="option"]').length !== 1) return + const selected = document.querySelector('[role="dialog"] [role="option"][aria-selected="true"]') + if (!selected?.textContent?.includes(title)) return + performance.measure("palette-result", "palette-input") + observer.disconnect() + }) + observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true }) + }, + { once: true, capture: true }, + ) + }, title) + await input.fill(query) + await expect(dialog.getByRole("option")).toHaveCount(1) + await expect(dialog.getByRole("option", { name: new RegExp(`^${title}(?:$| )`) })).toHaveAttribute( + "aria-selected", + "true", + ) + const result = await page.evaluate(() => + performance.getEntriesByName("palette-result").map((entry) => entry.duration), + ) + expect(result).toHaveLength(1) + report({ inputToResultMs: result[0] }, { home, query, data: "fixture; immediate server responses" }) + }) +} diff --git a/packages/app/e2e/regression/background-read-failure.spec.ts b/packages/app/e2e/regression/background-read-failure.spec.ts new file mode 100644 index 00000000000..b5940d37172 --- /dev/null +++ b/packages/app/e2e/regression/background-read-failure.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test" +import { openCommandPalette, paletteSession } from "../utils/command-palette" + +test.use({ serviceWorkers: "block" }) + +test("failed event-driven reads report an error and recover without an unhandled rejection", async ({ page }) => { + const errors: string[] = [] + page.on("pageerror", (error) => errors.push(error.message)) + const palette = await openCommandPalette(page) + const path = `**/api/session/${paletteSession.id}` + await page.route(path, (route) => route.abort("failed")) + const requested = page.waitForRequest(path) + await page.evaluate((sessionID) => { + const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } } + if (!host.__mockServerStream) throw new Error("Missing fixture event stream") + host.__mockServerStream.push([ + { + id: "evt_failed_refresh", + created: 2, + type: "session.viewed", + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { sessionID, idle: 2 }, + }, + ]) + }, paletteSession.id) + await requested + await expect(page.getByText("Request failed", { exact: true })).toBeVisible() + await palette.input.fill("copy session") + await expect(palette.dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ) + await palette.input.press("Escape") + await page.unroute(path) + await page.evaluate((sessionID) => { + const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } } + if (!host.__mockServerStream) throw new Error("Missing fixture event stream") + host.__mockServerStream.push([ + { + id: "evt_recovered_refresh", + created: 3, + type: "session.renamed", + durable: { aggregateID: sessionID, seq: 2, version: 1 }, + data: { sessionID, title: "Recovered session" }, + }, + ]) + }, paletteSession.id) + await expect(page.getByRole("heading", { name: "Recovered session", exact: true })).toBeVisible() + expect(errors).toEqual([]) +}) diff --git a/packages/app/e2e/regression/command-palette.spec.ts b/packages/app/e2e/regression/command-palette.spec.ts new file mode 100644 index 00000000000..1086d2f946d --- /dev/null +++ b/packages/app/e2e/regression/command-palette.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from "@playwright/test" +import { captureConsoleWarnings, openCommandPalette, paletteSession } from "../utils/command-palette" + +test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] }) + +test("copies the session ID while file and session searches are still pending", async ({ page }) => { + const warnings = captureConsoleWarnings(page) + const { dialog, input } = await openCommandPalette(page) + const release = Promise.withResolvers() + await page.route(/\/api\/(session\?|fs\/find\?)/, async (route) => { + await release.promise + await route.fallback() + }) + await input.pressSequentially("copy session") + const copy = dialog.getByRole("option", { name: "Copy Session ID", exact: true }) + await expect(copy).toHaveAttribute("aria-selected", "true") + await input.press("Enter") + await expect(dialog).toHaveCount(0) + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(paletteSession.id) + await expect(page.locator('[data-testid^="toast-v2-"] [data-slot="icon-svg"]')).toBeVisible() + expect(warnings).toEqual([]) + release.resolve() +}) + +test("home commands do not wait for session search", async ({ page }) => { + const { dialog, input } = await openCommandPalette(page, true) + const release = Promise.withResolvers() + await page.route("**/api/session?*", async (route) => { + await release.promise + await route.fallback() + }) + await input.fill("open settings") + await expect(dialog.getByRole("option")).toHaveCount(1) + await expect(dialog.getByRole("option", { name: /^Open settings/ })).toHaveAttribute("aria-selected", "true") + await input.press("Enter") + await expect(page).toHaveURL("/settings") + await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences", exact: true })).toBeVisible() + release.resolve() +}) + +test("appends search results without resetting the selected command", async ({ page }) => { + const { dialog, input } = await openCommandPalette(page) + const files = Promise.withResolvers() + const sessions = Promise.withResolvers() + await page.route("**/api/fs/find?*", async (route) => { + await files.promise + await route.fulfill({ json: { data: [{ path: "copy.txt", type: "file" }] } }) + }) + await page.route("**/api/session?*", async (route) => { + await sessions.promise + await route.fulfill({ + json: { + data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "Copy fixture" }], + }, + }) + }) + await input.fill("copy") + const project = dialog.getByRole("option", { name: "Copy Project ID", exact: true }) + await expect(project).toBeVisible() + // Select a non-first command with the keyboard before remote results arrive. + await input.press("ArrowDown") + await expect(project).toHaveAttribute("aria-selected", "true") + files.resolve() + await expect(dialog.getByRole("option", { name: "/ copy.txt", exact: true })).toBeVisible() + await expect(project).toHaveAttribute("aria-selected", "true") + // File results are usable even while sessions are still pending. + sessions.resolve() + await expect(dialog.getByRole("option", { name: /Copy fixture/ })).toBeVisible() + await expect(project).toHaveAttribute("aria-selected", "true") + await input.fill("copy session") + await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ) + await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0) +}) + +test("keeps the automatically selected file when session results arrive later", async ({ page }) => { + const { dialog, input } = await openCommandPalette(page) + const sessions = Promise.withResolvers() + await page.route("**/api/fs/find?*", (route) => + route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }), + ) + await page.route("**/api/session?*", async (route) => { + await sessions.promise + await route.fulfill({ + json: { + data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "README work" }], + }, + }) + }) + await input.fill("README") + const file = dialog.getByRole("option", { name: "/ README.md", exact: true }) + await expect(file).toHaveAttribute("aria-selected", "true") + sessions.resolve() + await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible() + await expect(file).toHaveAttribute("aria-selected", "true") + await input.press("Enter") + await expect(dialog).toHaveCount(0) + await expect(page.getByRole("tab", { name: "README.md", exact: true })).toBeVisible() + await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible() +}) diff --git a/packages/app/e2e/regression/command-registration.spec.ts b/packages/app/e2e/regression/command-registration.spec.ts new file mode 100644 index 00000000000..46bdc068fbd --- /dev/null +++ b/packages/app/e2e/regression/command-registration.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from "@playwright/test" +import { captureConsoleWarnings, openCommandPalette } from "../utils/command-palette" + +test.use({ serviceWorkers: "block", video: "off" }) + +test("opening and closing files does not duplicate tab commands", async ({ page }) => { + const warnings = captureConsoleWarnings(page) + const palette = await openCommandPalette(page) + await page.route("**/api/fs/find?*", (route) => + route.fulfill({ + headers: { "access-control-allow-origin": "*" }, + json: { data: [{ path: "fixture.txt", type: "file" }] }, + }), + ) + await palette.input.fill("fixture.txt") + await palette.dialog.getByRole("option", { name: /fixture\.txt/ }).click() + const file = page.getByRole("tab", { name: /fixture\.txt/ }) + await expect(file).toBeVisible() + await expect(palette.dialog).toHaveCount(0) + await page + .getByRole("complementary", { name: "Review and files" }) + .getByRole("button", { name: "Close tab", exact: true }) + .click() + await expect(file).toHaveCount(0) + await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible() + expect(warnings).toEqual([]) +}) + +test("navigation replaces commands without retaining disposed owners", async ({ page }) => { + const warnings = captureConsoleWarnings(page) + const palette = await openCommandPalette(page, true) + await palette.input.press("Escape") + await expect(palette.dialog).toHaveCount(0) + await page + .getByRole("region", { name: "Recent sessions" }) + .getByRole("button", { name: /Palette fixture session/ }) + .click() + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() + await page.keyboard.press("ControlOrMeta+t") + await expect(page).toHaveURL(/\/new-session\?/) + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() + await page.locator('[data-component="composer-editor"]').blur() + await page.keyboard.press("Control+l") + await expect(page.locator('[data-component="composer-editor"]')).toBeFocused() + await page.keyboard.press("ControlOrMeta+Shift+P") + const dialog = page.getByRole("dialog") + await expect(dialog.getByRole("textbox")).toBeFocused() + await expect(dialog.getByRole("textbox")).toHaveAttribute("placeholder", "Search files, commands, and sessions") + await dialog.getByRole("textbox").fill("copy session") + await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0) + await dialog.getByRole("textbox").press("Escape") + await expect(dialog).toHaveCount(0) + await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click() + await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible() + for (const count of [3, 4]) { + await page.getByRole("button", { name: "New session", exact: true }).click() + await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(count) + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() + } + await page.setViewportSize({ width: 600, height: 800 }) + await page.locator('[data-slot="mobile-tabs-trigger"]').click() + await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4) + await page.setViewportSize({ width: 1280, height: 800 }) + await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4) + await page.keyboard.press("ControlOrMeta+w") + await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(3) + await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click() + await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible() + await page.keyboard.press("ControlOrMeta+Shift+P") + await expect(dialog.getByRole("textbox")).toBeFocused() + await dialog.getByRole("textbox").fill("copy session") + await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ) + expect(warnings).toEqual([]) +}) diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 1f0f28e450d..7074c167696 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -1,6 +1,7 @@ -import { expect, test, type Page, type Route } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import { base64Encode } from "@opencode-ai/util/encode" -import { currentSession } from "../utils/mock-server" +import { createMockServerHandler } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const serverB = "http://127.0.0.1:4097" @@ -8,6 +9,8 @@ const sessionA = session("ses_server_a", "C:/server-a", "Server A session") const sessionB = session("ses_server_b", "/home/server-b", "Server B session") const childB = { ...session("ses_server_b_child", sessionB.directory, "Server B subagent"), parentID: sessionB.id } +test.use({ serviceWorkers: "block" }) + test("tab busy indicator reflects activity in the tab session family", async ({ page }, info) => { await mockServers(page) await page.addInitScript( @@ -27,7 +30,7 @@ test("tab busy indicator reflects activity in the tab session family", async ({ const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}` const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` await page.goto(hrefB) - await expect(page.getByText(sessionB.title).first()).toBeVisible() + await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible() // The parent is idle, but its tab remains active while the background child runs. const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`) @@ -52,65 +55,61 @@ function session(id: string, directory: string, title: string) { } async function mockServers(page: Page) { + // Both servers stay connected while the client hydrates their active-session snapshots. + await installSseTransport(page, { server: serverA }) + await installSseTransport(page, { server: serverB }) + const servers = new Map( + [sessionA, sessionB].map( + (current) => + [ + current === sessionA ? serverA : serverB, + createMockServerHandler({ + directory: current.directory, + project: { + id: current.projectID, + worktree: current.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + sessions: current === sessionB ? [current, childB] : [current], + sessionStatus: current === sessionB ? { [childB.id]: { type: "running" } } : {}, + provider: { all: [], connected: [], default: {} }, + pageMessages: () => ({ items: [] }), + }), + ] as const, + ), + ) + page.on("close", () => servers.forEach((server) => void server.dispose())) await page.route("**/api/**", async (route) => { const url = new URL(route.request().url()) - if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + const server = servers.get(url.origin) + if (!server) return route.fallback() 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 === "/api/event") return sse(route) - if (url.pathname === "/api/health") return json(route, { pid: 1 }) - if (url.pathname === "/api/session/active") - return json(route, { data: url.origin === serverB ? { [childB.id]: { type: "running" } } : {} }) - if (url.pathname === "/api/session") - return json(route, { - data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)], - cursor: {}, + if (directory && directory !== current.directory) + return route.fulfill({ + status: 500, + json: { name: "InvalidDirectory" }, + headers: { "access-control-allow-origin": "*" }, }) - if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) - if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) - if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname)) - return json(route, { location: { directory: current.directory }, data: [] }) - if (url.pathname === "/api/model/default") - return json(route, { location: { directory: current.directory }, data: null }) - if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request") - return json(route, { location: { directory: current.directory }, data: [] }) - if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] }) - if (url.pathname === "/api/mcp/resource") - return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } }) - if (url.pathname === "/api/project" || url.pathname === "/api/project/current") { - const project = { - id: current.projectID, - canonical: current.directory, - vcs: "git", - time: { created: 1, updated: 1 }, - sandboxes: [], - } - return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory }) - } - if (url.pathname === "/api/location") return json(route, { directory: current.directory }) - if (url.pathname === "/api/vcs") - return json(route, { - location: { directory: current.directory }, - data: { branch: "main", defaultBranch: "main" }, + if (route.request().method() === "OPTIONS") + return route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" }, }) - return json(route, {}) - }) -} - -function json(route: Route, body: unknown, status = 200) { - return route.fulfill({ - status, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify(body), - }) -} - -function sse(route: Route) { - return route.fulfill({ - status: 200, - contentType: "text/event-stream", - body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n', + const body = route.request().postDataBuffer() + const response = await server.handler( + new Request(url, { + method: route.request().method(), + headers: route.request().headers(), + body: body ? Uint8Array.from(body) : undefined, + }), + ) + return route.fulfill({ + status: response.status, + headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" }, + body: Buffer.from(await response.arrayBuffer()), + }) }) } diff --git a/packages/app/e2e/utils/command-palette.ts b/packages/app/e2e/utils/command-palette.ts new file mode 100644 index 00000000000..3a452fcde4c --- /dev/null +++ b/packages/app/e2e/utils/command-palette.ts @@ -0,0 +1,57 @@ +import { expect, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/util/encode" +import { mockOpenCodeServer } from "./mock-server" +import { APP_READY_TIMEOUT } from "./waits" + +export const paletteSession = { + id: "ses_command_palette", + projectID: "proj_command_palette", + directory: "C:/OpenCode/CommandPalette", + title: "Palette fixture session", + time: { created: 1700000000000, updated: 1700000000000 }, +} + +export function captureConsoleWarnings(page: Page) { + const warnings: string[] = [] + page.on("console", (message) => { + if (message.type() !== "warning" && message.type() !== "error") return + // This message comes from test isolation, not application code. + if (message.text() === "Service Worker registration blocked by Playwright") return + warnings.push(message.text()) + }) + return warnings +} + +export async function openCommandPalette(page: Page, home = false) { + await mockOpenCodeServer(page, { + directory: paletteSession.directory, + project: { + id: paletteSession.projectID, + worktree: paletteSession.directory, + vcs: "git", + name: "command-palette", + time: paletteSession.time, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [paletteSession], + pageMessages: () => ({ items: [] }), + findFiles: () => [], + }) + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.goto(home ? "/" : `/server/${base64Encode(server)}/session/${paletteSession.id}`) + if (home) { + await expect( + page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }), + ).toBeEnabled({ timeout: APP_READY_TIMEOUT }) + } + if (!home) { + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT }) + } + await page.keyboard.press("ControlOrMeta+Shift+P") + const dialog = page.getByRole("dialog") + const input = dialog.getByRole("textbox") + await expect(input).toBeFocused() + await expect(dialog.getByRole("option")).not.toHaveCount(0) + return { dialog, input } +} diff --git a/packages/app/src/home/sessions/command-palette.tsx b/packages/app/src/home/sessions/command-palette.tsx index ce84f5f372d..7d8ada7d634 100644 --- a/packages/app/src/home/sessions/command-palette.tsx +++ b/packages/app/src/home/sessions/command-palette.tsx @@ -52,10 +52,9 @@ export function HomeCommandPalette(props: { } if (item.type === "session") props.onSelectSession(item) } - const loadItems = async (text: string) => { - const query = text.trim() + const items = (query: string) => { if (!query) return commandEntries().slice(0, 5) - return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))] + return commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)) } onCleanup(() => { @@ -66,7 +65,8 @@ export function HomeCommandPalette(props: { return ( dialog.close()} diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index e6d1f6f662d..6739e8aff91 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -13,6 +13,9 @@ import { createServerNotificationState } from "@/shell/notifications/notificatio import { Persist, persisted } from "@/runtime/persistence/storage" import { createDesktopData } from "./data" import { ModelState } from "./persistence" +import { useLanguage } from "@/runtime/i18n/language" +import { showToast } from "@/shell/notifications/toast" +import { formatServerError } from "./errors" export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ name: "Global", @@ -127,6 +130,7 @@ function createServerController( scope: ServerScope, projects: ReturnType, ) { + const language = useLanguage() const connKey = ServerConnection.key(conn) const sdk = createServerSdkContext(conn, scope) const source = createData({ @@ -137,6 +141,13 @@ function createServerController( }, connection: sdk.connection, directory: "", + onError(error) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: formatServerError(error, language.t), + }) + }, }) const data = createDesktopData({ data: source, diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index 0b8d917076d..d036aad9d54 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -321,9 +321,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }), tab && fileCommand({ - id: "tab.close", + id: "file.close", title: language.t("command.tab.close"), - keybind: "mod+w", + keybind: settings.keybinds.get("tab.close") ?? "mod+w", + when: (event) => !(event.target instanceof Element && event.target.closest('[data-component="terminal"]')), onSelect: closeTab, }), ].filter((v) => !!v) diff --git a/packages/app/src/session/files/session-side-panel.tsx b/packages/app/src/session/files/session-side-panel.tsx index b0f577c2576..5689dd03d32 100644 --- a/packages/app/src/session/files/session-side-panel.tsx +++ b/packages/app/src/session/files/session-side-panel.tsx @@ -219,7 +219,7 @@ export function SessionSidePanel(props: { return active !== "review" && active !== "context" && active !== "empty" }) const openFileKeybind = createMemo(() => command.keybindParts("file.open")) - const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) + const closeTabKeybind = createMemo(() => command.keybindParts("file.close")) createEffect(() => { if (!file.ready()) return diff --git a/packages/app/src/session/files/tab.tsx b/packages/app/src/session/files/tab.tsx index 3aebdd45816..2ef16052b89 100644 --- a/packages/app/src/session/files/tab.tsx +++ b/packages/app/src/session/files/tab.tsx @@ -19,7 +19,7 @@ export function SortableTab(props: { const file = useFile() const language = useLanguage() const command = useCommand() - const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) + const closeTabKeybind = createMemo(() => command.keybindParts("file.close")) const sortable = useSortable({ get id() { return props.tab diff --git a/packages/app/src/shell/commands/command.tsx b/packages/app/src/shell/commands/command.tsx index 2efbd3599cb..4801d3e7243 100644 --- a/packages/app/src/shell/commands/command.tsx +++ b/packages/app/src/shell/commands/command.tsx @@ -430,7 +430,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex key: id, options, } - setStore("registrations", (arr) => addCommandRegistration(arr, entry)) + // Register only committed owners. Updating the registry during a transition + // can restore its pending snapshot after the outgoing owner's cleanup. + onMount(() => setStore("registrations", (arr) => addCommandRegistration(arr, entry))) onCleanup(() => { setStore("registrations", (arr) => arr.filter((x) => x !== entry)) }) diff --git a/packages/app/src/shell/commands/dialog.tsx b/packages/app/src/shell/commands/dialog.tsx index 53e09a86632..98949d36f76 100644 --- a/packages/app/src/shell/commands/dialog.tsx +++ b/packages/app/src/shell/commands/dialog.tsx @@ -5,21 +5,15 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/dialog" import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { TextInput } from "@opencode-ai/ui/text-input" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js" +import { createStore } from "solid-js/store" import { formatKeybindParts } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { useTabs } from "@/shell/tabs/tabs" import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar" import { getRelativeTime } from "@/shell/time" -import { - createCommandPaletteCommandEntry, - createCommandPaletteFileEntry, - createCommandPaletteModel, - createServerSessionEntries, - uniqueCommandPaletteEntries, - type CommandPaletteEntry, -} from "./palette" +import { createCommandPaletteFileEntry, createCommandPaletteModel, type CommandPaletteEntry } from "./palette" +import { createCommandPaletteSearch } from "./search" import "./dialog.css" function groups(entries: CommandPaletteEntry[]) { @@ -35,23 +29,24 @@ export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: st export function DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) { const palette = createCommandPaletteModel(props) - const loadItems = async (text: string) => { - const q = text.trim() + const items = (q: string) => { if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] - - const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))]) - const category = palette.language.t("palette.group.files") - return [ - ...palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q)), - ...nextSessions, - ...files.map((path) => createCommandPaletteFileEntry(path, category)), - ] + return palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q)) } return ( { + if (!query) return [] + const files = await palette.file.searchFiles(query, { signal }) + const category = palette.language.t("palette.group.files") + return files.map((path) => createCommandPaletteFileEntry(path, category)) + }, + ]} highlight={palette.highlight} select={palette.select} close={palette.close} @@ -61,29 +56,31 @@ export function DialogCommandPalette(props: { onOpenFile?: (path: string) => voi export function CommandPaletteView(props: { placeholder: string - loadItems: (text: string) => CommandPaletteEntry[] | Promise + items: (query: string) => CommandPaletteEntry[] + sources: ((query: string, signal: AbortSignal) => Promise)[] highlight: (item: CommandPaletteEntry | undefined) => void select: (item: CommandPaletteEntry | undefined) => void close: () => void }) { const language = useLanguage() const tabs = useTabs() - const [query, setQuery] = createSignal("") - const [active, setActive] = createSignal(0) + const [store, setStore] = createStore({ query: "", active: undefined as string | undefined }) - const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] }) - // Render stale results while a new query loads to avoid flashing "Loading" per keystroke. - const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) + const search = createCommandPaletteSearch({ query: () => store.query, items: props.items, sources: props.sources }) + const visibleEntries = search.items const groupedEntries = createMemo(() => groups(visibleEntries())) - const activeEntry = createMemo(() => visibleEntries()[active()]) + // Keep keyboard selection stable when another search source adds results. + const activeEntry = createMemo( + () => visibleEntries().find((entry) => entry.id === store.active) ?? visibleEntries()[0], + ) const openSessions = createMemo( () => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))), ) createEffect(() => { - query() - visibleEntries() - setActive(0) + // Pin automatic selection too: a later source can insert rows before it. + const id = activeEntry()?.id + if (store.active !== id) setStore("active", id) }) createEffect(() => { @@ -95,7 +92,8 @@ export function CommandPaletteView(props: { const move = (delta: -1 | 1) => { const count = visibleEntries().length if (count === 0) return - setActive((index) => (index + delta + count) % count) + const index = visibleEntries().findIndex((entry) => entry.id === activeEntry()?.id) + setStore("active", visibleEntries()[(index + delta + count) % count].id) requestAnimationFrame(() => { resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }) }) @@ -128,14 +126,14 @@ export function CommandPaletteView(props: { @@ -145,7 +143,7 @@ export function CommandPaletteView(props: { when={visibleEntries().length > 0} fallback={
- {entries.loading ? language.t("common.loading") : language.t("palette.empty")} + {search.loading() ? language.t("common.loading") : language.t("palette.empty")}
} > @@ -166,7 +164,7 @@ export function CommandPaletteView(props: { ? openSessions().has(`${item.server}\0${item.sessionID}`) : false } - onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} + onActive={() => setStore("active", item.id)} onSelect={() => props.select(item)} /> )} diff --git a/packages/app/src/shell/commands/search.ts b/packages/app/src/shell/commands/search.ts new file mode 100644 index 00000000000..03fdbe1498a --- /dev/null +++ b/packages/app/src/shell/commands/search.ts @@ -0,0 +1,41 @@ +import { createMemo, createResource, onCleanup } from "solid-js" +import { uniqueCommandPaletteEntries, type CommandPaletteEntry } from "./palette" + +export function createCommandPaletteSearch(props: { + query: () => string + items: (query: string) => CommandPaletteEntry[] + sources: ((query: string, signal: AbortSignal) => Promise)[] +}) { + const query = createMemo(() => props.query().trim()) + const local = createMemo(() => props.items(query())) + const sources = props.sources.map((load) => { + let abort: AbortController | undefined + onCleanup(() => abort?.abort()) + const [result] = createResource( + query, + async (query) => { + abort?.abort() + const current = new AbortController() + abort = current + return { query, items: await load(query, current.signal).catch(() => []) } + }, + // Remote searches must not suspend the dialog's local results on first render. + { initialValue: { query: "", items: [] as CommandPaletteEntry[] } }, + ) + return result + }) + + return { + items: createMemo(() => + uniqueCommandPaletteEntries([ + ...local(), + ...sources.flatMap((source) => { + // Never keep results for an older query selectable while the next one loads. + const result = source.latest + return result.query === query() ? result.items : [] + }), + ]), + ), + loading: () => sources.some((source) => source.loading), + } +} diff --git a/packages/app/src/shell/notifications/toast.tsx b/packages/app/src/shell/notifications/toast.tsx index 58f1000f2e3..f3571179c1b 100644 --- a/packages/app/src/shell/notifications/toast.tsx +++ b/packages/app/src/shell/notifications/toast.tsx @@ -1,5 +1,6 @@ import { Icon, type IconProps } from "@opencode-ai/ui/icon" import { Toast, showToast, toaster, type ToastOptions } from "@opencode-ai/ui/toast" +import type { JSX } from "solid-js" type AppToastOptions = Omit & { icon?: IconProps["name"] @@ -30,6 +31,7 @@ export function dismissToast(toastId: number) { function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastOptions["variant"]) { const name = icon ?? (variant === "success" ? "check" : undefined) - if (!name) return - return + if (!name) return undefined + // Solid resolves JSX accessors under the toast's render owner, not this imperative call site. + return (() => ) as unknown as JSX.Element } diff --git a/packages/app/test-browser/command-palette-search.test.ts b/packages/app/test-browser/command-palette-search.test.ts new file mode 100644 index 00000000000..51b4e0cfef6 --- /dev/null +++ b/packages/app/test-browser/command-palette-search.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test" +import { createComputed, createRoot, createSignal } from "solid-js" +import type { CommandPaletteEntry } from "@/shell/commands/palette" +import { createCommandPaletteSearch } from "@/shell/commands/search" + +const copy: CommandPaletteEntry = { + id: "command:session.copyID", + type: "command", + title: "Copy Session ID", + category: "Commands", +} +const file: CommandPaletteEntry = { id: "file:copy.txt", type: "file", title: "copy.txt", category: "Files" } +const session: CommandPaletteEntry = { + id: "session:copy", + type: "session", + title: "Copy session", + category: "Sessions", +} + +describe("command palette search", () => { + test("matches commands synchronously and cancels obsolete requests", () => { + const signals: AbortSignal[] = [] + const root = createRoot((dispose) => { + const [query, setQuery] = createSignal("") + const search = createCommandPaletteSearch({ + query, + items: (text) => (text === "copy session" ? [copy] : []), + sources: [ + (_text, signal) => { + signals.push(signal) + return new Promise(() => {}) + }, + ], + }) + return { search, setQuery, dispose } + }) + root.setQuery(" copy session ") + expect(root.search.items()).toEqual([copy]) + expect(root.search.loading()).toBe(true) + expect(signals[0].aborted).toBe(true) + root.setQuery("no match") + expect(root.search.items()).toEqual([]) + expect(signals[1].aborted).toBe(true) + root.dispose() + expect(signals[2].aborted).toBe(true) + }) + + test("publishes each source independently and drops stale results on a new query", async () => { + const files = Promise.withResolvers() + const sessions = Promise.withResolvers() + const fileVisible = Promise.withResolvers() + const sessionVisible = Promise.withResolvers() + const root = createRoot((dispose) => { + const [query, setQuery] = createSignal("copy") + const search = createCommandPaletteSearch({ + query, + items: () => [copy], + sources: [() => sessions.promise, () => files.promise], + }) + createComputed(() => { + if (search.items().some((entry) => entry.id === file.id)) fileVisible.resolve() + if (search.items().some((entry) => entry.id === session.id)) sessionVisible.resolve() + }) + return { search, setQuery, dispose } + }) + expect(root.search.items()).toEqual([copy]) + files.resolve([file]) + await fileVisible.promise + expect(root.search.items()).toEqual([copy, file]) + expect(root.search.loading()).toBe(true) + sessions.resolve([session]) + await sessionVisible.promise + expect(root.search.items()).toEqual([copy, session, file]) + expect(root.search.loading()).toBe(false) + root.setQuery("new query") + expect(root.search.items()).toEqual([copy]) + root.dispose() + }) + + test("failed searches do not hide commands or successful sources", async () => { + const settled = Promise.withResolvers() + const root = createRoot((dispose) => { + const search = createCommandPaletteSearch({ + query: () => "copy", + items: () => [copy], + sources: [() => Promise.reject(new Error("offline")), () => Promise.resolve([file])], + }) + createComputed(() => { + if (!search.loading()) settled.resolve() + }) + return { search, dispose } + }) + await settled.promise + expect(root.search.items()).toEqual([copy, file]) + root.dispose() + }) +}) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 72c3c18622d..37d8e264d1d 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -66,6 +66,8 @@ export type CreateDataInput = { readonly connection?: { readonly status: () => "connected" | "connecting" | "reconnecting" } + /** Receives failed event-driven reads. Explicit reads still reject to their caller. */ + readonly onError?: (error: unknown) => void } const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") @@ -187,6 +189,17 @@ function createSync() { export function createData(config: CreateDataInput) { const api = config.api + let disposed = false + onCleanup(() => (disposed = true)) + + function refresh(load: () => Promise) { + if (disposed || (config.connection && config.connection.status() !== "connected")) return + void load().catch((error) => { + if (disposed || (config.connection && config.connection.status() !== "connected")) return + if (config.onError) return config.onError(error) + console.error("Failed to refresh client data", error) + }) + } const [store, setStore] = createStore({ session: { @@ -554,31 +567,34 @@ export function createData(config: CreateDataInput) { case "server.connected": { const updates = new Map() activeUpdates = updates - void api() - .session.active() - .then((active) => { - if (activeUpdates !== updates) return - // Lifecycle events received during hydration supersede the snapshot. - const snapshot = new Map(Object.keys(active).map((id) => [id, "running"])) - updates.forEach((status, id) => { - if (status === undefined) return snapshot.delete(id) - snapshot.set(id, status) + refresh(() => + api() + .session.active() + .then((active) => { + if (activeUpdates !== updates) return + // Lifecycle events received during hydration supersede the snapshot. + const snapshot = new Map(Object.keys(active).map((id) => [id, "running"])) + updates.forEach((status, id) => { + if (status === undefined) return snapshot.delete(id) + snapshot.set(id, status) + }) + activeUpdates = undefined + setStore("session", "active", reconcile(Object.fromEntries(snapshot))) }) - activeUpdates = undefined - setStore("session", "active", reconcile(Object.fromEntries(snapshot))) - }) - .catch(() => { - if (activeUpdates === updates) activeUpdates = undefined - }) - void api() - .location.get({ location: locationQuery(defaultLocation()) }) - .then((location) => { - const key = locationKey(location) - setStore("location", key, { info: location }) - }) - .catch((error) => console.error("Failed to preload location", error)) - void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error)) - void result.project.sync().catch((error) => console.error("Failed to preload projects", error)) + .catch(() => { + if (activeUpdates === updates) activeUpdates = undefined + }), + ) + refresh(() => + api() + .location.get({ location: locationQuery(defaultLocation()) }) + .then((location) => { + const key = locationKey(location) + setStore("location", key, { info: location }) + }), + ) + refresh(() => result.location.vcs.sync()) + refresh(() => result.project.sync()) return } case "project.updated": @@ -587,7 +603,7 @@ export function createData(config: CreateDataInput) { case "session.created": sessionOutbox.delete(event.data.sessionID) result.session.invalidate(event.data.sessionID) - void result.session.sync(event.data.sessionID) + refresh(() => result.session.sync(event.data.sessionID)) // Band-aid: a newly created session starts empty, so live events can be its source of truth. // Fetching pending inputs and projected messages separately lets promotion move an input between snapshots, // causing both requests to miss it and overwrite event-built state. Skip those racy initial reads until @@ -628,25 +644,28 @@ export function createData(config: CreateDataInput) { model: event.data.model, time: { created: event.created }, }) - void api() - .session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) }) - .then((item) => { - message.update(event.data.sessionID, (draft, index) => { - const position = index.get(item.id) - if (position === undefined) return message.append(draft, index, item) - draft[position] = item - }) - }) - .catch((error) => console.error("Failed to load projected model switch message", error)) + refresh(() => + api() + .session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) }) + .then((item) => { + message.update(event.data.sessionID, (draft, index) => { + const position = index.get(item.id) + if (position === undefined) return message.append(draft, index, item) + draft[position] = item + }) + }), + ) return case "session.renamed": { // Preserve the live title when it races the session's initial read. - const family = sync.pending(`session.family:${event.data.sessionID}`) - ? result.session.sync(event.data.sessionID, { children: true }) - : Promise.resolve() - void Promise.all([result.session.sync(event.data.sessionID), family]).then(() => { - if (store.session.info[event.data.sessionID]) - setStore("session", "info", event.data.sessionID, "title", event.data.title) + refresh(() => { + const family = sync.pending(`session.family:${event.data.sessionID}`) + ? result.session.sync(event.data.sessionID, { children: true }) + : Promise.resolve() + return Promise.all([result.session.sync(event.data.sessionID), family]).then(() => { + if (store.session.info[event.data.sessionID]) + setStore("session", "info", event.data.sessionID, "title", event.data.title) + }) }) return } @@ -680,7 +699,7 @@ export function createData(config: CreateDataInput) { if (!directory) { if (info.location.workspaceID) continue result.session.invalidate(sessionID) - void result.session.sync(sessionID) + refresh(() => result.session.sync(sessionID)) continue } const adopted = Worktree.adopt( @@ -791,7 +810,7 @@ export function createData(config: CreateDataInput) { }) if (!sync.pending(`session.message:${event.data.sessionID}`)) return result.session.message.invalidate(event.data.sessionID) - void result.session.message.sync(event.data.sessionID) + refresh(() => result.session.message.sync(event.data.sessionID)) return } case "session.step.started": @@ -992,12 +1011,12 @@ export function createData(config: CreateDataInput) { // An event can overtake the first read; queue a revalidation when that read is still active. if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return result.session.invalidate(event.data.sessionID) - void result.session.sync(event.data.sessionID) + refresh(() => result.session.sync(event.data.sessionID)) return case "session.viewed": if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return result.session.invalidate(event.data.sessionID) - void result.session.sync(event.data.sessionID) + refresh(() => result.session.sync(event.data.sessionID)) return case "session.revert.staged": if (store.session.info[event.data.sessionID]) @@ -1105,7 +1124,7 @@ export function createData(config: CreateDataInput) { const location = { directory: ref[0], workspaceID: ref[1] ?? undefined } if (event.type === "credential.updated") { result.location.integration.invalidate(location) - void result.location.integration.sync(location) + refresh(() => result.location.integration.sync(location)) return } setStore("location", key, (data) => ({ @@ -1123,7 +1142,7 @@ export function createData(config: CreateDataInput) { })) result.location.model.invalidate(location) result.location.provider.invalidate(location) - void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]) + refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])) }) return } @@ -1134,19 +1153,19 @@ export function createData(config: CreateDataInput) { case "catalog.updated": result.location.model.invalidate(location) result.location.provider.invalidate(location) - void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]) + refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])) break case "agent.updated": result.location.agent.invalidate(location) - void result.location.agent.sync(location) + refresh(() => result.location.agent.sync(location)) break case "command.updated": result.location.command.invalidate(location) - void result.location.command.sync(location) + refresh(() => result.location.command.sync(location)) break case "skill.updated": result.location.skill.invalidate(location) - void result.location.skill.sync(location) + refresh(() => result.location.skill.sync(location)) break case "vcs.branch.updated": setStore("location", locationKey(location), (data) => ({ @@ -1181,31 +1200,33 @@ export function createData(config: CreateDataInput) { break case "reference.updated": result.location.reference.invalidate(location) - void result.location.reference.sync(location) + refresh(() => result.location.reference.sync(location)) break case "integration.updated": result.location.integration.invalidate(location) result.location.model.invalidate(location) result.location.provider.invalidate(location) - void Promise.all([ - result.location.integration.sync(location), - result.location.model.sync(location), - result.location.provider.sync(location), - ]) + refresh(() => + Promise.all([ + result.location.integration.sync(location), + result.location.model.sync(location), + result.location.provider.sync(location), + ]), + ) break case "config.updated": case "websearch.updated": - void result.location.websearch.refresh(location) + refresh(() => result.location.websearch.refresh(location)) break // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, // so the mcp list syncs here rather than off integration.updated. case "mcp.status.changed": result.location.mcp.server.invalidate(location) - void result.location.mcp.server.sync(location) + refresh(() => result.location.mcp.server.sync(location)) break case "mcp.resources.changed": result.location.mcp.resource.invalidate(location) - void result.location.mcp.resource.sync(location) + refresh(() => result.location.mcp.resource.sync(location)) break } } diff --git a/packages/client/test/solid-refresh.test.ts b/packages/client/test/solid-refresh.test.ts new file mode 100644 index 00000000000..246aad94a81 --- /dev/null +++ b/packages/client/test/solid-refresh.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createData, type CreateDataInput } from "../src/solid" +import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise" + +test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => { + const listeners = new Set[0]>() + const reported = Promise.withResolvers() + const errors: unknown[] = [] + const state = { offline: true, requests: 0 } + const session: SessionInfo = { + id: "ses_refresh_failure", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0, idle: 2 }, + location: { directory: "/project" }, + } + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async () => { + state.requests++ + if (state.offline) throw new TypeError("Failed to fetch") + return Response.json({ data: { ...session, title: "Recovered" } }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + onError(error) { + errors.push(error) + reported.resolve(error) + }, + }), + dispose, + })) + const event: OpenCodeEvent = { + id: "evt_refresh_failure", + created: 2, + type: "session.viewed", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + data: { sessionID: session.id, idle: 2 }, + } + try { + setup.data.session.remember(session) + setup.data.session.invalidate(session.id) + await expect(setup.data.session.sync(session.id)).rejects.toThrow("Transport") + expect(errors).toEqual([]) + listeners.forEach((listener) => listener({ name: event.type, details: event })) + expect(String(await reported.promise)).toContain("Transport") + expect(errors).toHaveLength(1) + expect(setup.data.session.get(session.id)?.title).toBeUndefined() + state.offline = false + listeners.forEach((listener) => listener({ name: event.type, details: event })) + await setup.data.session.sync(session.id) + expect(setup.data.session.get(session.id)?.title).toBe("Recovered") + expect(state.requests).toBe(3) + } finally { + setup.dispose() + } +}) + +test.each(["reconnecting", "disposed"] as const)("background reads respect %s owners", async (mode) => { + const listeners = new Set[0]>() + const pending = Promise.withResolvers() + const errors: unknown[] = [] + const state = { requests: 0 } + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: () => { + state.requests++ + return pending.promise + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + connection: { status: () => (mode === "reconnecting" ? "reconnecting" : "connected") }, + onError: (error) => errors.push(error), + }), + dispose, + })) + const event: OpenCodeEvent = { + id: "evt_refresh_owner", + type: "command.updated", + location: { directory: "/project" }, + data: {}, + } + listeners.forEach((listener) => listener({ name: event.type, details: event })) + if (mode === "reconnecting") { + expect(state.requests).toBe(0) + setup.dispose() + return + } + const joined = setup.data.location.command.sync() + setup.dispose() + pending.reject(new TypeError("Failed to fetch")) + await expect(joined).rejects.toThrow("Transport") + expect(state.requests).toBe(1) + expect(errors).toEqual([]) +}) diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 23ae1e830a9..4163e0994a1 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "electron-vite" +import { pickerPlugin } from "./scripts/picker" const channel = (() => { const raw = process.env.OPENCODE_CHANNEL @@ -10,7 +11,6 @@ const channel = (() => { const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}` const appPlugin = (await import("@opencode-ai/app/vite")).default -const picker = (await import("@brendonovich/vite-plugin-opencode")).default() const sentry = process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT ? (await import("@sentry/vite-plugin")).sentryVitePlugin({ @@ -91,7 +91,7 @@ const require = __cjs_mod__.createRequire(import.meta.url); "import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION), "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), }, - plugins: [picker, appPlugin, sentry], + plugins: [pickerPlugin(), appPlugin, sentry], publicDir: "../../../app/public", root: "src/renderer", build: { diff --git a/packages/desktop/scripts/fixtures/picker/index.html b/packages/desktop/scripts/fixtures/picker/index.html new file mode 100644 index 00000000000..b9b0f843086 --- /dev/null +++ b/packages/desktop/scripts/fixtures/picker/index.html @@ -0,0 +1,10 @@ + + + + Picker fixture + + + + + + diff --git a/packages/desktop/scripts/fixtures/picker/main.ts b/packages/desktop/scripts/fixtures/picker/main.ts new file mode 100644 index 00000000000..c5500497665 --- /dev/null +++ b/packages/desktop/scripts/fixtures/picker/main.ts @@ -0,0 +1 @@ +document.body.dataset.ready = "true" diff --git a/packages/desktop/scripts/picker.test.ts b/packages/desktop/scripts/picker.test.ts new file mode 100644 index 00000000000..2b4bd0d2bfe --- /dev/null +++ b/packages/desktop/scripts/picker.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test" +import { loadConfigFromFile, RendererConfigFactory } from "electron-vite" +import { createServer } from "vite" +import { fileURLToPath } from "node:url" +import { pickerPlugin } from "./picker" + +test("injects a browser-loadable URL instead of a bare virtual module", () => { + const plugin = pickerPlugin() + const tag = plugin.transformIndexHtml.handler()[0] + expect(tag.attrs.src).toBe("/__vite_opencode_picker_client.js") + const id = plugin.resolveId(tag.attrs.src) + expect(id).toBeDefined() + expect(plugin.load(id!)).toContain("opencodePickerUi") +}) + +test.each([true, false])( + "serves browser-loadable picker scripts with bundled dev = %s", + async (bundledDev) => { + const loaded = await loadConfigFromFile( + { command: "serve", mode: "development" }, + fileURLToPath(new URL("../electron.vite.config.ts", import.meta.url)), + ) + if (!loaded.config.renderer) throw new Error("Missing renderer configuration") + const config = await new RendererConfigFactory( + loaded.config.renderer, + { configFile: false, mode: "development" }, + { root: fileURLToPath(new URL("..", import.meta.url)) }, + ).build() + const server = await createServer({ + ...config, + configFile: false, + root: fileURLToPath(new URL("./fixtures/picker", import.meta.url)), + build: { + ...config.build, + rolldownOptions: { input: { main: fileURLToPath(new URL("./fixtures/picker/index.html", import.meta.url)) } }, + }, + experimental: { bundledDev }, + server: { host: "127.0.0.1", port: 0 }, + logLevel: "silent", + }) + await server.listen() + const url = server.resolvedUrls?.local[0] + if (!url) throw new Error("Missing fixture server URL") + const socket = bundledDev + ? new WebSocket(`${url.replace("http:", "ws:")}?token=${server.config.webSocketToken}`, "vite-hmr") + : undefined + try { + if (socket && !server.environments.client.bundledDev?.hasBuildOutput) { + await new Promise((resolve, reject) => { + socket.addEventListener("error", reject, { once: true }) + socket.addEventListener("message", (event) => { + const message: { type: string } = JSON.parse(String(event.data)) + if (message.type === "full-reload") resolve() + }) + }) + } + for (const path of ["/", "/index.html", "/server/example/session/example", "/new-session?draftId=example"]) { + const html = await fetch(new URL(path, url)).then((response) => response.text()) + const sources = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map((match) => match[1]) + const scripts = await Promise.all( + sources.map((source) => fetch(new URL(source, url)).then((response) => response.text())), + ) + expect(scripts.length).toBeGreaterThan(0) + if (bundledDev) expect(scripts.join("\n")).toContain("opencodePickerUi") + expect([html, ...scripts].join("\n")).not.toMatch(/\bimport(?:\s*\(\s*|\s*)["']virtual:/) + } + const direct = await fetch(new URL("/__vite_opencode_picker_client.js", url)) + expect(direct.ok).toBe(true) + expect(await direct.text()).toContain("opencodePickerUi") + } finally { + socket?.close() + await server.close() + } + }, + 30_000, +) diff --git a/packages/desktop/scripts/picker.ts b/packages/desktop/scripts/picker.ts new file mode 100644 index 00000000000..466a0a8983f --- /dev/null +++ b/packages/desktop/scripts/picker.ts @@ -0,0 +1,26 @@ +import picker from "@brendonovich/vite-plugin-opencode" + +export function pickerPlugin() { + const plugin = picker() + const client = "/__vite_opencode_picker_client.js" + return { + ...plugin, + resolveId(id: string) { + return plugin.resolveId(id === client ? "virtual:vite-opencode-picker/client" : id) + }, + configureServer(server: Parameters[0]) { + server.middlewares.use(client, (_request, response) => { + response.setHeader("content-type", "text/javascript") + response.end(plugin.load(plugin.resolveId("virtual:vite-opencode-picker/client")!)) + }) + plugin.configureServer(server) + }, + transformIndexHtml: { + order: "pre" as const, + handler() { + // A real URL stays loadable if bundled dev leaves the HTML import unbundled. + return [{ tag: "script", attrs: { type: "module", src: client }, injectTo: "body" as const }] + }, + }, + } +}