fix(gui-extensions): fix stale details, browser cleanup, update checks and panel state (#53075)

This commit is contained in:
Luke Parker
2026-10-04 17:21:04 +10:00
committed by GitHub
Unverified
parent 077444b94b
commit 715de7af15
16 changed files with 503 additions and 183 deletions
@@ -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 }) => {
+80 -1
View File
@@ -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"
@@ -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`
}
+18 -2
View File
@@ -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<void>()
await serveChangelog(page, changelog, held.promise)
await mockWorkspace(page, { name: "Whats New", sessions: [], seed: upgraded })
@@ -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 }
+1
View File
@@ -17,6 +17,7 @@
ssh: query.get("ssh"),
storage: query.get("storage"),
hold: query.get("hold"),
updater: query.get("updater"),
})
</script>
</body>
+5 -1
View File
@@ -288,7 +288,11 @@ export function createModel(ctx: SetupContext<typeof definition>) {
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) => {
@@ -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<string, boolean | undefined>
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<string, Set<(event: InspectEvent) => void>>()
const emitInspect = (event: InspectEvent) => inspectors.get(store.session)?.forEach((listener) => listener(event))
const bindings = new Map<string, string>()
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<typeof definition>
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 (
<ExtensionContext.Provider value={extension}>
@@ -271,33 +325,61 @@ export function mountBrowserPane(input: PaneHost) {
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
</For>
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
<button
onClick={() =>
onClick={() => {
setStore({ url: "about:blank", loading: false })
report()
}}
>
Blank page
</button>
<button
onClick={() => {
setStore({ url: "about:blank", loading: true })
report()
}}
>
Loading page
</button>
<button
onClick={() => {
setStore({
loading: true,
generation: store.generation + 1,
loadErrors: { [store.session]: undefined },
})
}
report()
}}
>
Load current page
</button>
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
<button
onClick={() => {
setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")
report()
}}
>
Failed page
</button>
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
<button
onClick={() => {
setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })
report()
}}
>
Block navigation
</button>
<button
onClick={() =>
onClick={() => {
setStore({
url: store.pendingURL,
pendingURL: undefined,
loading: false,
generation: store.generation + 1,
})
}
report()
}}
>
Complete navigation
</button>
@@ -308,11 +390,16 @@ export function mountBrowserPane(input: PaneHost) {
<button onClick={pick}>Pick element</button>
</nav>
<p>Captures: {store.captures}</p>
<p>Session getter reads: {reads.directory}</p>
<p>Picker: {store.picker[store.session] ? "on" : "off"}</p>
<p>Highlights: {store.highlights.join(",")}</p>
<For each={tabs}>
{(tab) => <p>Picker {tab.title}: {store.picker[tab.title] ? "on" : "off"}</p>}
</For>
<p>Highlights: {store.highlights.map((item) => item.ref ?? "clear").join(",")}</p>
<p>Highlight owners: {store.highlights.map((item) => item.sessionKey).join(",")}</p>
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted && session()}>
{(view) => <SessionBrowserPane tab={() => current()} session={view()} model={model} />}
{(view) => <SessionBrowserPane tab={() => current()} session={view()} screen={screen} model={model} />}
</Show>
</div>
<ul data-testid="fixture-comments">
+44 -22
View File
@@ -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<PanelTab>; session: MountedSession; model: Model }) {
export default function SessionBrowserPane(props: {
tab: Accessor<PanelTab>
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<PanelTab>; 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<PaneState["picking"]>, 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<PanelTab>; 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<PanelTab>; 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<PanelTab>; 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<PanelTab>; 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<PanelTab>; 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<PanelTab>; 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<PanelTab>; ses
onSubmit={submitComment}
mention={{
items: (query) =>
extension.screen.current()?.file.search(query, { kind: "any" }) ?? Promise.resolve([]),
props.screen.file.search(query, { kind: "any" }),
}}
selection={
<span class="flex min-w-0 items-center gap-1" dir="ltr">
@@ -170,7 +170,7 @@ const setup: Setup<typeof definition> = (ctx) => {
{(value) => (
<Show when={!value().pending(props.session)}>
<Suspense>
<SessionBrowserPane tab={() => props.tab} session={props.session} model={value()} />
<SessionBrowserPane tab={() => props.tab} session={props.session} screen={props.screen} model={value()} />
</Suspense>
</Show>
)}
@@ -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 (
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
@@ -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 (
<ServicePopover
{...props}
loading={load.loading}
ready={data.location.config.list({ directory: props.directory }) !== undefined}
ready={data().location.config.list({ directory: props.directory }) !== undefined}
empty={names().length === 0}
error={load.error}
retry={refetch}
@@ -172,11 +172,12 @@ function LspMenu(props: ServiceMenuProps) {
function McpMenu(props: ServiceMenuProps) {
const ctx = useExtension()
const system = ctx.system
const data = props.session.server.data
const data = () => 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) {
<ServicePopover
{...props}
loading={load.loading}
ready={data.location.mcp.server.list({ directory: props.directory }) !== undefined}
ready={data().location.mcp.server.list({ directory: props.directory }) !== undefined}
empty={servers().length === 0}
error={load.error}
retry={refetch}
@@ -334,11 +337,13 @@ function McpMenu(props: ServiceMenuProps) {
function ServiceCatalog(props: ServiceMenuProps) {
const ctx = useExtension()
const data = props.session.server.data
const data = () => 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}
@@ -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
}
+19 -10
View File
@@ -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<string, true | undefined> }>({
filter: "",
initializingGit: {},
})
// The review panel's scroller and the file it scrolls to belong to the session the view shows.
const [scroll, setScroll] = createVisitState<HTMLDivElement | undefined>(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(() => {
@@ -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()
},
)
})
@@ -12,13 +12,7 @@ type Context = SetupContext<typeof definition>
/** 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