Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
50e5614
feat(gui-extensions): add lifetime primitives, typed composition and …
Hona Oct 2, 2026
25d6a31
refactor(gui-extensions): migrate built-ins to declared dependencies …
Hona Oct 2, 2026
5fd0468
refactor(gui-extensions): rename the SDK and host vocabulary
Hona Oct 2, 2026
82078c3
refactor(gui-extensions): host APIs as context properties and canonic…
Hona Oct 2, 2026
c16331a
refactor(gui-extensions): rename summary and usage, move preferences …
Hona Oct 2, 2026
c817fed
test(app): keep What's New behaviour after its move to the updater
Hona Oct 2, 2026
a52aeb1
docs(gui-extensions): document the SDK and add an example extension
Hona Oct 2, 2026
5622cf9
ci: run the app component tests
Hona Oct 2, 2026
4c3f564
refactor(gui-extensions): make dependency and session ownership explicit
Hona Oct 2, 2026
2189ec1
chore(gui-extensions): integrate the latest v2 changes
Hona Oct 2, 2026
2ad98bf
docs(gui-extensions): teach the SDK through shipped plugins
Hona Oct 2, 2026
e87782e
fix(app): keep file tabs and migrated shortcuts correct across sessions
Hona Oct 3, 2026
8078ad0
fix(app): show extension dialogs after the interface mounts
Hona Oct 3, 2026
7c33ac1
fix(gui-extensions): close lifetime and storage gaps found in review
Hona Oct 4, 2026
a50079f
fix(gui-extensions): follow session moves and cancel dialogs reliably
Hona Oct 4, 2026
6202b3a
fix(app): wait for routes before extension writes and keep routed ses…
Hona Oct 4, 2026
b40a19d
ci: lint only the pull request's own changes
Hona Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
11 changes: 11 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,20 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
# A pull request checks out its merge into the current base; the first parent is that base.
fetch-depth: 2

- name: Setup Bun
uses: ./.github/actions/setup-bun

- name: Run checks
run: bun run check

# Every GUI package file (app, desktop, gui-extensions, ui, session-ui) a pull request adds or edits must be free of
# oxlint problems, warn-level rules (anti-slop) included. Other packages are not affected.
- name: Lint changed files
if: github.event_name == 'pull_request'
# Against the merge's first parent, so base-branch commits the pull request has not merged are not counted as its
# changes (the event's base SHA can predate them).
run: bun run lint:changed HEAD^1
9 changes: 9 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,13 @@ jobs:
CI: true
timeout-minutes: 15

- name: Run app component tests
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
run: bun --cwd packages/app test:components
env:
CI: true
timeout-minutes: 15

- name: Upload Playwright artifacts
if: always() && env.E2E_ENABLED == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
Expand All @@ -264,3 +271,5 @@ jobs:
packages/app/e2e/playwright-report
packages/session-ui/component-tests/test-results
packages/session-ui/component-tests/playwright-report
packages/app/component-tests/test-results
packages/app/component-tests/playwright-report
52 changes: 52 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,60 @@
"anti-slop-effect/prefer-effect-match": "warn"
}
},
{
"files": ["packages/gui-extensions/src/*.ts", "packages/gui-extensions/src/*.tsx"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "solid-js",
"importNames": ["createEffect", "createRenderEffect", "createComputed"],
"message": "Extension code runs side effects through the SDK: createKeyed(source, fn, { otherwise }) per provider generation or value, createMemo or a plain function for derived values, createLatest for async data, createVisitState for per-visit state, and the handler for logic a user action causes. An escape hatch needs an oxlint-disable comment with a reason."
}
]
}
]
}
},
{
"files": ["packages/gui-extensions/src/*/**"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "solid-js",
"importNames": ["createEffect", "createRenderEffect", "createComputed"],
"message": "Extension code runs side effects through the SDK: createKeyed(source, fn, { otherwise }) per provider generation or value, createMemo or a plain function for derived values, createLatest for async data, createVisitState for per-visit state, and the handler for logic a user action causes. An escape hatch needs an oxlint-disable comment with a reason."
}
],
"patterns": [
{
"regex": "^@opencode/(app|desktop)(/|$)",
"message": "GUI extensions never import the app or desktop packages. Use the SDK."
},
{
"regex": "^@/",
"message": "GUI extensions never import app internals. Use the SDK."
},
{
"group": ["../*/*", "!../*/contract", "!../sdk/*"],
"message": "Import another extension only through its contract.ts."
},
{
"regex": "\\.css$",
"message": "Import CSS with ?inline and contribute it with ctx.add(Style, css)."
}
]
}
]
}
},
{
"files": ["packages/gui-extensions/src/sdk/**"],
"rules": {
"no-restricted-imports": [
"error",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"dev:www": "bun run --cwd services/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint && ast-grep scan -c script/ast-grep/gui-extensions/sgconfig.yml",
"lint": "oxlint && ast-grep scan -c script/ast-grep/gui-extensions/sgconfig.yml && bun script/sdk-docs.ts",
"lint:changed": "bun script/lint-changed.ts",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
Expand Down
96 changes: 61 additions & 35 deletions packages/app/component-tests/browser-pane-restore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"

const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}`

const modules = {
fixture: source("../../gui-extensions/src/browser/panel.fixture.tsx"),
host: source("../src/runtime/extension/host.tsx"),
Expand All @@ -13,49 +14,74 @@ const modules = {
fileRenderer: source("../../gui-extensions/src/file/renderer.tsx"),
}

story.beforeEach(async ({ mount, page }) => {
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
await mount("ui-line-comment--editor")
await page.evaluate(async (modules) => {
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
import(modules.fixture),
import(modules.host),
import(modules.panels),
import(modules.language),
import(modules.browser),
import(modules.file),
])

mountBrowserRegion({
LanguageProvider: language.LanguageProvider,
ExtensionHostProvider: host.ExtensionHostProvider,
useExtensionHost: host.useExtensionHost,
createRegion: panels.createRegion,
definitions: [
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
{ ...file.default, renderer: () => import(modules.fileRenderer) },
],
})
}, modules)
})

story("keeps a restored browser tab selected and undrawn until the desktop's first inventory", async ({ page }) => {
const root = page.getByTestId("browser-region-fixture")
const tabs = root.getByRole("tab")
const tree = root.getByTestId("tree")
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
await expect(tabs).toHaveText(["alpha.ts"])
await expect(tree).toHaveText('{"tab":"changes"}')

// Beta was left on its browser tab, which the desktop has not reported yet.
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
await expect(root.getByTestId("selected")).toHaveText(/^browser:tab_/)
await expect(tabs).toHaveText(["beta.ts"])
// No fallback tab was selected, so the file tab's selection never switched the tree to All files.
await expect(tree).toHaveText('{"tab":"changes"}')

await root.getByRole("button", { name: "First inventory", exact: true }).click()
await expect(tabs).toHaveText(["beta.ts", "Preview"])
await expect(root.getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(tree).toHaveText('{"tab":"changes"}')
})

story(
"keeps a restored browser tab selected and undrawn until the desktop's first inventory",
async ({ mount, page }) => {
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
await mount("ui-line-comment--editor")
await page.evaluate(async (modules) => {
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
import(modules.fixture),
import(modules.host),
import(modules.panels),
import(modules.language),
import(modules.browser),
import(modules.file),
])
mountBrowserRegion({
LanguageProvider: language.LanguageProvider,
ExtensionHostProvider: host.ExtensionHostProvider,
useExtensionHost: host.useExtensionHost,
createRegion: panels.createRegion,
definitions: [
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
{ ...file.default, renderer: () => import(modules.fileRenderer) },
],
})
}, modules)
"keeps the browser tabs while the pane's Ipc is away and registers them again when it returns",
async ({ page }) => {
const root = page.getByTestId("browser-region-fixture")
const tabs = root.getByRole("tab")
const tree = root.getByTestId("tree")
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
await expect(tabs).toHaveText(["alpha.ts"])
await expect(tree).toHaveText('{"tab":"changes"}')

// Beta was left on its browser tab, which the desktop has not reported yet.
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
await expect(root.getByTestId("selected")).toHaveText(/^browser:tab_/)
await expect(tabs).toHaveText(["beta.ts"])
// No fallback tab was selected, so the file tab's selection never switched the tree to All files.
await expect(tree).toHaveText('{"tab":"changes"}')

await root.getByRole("button", { name: "First inventory", exact: true }).click()
await expect(tabs).toHaveText(["beta.ts", "Preview"])

// The pane's main extension reloads: every binding goes with it, and the strip keeps the tab it will restore.
await root.getByRole("button", { name: "Pane away", exact: true }).click()
await expect(tabs).toHaveText(["beta.ts", "Preview"])
await expect(root.getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(tree).toHaveText('{"tab":"changes"}')

// Both attachments register again at once, without a retry timer; Beta hands main the tab to restore.
await root.getByRole("button", { name: "Pane back", exact: true }).click()
await expect(root.getByText("Registrations: 4", { exact: true })).toBeVisible()
await expect(root.getByText("Beta restores: 1", { exact: true })).toBeVisible()
await expect(tabs).toHaveText(["beta.ts", "Preview"])
},
)
30 changes: 25 additions & 5 deletions packages/app/component-tests/browser-pane.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,33 @@ import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"

const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}`

const modules = {
fixture: source("../../gui-extensions/src/browser/panel.fixture.tsx"),
surface: source("../src/runtime/extension/surface.tsx"),
embeds: source("../src/runtime/extension/embeds.tsx"),
language: source("../src/runtime/i18n/language.tsx"),
}

story.beforeEach(async ({ mount, page }) => {
// Any story loads the app styles; the fixture mounts the pane beside it on the real host surface.
// Any story loads the app styles; the fixture mounts the pane beside it on the real host embeds.
await mount("ui-line-comment--editor")
await page.evaluate(async (modules) => {
const [{ mountBrowserPane }, { createSurfaces }, language] = await Promise.all([
const [{ mountBrowserPane }, { createEmbeds }, language] = await Promise.all([
import(modules.fixture),
import(modules.surface),
import(modules.embeds),
import(modules.language),
])

mountBrowserPane({
createSurfaces,
createEmbeds,
LanguageProvider: language.LanguageProvider,
UiI18nBridge: language.UiI18nBridge,
useLanguage: language.useLanguage,
})
}, modules)
await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})

story("hides a native page when another takes the pane, the pane hides, or it unmounts", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const alpha = root.getByTestId("native-Alpha")
Expand All @@ -50,11 +53,14 @@ story("hides the native view immediately while the pane stays mounted", async ({
const root = page.getByTestId("browser-pane-fixture")
const toggle = root.getByRole("button", { name: "Toggle Review tab", exact: true })
await expect(toggle).toBeEnabled()

// Read in the same task as the click so a deferred animation-frame hide cannot pass.
const visible = await toggle.evaluate((element) => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }))

return document.querySelector('[data-testid="native-Alpha"]')?.getAttribute("data-visible")
})

expect(visible).toBe("false")
await expect(root.locator("#browser-panel")).toHaveCount(1)
await toggle.click()
Expand Down Expand Up @@ -108,6 +114,19 @@ story("comments on a picked element over a still of the page", async ({ page })
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")

// The pane stays mounted when Beta is routed, and its picker then listens to Beta's page.
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await picker.click()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
await expect(editor).toBeFocused()
await editor.fill("Beta's button too")
await editor.press("Enter")
await expect(root.getByTestId("fixture-comments").getByRole("listitem")).toHaveText([
"button.primary @e7: Make this the primary colour",
"button.primary @e7: Beta's button too",
])
})

story("cancels the picker and a comment with Escape", async ({ page }) => {
Expand Down Expand Up @@ -157,6 +176,7 @@ story("keeps the comment editor and its actions inside the page", async ({ page
.poll(async () => {
const surface = await root.locator('[data-component="browser-comment"]').boundingBox()
const box = await editor.boundingBox()

return !!surface && !!box && box.y + box.height <= surface.y + surface.height
})
.toBe(true)
Expand Down
84 changes: 84 additions & 0 deletions packages/app/component-tests/extension-graph.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { fileURLToPath } from "node:url"
import type { Definition } from "@opencode/gui-extensions/sdk"
import { expect, story } from "../../storybook/playwright/story"

const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}`

const modules = {
fixture: source("./extension-host.fixture.tsx"),
builtins: source("../../gui-extensions/src/renderer.ts"),
}

story.beforeEach(async ({ mount }) => {
// Any story loads the app; the fixture mounts the real host beside it.
await mount("ui-line-comment--editor")
})

// The built-in renderer composition as a graph: `requires` edges must not form a cycle, and every optional (`uses`)
// edge gets one row that boots the real host with that provider disabled. The first row disables nothing.
story("built-ins: no requires cycle, and each consumer activates without each optional provider", async ({ page }) => {
const result = await page.evaluate(async (modules) => {
const [{ mountExtensions, until }, { builtins }] = await Promise.all([
import(modules.fixture),
import(modules.builtins),
])

// The window this host serves has no OS, so OS-specific built-ins are left out as the app leaves them out.
const composition: readonly Definition[] = builtins
const definitions = composition.filter((definition) => !definition.os)
const ids = (tokens: Definition["provides"]) => Object.values(tokens ?? {}).map((token) => token.id)
const providerOf = (token: string) => definitions.find((definition) => ids(definition.provides).includes(token))?.id

const requires = new Map(
definitions.map((definition) => [
definition.id,
ids(definition.requires).flatMap((token) => providerOf(token) ?? []),
]),
)

const cycles = definitions.flatMap((definition) => {
const walk = (id: string, path: readonly string[]): string[][] =>
path.includes(id)
? id === definition.id
? [[...path, id]]
: []
: (requires.get(id) ?? []).flatMap((next) => walk(next, [...path, id]))

return walk(definition.id, [])
})

// A renderer that uses the Ipc its own main entry provides depends on no other extension.
const edges = definitions.flatMap((consumer) =>
ids(consumer.uses).flatMap((token) => {
const provider = providerOf(token)

return provider && provider !== consumer.id ? [{ consumer: consumer.id, provider, token }] : []
}),
)

const rows = [{ consumer: "*", provider: "", token: "" }, ...edges]
const outcomes = []

for (const row of rows) {
const host = mountExtensions({ definitions, disabled: row.provider ? [row.provider] : [] })
host.release()
await until(() => host.ready())
const consumers = row.consumer === "*" ? definitions.map((definition) => definition.id) : [row.consumer]
outcomes.push({
...row,
results: consumers.map((id) => ({ id, status: host.status(id), failure: host.failure(id)?.error ?? null })),
})
host.unmount()
}

return { cycles, outcomes }
}, modules)

expect(result.cycles).toEqual([])
expect(result.outcomes).toEqual(
result.outcomes.map((outcome) => ({
...outcome,
results: outcome.results.map((item) => ({ id: item.id, status: "active", failure: null })),
})),
)
})
Loading
Loading