From 715de7af15930ef30d7e7d7c17f062fd10b99d19 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:21:04 +1000 Subject: [PATCH] fix(gui-extensions): fix stale details, browser cleanup, update checks and panel state (#53075) --- .../app/component-tests/browser-pane.spec.ts | 61 ++++- packages/app/e2e/regression/review.spec.ts | 81 ++++++- .../app/e2e/regression/terminal-input.spec.ts | 41 +++- packages/app/e2e/regression/whats-new.spec.ts | 20 +- .../app/e2e/utils/settings-wsl.fixture.tsx | 5 + packages/app/e2e/utils/settings-wsl.html | 1 + packages/gui-extensions/src/browser/model.ts | 6 +- .../src/browser/panel.fixture.tsx | 213 ++++++++++++------ packages/gui-extensions/src/browser/panel.tsx | 66 ++++-- .../gui-extensions/src/browser/renderer.tsx | 2 +- packages/gui-extensions/src/details/panel.tsx | 6 +- .../src/details/server-panel.tsx | 91 ++++---- .../src/details/workspace-menu.tsx | 28 +-- packages/gui-extensions/src/review/model.ts | 29 ++- .../gui-extensions/src/terminal/panel.tsx | 13 +- .../gui-extensions/src/updater/actions.tsx | 23 +- 16 files changed, 503 insertions(+), 183 deletions(-) diff --git a/packages/app/component-tests/browser-pane.spec.ts b/packages/app/component-tests/browser-pane.spec.ts index bd79e9fdaa4..5312f0df42c 100644 --- a/packages/app/component-tests/browser-pane.spec.ts +++ b/packages/app/component-tests/browser-pane.spec.ts @@ -92,6 +92,7 @@ story("comments on a picked element over a still of the page", async ({ page }) await picker.click() await expect(picker).toHaveAttribute("aria-pressed", "true") await expect(root.getByText("Picker: on", { exact: true })).toBeVisible() + await expect(root.getByText("Session getter reads: 0", { exact: true })).toBeVisible() await expect(root.getByRole("status")).toHaveText( "Click an element in the page to comment on it. Press Escape to cancel.", ) @@ -100,6 +101,7 @@ story("comments on a picked element over a still of the page", async ({ page }) await expect(picker).toHaveAttribute("aria-pressed", "false") const editor = root.locator('[data-slot="browser-comment-editor"] textarea') await expect(editor).toBeFocused() + await expect(root.getByText("Session getter reads: 0", { exact: true })).toBeVisible() await expect(root.locator('[data-slot="browser-comment-editor"]')).toContainText("button.primary") // The spotlight frames the picked element in surface pixels. await expect(root.locator('[data-slot="browser-comment-spotlight"]')).toHaveCSS("left", "48px") @@ -147,6 +149,44 @@ story("cancels the picker and a comment with Escape", async ({ page }) => { await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") }) +story("cleans up the originating session's picker and comment after a session switch", async ({ page }) => { + const root = page.getByTestId("browser-pane-fixture") + const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true }) + await picker.click() + await expect(root.getByText("Picker Alpha: on", { exact: true })).toBeVisible() + await root.getByRole("button", { name: "Beta", exact: true }).click() + await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true") + await expect(root.getByText("Picker Alpha: off", { exact: true })).toBeVisible() + await expect(picker).toHaveAttribute("aria-pressed", "false") + + await root.getByRole("button", { name: "Alpha", exact: true }).click() + await picker.click() + await root.getByRole("button", { name: "Pick element", exact: true }).click() + await expect(root.locator('[data-slot="browser-comment-editor"] textarea')).toBeFocused() + await root.getByRole("button", { name: "Beta", exact: true }).click() + await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0) + await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible() + await expect(root.getByText("Highlight owners: Alpha", { exact: true })).toBeVisible() +}) + +story("ends the native picker and highlight when the pane unmounts", async ({ page }) => { + const root = page.getByTestId("browser-pane-fixture") + const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true }) + await picker.click() + await expect(root.getByText("Picker: on", { exact: true })).toBeVisible() + await root.getByRole("button", { name: "Unmount pane", exact: true }).click() + await expect(root.locator("#browser-panel")).toHaveCount(0) + await expect(root.getByText("Picker: off", { exact: true })).toBeVisible() + + await root.getByRole("button", { name: "Alpha", exact: true }).click() + await picker.click() + await root.getByRole("button", { name: "Pick element", exact: true }).click() + await expect(root.locator('[data-slot="browser-comment-editor"] textarea')).toBeFocused() + await root.getByRole("button", { name: "Unmount pane", exact: true }).click() + await expect(root.locator("#browser-panel")).toHaveCount(0) + await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible() +}) + story("keeps a comment draft but drops its ref when the page navigates", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click() @@ -231,17 +271,22 @@ story("keeps the submitted URL visible until the browser reports navigation", as await expect(address).toHaveValue("https://example.com/") }) -story("restores the current URL when a submitted navigation is blocked", async ({ page }) => { +story("restores the current URL each time the same submitted navigation is blocked", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") await root.getByRole("button", { name: "Delay navigation", exact: true }).click() const address = root.getByRole("textbox", { name: "Browser address", exact: true }) - await address.fill("https://blocked.example/") - await address.press("Enter") - await expect(address).toHaveValue("https://blocked.example/") - await root.getByRole("button", { name: "Block navigation", exact: true }).click() - await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible() - await expect(address).toHaveValue("https://alpha.example/") - await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + + for (const submission of [1, 2]) { + await story.step(`blocked submission ${submission}`, async () => { + await address.fill("https://blocked.example/") + await address.press("Enter") + await expect(address).toHaveValue("https://blocked.example/") + await root.getByRole("button", { name: "Block navigation", exact: true }).click() + await expect(root.getByRole("alert")).toHaveText("Request failed") + await expect(address).toHaveValue("https://alpha.example/") + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + }) + } }) story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => { diff --git a/packages/app/e2e/regression/review.spec.ts b/packages/app/e2e/regression/review.spec.ts index 59a57e019ac..c6a879e0f09 100644 --- a/packages/app/e2e/regression/review.spec.ts +++ b/packages/app/e2e/regression/review.spec.ts @@ -1,7 +1,7 @@ import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise" import { expect, test, type Locator, type Page } from "@playwright/test" import { base64Encode } from "@opencode/util/encode" -import { SERVER, project, provider, session, workspaceKey } from "../utils/app" +import { SERVER, holdRoute, project, provider, session, workspaceKey } from "../utils/app" import { mockOpenCodeServer } from "../utils/mock-server" import { fileDiff, fileNode, openSession } from "../utils/workspace" import { expectSessionTitle } from "../utils/waits" @@ -33,6 +33,85 @@ for (const view of ["desktop", "mobile"] as const) { }) } +test("Git initialization belongs to its session and refreshes after a session switch", async ({ page }) => { + const requests: { directory: string; provider?: string }[] = [] + + const workspace = await openSession(page, { + name: "ReviewInitSwitch", + project: { vcs: undefined }, + onVcsInit: (input) => requests.push(input), + sessions: [ + { id: "ses_init_a", title: "Initialize Alpha" }, + { id: "ses_init_b", title: "Initialize Beta" }, + ], + seed: { panes: { ses_init_a: { review: true }, ses_init_b: { review: true } } }, + }) + + // The successful init finishes while another session is routed. + const request = await holdRoute(page, (url) => url.pathname === "/api/vcs/init", { method: "POST" }) + const panel = page.locator("#review-panel") + const init = panel.getByRole("button", { name: "Create Git repository", exact: true }) + const pending = panel.getByRole("button", { name: "Creating Git repository…", exact: true }) + await expect(init).toBeEnabled() + await init.click() + await request.arrived + await expect(pending).toBeDisabled() + + await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Beta" }).click() + await expectSessionTitle(page, "Initialize Beta") + await expect(init).toBeEnabled() + await expect(pending).toHaveCount(0) + + await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Alpha" }).click() + await expectSessionTitle(page, "Initialize Alpha") + await expect(pending).toBeDisabled() + await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Beta" }).click() + await expectSessionTitle(page, "Initialize Beta") + await expect(init).toBeEnabled() + + const response = page.waitForResponse((item) => new URL(item.url()).pathname === "/api/vcs/init") + // The worktree event can refresh project-wide UI by itself; the completed action must also reload Alpha's cache. + const refreshed = page.waitForResponse((item) => new URL(item.url()).pathname === "/api/session/ses_init_a") + request.release() + expect((await response).status()).toBe(204) + expect((await refreshed).status()).toBe(200) + expect(requests).toEqual([{ directory: workspace.directory, provider: "git" }]) + await expect(panel.getByRole("button", { name: "Git changes", exact: true })).toBeVisible() + await expect(init).toHaveCount(0) + await expect(page.getByText("Request failed", { exact: true })).toHaveCount(0) + + await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Alpha" }).click() + await expectSessionTitle(page, "Initialize Alpha") + await expect(panel.getByRole("button", { name: "Git changes", exact: true })).toBeVisible() + await expect(pending).toHaveCount(0) +}) + +test("a rejected Git init releases its session's button without showing an error in another session", async ({ page }) => { + await openSession(page, { + name: "ReviewInitFailure", + project: { vcs: undefined }, + sessions: [ + { id: "ses_init_failure_a", title: "Initialize Alpha" }, + { id: "ses_init_failure_b", title: "Initialize Beta" }, + ], + seed: { panes: { ses_init_failure_a: { review: true }, ses_init_failure_b: { review: true } } }, + }) + const request = await holdRoute(page, (url) => url.pathname === "/api/vcs/init", { method: "POST" }) + const init = page.locator("#review-panel").getByRole("button", { name: "Create Git repository", exact: true }) + await init.click() + await request.arrived + await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Beta" }).click() + await expectSessionTitle(page, "Initialize Beta") + await expect(init).toBeEnabled() + const response = page.waitForResponse((item) => new URL(item.url()).pathname === "/api/vcs/init") + request.release() + expect((await response).status()).toBe(501) + await expect(page.getByText("Request failed", { exact: true })).toHaveCount(0) + await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Alpha" }).click() + await expectSessionTitle(page, "Initialize Alpha") + await expect(init).toBeEnabled() +}) + test("open file tab browses, searches, and tracks missing files", async ({ page }) => { const searches: { query: string; dirs?: string; limit?: number }[] = [] const directory = "C:/OpenCode/ReviewOpenFile" diff --git a/packages/app/e2e/regression/terminal-input.spec.ts b/packages/app/e2e/regression/terminal-input.spec.ts index 7b17725a37b..05b6b4d26b4 100644 --- a/packages/app/e2e/regression/terminal-input.spec.ts +++ b/packages/app/e2e/regression/terminal-input.spec.ts @@ -48,7 +48,9 @@ test("routes typing to the composer unless the open terminal is focused", async await expect.poll(() => pty.sockets.length).toBe(1) pty.send("ready") await expect(terminal).toHaveCSS("opacity", "1") - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) document.activeElement.blur() + }) await page.keyboard.type("a") await expect(editor).toBeFocused() await expect(editor).toHaveText("a") @@ -64,6 +66,7 @@ for (const mount of ["cached", "explicit"] as const) { await route.continue() }) const cached = { id: "pty_cached", title: "Terminal 1" } + const { pty, sessions } = await mockWorkspace(page, { ...workspace, pty: mount === "cached" ? { initial: [cached] } : {}, @@ -76,14 +79,17 @@ for (const mount of ["cached", "explicit"] as const) { } : undefined, }) + await page.goto(sessionHref(sessions[0]!.id), { waitUntil: "commit" }) await expectSessionTitle(page, "TerminalInput") const editor = page.locator('[data-component="composer-editor"]') const terminal = page.locator('[data-component="terminal"]') + if (mount === "explicit") { await expect(editor).toBeEditable() await page.keyboard.press("Control+Backquote") } + await expect(terminal).toBeVisible() await ghostty.promise await editor.click() @@ -95,6 +101,7 @@ for (const mount of ["cached", "explicit"] as const) { pty.send("ready") await expect(terminal).toHaveCSS("opacity", "1") await expect(editor).toBeFocused() + if (mount === "cached") expect(pty.created).toEqual([]) }) } @@ -113,8 +120,40 @@ test("focuses a terminal created from the new-terminal button", async ({ page }) await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true) }) +test("closing the last restored terminal does not create a hidden replacement", async ({ page }) => { + const cached = { id: "pty_cached", title: "Terminal 1" } + + const { pty } = await openSession(page, { + ...workspace, + pty: { initial: [cached] }, + seed: { + panes: { ses_terminalinput: { terminal: true, terminalHeight: 320 } }, + storage: { [legacyTerminalKey()]: { active: cached.id, all: [{ ...cached, titleNumber: 1 }] } }, + }, + }) + + const terminal = page.locator('[data-component="terminal"]') + await expect(terminal.locator("textarea")).toHaveCount(1) + await expect.poll(() => pty.sockets.length).toBe(1) + expect(pty.created).toEqual([]) + + await page.locator("#terminal-panel").getByRole("button", { name: "Close terminal", exact: true }).click() + await expect(terminal).toBeHidden() + await expect.poll(() => pty.removed).toEqual([cached.id]) + expect(pty.created).toEqual([]) + + await page.keyboard.press("Control+Backquote") + await expect(terminal.locator("textarea")).toHaveCount(1) + await expect.poll(() => pty.created.map((item) => item.id)).toEqual(["pty_1"]) + await expect(page.locator("#terminal-panel").getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ) +}) + function legacyTerminalKey() { const dir = base64Encode("C:/OpenCode/TerminalInput") const head = dir.slice(0, 12).replace(/[^a-zA-Z0-9._-]/g, "-") + return `opencode.workspace.${head}.${checksum(dir) ?? "0"}.dat:workspace:terminal` } diff --git a/packages/app/e2e/regression/whats-new.spec.ts b/packages/app/e2e/regression/whats-new.spec.ts index a27c35c5ea0..9e91bb5170c 100644 --- a/packages/app/e2e/regression/whats-new.spec.ts +++ b/packages/app/e2e/regression/whats-new.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from "@playwright/test" import pkg from "../../package.json" with { type: "json" } -import type { SeedInput } from "../utils/app" +import { SERVER, type SeedInput } from "../utils/app" import { openHeldStart } from "../utils/direction" import { expectAppVisible } from "../utils/waits" import { mockWorkspace } from "../utils/workspace" @@ -111,7 +111,23 @@ test("Don't show these in the future turns release notes off and closes What's N await expect.poll(() => stored(page, RELEASE_NOTES)).toEqual({ enabled: false }) }) -test("What's New opened after the attachment mounts waits for the routes, then keeps focus over restored Settings", async ({ page }) => { +test("a rejected update check shows one failure title without repeating it as a description", async ({ page }) => { + await mockWorkspace(page, { name: "UpdateCheck", sessions: [] }) + await page.goto( + `/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "stopped", updater: "" })}`, + ) + const settings = page.getByTestId("settings-screen") + const check = settings.getByRole("button", { name: "Check now", exact: true }) + await expect(check).toBeEnabled() + await check.click() + await expect(page.getByText("Request failed", { exact: true })).toBeVisible() + await expect(page.getByText("Request failed", { exact: true })).toHaveCount(1) + await expect(page.getByText("You're up to date", { exact: true })).toHaveCount(0) +}) + +test("What's New opened after the attachment mounts waits for the routes, then keeps focus over restored Settings", async ({ + page, +}) => { const held = Promise.withResolvers() await serveChangelog(page, changelog, held.promise) await mockWorkspace(page, { name: "Whats New", sessions: [], seed: upgraded }) diff --git a/packages/app/e2e/utils/settings-wsl.fixture.tsx b/packages/app/e2e/utils/settings-wsl.fixture.tsx index d265adffb7e..a8bba664f92 100644 --- a/packages/app/e2e/utils/settings-wsl.fixture.tsx +++ b/packages/app/e2e/utils/settings-wsl.fixture.tsx @@ -29,6 +29,8 @@ export function mount(input: { ssh?: string | null storage?: string | null hold?: string | null + /** Enables the updater Ipc; check and install reject with this message, which may be empty. */ + updater?: string | null }) { const root = document.getElementById("root") @@ -174,6 +176,7 @@ export function mount(input: { const bridge: Bridge = { packaged: false, async call(request) { + if (request.ipc === "updater" && input.updater != null) throw new Error(input.updater) const method = methods.get(request.ipc)?.[request.method] if (!method) throw new Error("Unexpected fixture action") @@ -184,6 +187,8 @@ export function mount(input: { return result ?? null }, async subscribe(ipc) { + if (ipc === "updater" && input.updater != null) return { available: true, state: { status: "idle" } } + if (ipc === "wsl" || ipc === "ssh") return { available: true, state: snapshot(ipc) } return { available: false } diff --git a/packages/app/e2e/utils/settings-wsl.html b/packages/app/e2e/utils/settings-wsl.html index 96b01b23afc..65311c4678e 100644 --- a/packages/app/e2e/utils/settings-wsl.html +++ b/packages/app/e2e/utils/settings-wsl.html @@ -17,6 +17,7 @@ ssh: query.get("ssh"), storage: query.get("storage"), hold: query.get("hold"), + updater: query.get("updater"), }) diff --git a/packages/gui-extensions/src/browser/model.ts b/packages/gui-extensions/src/browser/model.ts index 92110b0e853..a9482bdf512 100644 --- a/packages/gui-extensions/src/browser/model.ts +++ b/packages/gui-extensions/src/browser/model.ts @@ -288,7 +288,11 @@ export function createModel(ctx: SetupContext) { const command = (session: Session, action: Browser.Action) => { const id = session.key - setState("errors", id, undefined) + batch(() => { + setState("errors", id, undefined) + + if (state.attachments[id]) setState("attachments", id, "error", undefined) + }) // An unreachable pane is suspended, not a failed request. const failed = (cause: unknown) => { diff --git a/packages/gui-extensions/src/browser/panel.fixture.tsx b/packages/gui-extensions/src/browser/panel.fixture.tsx index 1fcc99346f4..28c27d7051a 100644 --- a/packages/gui-extensions/src/browser/panel.fixture.tsx +++ b/packages/gui-extensions/src/browser/panel.fixture.tsx @@ -16,6 +16,7 @@ import { Portal, render } from "solid-js/web" import type { Bridge, BridgeLayout } from "../sdk/bridge" import { ExtensionContext, + Live, PanelContext, type Appearance, type Build, @@ -36,12 +37,13 @@ import { type Servers, type SessionRef, type SessionScreen, + type SetupContext, type Storage, type Workspaces, } from "../sdk" -import type { InspectEvent } from "./connection" import browserEn from "./i18n/en" -import type { Model } from "./model" +import type definition from "./index" +import { createModel } from "./model" import SessionBrowserPane from "./panel" import { BrowserPane, type PaneEvent } from "./ipc" @@ -69,7 +71,7 @@ type PaneFixtureState = { captures: number holdCapture: boolean picker: Record - highlights: string[] + highlights: { sessionKey: string; ref?: Browser.Ref }[] comments: ComposerNote[] } @@ -107,9 +109,14 @@ export function mountBrowserPane(input: PaneHost) { // Each capture waits until the fixture releases it, so a spec can observe the pending state. const held: (() => void)[] = [] - // Picker events reach the routed session's listeners only, as the model keys them by session. - const inspectors = new Map void>>() - const emitInspect = (event: InspectEvent) => inspectors.get(store.session)?.forEach((listener) => listener(event)) + const bindings = new Map() + const listeners = new Set<(value: { binding: string; event: PaneEvent }) => void>() + + const emit = (key: string, event: PaneEvent) => { + const binding = bindings.get(key) + + if (binding) listeners.forEach((listener) => listener({ binding, event })) + } const tabs = ["Alpha", "Beta"].map((name) => ({ id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`), @@ -162,56 +169,78 @@ export function mountBrowserPane(input: PaneHost) { open: () => [], } - // One object per session, as the host gives each routed session its own. + const server = { id: "browser-test", compatible: true, data: { on: () => () => undefined } } + const [reads, setReads] = createStore({ directory: 0 }) + + // Frozen session refs, as the real host supplies. Only the key belongs in the pane's transient state. const views = new Map( ["Alpha", "Beta", "Empty"].map((key) => { - // SAFETY: the pane reads only `key` of its session. + const value = Object.freeze({ + key, + id: key, + server, + pending: false, + location: { directory: "/repo" }, + get directory() { + setReads("directory", (count) => count + 1) + + return "/repo" + }, + }) + + // SAFETY: the pane reads only `key`; the real model reads these listed fields and `server.data.on`. // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above - return [key, { key } as unknown as MountedSession] as const + return [key, value as unknown as MountedSession] as const }), ) const session = () => views.get(store.session) ?? views.get("Alpha") // The session screen: one object that follows the route, with the composer the pane attaches comments to. + // SAFETY: the pane reads only file.search and composer.attach; the model's workspace-link paths are not invoked. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above const screen = { - get session() { - return session() - }, file: { search: async () => [] }, composer: { attach: (note: ComposerNote) => setStore("comments", (items) => [...items, note]) }, + } as unknown as SessionScreen + + const report = (key = store.session) => { + const tab = tabs.find((item) => item.title === key) + + if (!tab) return + emit(key, { + type: "state", + state: { + tabs: [ + { + ...tab, + url: store.url ?? tab.url, + loading: store.loading, + generation: store.generation, + loadError: store.loadErrors[key], + }, + ], + focusedTabID: tab.id, + }, + error: store.error, + }) } - const fake = { ...base, screen: { current: () => screen } } + // Address rejection counters and cleanup run through the real model over this main-process Ipc boundary. + const pane: PaneClient = { + register: async (value) => { + bindings.set(value.session, value.binding) + queueMicrotask(() => { + report(value.session) + const tab = tabs.find((item) => item.title === value.session) - // SAFETY: the pane reads only `id`, `t`, `keybinds`, `desktop`, `embeds`, and the screen's `file.search` and - // `composer.attach`, of its extension's context. - // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above - const extension = fake as unknown as Context - - const fakeModel: Pick< - Model, - "tab" | "suspended" | "embed" | "error" | "mount" | "load" | "command" | "inspect" | "highlight" | "onInspect" - > = { - tab: (_session, id) => { - const tab = current() - - if (tab.id !== id) return - - return { - ...tab, - url: store.url ?? tab.url, - loading: store.loading, - generation: store.generation, - loadError: store.loadErrors[store.session], - } + if (tab) emit(value.session, { type: "embed", tabID: tab.id, embed: `embed-${tab.title}` }) + }) }, - suspended: () => false, - embed: (_session, id) => `embed-${tabs.find((tab) => tab.id === id)?.title}`, - error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined), - mount: () => () => [], - load: () => undefined, - command: (_session, command) => { + load: async () => undefined, + command: async (value) => { + const command = value.command + setStore("error", undefined) if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined) @@ -227,28 +256,52 @@ export function mountBrowserPane(input: PaneHost) { } if (command.type === "stop") setStore("loading", false) + report() }, // The desktop confirms each picker change, as the page does once inspect mode is armed. - inspect: (_session, tabID, enabled) => { - setStore("picker", store.session, enabled) - emitInspect({ type: "inspect", tabID, active: enabled }) + inspect: async (value) => { + const key = Array.from(bindings).find((item) => item[1] === value.binding)?.[0] + + if (!key) throw new Error("Unknown picker binding") + setStore("picker", key, value.enabled) + emit(key, { type: "inspect", tabID: value.tabID, active: value.enabled }) }, - highlight: (_session, _tabID, ref) => setStore("highlights", (items) => [...items, ref ?? "clear"]), - onInspect: (session, listener) => { - const set = inspectors.get(session.key) ?? new Set() + highlight: async (value) => { + const key = Array.from(bindings).find((item) => item[1] === value.binding)?.[0] - set.add(listener) - inspectors.set(session.key, set) + if (!key) throw new Error("Unknown highlight binding") + setStore("highlights", (items) => [...items, { sessionKey: key, ref: value.ref }]) + }, + close: async () => undefined, + state: () => undefined, + on: (_name, listener) => { + // SAFETY: the pane's Ipc has one event, whose payload is exactly this binding and PaneEvent. + const added = listener as (value: { binding: string; event: PaneEvent }) => void + listeners.add(added) - return () => set.delete(listener) + return () => void listeners.delete(added) }, } - // SAFETY: these are every member of its model the pane reads (`props.model.*` in `./panel`). - const model = fakeModel as Model + const live = Live.accessor(() => ({ status: "active", generation: 1, value: pane })) - const pick = () => - emitInspect({ + const fake = { + ...base, + uses: { pane: { load: () => live } }, + sessions: { current: session, list: () => Array.from(views.values()).filter((view) => view.key !== "Empty") }, + screen: { current: () => screen }, + layout: { narrow: () => false, stored: () => [], state: () => "visible", open() {}, close() {} }, + links: { open() {} }, + } + + // SAFETY: the real model and pane use only the host boundaries implemented above in this fixture's flows. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above + const extension = fake as unknown as Context & SetupContext + const model = createModel(extension) + + const pick = () => { + setStore("picker", store.session, false) + emit(store.session, { type: "inspect", tabID: current().id, active: false, @@ -261,6 +314,7 @@ export function mountBrowserPane(input: PaneHost) { rect: { x: 48, y: 40, width: 160, height: 36 }, }, }) + } return ( @@ -271,33 +325,61 @@ export function mountBrowserPane(input: PaneHost) { {(name) => } - - + + - + - @@ -308,11 +390,16 @@ export function mountBrowserPane(input: PaneHost) {

Captures: {store.captures}

+

Session getter reads: {reads.directory}

Picker: {store.picker[store.session] ? "on" : "off"}

-

Highlights: {store.highlights.join(",")}

+ + {(tab) =>

Picker {tab.title}: {store.picker[tab.title] ? "on" : "off"}

} +
+

Highlights: {store.highlights.map((item) => item.ref ?? "clear").join(",")}

+

Highlight owners: {store.highlights.map((item) => item.sessionKey).join(",")}

- {(view) => current()} session={view()} model={model} />} + {(view) => current()} session={view()} screen={screen} model={model} />}
    diff --git a/packages/gui-extensions/src/browser/panel.tsx b/packages/gui-extensions/src/browser/panel.tsx index 3c512f4550f..595d2591d0e 100644 --- a/packages/gui-extensions/src/browser/panel.tsx +++ b/packages/gui-extensions/src/browser/panel.tsx @@ -9,7 +9,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { createMemo, For, on, onCleanup, Show, type Accessor } from "solid-js" import { createStore } from "solid-js/store" import type { Browser } from "@opencode/plugin-browser/rpc" -import { createKeyed, useExtension, usePanel, type PanelTab, type MountedSession } from "../sdk" +import { createKeyed, useExtension, usePanel, type PanelTab, type MountedSession, type SessionScreen } from "../sdk" import { commentNote } from "./comment" import type { Model } from "./model" import type { PaneElement } from "./ipc" @@ -24,10 +24,11 @@ type PaneState = { /** The movement count at a submit; the next reported movement ends the submitted navigation. */ navigating: number | undefined /** The tab whose element picker is on. */ - picking: Browser.TabID | undefined + picking: { sessionKey: string; tabID: Browser.TabID } | undefined /** A picked element awaiting its comment. The page stays frozen as a still until it closes. */ comment: | { + sessionKey: string tabID: Browser.TabID url: string /** The tab's navigation count at the pick; the element's ref dies when it changes. */ @@ -40,7 +41,12 @@ type PaneState = { editorHeight: number } -export default function SessionBrowserPane(props: { tab: Accessor; session: MountedSession; model: Model }) { +export default function SessionBrowserPane(props: { + tab: Accessor + session: MountedSession + screen: SessionScreen + model: Model +}) { const extension = useExtension() const keybinds = extension.keybinds const desktop = extension.desktop @@ -122,20 +128,20 @@ export default function SessionBrowserPane(props: { tab: Accessor; ses } const inspectable = () => !!address() && !failed() && !suspended() - const picking = () => !!state() && store.picking === state()?.id + const picking = () => store.picking?.sessionKey === props.session.key && store.picking?.tabID === state()?.id // A comment on a picked element freezes the page so its editor can float above it. - const commenting = () => !!store.comment && store.comment.tabID === state()?.id + const commenting = () => store.comment?.sessionKey === props.session.key && store.comment?.tabID === state()?.id - const setPicking = (tabID: Browser.TabID, enabled: boolean) => { - props.model.inspect(props.session, tabID, enabled) - setStore("picking", enabled ? tabID : undefined) + const setPicking = (current: NonNullable, enabled: boolean) => { + props.model.inspect({ key: current.sessionKey }, current.tabID, enabled) + setStore("picking", enabled ? current : undefined) } const closeComment = () => { const current = store.comment if (!current) return - props.model.highlight(props.session, current.tabID) + props.model.highlight({ key: current.sessionKey }, current.tabID) setStore("comment", undefined) } @@ -145,7 +151,7 @@ export default function SessionBrowserPane(props: { tab: Accessor; ses if (!tab || !inspectable()) return if (store.comment) closeComment() - setPicking(tab.id, !picking()) + setPicking({ sessionKey: props.session.key, tabID: tab.id }, !picking()) } const submitComment = (value: string) => { @@ -153,10 +159,13 @@ export default function SessionBrowserPane(props: { tab: Accessor; ses if (!current) return const tab = state() + // The draft outlives a reload or agent navigation, but the ref no longer names anything. - const live = tab?.id === current.tabID && tab.generation === current.generation - // The screen's composer, read when the user submits: it serves the session the pane shows. - extension.screen.current()?.composer.attach( + const live = + props.session.key === current.sessionKey && tab?.id === current.tabID && tab.generation === current.generation + + // The owning screen's composer serves the session the pane shows. + props.screen.composer.attach( commentNote({ origin: extension.id, tabID: current.tabID, @@ -230,12 +239,13 @@ export default function SessionBrowserPane(props: { tab: Accessor; ses onCleanup( props.model.onInspect(session, (event) => { if (event.active) { - setStore("picking", event.tabID) + setStore("picking", { sessionKey: session.key, tabID: event.tabID }) return } - if (store.picking === event.tabID) setStore("picking", undefined) + if (store.picking?.sessionKey === session.key && store.picking.tabID === event.tabID) + setStore("picking", undefined) if (!event.element) return const tab = state() @@ -247,6 +257,7 @@ export default function SessionBrowserPane(props: { tab: Accessor; ses } setStore("comment", { + sessionKey: session.key, tabID: tab.id, url: tab.url, generation: tab.generation, @@ -260,20 +271,31 @@ export default function SessionBrowserPane(props: { tab: Accessor; ses // A picker or comment belongs to the page on screen: the page's picker stops when its tab is switched away or the // pane hides, and a comment closes with its tab. const endPicker = () => { - const tabID = store.picking + const current = store.picking - if (tabID && (tabID !== state()?.id || !visible())) setPicking(tabID, false) + if (current && (current.sessionKey !== props.session.key || current.tabID !== state()?.id || !visible())) + setPicking(current, false) } + onCleanup(() => { + if (store.picking) setPicking(store.picking, false) + closeComment() + }) + createKeyed(visible, endPicker, { otherwise: endPicker }) createKeyed( - () => state()?.id, - (id) => { + () => { + const tab = state() + + return tab ? { session: props.session.key, tabID: tab.id } : undefined + }, + (current) => { endPicker() - if (store.comment?.tabID !== id) closeComment() + if (store.comment?.sessionKey !== current.session || store.comment.tabID !== current.tabID) closeComment() }, { + equals: (previous, next) => previous.session === next.session && previous.tabID === next.tabID, otherwise: () => { endPicker() closeComment() @@ -384,7 +406,7 @@ export default function SessionBrowserPane(props: { tab: Accessor; ses data-action="browser-inspect" disabled={!inspectable()} state={picking() ? "pressed" : undefined} - classList={{ "!text-v2-icon-icon-accent": picking() || !!store.comment }} + classList={{ "!text-v2-icon-icon-accent": picking() || commenting() }} aria-pressed={picking()} aria-label={extension.t("inspect")} onClick={toggleInspect} @@ -530,7 +552,7 @@ export default function SessionBrowserPane(props: { tab: Accessor; ses onSubmit={submitComment} mention={{ items: (query) => - extension.screen.current()?.file.search(query, { kind: "any" }) ?? Promise.resolve([]), + props.screen.file.search(query, { kind: "any" }), }} selection={ diff --git a/packages/gui-extensions/src/browser/renderer.tsx b/packages/gui-extensions/src/browser/renderer.tsx index 634815c5d59..2ac371ea034 100644 --- a/packages/gui-extensions/src/browser/renderer.tsx +++ b/packages/gui-extensions/src/browser/renderer.tsx @@ -170,7 +170,7 @@ const setup: Setup = (ctx) => { {(value) => ( - props.tab} session={props.session} model={value()} /> + props.tab} session={props.session} screen={props.screen} model={value()} /> )} diff --git a/packages/gui-extensions/src/details/panel.tsx b/packages/gui-extensions/src/details/panel.tsx index 00e9f60b9e5..96a682d9282 100644 --- a/packages/gui-extensions/src/details/panel.tsx +++ b/packages/gui-extensions/src/details/panel.tsx @@ -32,7 +32,7 @@ export type DetailsPanelProps = { export default function SessionDetailsPanel(props: DetailsPanelProps) { const ctx = useExtension() const locale = ctx.locale - const data = props.session.server.data + const data = () => props.session.server.data const placement = createMemo(() => props.mobile ? "top-end" : locale.direction() === "rtl" ? "right-start" : "left-start", @@ -48,8 +48,8 @@ export default function SessionDetailsPanel(props: DetailsPanelProps) { return getFilename(workspace ?? props.session.directory) } - const branch = () => data.location.vcs.info({ directory: props.session.directory })?.branch.current - const baseBranch = () => data.location.vcs.info({ directory: props.project.worktree })?.branch.current + const branch = () => data().location.vcs.info({ directory: props.session.directory })?.branch.current + const baseBranch = () => data().location.vcs.info({ directory: props.project.worktree })?.branch.current return (
    diff --git a/packages/gui-extensions/src/details/server-panel.tsx b/packages/gui-extensions/src/details/server-panel.tsx index 74c976a551c..201069e3e80 100644 --- a/packages/gui-extensions/src/details/server-panel.tsx +++ b/packages/gui-extensions/src/details/server-panel.tsx @@ -112,35 +112,35 @@ function ServiceMenu(props: ServiceMenuProps) { function LspMenu(props: ServiceMenuProps) { const ctx = useExtension() - const data = props.session.server.data + const data = () => props.session.server.data const [load, { refetch }] = createResource( - () => props.shown && props.directory, - (directory) => { - data.location.config.invalidate({ directory }) + () => props.shown && data(), + (current) => { + const directory = props.directory - return data.location.config.sync({ directory }) + current.location.config.invalidate({ directory }) + + return current.location.config.sync({ directory }) }, ) - const names = createMemo(() => configuredLsps(data.location.config.list({ directory: props.directory }) ?? [])) + const names = createMemo(() => configuredLsps(data().location.config.list({ directory: props.directory }) ?? [])) - createKeyed( - () => props.directory, - (directory) => - onCleanup( - data.on("config.updated", (event) => { - if (event.location?.directory !== directory) return - void refetch() - }), - ), + createKeyed(data, (current) => + onCleanup( + current.on("config.updated", (event) => { + if (event.location?.directory !== props.directory) return + void refetch() + }), + ), ) return ( props.session.server.data const toggle = useMutation(() => ({ mutationFn: async (input: { name: string; enabled: boolean }) => { const client = props.session.server.client + const source = data() const ref = { directory: props.directory } const server = (await client.mcp.list({ location: ref })).data.find((item) => item.name === input.name) @@ -190,9 +191,9 @@ function McpMenu(props: ServiceMenuProps) { await client.mcp.connect({ server: input.name, location: ref }) } - data.location.mcp.server.invalidate(ref) - await data.location.mcp.server.sync(ref) - const current = data.location.mcp.server.list(ref)?.find((item) => item.name === input.name) + source.location.mcp.server.invalidate(ref) + await source.location.mcp.server.sync(ref) + const current = source.location.mcp.server.list(ref)?.find((item) => item.name === input.name) if (input.enabled && current?.status.status === "needs_auth" && current.integrationID) { const integration = await client.integration.get({ integrationID: current.integrationID, location: ref }) @@ -209,8 +210,8 @@ function McpMenu(props: ServiceMenuProps) { system.openExternal(attempt.data.url) } - data.location.mcp.resource.invalidate(ref) - await data.location.mcp.resource.sync(ref) + source.location.mcp.resource.invalidate(ref) + await source.location.mcp.resource.sync(ref) // A successful HTTP response can still leave the MCP connection in a failed state. const status = current?.status @@ -225,15 +226,17 @@ function McpMenu(props: ServiceMenuProps) { })) const [load, { refetch }] = createResource( - () => props.shown && props.directory, - async (directory) => { - data.location.mcp.server.invalidate({ directory }) - await data.location.mcp.server.sync({ directory }) + () => props.shown && data(), + async (current) => { + const directory = props.directory + + current.location.mcp.server.invalidate({ directory }) + await current.location.mcp.server.sync({ directory }) }, ) const servers = createMemo(() => - (data.location.mcp.server.list({ directory: props.directory }) ?? []).toSorted((a, b) => + (data().location.mcp.server.list({ directory: props.directory }) ?? []).toSorted((a, b) => a.name.localeCompare(b.name), ), ) @@ -242,7 +245,7 @@ function McpMenu(props: ServiceMenuProps) { props.session.server.data const [items, { refetch }] = createResource( - () => props.shown && props.directory, - async (directory) => { + () => props.shown && data(), + async (current) => { + const directory = props.directory + if (props.service.type === "plugins") { const result = await props.session.server.client.plugin.list({ location: { directory } }) @@ -351,8 +356,8 @@ function ServiceCatalog(props: ServiceMenuProps) { })) } - data.location.skill.invalidate({ directory }) - await data.location.skill.sync({ directory }) + current.location.skill.invalidate({ directory }) + await current.location.skill.sync({ directory }) return undefined }, @@ -366,7 +371,7 @@ function ServiceCatalog(props: ServiceMenuProps) { ? loaded() ? (items.latest ?? []) : [] - : (data.location.skill.list({ directory: props.directory }) ?? []).map((skill) => ({ + : (data().location.skill.list({ directory: props.directory }) ?? []).map((skill) => ({ name: skill.name, status: "active", error: undefined, @@ -375,15 +380,13 @@ function ServiceCatalog(props: ServiceMenuProps) { return entries.toSorted((a, b) => a.name.localeCompare(b.name)) }) - createKeyed( - () => props.directory, - (directory) => - onCleanup( - data.on(props.service.type === "plugins" ? "plugin.updated" : "skill.updated", (event) => { - if (event.location?.directory !== directory) return - void refetch() - }), - ), + createKeyed(data, (current) => + onCleanup( + current.on(props.service.type === "plugins" ? "plugin.updated" : "skill.updated", (event) => { + if (event.location?.directory !== props.directory) return + void refetch() + }), + ), ) return ( @@ -393,7 +396,7 @@ function ServiceCatalog(props: ServiceMenuProps) { ready={ props.service.type === "plugins" ? loaded() - : data.location.skill.list({ directory: props.directory }) !== undefined + : data().location.skill.list({ directory: props.directory }) !== undefined } empty={list().length === 0} error={items.error} diff --git a/packages/gui-extensions/src/details/workspace-menu.tsx b/packages/gui-extensions/src/details/workspace-menu.tsx index 970093fe928..998ad620f85 100644 --- a/packages/gui-extensions/src/details/workspace-menu.tsx +++ b/packages/gui-extensions/src/details/workspace-menu.tsx @@ -4,7 +4,7 @@ import { showToast } from "@opencode/ui/toast" import { comparablePath, containsDirectory, getFilename, sameDirectory } from "@opencode/util/path" import { createStore } from "solid-js/store" import { For, onCleanup, Show, type ComponentProps, type JSX } from "solid-js" -import { useExtension, type Project, type MountedSession } from "../sdk" +import { createKeyed, useExtension, type Project, type MountedSession } from "../sdk" import { workspaceDirectories } from "./paths" export function SessionWorkspaceMenu(props: { @@ -16,14 +16,14 @@ export function SessionWorkspaceMenu(props: { children: JSX.Element }) { const ctx = useExtension() - const data = props.session.server.data + const data = () => props.session.server.data const [store, setStore] = createStore<{ selected: string | undefined; directories: string[] }>({ selected: undefined, directories: workspaceDirectories(props.project), }) - const blocked = () => data.session.status(props.session.id) === "running" + const blocked = () => data().session.status(props.session.id) === "running" const currentWorkspace = () => store.directories.find((workspace) => containsDirectory(workspace, props.directory)) const workspaces = () => @@ -37,14 +37,16 @@ export function SessionWorkspaceMenu(props: { items.flatMap((item) => (sameDirectory(props.project.worktree, item.directory) ? [] : [item.directory])), ) - onCleanup( - data.on("worktree.updated", (event) => { - if (event.data.projectID !== props.project.id) return - void props.session.server.client.worktree - .list({ projectID: props.project.id }) - .then(update) - .catch(() => undefined) - }), + createKeyed(data, (current) => + onCleanup( + current.on("worktree.updated", (event) => { + if (event.data.projectID !== props.project.id) return + void props.session.server.client.worktree + .list({ projectID: props.project.id }) + .then(update) + .catch(() => undefined) + }), + ), ) const onOpenChange = (open: boolean) => { @@ -59,11 +61,11 @@ export function SessionWorkspaceMenu(props: { const createWorktree = async (client: MountedSession["server"]["client"], directory: string) => { const project = - data.location.info({ directory })?.project ?? (await client.location.get({ location: { directory } })).project + data().location.info({ directory })?.project ?? (await client.location.get({ location: { directory } })).project const created = await client.worktree.create({ projectID: project.id, from: project.canonical }) // Populate the client cache before the destination session mounts. - await data.location.syncInfo({ directory: created.directory }) + await data().location.syncInfo({ directory: created.directory }) return created.directory } diff --git a/packages/gui-extensions/src/review/model.ts b/packages/gui-extensions/src/review/model.ts index 1e5d0e36e93..a0d36f4f6d5 100644 --- a/packages/gui-extensions/src/review/model.ts +++ b/packages/gui-extensions/src/review/model.ts @@ -55,7 +55,10 @@ export function createReviewModel(input: { const directory = () => screen.file.root // The filter is transient by design: a persisted filter would silently hide files after a reload. - const [state, setState] = createStore({ filter: "", initializingGit: false }) + const [state, setState] = createStore<{ filter: string; initializingGit: Record }>({ + filter: "", + initializingGit: {}, + }) // The review panel's scroller and the file it scrolls to belong to the session the view shows. const [scroll, setScroll] = createVisitState(undefined) @@ -259,12 +262,14 @@ export function createReviewModel(input: { lifetime.disposed = true }) - // Initializes Git for the routed session. The request keeps that session's object, so its server and location stay - // its own; the outcome applies only while the screen still routes it. - const initializeGit = () => { - if (state.initializingGit) return + const alive = () => !lifetime.disposed && !ctx.signal.aborted + // Initializes Git for the routed session. The request keeps that session's object, so its server and location stay + // its own. Cache refresh follows the request's session; only error feedback depends on which session is routed. + const initializeGit = () => { const session = view() + + if (state.initializingGit[session.key]) return const location = session.location if (!location || !session.server.connected) { @@ -273,13 +278,13 @@ export function createReviewModel(input: { return } - const current = () => !lifetime.disposed && !ctx.signal.aborted && view().key === session.key + const current = () => alive() && view().key === session.key - setState("initializingGit", true) + setState("initializingGit", session.key, true) void session.server.client.vcs .init({ location, provider: "git" }) .then(async () => { - if (!current()) return + if (!alive()) return const data = session.server.data @@ -288,7 +293,11 @@ export function createReviewModel(input: { data.location.invalidate(location) data.location.vcs.invalidate(location) await data.project.sync() + + if (!alive()) return await data.session.sync(session.id) + + if (!alive()) return await Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)]) }) .catch((error) => { @@ -301,7 +310,7 @@ export function createReviewModel(input: { }) }) .finally(() => { - if (current()) setState("initializingGit", false) + if (alive()) setState("initializingGit", session.key, undefined) }) } @@ -574,7 +583,7 @@ export function createReviewModel(input: { focusFile, hasChanges, initializeGit, - initializingGit: () => state.initializingGit, + initializingGit: () => !!state.initializingGit[view().key], loadDiff, mode, noGit: createMemo(() => { diff --git a/packages/gui-extensions/src/terminal/panel.tsx b/packages/gui-extensions/src/terminal/panel.tsx index e2446d1ea3e..a39906e7ba3 100644 --- a/packages/gui-extensions/src/terminal/panel.tsx +++ b/packages/gui-extensions/src/terminal/panel.tsx @@ -60,21 +60,22 @@ export default function TerminalPanel(props: { model: TerminalModel; session: Mo }) }) - // While the dock shows, an empty workspace gets its first terminal, once per workspace in a row. + // While the dock shows, a workspace first observed empty gets its first terminal, once per workspace in a row. createKeyed(opened, () => { - const created = { workspace: "" } + const seen = { workspace: "" } createKeyed( () => { const workspace = terminal() - return workspace.ready() && workspace.all().length === 0 ? workspace : undefined + return workspace.ready() ? workspace : undefined }, (workspace) => { - if (created.workspace === workspace.key) return + if (seen.workspace === workspace.key) return - workspace.new() - created.workspace = workspace.key + seen.workspace = workspace.key + + if (workspace.all().length === 0) workspace.new() }, ) }) diff --git a/packages/gui-extensions/src/updater/actions.tsx b/packages/gui-extensions/src/updater/actions.tsx index 866112aa5a7..abce10e2c42 100644 --- a/packages/gui-extensions/src/updater/actions.tsx +++ b/packages/gui-extensions/src/updater/actions.tsx @@ -12,13 +12,7 @@ type Context = SetupContext /** Restarts into the staged update. A beta build moving to stable confirms the installer download first. */ export function install(ctx: Context, client: Client) { - const download = () => - client.install().catch((cause: unknown) => { - showToast({ - title: ctx.t("common.requestFailed"), - description: cause instanceof Error && cause.message ? cause.message : ctx.t("common.requestFailed"), - }) - }) + const download = () => client.install().catch((cause: unknown) => requestFailed(ctx, cause)) const state = client.state() @@ -35,7 +29,11 @@ export function install(ctx: Context, client: Client) { } export async function check(ctx: Context, client: Client) { - const state = await client.check() + const state = await client + .check({ signal: ctx.signal }) + .catch((cause: unknown) => requestFailed(ctx, cause)) + + if (!state || ctx.signal.aborted) return if (state.status === "download-required") { install(ctx, client) @@ -57,6 +55,15 @@ export async function check(ctx: Context, client: Client) { } } +function requestFailed(ctx: Context, cause: unknown) { + if (ctx.signal.aborted) return + + showToast({ + title: ctx.t("common.requestFailed"), + description: cause instanceof Error && cause.message ? cause.message : undefined, + }) +} + function DialogStableDownload(props: { ctx: Context dialog: DialogHandle