Compare commits

...
25 Commits
Author SHA1 Message Date
LukeParkerDev 4f1dbd8189 chore(context): name the built-in extension in settings 2026-09-09 09:53:02 +10:00
LukeParkerDev 33a323b2a5 refactor(app): extract context usage into an extension 2026-09-09 09:53:02 +10:00
LukeParkerDev 37218177cf test(app): use review extension tab label 2026-09-09 09:53:00 +10:00
LukeParkerDev fbb70f1317 chore(review): name the built-in extension in settings 2026-09-09 09:52:59 +10:00
LukeParkerDev 68c1138585 refactor(review): compose shared file navigation controls 2026-09-09 09:52:58 +10:00
LukeParkerDev 04e3fc280d fix(review): retain mobile diff presentation and wrapping 2026-09-09 09:52:58 +10:00
LukeParkerDev 444be68cc2 refactor(app): extract review and file viewers into an extension 2026-09-09 09:52:57 +10:00
LukeParkerDev ebe5420755 chore(browser): describe the built-in extension in settings 2026-09-09 09:52:54 +10:00
LukeParkerDev a531a61a72 refactor(desktop): extract the browser extension package 2026-09-09 09:52:53 +10:00
LukeParkerDev d4539ff55c fix(ui): declare auxiliary panel layer order 2026-09-09 09:52:48 +10:00
LukeParkerDev 7acdea1437 test(app): measure retained terminal snapshot tail 2026-09-09 09:37:40 +10:00
LukeParkerDev 890f8fe863 fix(plugin): release unused workspace stores 2026-09-09 09:35:17 +10:00
LukeParkerDev fa4bce6f7a feat(plugin): add workspace state and auxiliary dock primitives 2026-09-09 09:12:18 +10:00
LukeParkerDev d8804ffd65 refactor(app): separate tools and extensions settings 2026-09-09 08:40:12 +10:00
LukeParkerDev 7bb391582b fix(app): expose complete extension metadata types 2026-09-09 08:14:44 +10:00
LukeParkerDev 07dcde43c6 feat(desktop): port extension manager with live reload 2026-09-09 08:06:40 +10:00
LukeParkerDev 66b16d40f0 feat(ui): share file navigation controls and mobile panel layout 2026-09-08 20:51:07 +10:00
LukeParkerDev de7ab3b082 feat(plugin): share the configured diff presentation preference 2026-09-08 20:48:23 +10:00
LukeParkerDev 29a05fa343 fix(app): render extension icons with their declaring context 2026-09-08 20:46:42 +10:00
LukeParkerDev 0dc7aa2411 feat(plugin): expose tab closing policy to session contributions 2026-09-08 20:42:14 +10:00
LukeParkerDev b5222ef904 fix(app): retain active panel groups across preview replacement 2026-09-08 20:38:05 +10:00
LukeParkerDev 30c25008ac fix(app): preserve grouped panel content and shared translations 2026-09-08 20:35:27 +10:00
LukeParkerDev 7466567c51 feat(plugin): expose shared session capabilities and panel references 2026-09-08 20:20:25 +10:00
LukeParkerDev 39310ee609 fix(plugin): retain panel order across route remounts 2026-09-08 19:31:55 +10:00
LukeParkerDev cc40f5844c feat(plugin): explore a desktop extension host 2026-09-08 19:04:39 +10:00
201 changed files with 7639 additions and 3458 deletions
+75 -1
View File
@@ -63,7 +63,9 @@
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-context-desktop": "workspace:*",
"@opencode/plugin-review-desktop": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
@@ -426,7 +428,9 @@
"@lydell/node-pty": "catalog:",
"@opencode/app": "workspace:*",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin-browser-desktop": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/ui": "workspace:*",
"@sentry/solid": "catalog:",
@@ -597,8 +601,12 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@zip.js/zip.js": "2.7.62",
"electron": "42.10.1",
"solid-js": "catalog:",
"typescript": "catalog:",
"vite": "8.2.2",
"vite-plugin-solid": "2.11.14",
},
"peerDependencies": {
"@opencode/theme": "workspace:*",
@@ -628,6 +636,66 @@
"typescript": "catalog:",
},
},
"packages/plugin-browser-desktop": {
"name": "@opencode/plugin-browser-desktop",
"version": "0.0.0",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/ui": "workspace:*",
"effect": "catalog:",
"lighthouse": "13.4.1",
"solid-js": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"devtools-protocol": "0.0.1687809",
"electron": "42.10.1",
"puppeteer-core": "25.9.0",
},
},
"packages/plugin-context-desktop": {
"name": "@opencode/plugin-context-desktop",
"version": "0.0.0",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
"@opencode/util": "workspace:*",
"luxon": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/plugin-review-desktop": {
"name": "@opencode/plugin-review-desktop",
"version": "0.0.0",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
"@opencode/util": "workspace:*",
"@solid-primitives/event-listener": "catalog:",
"@solid-primitives/scheduled": "1.5.3",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/posts": {
"name": "@opencode/posts",
"dependencies": {
@@ -2215,6 +2283,12 @@
"@opencode/plugin-browser": ["@opencode/plugin-browser@workspace:packages/plugin-browser"],
"@opencode/plugin-browser-desktop": ["@opencode/plugin-browser-desktop@workspace:packages/plugin-browser-desktop"],
"@opencode/plugin-context-desktop": ["@opencode/plugin-context-desktop@workspace:packages/plugin-context-desktop"],
"@opencode/plugin-review-desktop": ["@opencode/plugin-review-desktop@workspace:packages/plugin-review-desktop"],
"@opencode/posts": ["@opencode/posts@workspace:packages/posts"],
"@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"],
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test"
import { readFile } from "node:fs/promises"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test("context opens independently, exports its session, and restores the closed panel", async ({ page }, info) => {
await page.setViewportSize({ width: 1440, height: 900 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
const toggle = page.getByRole("button", { name: "View context usage", exact: true })
await toggle.click()
const context = page.getByRole("tabpanel", { name: "Context", exact: true })
await expect(context.getByText("Total Tokens", { exact: true })).toBeVisible()
await expect(context.getByText(fixture.expected.sourceTitle, { exact: true })).toBeVisible()
await expect(context.getByText("Raw messages", { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath("context-extension.png") })
const pending = page.waitForEvent("download")
await context.getByRole("button", { name: "Export session", exact: true }).click()
const download = await pending
const exported: { info: { id: string }; messages: { id: string }[] } = JSON.parse(
await readFile((await download.path())!, "utf8"),
)
expect(exported.info.id).toBe(fixture.sourceID)
expect(exported.messages.length).toBeGreaterThan(0)
expect(new Set(exported.messages.map((message) => message.id)).size).toBe(exported.messages.length)
await toggle.click()
await expect(page.getByRole("tab", { name: "Context", exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toHaveAttribute(
"aria-expanded",
"false",
)
await toggle.click()
await expect(context.getByText(fixture.expected.sourceTitle, { exact: true })).toBeVisible()
})
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="UTF-8" /><style>html,body,#root{height:100%;margin:0}#root{display:flex;flex-direction:column}</style></head><body><div id="root"></div><script type="module" src="./fixture.tsx"></script></body></html>
+81
View File
@@ -0,0 +1,81 @@
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider } from "../../src/runtime/platform/platform"
import { createWebPlatform } from "../../src/runtime/platform/web"
import { ServerConnection } from "../../src/runtime/server/registry"
import { Plugin } from "@opencode/plugin/desktop"
import { Panel } from "@opencode/plugin/desktop/solid"
import { Button } from "@opencode/ui/button"
import { TextInput } from "@opencode/ui/text-input"
import { Stack, Text } from "@opencode/ui/layout"
import { createMemoryHistory, MemoryRouter } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { base64Encode } from "@opencode/util/encode"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
const plugin = Plugin.define({
id: "test.panels",
setup(ctx) {
const [state, setState] = createStore({ available: true, closed: 0 })
const [draft, saveDraft] = ctx.storage.memory("draft", { initial: { text: "" } })
ctx.ui.slot({
append: "titlebar.actions",
render: () => (
<>
<Button
onClick={() => {
const session = ctx.sessions.current()
if (session) ctx.ui.panel.open("notes", session)
}}
>
Open notes
</Button>
<Button onClick={() => setState("available", !state.available)}>Toggle contribution</Button>
</>
),
})
ctx.ui.slot({
append: "session.panel",
when: () => state.available,
render: () => (
<>
<Panel id="notes" title="Notes" onClose={() => setState("closed", (count) => count + 1)}>
<Stack padding="medium">
<TextInput
aria-label="Notes draft"
value={draft.text}
onInput={(event) =>
saveDraft((draft) => {
draft.text = event.currentTarget.value
})
}
/>
</Stack>
</Panel>
<Panel id="results" title="Results">
<Stack padding="medium">
<Text>Closed notes: {state.closed}</Text>
</Stack>
</Panel>
</>
),
})
},
})
const web = createWebPlatform("test")
const history = createMemoryHistory()
history.set({ value: `/server/${base64Encode(web.currentServerUrl!)}/session/${fixture.sourceID}` })
render(
() => (
<PlatformProvider value={{ ...web.platform, extensionPlugins: [plugin] }}>
<AppBaseProviders>
<AppInterface
servers={[{ type: "http", http: { url: web.currentServerUrl! } }]}
defaultServer={ServerConnection.Key.make(web.currentServerUrl!)}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</AppBaseProviders>
</PlatformProvider>
),
document.getElementById("root")!,
)
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="UTF-8" /><style>html,body,#root{height:100%;margin:0}#root{display:flex;flex-direction:column}</style></head><body><div id="root"></div><script type="module" src="./manager-fixture.tsx"></script></body></html>
@@ -0,0 +1,53 @@
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider } from "../../src/runtime/platform/platform"
import { createWebPlatform } from "../../src/runtime/platform/web"
import { ServerConnection } from "../../src/runtime/server/registry"
import { createMemoryHistory, MemoryRouter } from "@solidjs/router"
import { render } from "solid-js/web"
import { Schema } from "effect"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
const web = createWebPlatform("test")
const history = createMemoryHistory()
history.set({ value: "/settings" })
const inventory = Schema.Array(ExtensionManager.Installed)
async function request(name: string, body?: BodyInit) {
const response = await fetch(`/__desktop-extensions/${name}`, { method: body ? "POST" : "GET", body })
const value: unknown = await response.json()
if (!response.ok)
throw new ExtensionManager.ManagerError(Schema.decodeUnknownSync(ExtensionManager.Failure)(value).code)
return value
}
const manager: ExtensionManager.Transport = {
list: async () => Schema.decodeUnknownSync(inventory)(await request("list")),
install: async (data) =>
Schema.decodeUnknownSync(inventory)(await request("install", data as Uint8Array<ArrayBuffer>)),
installURL: async (url) => Schema.decodeUnknownSync(inventory)(await request("url", JSON.stringify({ url }))),
enable: async (id, enabled) =>
Schema.decodeUnknownSync(inventory)(await request("enable", JSON.stringify({ id, enabled }))),
reload: async (id) => Schema.decodeUnknownSync(inventory)(await request("reload", JSON.stringify({ id }))),
source: async (id, revision) =>
Schema.decodeUnknownSync(ExtensionManager.Source)(await request("source", JSON.stringify({ id, revision }))),
assetURL: (id, revision, path) => `/__desktop-extensions/assets/${id}/${revision}/${path}`,
onChange(callback) {
const listener = (event: Event) => {
if (event instanceof CustomEvent) callback(Schema.decodeUnknownSync(inventory)(event.detail))
}
window.addEventListener("test-extensions-changed", listener)
return () => window.removeEventListener("test-extensions-changed", listener)
},
}
render(
() => (
<PlatformProvider value={{ ...web.platform, extensionManager: manager }}>
<AppBaseProviders>
<AppInterface
servers={[{ type: "http", http: { url: web.currentServerUrl! } }]}
defaultServer={ServerConnection.Key.make(web.currentServerUrl!)}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</AppBaseProviders>
</PlatformProvider>
),
document.getElementById("root")!,
)
+264
View File
@@ -0,0 +1,264 @@
import { expect, test } from "@playwright/test"
import { Schema } from "effect"
import { createServer } from "node:http"
import { once } from "node:events"
import { openDatabase } from "../../../desktop/src/main/storage/database"
import { createExtensionManager, readExtensionAsset } from "../../../desktop/src/main/extensions/manager"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { extensionArchive } from "../../../desktop/test/extensions/fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs } from "../performance/timeline/timeline-test-helpers"
const renderer = (version: string) => `
const { usePlugin } = require('@opencode/plugin/desktop');
const { createEffect, createComponent } = require('solid-js');
const { Panel } = require('@opencode/plugin/desktop/solid');
const { Schema } = require('effect');
module.exports.default = { id: 'test.lifecycle', setup(ctx) {
const [documentState, setDocument, ready] = ctx.storage.persist('document', Schema.Struct({ text: Schema.String }), { text: 'empty' }, { legacyKey: 'extension-fixture-legacy' });
const [state, update] = ctx.storage.memory('counts', { initial: { starts: 0, stops: 0 } });
update(state => state.starts++);
ctx.lifecycle.own(() => update(state => state.stops++));
const marker = (id) => {
const output = document.createElement('output'); output.hidden = true; output.dataset.testid = id;
createEffect(() => output.textContent = ${JSON.stringify(version)} + ':' + state.starts + ':' + state.stops);
return output;
};
ctx.commands.register(() => [
{ id: 'show', title: 'Show extension fixture', bind: 'mod+shift+y', run() { const session = ctx.sessions.current(); if (session) ctx.ui.panel.open('state', session) } },
{ id: 'save', title: 'Save extension fixture', bind: 'mod+shift+x', run() { setDocument('text', 'saved') } },
]);
ctx.ui.slot({ append: 'session.panel', render: () => createComponent(Panel, { id: 'state', title: 'File utilities', group: 'state', get children() { return marker('extension-panel-lifecycle') } }) });
ctx.ui.slot({ append: 'app', render() {
if (usePlugin().lifecycle.signal !== ctx.lifecycle.signal) throw new Error('Shared plugin context identity was lost');
const stored = document.createElement('output'); stored.hidden = true; stored.dataset.testid = 'extension-persisted';
createEffect(() => stored.textContent = ready() ? documentState.text : 'loading');
return [marker('extension-lifecycle'), stored];
} });
} };`
const payload = Schema.Struct({
id: Schema.optionalKey(Schema.String),
revision: Schema.optionalKey(Schema.String),
enabled: Schema.optionalKey(Schema.Boolean),
url: Schema.optionalKey(Schema.String),
})
for (const direction of ["ltr", "rtl"] as const)
test(`manager installs, hot-replaces, reloads, and disables across windows in ${direction}`, async ({
page,
context,
}, info) => {
const database = openDatabase(":memory:")
const manager = createExtensionManager({
db: database.db,
fetch,
changed(_id, entries) {
context.pages().forEach((page) => {
void page.evaluate(
(entries) => window.dispatchEvent(new CustomEvent("test-extensions-changed", { detail: entries })),
entries,
)
})
},
})
const archive = async (version: string, code = renderer(version)) =>
extensionArchive({
version,
renderer: code,
files: { "assets/style.css": ":root { --installed-extension-marker: 1; }" },
manifest: {
imports: ["@opencode/plugin/desktop", "@opencode/plugin/desktop/solid", "solid-js", "effect"],
style: "assets/style.css",
},
})
const first = await archive("1.0.0")
const http = createServer((_request, response) => {
response.setHeader("Content-Type", "application/vnd.ocdx")
response.end(first)
})
http.listen(0, "127.0.0.1")
await once(http, "listening")
const address = http.address()
if (!address || typeof address === "string") throw new Error("Fixture address is unavailable")
await context.route("**/__desktop-extensions/**", async (route) => {
const path = new URL(route.request().url()).pathname.split("/").slice(2)
try {
if (path[0] === "assets") {
const bytes = readExtensionAsset(database.db, path[1], path[2], path.slice(3).join("/"))
await route.fulfill({ status: bytes ? 200 : 404, body: bytes, contentType: "text/css" })
return
}
const data = Schema.decodeUnknownSync(payload)(
JSON.parse(path[0] === "install" ? "{}" : (route.request().postData() ?? "{}")),
)
const result =
path[0] === "install"
? await manager.install(route.request().postDataBuffer()!)
: path[0] === "url"
? await manager.installURL(data.url ?? "")
: path[0] === "enable"
? manager.enable(data.id ?? "", data.enabled ?? false)
: path[0] === "reload"
? manager.reload(data.id ?? "")
: path[0] === "source"
? manager.source(data.id ?? "", data.revision)
: manager.list()
await route.fulfill({ json: result })
} catch (error) {
await route.fulfill({
status: 400,
json: { code: error instanceof ExtensionManager.ManagerError ? error.code : "storage" },
})
}
})
const open = async (target: typeof page) => {
await mockOpenCodeServer(target, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(target)
await target.addInitScript(() =>
localStorage.setItem("opencode.global.dat:extension-fixture-legacy", JSON.stringify({ text: "legacy" })),
)
await target.goto("/e2e/extensions/manager-fixture.html")
await target.getByTestId("settings-screen").getByRole("tab", { name: "Extensions", exact: true }).click()
await expect(target.getByRole("heading", { name: "Install extensions", exact: true })).toBeVisible()
const settings = target.getByTestId("settings-screen")
const navigation = settings.locator(".settings-nav")
await expect(
navigation
.locator('[data-slot="settings-nav-group"]')
.filter({ has: target.getByRole("tab", { name: "Extensions", exact: true }) })
.getByRole("tab"),
).toHaveText(["Extensions", "Experimental"])
await expect(
navigation
.locator('[data-slot="settings-nav-group"]')
.filter({ has: target.getByRole("tab", { name: "Tools", exact: true }) })
.getByRole("tab"),
).toHaveText(["Providers", "Models", "Tools"])
await expect(settings.getByRole("tab", { name: "Desktop", exact: true })).toHaveCount(0)
await expect(settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveCount(0)
await settings.getByRole("tab", { name: "Tools", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "MCPs", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Skills", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Install extensions", exact: true })).toHaveCount(0)
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await target.evaluate((direction) => {
document.documentElement.dir = direction
}, direction)
}
try {
await open(page)
await page.setViewportSize({ width: 640, height: 900 })
await page.getByRole("button", { name: "Extensions", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Tools", exact: true }).click()
await expect(page.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
await page.getByRole("button", { name: "Tools", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Extensions", exact: true }).click()
await expect(page.getByRole("heading", { name: "Install extensions", exact: true })).toBeVisible()
await page.setViewportSize({ width: 1280, height: 720 })
await expect(page.locator('[data-component="desktop-extension-manager"]').getByRole("status")).toHaveCount(0)
await page.screenshot({ path: info.outputPath("extensions-manager-empty.png") })
await page
.getByRole("textbox", { name: "Extension URL", exact: true })
.fill(`http://127.0.0.1:${address.port}/extension.ocdx`)
await page.getByRole("textbox", { name: "Extension URL", exact: true }).press("Enter")
const enabled = page.getByRole("switch", { name: "Enable File utilities", exact: true })
await expect(enabled).toBeChecked()
await expect(page.getByTestId("extension-lifecycle")).toHaveText("1.0.0:1:0")
await expect(page.getByTestId("extension-persisted")).toHaveText("legacy")
await page.keyboard.press("Control+Shift+x")
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
await page.screenshot({ path: info.outputPath("extensions-manager.png") })
await page.getByRole("button", { name: "Reload File utilities", exact: true }).click()
await expect(page.getByTestId("extension-lifecycle")).toHaveText("1.0.0:2:1")
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
await expect(page.locator('link[href*="/__desktop-extensions/assets/"]')).toHaveCount(1)
const second = await context.newPage()
await open(second)
await expect(second.getByTestId("extension-lifecycle")).toHaveText("1.0.0:1:0")
await expect(second.getByTestId("extension-persisted")).toHaveText("saved")
await second.locator('header a[href$="/ses_smoke_source"]').click()
await expect(second.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
await expect(second.getByRole("button", { name: "Toggle review", exact: true })).toBeEnabled()
await second.keyboard.press("Control+Shift+y")
await expect(second.getByTestId("extension-panel-lifecycle")).toHaveText("1.0.0:1:0")
await page.getByLabel("Extension files", { exact: true }).setInputFiles({
name: "update.ocdx",
mimeType: "application/vnd.ocdx",
buffer: Buffer.from(await archive("2.0.0")),
})
await expect(page.getByTestId("extension-lifecycle")).toHaveText("2.0.0:3:2")
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
await expect(second.getByTestId("extension-lifecycle")).toHaveText("2.0.0:2:1")
await expect(second.getByTestId("extension-panel-lifecycle")).toHaveText("2.0.0:2:1")
await page.getByLabel("Extension files", { exact: true }).setInputFiles({
name: "broken.ocdx",
mimeType: "application/vnd.ocdx",
buffer: Buffer.from(
await archive(
"3.0.0",
`module.exports.default = { id: 'test.lifecycle', setup(ctx) { ctx.ui.slot({ append: 'app', render() { const node = document.createElement('output'); node.hidden = true; node.dataset.testid = 'partial-load'; return node } }); throw new Error('broken update') } }`,
),
),
})
await expect(page.getByRole("alert")).toHaveText("Unable to activate File utilities.")
await expect(page.getByTestId("extension-lifecycle")).toHaveText("2.0.0:3:2")
await expect(page.getByTestId("partial-load")).toHaveCount(0)
await expect(page.locator('link[href*="/__desktop-extensions/assets/"]')).toHaveCount(1)
await page.getByLabel("Extension files", { exact: true }).setInputFiles({
name: "fixed.ocdx",
mimeType: "application/vnd.ocdx",
buffer: Buffer.from(await archive("4.0.0")),
})
await expect(page.getByTestId("extension-lifecycle")).toHaveText("4.0.0:4:3")
await second.keyboard.press("Control+,")
await second.getByTestId("settings-screen").getByRole("tab", { name: "Extensions", exact: true }).click()
await page
.locator('[data-component="settings-row"]')
.filter({ has: enabled })
.locator('[data-slot="switch-control"]')
.click()
await expect(second.getByRole("switch", { name: "Enable File utilities", exact: true })).not.toBeChecked()
await expect(page.getByTestId("extension-lifecycle")).toHaveCount(0)
await expect(page.getByTestId("extension-persisted")).toHaveCount(0)
await expect(second.getByTestId("extension-lifecycle")).toHaveCount(0)
await expect(page.locator('link[href*="/__desktop-extensions/assets/"]')).toHaveCount(0)
await enabled.press("Space")
await expect(page.getByTestId("extension-lifecycle")).toHaveText("4.0.0:5:4")
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
await expect(second.getByTestId("extension-lifecycle")).toHaveText("4.0.0:4:3")
const archives = await Promise.all(
["Text helpers", "Path helpers"].map(async (name, index) => ({
name: `helper-${index}.ocdx`,
bytes: Buffer.from(await extensionArchive({ id: `test.helper-${index}`, manifest: { name } })).toString(
"base64",
),
})),
)
const drop = await page.evaluateHandle((archives) => {
const transfer = new DataTransfer()
archives.forEach((archive) =>
transfer.items.add(
new File([Uint8Array.from(atob(archive.bytes), (char) => char.charCodeAt(0))], archive.name),
),
)
return transfer
}, archives)
await page.locator(".desktop-extension-drop").dispatchEvent("drop", { dataTransfer: drop })
await expect(page.getByRole("switch", { name: "Enable Text helpers", exact: true })).toBeChecked()
await expect(second.getByRole("switch", { name: "Enable Path helpers", exact: true })).toBeChecked()
await drop.dispose()
await second.close()
} finally {
http.close()
database.close()
}
})
@@ -0,0 +1,48 @@
import { test, expect } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs } from "../performance/timeline/timeline-test-helpers"
test("independent panel instances retain drafts, close explicitly, and obey plugin availability", async ({
page,
}, info) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page)
await page.goto("/e2e/extensions/fixture.html")
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
await page.getByRole("button", { name: "Open notes", exact: true }).click()
await page.getByRole("textbox", { name: "Notes draft", exact: true }).fill("Retained draft")
await page.getByRole("tab", { name: "Results", exact: true }).click()
await expect(page.getByText("Closed notes: 0", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Notes", exact: true }).click()
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
await page
.locator('[data-slot="tabs-trigger-wrapper"][data-value="extension:test.panels:notes"]')
.getByRole("button", { name: "Close tab", exact: true })
.click()
await page.getByRole("tab", { name: "Results", exact: true }).click()
await expect(page.getByText("Closed notes: 1", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Open notes", exact: true }).click()
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
await page.screenshot({ path: info.outputPath("extension-panels.png") })
await page
.getByRole("tab", { name: "Results", exact: true })
.dragTo(page.getByRole("tab", { name: "Notes", exact: true }))
await expect(page.getByRole("tab", { name: /^(Notes|Results)$/ })).toHaveText(["Results", "Notes"])
await page.getByRole("button", { name: "Home", exact: true }).click()
await page.locator('header a[href$="/ses_smoke_source"]').click()
await page.getByRole("button", { name: "Open notes", exact: true }).click()
await expect(page.getByRole("tab", { name: /^(Notes|Results)$/ })).toHaveText(["Results", "Notes"])
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
await page.getByRole("button", { name: "Toggle contribution", exact: true }).click()
await expect(page.getByRole("tab", { name: "Notes", exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Toggle contribution", exact: true }).click()
await page.getByRole("button", { name: "Open notes", exact: true }).click()
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
})
@@ -0,0 +1,26 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs } from "../performance/timeline/timeline-test-helpers"
test.use({ viewport: { width: 1280, height: 720 }, serviceWorkers: "block" })
test("server tools remain available without a native extension manager", async ({ page }, info) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Tools", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Skills", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toHaveCount(0)
await page.screenshot({ path: info.outputPath("tools-settings.png") })
})
@@ -28,7 +28,9 @@ resizing reduces the effective row capacity. The report records actual retained
rows and the first retained fixture record rather than assuming 10,000 rows. Completion
requires the final marker in Ghostty and completion of its write callbacks, not
just WebSocket delivery. Teardown requires Home readiness and the final serialized
snapshot. Input, focus, resizing, and native process survival are checked.
snapshot, capped at the production limit of 2,000 scrollback rows plus the screen.
The snapshot must contain the same ordered workload records as that retained tail.
Input, focus, resizing, and native process survival are checked.
`probe.ts` is included only by this benchmark build. It observes actual writes,
renderer calls, and serialization. Chrome `TaskDuration` measures renderer task
@@ -236,6 +236,15 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
const close = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"]) [data-component="icon-button-v2"]`)
await expect(close).toBeVisible()
const cpuBefore = await cdp.send("Performance.getMetrics")
const retained = await page.evaluate(() => {
const term = window.terminalProbe.term!
const buffer = term.buffer.normal
const start = Math.max(0, buffer.length - term.rows - 2_000)
return Array.from(
{ length: buffer.length - start },
(_, index) => buffer.getLine(start + index)?.translateToString(true) ?? "",
).join("\n")
})
const start = await page.evaluate(() => performance.now())
await close.click()
await expect(page).toHaveURL("/")
@@ -256,9 +265,11 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
cpuBefore.metrics.find((x) => x.name === "TaskDuration")!.value) *
1000
const snapshot = await page.evaluate(() => window.terminalProbe.serialized[0].value)
expect(Array.from(snapshot.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))).toEqual(
Array.from({ length: 12_000 - produced.firstRecord }, (_, index) => produced.firstRecord + index),
)
const records = (text: string) => Array.from(text.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))
const expected = records(retained)
const actual = records(snapshot)
expect(actual.length).toBe(expected.length)
expect(actual.every((record, index) => record === expected[index])).toBe(true)
expect(snapshot).toContain("TERMINAL_WORKLOAD_DONE")
await writeFile(
path.join(
@@ -301,6 +312,7 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
await expect
.poll(async () => sizes.at(-1)?.cols === (await page.evaluate(() => window.terminalProbe.term!.cols)))
.toBe(true)
await expect(page.locator('#terminal-panel [data-slot="tabs-list"]')).toHaveCSS("padding-inline-start", "12px")
expect(closed).toBe(0)
}
if (process.env.TERMINAL_SCREENSHOTS && scenario !== "full-scrollback-teardown") {
@@ -59,6 +59,9 @@ test("keeps the file-browser sidebar mounted when switching file tabs", async ({
expect(await readProbe(page)).toBe(PROBE)
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
})
test.afterEach(async ({ page }, info) => {
if (info.status === "passed") await page.screenshot({ path: info.outputPath("review-files-extension.png") })
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -139,7 +139,7 @@ test("uses side placement by default and supports the terminal across the bottom
await expectSessionReady(page, { server, sessionID, title })
await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
await expect(page.locator("#review-panel").getByRole("tab", { name: "Files Changed 2740", exact: true })).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
@@ -169,7 +169,7 @@ test("worktree deletion sends the project location separately from the target",
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
})
test("extensions opens without waiting for MCPs", async ({ page }) => {
test("tools opens without waiting for MCPs", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
await mcps.promise
@@ -179,9 +179,9 @@ test("extensions opens without waiting for MCPs", async ({ page }) => {
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await settings.getByRole("tab", { name: "Tools", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
mcps.resolve()
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
@@ -55,7 +55,7 @@ for (const viewport of [
"Worktrees",
"Providers",
"Models",
"Extensions",
"Tools",
"Experimental",
"About",
]) {
@@ -206,9 +206,9 @@ test("animates review and terminal panels while caching hidden terminal content"
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expectAnimation(page, "terminal-panel-size-in")
await expectAnimation(page, "auxiliary-panel-size-in")
await page.keyboard.press("Control+Backquote")
await expectAnimation(page, "terminal-panel-size-out")
await expectAnimation(page, "auxiliary-panel-size-out")
await expect(panel).toBeHidden()
await expect(page.locator('[data-component="terminal"]')).toBeAttached()
})
+3 -1
View File
@@ -64,8 +64,10 @@
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-review-desktop": "workspace:*",
"@opencode/plugin-context-desktop": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
"@opencode/util": "workspace:*",
@@ -0,0 +1,49 @@
import path from "node:path"
const packages = path.resolve(import.meta.dirname, "../..")
const imports = new Set([
"solid-js",
"solid-js/store",
"solid-js/web",
"@tanstack/solid-query",
"effect",
"@opencode/plugin/desktop",
"@opencode/plugin/desktop/solid",
"@opencode/plugin/desktop/manager",
"@opencode/plugin/desktop/persistence",
"@opencode/schema/rpc",
"@opencode/client",
"@opencode/client/solid",
"@opencode/util/encode",
"@opencode/util/path",
])
for (const name of ["ui", "session-ui"]) {
const directory = path.join(packages, name)
const manifest: { name: string; exports: Record<string, string> } = await Bun.file(
path.join(directory, "package.json"),
).json()
for (const [key, target] of Object.entries(manifest.exports)) {
if (!/\.tsx?$/.test(target) || /storybook/.test(key)) continue
if (!key.includes("*")) {
imports.add(key === "." ? manifest.name : manifest.name + key.slice(1))
continue
}
const [prefix, suffix] = target.split("*")
for await (const file of new Bun.Glob(target.slice(2)).scan(directory)) {
if (/(\.test\.|\.stories\.|\.story\.)/.test(file)) continue
imports.add(
manifest.name +
key.slice(1).replace("*", ("./" + file.replaceAll("\\", "/")).slice(prefix.length, -suffix.length)),
)
}
}
}
await Bun.write(
path.join(packages, "app/src/extensions/modules.gen.ts"),
`// Generated by script/generate-extension-modules.ts. Shared renderer modules only.\nexport const modules: Record<string, () => Promise<unknown>> = {\n${Array.from(
imports,
)
.sort()
.map((name) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(name)}),`)
.join("\n")}\n}\n`,
)
-10
View File
@@ -4,16 +4,6 @@ export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export type {
BrowserPaneCommand,
BrowserPaneEndpoint,
BrowserPaneEvent,
BrowserPaneLayout,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneState,
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
+56
View File
@@ -0,0 +1,56 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import type { RegisteredPanel } from "./provider"
/** Grouped content stays mounted while any declaration in its group is available. */
export function ExtensionPanelContent(props: { panels: readonly RegisteredPanel[]; active: string | undefined }) {
const selected = createMemo(() => props.panels.find((panel) => panel.key === props.active))
const groups = createMemo(() => Array.from(new Set(props.panels.filter((panel) => panel.props.group).map(groupKey))))
const single = createMemo(() => {
const panel = selected()
return panel && !panel.props.group ? panel : undefined
})
return (
<>
<For each={groups()}>
{(key) => {
const [mounted, setMounted] = createSignal(false)
const active = () => !!selected() && groupKey(selected()!) === key
const declaration = props.panels.find((panel) => groupKey(panel) === key)!
createEffect(() => {
if (active()) setMounted(true)
})
return (
<Show when={mounted()}>
<div
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden flex flex-col"
classList={{ hidden: !active() }}
inert={!active()}
aria-label={selected()?.props.title}
>
{declaration.render()}
</div>
</Show>
)
}}
</For>
<Show when={single()} keyed>
{(panel) => (
<div
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden flex flex-col"
aria-label={panel.props.title}
>
{panel.render()}
</div>
)}
</Show>
</>
)
}
function groupKey(panel: RegisteredPanel) {
return `${panel.session.key}/${panel.plugin}/${panel.generation}/${panel.props.group ?? panel.key}`
}
+2
View File
@@ -0,0 +1,2 @@
export const extensionTabKey = (plugin: string, id: string) => `extension:${plugin}:${id}`
export const isExtensionTab = (id: string | undefined) => !!id?.startsWith("extension:")
+36
View File
@@ -0,0 +1,36 @@
import { Schema } from "effect"
import { Plugin } from "@opencode/plugin/desktop"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { evaluateBundle } from "@opencode/plugin/desktop/bundle"
export async function loadExtension(input: ExtensionManager.Source) {
const { modules } = await import("./modules.gen")
const shared = new Map(
await Promise.all(
input.manifest.imports.map(async (name) => {
const load = modules[name]
if (!load) throw new ExtensionManager.ManagerError("invalidModule")
return [name, await load()] as const
}),
),
)
const value = Schema.decodeUnknownOption(Schema.Struct({ default: Plugin.Definition }))(
evaluateBundle(input.source, shared),
)
if (value._tag === "None" || value.value.default.id !== input.manifest.id)
throw new ExtensionManager.ManagerError("invalidModule")
const definition = value.value.default
return {
...definition,
setup(context) {
if (input.manifest.style) {
const stylesheet = document.createElement("link")
stylesheet.rel = "stylesheet"
stylesheet.href = context.assets.url(input.manifest.style)
document.head.append(stylesheet)
context.lifecycle.own(() => stylesheet.remove())
}
return definition.setup(context)
},
} satisfies Plugin.Definition
}
+216
View File
@@ -0,0 +1,216 @@
// Generated by script/generate-extension-modules.ts. Shared renderer modules only.
export const modules: Record<string, () => Promise<unknown>> = {
"@opencode/client": () => import("@opencode/client"),
"@opencode/client/solid": () => import("@opencode/client/solid"),
"@opencode/plugin/desktop": () => import("@opencode/plugin/desktop"),
"@opencode/plugin/desktop/manager": () => import("@opencode/plugin/desktop/manager"),
"@opencode/plugin/desktop/persistence": () => import("@opencode/plugin/desktop/persistence"),
"@opencode/plugin/desktop/solid": () => import("@opencode/plugin/desktop/solid"),
"@opencode/schema/rpc": () => import("@opencode/schema/rpc"),
"@opencode/session-ui/actions": () => import("@opencode/session-ui/actions"),
"@opencode/session-ui/attachment-card": () => import("@opencode/session-ui/attachment-card"),
"@opencode/session-ui/basic-tool": () => import("@opencode/session-ui/basic-tool"),
"@opencode/session-ui/comment-card": () => import("@opencode/session-ui/comment-card"),
"@opencode/session-ui/context": () => import("@opencode/session-ui/context"),
"@opencode/session-ui/context/data": () => import("@opencode/session-ui/context/data"),
"@opencode/session-ui/context/markdown": () => import("@opencode/session-ui/context/markdown"),
"@opencode/session-ui/dock-prompt": () => import("@opencode/session-ui/dock-prompt"),
"@opencode/session-ui/document": () => import("@opencode/session-ui/document"),
"@opencode/session-ui/file": () => import("@opencode/session-ui/file"),
"@opencode/session-ui/file-media": () => import("@opencode/session-ui/file-media"),
"@opencode/session-ui/file-search": () => import("@opencode/session-ui/file-search"),
"@opencode/session-ui/file-ssr": () => import("@opencode/session-ui/file-ssr"),
"@opencode/session-ui/line-comment": () => import("@opencode/session-ui/line-comment"),
"@opencode/session-ui/line-comment-annotations": () => import("@opencode/session-ui/line-comment-annotations"),
"@opencode/session-ui/line-comment-styles": () => import("@opencode/session-ui/line-comment-styles"),
"@opencode/session-ui/markdown": () => import("@opencode/session-ui/markdown"),
"@opencode/session-ui/markdown-cache": () => import("@opencode/session-ui/markdown-cache"),
"@opencode/session-ui/markdown-stream": () => import("@opencode/session-ui/markdown-stream"),
"@opencode/session-ui/message": () => import("@opencode/session-ui/message"),
"@opencode/session-ui/message-file": () => import("@opencode/session-ui/message-file"),
"@opencode/session-ui/message-nav": () => import("@opencode/session-ui/message-nav"),
"@opencode/session-ui/message-part": () => import("@opencode/session-ui/message-part"),
"@opencode/session-ui/pierre": () => import("@opencode/session-ui/pierre"),
"@opencode/session-ui/pierre/comment-hover": () => import("@opencode/session-ui/pierre/comment-hover"),
"@opencode/session-ui/pierre/commented-lines": () => import("@opencode/session-ui/pierre/commented-lines"),
"@opencode/session-ui/pierre/diff-selection": () => import("@opencode/session-ui/pierre/diff-selection"),
"@opencode/session-ui/pierre/file-find": () => import("@opencode/session-ui/pierre/file-find"),
"@opencode/session-ui/pierre/file-runtime": () => import("@opencode/session-ui/pierre/file-runtime"),
"@opencode/session-ui/pierre/file-selection": () => import("@opencode/session-ui/pierre/file-selection"),
"@opencode/session-ui/pierre/index": () => import("@opencode/session-ui/pierre/index"),
"@opencode/session-ui/pierre/media": () => import("@opencode/session-ui/pierre/media"),
"@opencode/session-ui/pierre/selection-bridge": () => import("@opencode/session-ui/pierre/selection-bridge"),
"@opencode/session-ui/pierre/virtualizer": () => import("@opencode/session-ui/pierre/virtualizer"),
"@opencode/session-ui/pierre/worker": () => import("@opencode/session-ui/pierre/worker"),
"@opencode/session-ui/session-diff": () => import("@opencode/session-ui/session-diff"),
"@opencode/session-ui/session-retry": () => import("@opencode/session-ui/session-retry"),
"@opencode/session-ui/session-review": () => import("@opencode/session-ui/session-review"),
"@opencode/session-ui/timeline": () => import("@opencode/session-ui/timeline"),
"@opencode/session-ui/timeline/detail": () => import("@opencode/session-ui/timeline/detail"),
"@opencode/session-ui/timeline/projection": () => import("@opencode/session-ui/timeline/projection"),
"@opencode/session-ui/timeline/row": () => import("@opencode/session-ui/timeline/row"),
"@opencode/session-ui/tool-count-label": () => import("@opencode/session-ui/tool-count-label"),
"@opencode/session-ui/tool-count-summary": () => import("@opencode/session-ui/tool-count-summary"),
"@opencode/session-ui/tool-error-card": () => import("@opencode/session-ui/tool-error-card"),
"@opencode/session-ui/tool-status-title": () => import("@opencode/session-ui/tool-status-title"),
"@opencode/session-ui/v2/line-comment-annotations-v2": () => import("@opencode/session-ui/v2/line-comment-annotations-v2"),
"@opencode/session-ui/v2/session-file-panel-v2": () => import("@opencode/session-ui/v2/session-file-panel-v2"),
"@opencode/session-ui/v2/session-progress-indicator-v2": () => import("@opencode/session-ui/v2/session-progress-indicator-v2"),
"@opencode/session-ui/v2/session-review-empty-changes-v2": () => import("@opencode/session-ui/v2/session-review-empty-changes-v2"),
"@opencode/session-ui/v2/session-review-empty-no-git-v2": () => import("@opencode/session-ui/v2/session-review-empty-no-git-v2"),
"@opencode/session-ui/v2/session-review-file-preview-v2": () => import("@opencode/session-ui/v2/session-review-file-preview-v2"),
"@opencode/session-ui/v2/session-review-v2": () => import("@opencode/session-ui/v2/session-review-v2"),
"@opencode/ui/accordion": () => import("@opencode/ui/accordion"),
"@opencode/ui/animated-number": () => import("@opencode/ui/animated-number"),
"@opencode/ui/app-icon": () => import("@opencode/ui/app-icon"),
"@opencode/ui/auxiliary-panel": () => import("@opencode/ui/auxiliary-panel"),
"@opencode/ui/avatar": () => import("@opencode/ui/avatar"),
"@opencode/ui/badge": () => import("@opencode/ui/badge"),
"@opencode/ui/button": () => import("@opencode/ui/button"),
"@opencode/ui/card": () => import("@opencode/ui/card"),
"@opencode/ui/checkbox": () => import("@opencode/ui/checkbox"),
"@opencode/ui/collapsible": () => import("@opencode/ui/collapsible"),
"@opencode/ui/context": () => import("@opencode/ui/context"),
"@opencode/ui/context-menu": () => import("@opencode/ui/context-menu"),
"@opencode/ui/context/dialog": () => import("@opencode/ui/context/dialog"),
"@opencode/ui/context/file": () => import("@opencode/ui/context/file"),
"@opencode/ui/context/helper": () => import("@opencode/ui/context/helper"),
"@opencode/ui/context/i18n": () => import("@opencode/ui/context/i18n"),
"@opencode/ui/context/marked": () => import("@opencode/ui/context/marked"),
"@opencode/ui/context/marked-base": () => import("@opencode/ui/context/marked-base"),
"@opencode/ui/context/marked-parser": () => import("@opencode/ui/context/marked-parser"),
"@opencode/ui/context/marked-theme": () => import("@opencode/ui/context/marked-theme"),
"@opencode/ui/context/marked-theme-register": () => import("@opencode/ui/context/marked-theme-register"),
"@opencode/ui/context/worker-pool": () => import("@opencode/ui/context/worker-pool"),
"@opencode/ui/dialog": () => import("@opencode/ui/dialog"),
"@opencode/ui/diff-changes": () => import("@opencode/ui/diff-changes"),
"@opencode/ui/divider": () => import("@opencode/ui/divider"),
"@opencode/ui/dock-surface": () => import("@opencode/ui/dock-surface"),
"@opencode/ui/favicon": () => import("@opencode/ui/favicon"),
"@opencode/ui/field": () => import("@opencode/ui/field"),
"@opencode/ui/file-icon": () => import("@opencode/ui/file-icon"),
"@opencode/ui/file-tree-item": () => import("@opencode/ui/file-tree-item"),
"@opencode/ui/font": () => import("@opencode/ui/font"),
"@opencode/ui/hooks": () => import("@opencode/ui/hooks"),
"@opencode/ui/hover-card": () => import("@opencode/ui/hover-card"),
"@opencode/ui/i18n/am": () => import("@opencode/ui/i18n/am"),
"@opencode/ui/i18n/ar": () => import("@opencode/ui/i18n/ar"),
"@opencode/ui/i18n/az": () => import("@opencode/ui/i18n/az"),
"@opencode/ui/i18n/bg": () => import("@opencode/ui/i18n/bg"),
"@opencode/ui/i18n/bn": () => import("@opencode/ui/i18n/bn"),
"@opencode/ui/i18n/br": () => import("@opencode/ui/i18n/br"),
"@opencode/ui/i18n/bs": () => import("@opencode/ui/i18n/bs"),
"@opencode/ui/i18n/ca": () => import("@opencode/ui/i18n/ca"),
"@opencode/ui/i18n/cs": () => import("@opencode/ui/i18n/cs"),
"@opencode/ui/i18n/da": () => import("@opencode/ui/i18n/da"),
"@opencode/ui/i18n/de": () => import("@opencode/ui/i18n/de"),
"@opencode/ui/i18n/dv": () => import("@opencode/ui/i18n/dv"),
"@opencode/ui/i18n/dz": () => import("@opencode/ui/i18n/dz"),
"@opencode/ui/i18n/el": () => import("@opencode/ui/i18n/el"),
"@opencode/ui/i18n/en": () => import("@opencode/ui/i18n/en"),
"@opencode/ui/i18n/es": () => import("@opencode/ui/i18n/es"),
"@opencode/ui/i18n/et": () => import("@opencode/ui/i18n/et"),
"@opencode/ui/i18n/fa": () => import("@opencode/ui/i18n/fa"),
"@opencode/ui/i18n/fi": () => import("@opencode/ui/i18n/fi"),
"@opencode/ui/i18n/fo": () => import("@opencode/ui/i18n/fo"),
"@opencode/ui/i18n/fr": () => import("@opencode/ui/i18n/fr"),
"@opencode/ui/i18n/he": () => import("@opencode/ui/i18n/he"),
"@opencode/ui/i18n/hi": () => import("@opencode/ui/i18n/hi"),
"@opencode/ui/i18n/hr": () => import("@opencode/ui/i18n/hr"),
"@opencode/ui/i18n/hu": () => import("@opencode/ui/i18n/hu"),
"@opencode/ui/i18n/hy": () => import("@opencode/ui/i18n/hy"),
"@opencode/ui/i18n/id": () => import("@opencode/ui/i18n/id"),
"@opencode/ui/i18n/is": () => import("@opencode/ui/i18n/is"),
"@opencode/ui/i18n/it": () => import("@opencode/ui/i18n/it"),
"@opencode/ui/i18n/ja": () => import("@opencode/ui/i18n/ja"),
"@opencode/ui/i18n/ka": () => import("@opencode/ui/i18n/ka"),
"@opencode/ui/i18n/km": () => import("@opencode/ui/i18n/km"),
"@opencode/ui/i18n/ko": () => import("@opencode/ui/i18n/ko"),
"@opencode/ui/i18n/lo": () => import("@opencode/ui/i18n/lo"),
"@opencode/ui/i18n/lt": () => import("@opencode/ui/i18n/lt"),
"@opencode/ui/i18n/lv": () => import("@opencode/ui/i18n/lv"),
"@opencode/ui/i18n/mk": () => import("@opencode/ui/i18n/mk"),
"@opencode/ui/i18n/mn": () => import("@opencode/ui/i18n/mn"),
"@opencode/ui/i18n/ms": () => import("@opencode/ui/i18n/ms"),
"@opencode/ui/i18n/my": () => import("@opencode/ui/i18n/my"),
"@opencode/ui/i18n/ne": () => import("@opencode/ui/i18n/ne"),
"@opencode/ui/i18n/nl": () => import("@opencode/ui/i18n/nl"),
"@opencode/ui/i18n/no": () => import("@opencode/ui/i18n/no"),
"@opencode/ui/i18n/pa": () => import("@opencode/ui/i18n/pa"),
"@opencode/ui/i18n/pl": () => import("@opencode/ui/i18n/pl"),
"@opencode/ui/i18n/ro": () => import("@opencode/ui/i18n/ro"),
"@opencode/ui/i18n/ru": () => import("@opencode/ui/i18n/ru"),
"@opencode/ui/i18n/si": () => import("@opencode/ui/i18n/si"),
"@opencode/ui/i18n/sk": () => import("@opencode/ui/i18n/sk"),
"@opencode/ui/i18n/sl": () => import("@opencode/ui/i18n/sl"),
"@opencode/ui/i18n/sq": () => import("@opencode/ui/i18n/sq"),
"@opencode/ui/i18n/sr": () => import("@opencode/ui/i18n/sr"),
"@opencode/ui/i18n/sv": () => import("@opencode/ui/i18n/sv"),
"@opencode/ui/i18n/tg": () => import("@opencode/ui/i18n/tg"),
"@opencode/ui/i18n/th": () => import("@opencode/ui/i18n/th"),
"@opencode/ui/i18n/tk": () => import("@opencode/ui/i18n/tk"),
"@opencode/ui/i18n/tr": () => import("@opencode/ui/i18n/tr"),
"@opencode/ui/i18n/uk": () => import("@opencode/ui/i18n/uk"),
"@opencode/ui/i18n/ur": () => import("@opencode/ui/i18n/ur"),
"@opencode/ui/i18n/uz": () => import("@opencode/ui/i18n/uz"),
"@opencode/ui/i18n/vi": () => import("@opencode/ui/i18n/vi"),
"@opencode/ui/i18n/zh": () => import("@opencode/ui/i18n/zh"),
"@opencode/ui/i18n/zht": () => import("@opencode/ui/i18n/zht"),
"@opencode/ui/icon": () => import("@opencode/ui/icon"),
"@opencode/ui/icon-button": () => import("@opencode/ui/icon-button"),
"@opencode/ui/icons/app": () => import("@opencode/ui/icons/app"),
"@opencode/ui/icons/file-type": () => import("@opencode/ui/icons/file-type"),
"@opencode/ui/icons/provider": () => import("@opencode/ui/icons/provider"),
"@opencode/ui/image-preview": () => import("@opencode/ui/image-preview"),
"@opencode/ui/inline-input": () => import("@opencode/ui/inline-input"),
"@opencode/ui/keybind": () => import("@opencode/ui/keybind"),
"@opencode/ui/layout": () => import("@opencode/ui/layout"),
"@opencode/ui/line-comment": () => import("@opencode/ui/line-comment"),
"@opencode/ui/list": () => import("@opencode/ui/list"),
"@opencode/ui/loader": () => import("@opencode/ui/loader"),
"@opencode/ui/logo": () => import("@opencode/ui/logo"),
"@opencode/ui/menu": () => import("@opencode/ui/menu"),
"@opencode/ui/motion-spring": () => import("@opencode/ui/motion-spring"),
"@opencode/ui/popover": () => import("@opencode/ui/popover"),
"@opencode/ui/progress": () => import("@opencode/ui/progress"),
"@opencode/ui/progress-circle": () => import("@opencode/ui/progress-circle"),
"@opencode/ui/project-avatar": () => import("@opencode/ui/project-avatar"),
"@opencode/ui/provider-icon": () => import("@opencode/ui/provider-icon"),
"@opencode/ui/radio": () => import("@opencode/ui/radio"),
"@opencode/ui/resize-handle": () => import("@opencode/ui/resize-handle"),
"@opencode/ui/resize-state": () => import("@opencode/ui/resize-state"),
"@opencode/ui/scroll-view": () => import("@opencode/ui/scroll-view"),
"@opencode/ui/segmented-control": () => import("@opencode/ui/segmented-control"),
"@opencode/ui/select": () => import("@opencode/ui/select"),
"@opencode/ui/spinner": () => import("@opencode/ui/spinner"),
"@opencode/ui/split-button": () => import("@opencode/ui/split-button"),
"@opencode/ui/sticky-accordion-header": () => import("@opencode/ui/sticky-accordion-header"),
"@opencode/ui/switch": () => import("@opencode/ui/switch"),
"@opencode/ui/tab-state-indicator": () => import("@opencode/ui/tab-state-indicator"),
"@opencode/ui/tabs": () => import("@opencode/ui/tabs"),
"@opencode/ui/text-field": () => import("@opencode/ui/text-field"),
"@opencode/ui/text-input": () => import("@opencode/ui/text-input"),
"@opencode/ui/text-reveal": () => import("@opencode/ui/text-reveal"),
"@opencode/ui/text-shimmer": () => import("@opencode/ui/text-shimmer"),
"@opencode/ui/text-strikethrough": () => import("@opencode/ui/text-strikethrough"),
"@opencode/ui/textarea": () => import("@opencode/ui/textarea"),
"@opencode/ui/theme": () => import("@opencode/ui/theme"),
"@opencode/ui/theme/color": () => import("@opencode/ui/theme/color"),
"@opencode/ui/theme/context": () => import("@opencode/ui/theme/context"),
"@opencode/ui/theme/default-themes": () => import("@opencode/ui/theme/default-themes"),
"@opencode/ui/theme/index": () => import("@opencode/ui/theme/index"),
"@opencode/ui/theme/loader": () => import("@opencode/ui/theme/loader"),
"@opencode/ui/theme/resolve": () => import("@opencode/ui/theme/resolve"),
"@opencode/ui/theme/types": () => import("@opencode/ui/theme/types"),
"@opencode/ui/toast": () => import("@opencode/ui/toast"),
"@opencode/ui/tooltip": () => import("@opencode/ui/tooltip"),
"@opencode/ui/typewriter": () => import("@opencode/ui/typewriter"),
"@opencode/ui/wordmark": () => import("@opencode/ui/wordmark"),
"@opencode/util/encode": () => import("@opencode/util/encode"),
"@opencode/util/path": () => import("@opencode/util/path"),
"@tanstack/solid-query": () => import("@tanstack/solid-query"),
"effect": () => import("effect"),
"solid-js": () => import("solid-js"),
"solid-js/store": () => import("solid-js/store"),
"solid-js/web": () => import("solid-js/web"),
}
@@ -0,0 +1,92 @@
import { createEffect, onCleanup } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEventListener } from "@solid-primitives/event-listener"
import { useDialog } from "@opencode/ui/context/dialog"
import { useDesktopExtensions } from "./provider"
/** Native geometry, clipping and portal occlusion are host behavior shared by all extensions. */
export function ExtensionNativeSurface(props: { extensionID: string; id: string }) {
const host = useDesktopExtensions()
const dialog = useDialog()
let surface: HTMLDivElement | undefined
let frame: number | undefined
let until = 0
let last = ""
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
const measure = () => {
if (!surface) return
const rect = surface.getBoundingClientRect()
const covered = Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some(
(element) => {
const other = element.getBoundingClientRect()
return (
other.width > 0 &&
other.left < rect.right &&
other.right > rect.left &&
other.top < rect.bottom &&
other.bottom > rect.top
)
},
)
const zoom = host.zoom()
const visible =
document.visibilityState === "visible" &&
!dialog.active &&
!covered &&
surface.checkVisibility({ checkVisibilityCSS: true })
const color = getComputedStyle(
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
).backgroundColor
const key = `${props.id}:${visible}:${rect.x}:${rect.y}:${rect.width}:${rect.height}:${zoom}:${color}:${devicePixelRatio}`
if (key === last) return
last = key
if (paint) {
paint.clearRect(0, 0, 1, 1)
paint.fillStyle = color
paint.fillRect(0, 0, 1, 1)
}
const rgba = paint?.getImageData(0, 0, 1, 1).data
host.transport?.surface(props.extensionID, props.id, {
visible,
bounds: {
x: Math.round(rect.left * zoom),
y: Math.round(rect.top * zoom),
width: Math.max(0, Math.round(rect.right * zoom) - Math.round(rect.left * zoom)),
height: Math.max(0, Math.round(rect.bottom * zoom) - Math.round(rect.top * zoom)),
},
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
radius: Math.round(10 * zoom),
})
}
const tick = () => {
frame = undefined
measure()
if (performance.now() < until) frame = requestAnimationFrame(tick)
}
const schedule = () => {
until = performance.now() + 300
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(() => {
props.id
dialog.active
host.zoom()
schedule()
})
createResizeObserver(() => surface, measure)
createEventListener(window, "resize", schedule)
createEventListener(document, "visibilitychange", schedule)
const portals = new MutationObserver(schedule)
portals.observe(document.body, { childList: true })
const theme = new MutationObserver(schedule)
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
onCleanup(() => {
portals.disconnect()
theme.disconnect()
if (frame !== undefined) cancelAnimationFrame(frame)
host.transport?.surface(props.extensionID, props.id)
})
return <div ref={surface} data-component="native-surface" class="min-h-0 min-w-0 flex-1 bg-v2-background-bg-base" />
}
+550
View File
@@ -0,0 +1,550 @@
import {
createContext,
createEffect,
createMemo,
createRoot,
getOwner,
onCleanup,
runWithOwner,
untrack,
batch,
useContext,
type ParentProps,
type JSX,
} from "solid-js"
import { createStore, produce, reconcile, type Store, type SetStoreFunction } from "solid-js/store"
import { Schema } from "effect"
import type { Context, PanelProps, SessionContext, SlotClaim, Plugin } from "@opencode/plugin/desktop"
import type { Server, StorageOptions } from "@opencode/plugin/desktop/context"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { createLifecycle } from "@opencode/plugin/desktop/lifecycle"
import { client } from "@opencode/plugin/desktop/rpc"
import { resolveSlots, type Claim, type PlacementKind } from "@opencode/plugin/slots"
import { usePlatform } from "@/runtime/platform/platform"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs, tabKey } from "@/shell/tabs/tabs"
import { useCurrentRoute } from "@/shell/state/layout"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { persisted, Persist, removePersisted } from "@/runtime/persistence/storage"
import { base64Encode } from "@opencode/util/encode"
import { terminalFontFamily, useSettings } from "@/settings/model"
import { extensionTabKey } from "./keys"
import { showToast } from "@/shell/notifications/toast"
import { ReviewDesktop } from "@opencode/plugin-review-desktop"
import { ContextDesktop } from "@opencode/plugin-context-desktop"
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
export type Contribution = Claim<{
context: Context
generation: number
when?: () => boolean
render: SlotClaim["render"]
}>
export type RegisteredPanel = {
key: string
plugin: string
generation: number
session: SessionContext
props: PanelProps
render: () => JSX.Element
icon: () => JSX.Element
}
type PanelHost = { open(id: string): void; close(id: string): void; active(): string | undefined; visible?(): boolean }
const HostContext = createContext<ReturnType<typeof createHost>>()
export function DesktopExtensionsProvider(props: ParentProps) {
const host = createHost()
return <HostContext.Provider value={host}>{props.children}</HostContext.Provider>
}
export function useDesktopExtensions() {
const host = useContext(HostContext)
if (!host) throw new Error("Desktop extension host is unavailable")
return host
}
export const useOptionalDesktopExtensions = () => useContext(HostContext)
function createHost() {
const platform = usePlatform()
const global = useGlobal()
const tabs = useTabs()
const route = useCurrentRoute()
const commands = useCommand()
const language = useLanguage()
const settings = useSettings()
const owner = getOwner()
const [state, setState] = createStore({
claims: [] as Contribution[],
panels: [] as RegisteredPanel[],
sessions: [] as SessionContext[],
servers: [] as Server[],
services: {} as Record<string, SessionServices | undefined>,
installed: [] as readonly ExtensionManager.Installed[],
// Definitions are opaque: storing a factory prevents Solid from merging a
// replacement into the previous definition and hiding its identity change.
loaded: {} as Record<string, (() => Plugin.Definition) | undefined>,
failures: {} as Record<string, boolean | undefined>,
managerReady: false,
managerError: undefined as ExtensionManager.ErrorCode | undefined,
})
const sessions = new Map<string, SessionContext>()
const hosts = new Map<string, PanelHost>()
const instances = new Map<string, { definition: object; dispose: () => void }>()
const storage = new Map<string, unknown>()
const memories = new Map<string, unknown>()
const persistent = new Map<string, { value: unknown; users: number; reset(): void; dispose(): void }>()
const workspaceRemoved = new Set<(value: { serverID: string; directory: string }) => void>()
const attempted = new WeakSet<Plugin.Definition>()
let instanceID = 0
const builtins = () => [ReviewDesktop, ContextDesktop, ...(platform.extensionPlugins ?? [])]
createEffect(() => {
const manager = platform.extensionManager
if (!manager) return
let changed = false
let disposed = false
onCleanup(
manager.onChange((entries) => {
changed = true
setState("installed", entries)
setState("managerReady", true)
}),
)
onCleanup(() => {
disposed = true
})
void manager
.list()
.then((entries) => {
if (disposed || changed) return
setState("installed", entries)
setState("managerReady", true)
})
.catch(() => {
if (!disposed && !changed) {
setState("managerError", "storage")
setState("managerReady", true)
}
})
})
const pending = new Map<string, string>()
createEffect(() => {
const manager = platform.extensionManager
if (!manager) return
state.installed.forEach((entry) => {
if (!entry.enabled) {
pending.delete(entry.id)
setState("loaded", entry.id, undefined)
setState("failures", entry.id, undefined)
return
}
const token = `${entry.revision}/${entry.generation}`
if (pending.get(entry.id) === token) return
pending.set(entry.id, token)
setState("failures", entry.id, undefined)
void (async () => {
const { loadExtension } = await import("./load")
const value = await loadExtension(await manager.source(entry.id, entry.revision))
if (pending.get(entry.id) !== token) return
setState("loaded", entry.id, () => () => value)
})().catch((error) => {
if (pending.get(entry.id) === token) {
console.debug("[desktop-extensions] load failed", { id: entry.id, error })
setState("failures", entry.id, true)
}
})
})
})
onCleanup(() => pending.clear())
const resolved = createMemo(() =>
resolveSlots({
paths: new Set([
"app",
"titlebar.actions",
"settings.experimental",
"session.panel",
"session.panel.actions",
"session.composer.top",
"session.header.actions",
"session.panel.toolbar",
"session.panel.tools",
"session.sidebar",
"session.auxiliary",
"session.mobile.actions",
]),
claims: state.claims.filter((claim) => claim.render.when?.() ?? true),
}),
)
createEffect(() => {
const all = global.servers.list()
const available = all.map((connection): Server => {
const id = ServerConnection.key(connection)
const data = global.ensureServerCtx(connection)
return {
id,
local: ServerConnection.local(connection),
get url() {
return data.sdk.url
},
get client() {
return data.sdk.api
},
data: data.data,
get compatible() {
return !global.servers.health[id]?.incompatible
},
}
})
setState("servers", available)
platform.extensions?.configure(
all.map((connection) => ({
id: ServerConnection.key(connection),
...connection.http,
url: global.ensureServerCtx(connection).sdk.url,
})),
)
const owned = new Set(tabs.store.filter((tab) => tab.type === "session").map(tabKey))
Array.from(sessions).forEach(([key, session]) => {
if (!owned.has(session.ownerID)) sessions.delete(key)
})
tabs.store.forEach((tab) => {
if (tab.type !== "session") return
const connection = all.find((connection) => ServerConnection.key(connection) === tab.server)
if (!connection) return
const data = global.ensureServerCtx(connection)
const server = available.find((server) => server.id === tab.server)!
Array.from(new Set([tab.sessionId, tab.routeSessionId ?? tab.sessionId])).forEach((id) => {
const key = `${tab.server}\n${id}`
if (sessions.has(key)) return
sessions.set(key, {
key,
ownerID: tabKey(tab),
sessionID: id,
server,
get creating() {
return data.data.session.creating(id)
},
get location() {
return data.data.session.get(id)?.location
},
get services() {
return state.services[key]
},
})
})
})
setState("sessions", Array.from(sessions.values()))
})
const current = createMemo(() => {
const value = route()
if (value.type !== "session") return
return state.sessions.find((session) => session.server.id === value.server && session.sessionID === value.sessionId)
})
const stateFor = <Value extends object>(id: string, key: string, initial: Value, durable: boolean) => {
const cache = durable ? storage : memories
const name = `${id}.${key}`
const previous = cache.get(name)
if (previous) return previous as readonly [Store<Value>, (update: (draft: Value) => void) => void]
// Storage decodes JSON objects at the boundary; each plugin owns its value's shape and migrations.
const pair = runWithOwner(owner, () =>
durable
? persisted(
Persist.global(`extension.${name}`),
Schema.Record(Schema.String, Schema.Json),
Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))(initial),
)
: createStore(initial),
) as unknown as readonly [Store<Value>, SetStoreFunction<Value>]
const value = [pair[0], (update: (draft: Value) => void) => pair[1](produce(update))] as const
cache.set(name, value)
return value
}
const persistTarget = (id: string, key: string, options?: StorageOptions) => {
if (!options?.scope)
return {
...Persist.global(`extension.${id}.${key}`),
previousKeys: options?.legacyKey ? [options.legacyKey] : undefined,
}
const connection = global.servers.list().find((server) => ServerConnection.key(server) === options.scope!.serverID)
if (!connection) throw new Error("Extension storage server is unavailable")
return {
...Persist.serverWorkspace(
global.ensureServerCtx(connection).sdk.scope,
base64Encode(options.scope.directory),
`extension.${id}.${key}`,
),
previousKeys: options.legacyKey ? [`workspace:${options.legacyKey}`] : undefined,
}
}
const persistFor = <S extends Schema.ConstraintCodec<object, unknown>>(
id: string,
key: string,
schema: S,
initial: NoInfer<S["Type"]>,
options?: StorageOptions,
) => {
const target = persistTarget(id, key, options)
const name = JSON.stringify([target.storage, target.key])
const previous = persistent.get(name)
type Value = readonly [Store<S["Type"]>, SetStoreFunction<S["Type"]>, () => boolean]
const entry =
previous ??
createRoot((dispose) => {
const pair = persisted(target, schema, initial)
return {
value: [pair[0], pair[1], pair[3]] as const,
users: 0,
reset: () => pair[1](reconcile(initial)),
dispose,
}
}, owner)
persistent.set(name, entry)
entry.users++
// Overlapping activation generations share state. Evicted workspace owners
// release their hydrated buffers; the persistence layer flushes before disposal.
onCleanup(() => {
if (--entry.users) return
entry.dispose()
persistent.delete(name)
})
return entry.value as Value
}
const activate = (definition: NonNullable<typeof platform.extensionPlugins>[number]) =>
createRoot((dispose) => {
const lifecycle = createLifecycle()
const id = definition.id
const generation = ++instanceID
let activated = false
let nextClaim = 0
const context: Context = {
assets: {
url(path) {
const entry = state.installed.find((entry) => entry.id === id)
if (!entry || !platform.extensionManager) throw new Error("Extension assets are unavailable")
return platform.extensionManager.assetURL(id, entry.revision, path)
},
},
app: { version: platform.version, windowID: platform.windowID, native: !!platform.extensions },
lifecycle,
platform: {
...platform,
async saveFile(options, content) {
if (platform.saveFile) return platform.saveFile(options, content)
const url = URL.createObjectURL(new Blob([content], { type: "application/octet-stream" }))
const link = document.createElement("a")
link.href = url
link.download = options.defaultPath ?? "download"
link.click()
URL.revokeObjectURL(url)
return true
},
},
sessions: { list: () => state.sessions, current },
servers: { list: () => state.servers },
workspaces: {
onRemoved(handler) {
workspaceRemoved.add(handler)
return lifecycle.own(() => workspaceRemoved.delete(handler))
},
},
fonts: { console: () => terminalFontFamily(settings.appearance.terminalFont()) },
storage: {
persist: (key, schema, initial, options) => persistFor(id, key, schema, initial, options),
remove(key, options) {
const target = persistTarget(id, key, options)
const name = JSON.stringify([target.storage, target.key])
persistent.get(name)?.reset()
removePersisted(target, platform)
target.previousKeys?.forEach((key) => removePersisted({ ...target, key }, platform))
},
store: (key, options) => stateFor(id, key, options.initial, true),
memory: (key, options) => stateFor(id, key, options.initial, false),
},
i18n: {
locale: language.locale,
intl: language.intl,
plural: (key, count, params) => language.plural(key as Parameters<typeof language.plural>[0], count, params),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params) ?? key,
},
commands: {
register(values) {
return lifecycle.own(
createRoot((dispose) => {
commands.register(`${id}/${generation}/${nextClaim++}`, () =>
values().map((command) => ({
id: command.reference ?? `${id}.${command.id}`,
title: command.title,
description: command.description,
category: command.group,
disabled: command.enabled === false,
hidden: command.palette === false,
keybind: command.bind,
slash: command.slash,
when: command.when,
onSelect: () => {
void command.run()
},
})),
)
return dispose
}),
)
},
dispatch: (commandID) => commands.trigger(`${id}.${commandID}`),
keys: commands.keybindParts,
matches: commands.matches,
},
main: {
rpc(definition) {
const transport = platform.extensions
if (!transport) throw new Error("Native desktop extensions are unavailable on this platform")
return client(id, definition, transport, lifecycle.signal, lifecycle.own)
},
},
ui: {
toast: {
show: (options) =>
showToast({ title: options.title, description: options.message, variant: options.variant }),
},
slot(claim) {
const placements = ["append", "prepend", "before", "after", "replace"] as const
const kinds = placements.filter((kind) => claim[kind] !== undefined)
if (kinds.length !== 1) throw new Error("A slot requires exactly one placement")
const kind: PlacementKind = kinds[0]
const value: Contribution = {
key: `${id}/${generation}/${nextClaim++}`,
plugin: id,
placement: { kind, target: claim[kind]! },
render: { context, generation, when: claim.when, render: claim.render },
}
setState("claims", (items) => [...items, value])
return lifecycle.own(() => setState("claims", (items) => items.filter((item) => item.key !== value.key)))
},
panel: {
open(localID, session) {
const panel = state.panels.find(
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
)
const key = panel?.key ?? extensionTabKey(id, localID)
const host = hosts.get(session.key)
if (!host || !state.panels.some((panel) => panel.session.key === session.key && panel.key === key))
return false
host.open(key)
return true
},
close(localID, session) {
const key =
state.panels.find(
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
)?.key ?? extensionTabKey(id, localID)
const host = hosts.get(session.key)
if (!host) return false
host.close(key)
return true
},
selected: (localID, session) => {
const panel = state.panels.find(
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
)
return !!panel && hosts.get(session.key)?.active() === panel.key
},
visible: (localID, session) => {
const panel = state.panels.find(
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
)
const host = hosts.get(session.key)
return !!panel && host?.active() === panel.key && (host.visible?.() ?? true)
},
},
},
}
onCleanup(() => {
try {
lifecycle.dispose()
} finally {
if (activated) platform.extensions?.release(id)
}
})
try {
const cleanup = definition.setup(context)
if (cleanup) lifecycle.own(cleanup)
activated = true
return dispose
} catch (error) {
dispose()
throw error
}
})
createEffect(() => {
const definitions = [
...builtins(),
...Object.values(state.loaded)
.flatMap((load) => (load ? [load()] : []))
.filter((value) => !builtins().some((builtin) => builtin.id === value.id)),
]
Array.from(instances).forEach(([id, instance]) => {
if (definitions.some((definition) => definition.id === id)) return
instance.dispose()
instances.delete(id)
})
definitions.forEach((definition) => {
if (instances.get(definition.id)?.definition === definition || attempted.has(definition)) return
try {
batch(() => {
const dispose = untrack(() => activate(definition))
instances.get(definition.id)?.dispose()
instances.set(definition.id, { definition, dispose })
})
} catch (error) {
if (builtins().includes(definition)) throw error
console.debug("[desktop-extensions] activation failed", {
id: definition.id,
error: error instanceof Error ? error.stack : String(error),
})
setState("failures", definition.id, true)
attempted.add(definition)
}
})
})
onCleanup(() => instances.forEach((instance) => instance.dispose()))
return {
state,
resolved,
current,
transport: platform.extensions,
manager: platform.extensionManager,
builtins: (): readonly Plugin.Definition[] => builtins(),
failed: (id: string) => setState("failures", id, true),
workspaceRemoved(value: { serverID: string; directory: string }) {
workspaceRemoved.forEach((handler) => handler(value))
},
zoom: () => platform.webviewZoom?.() ?? 1,
bind(session: SessionContext, host: PanelHost, services?: SessionServices) {
hosts.set(session.key, host)
setState("services", session.key, services)
return () => {
if (hosts.get(session.key) === host) {
hosts.delete(session.key)
setState("services", session.key, undefined)
}
}
},
register(panel: RegisteredPanel) {
if (state.panels.some((item) => item.key === panel.key && item.session.key === panel.session.key))
throw new Error(`Duplicate extension panel: ${panel.key}`)
setState("panels", (items) => [...items, panel])
return () =>
setState("panels", (items) =>
items.filter((item) => item.key !== panel.key || item.session.key !== panel.session.key),
)
},
}
}
+176
View File
@@ -0,0 +1,176 @@
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
import { useOptionalDesktopExtensions } from "./provider"
import { ExtensionSlot } from "./slot"
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
import type { AuxiliaryPresentation } from "@opencode/plugin/desktop/context"
export function useExtensionPanels(input: {
serverID: () => string
sessionID: () => string | undefined
tabs: () => {
all(): string[]
setAll(value: string[]): void
active(): string | undefined
setActive(value: string): void
close(id: string): void
}
open(): void
services?: SessionServices
active?: () => string | undefined
}) {
const host = useOptionalDesktopExtensions()
const session = createMemo(() =>
host?.state.sessions.find(
(session) => session.sessionID === input.sessionID() && session.server.id === input.serverID(),
),
)
const panels = createMemo(() => host?.state.panels.filter((panel) => panel.session.key === session()?.key) ?? [])
createEffect(() => {
const current = session()
if (!current || !host) return
onCleanup(
host.bind(
current,
{
open(id) {
input.open()
const tabs = input.tabs()
if (!tabs.all().includes(id)) tabs.setAll([...tabs.all(), id])
tabs.setActive(id)
},
close: (id) => input.tabs().close(id),
active: () => input.active?.() ?? input.tabs().active(),
visible: () => input.services?.view.panel.opened() ?? true,
},
input.services,
),
)
})
createEffect(
on(
() => panels().map((panel) => panel.key),
(keys, previous) => {
const old = new Set(previous ?? [])
const tabs = input.tabs()
// Persisted instances can be waiting for their declarations to mount. Only
// remove contributions observed disappearing during this route lifetime.
const removed = new Set(previous?.filter((key) => !keys.includes(key)))
const current = tabs.all().filter((key) => !removed.has(key))
const added = keys.filter(
(key) =>
!old.has(key) &&
!current.includes(key) &&
panels().find((panel) => panel.key === key)?.props.initial !== "closed",
)
if (added.length || current.length !== tabs.all().length) tabs.setAll([...current, ...added])
},
),
)
createEffect(
on(
() => input.tabs().all(),
(current, previous) => {
previous
?.filter((key) => !current.includes(key))
.forEach((key) =>
panels()
.find((panel) => panel.key === key)
?.props.onClose?.(),
)
},
),
)
createEffect(
on(
() => input.tabs().active(),
(active) =>
panels()
.find((panel) => panel.key === active)
?.props.onSelect?.(),
),
)
return {
hasAuxiliary: () => {
const value = host?.resolved().slotted.get("session.auxiliary")
return (
!!value &&
(!!value.replace || value.before.length + value.prepend.length + value.append.length + value.after.length > 0)
)
},
auxiliary: (presentation: AuxiliaryPresentation) => (
// A secondary surface can span multiple sessions in the same workspace.
// Keep its owner and pass reactive identity instead of keying on the route.
<Show when={input.services && session()}>
{(current) => (
<ExtensionSlot
path="session.auxiliary"
input={{
get session() {
return current()
},
services: input.services!,
presentation,
}}
/>
)}
</Show>
),
mobileActions: () => (
<Show when={session()}>
{(current) => (
<ExtensionSlot
path="session.mobile.actions"
input={{
get session() {
return current()
},
}}
/>
)}
</Show>
),
panels,
keys: () => panels().map((panel) => panel.key),
canClose: (key: string) => panels().find((panel) => panel.key === key)?.props.closable !== false,
defaultPanel: () => panels().find((panel) => panel.props.default)?.key,
hasActions: () => {
const value = host?.resolved().slotted.get("session.panel.actions")
return (
!!value &&
(!!value.replace || value.before.length + value.prepend.length + value.append.length + value.after.length > 0)
)
},
declarations: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.panel" input={{ session }} />}
</Show>
),
actions: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.panel.actions" input={{ session }} />}
</Show>
),
toolbar: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.panel.toolbar" input={{ session }} />}
</Show>
),
tools: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.panel.tools" input={{ session }} />}
</Show>
),
sidebar: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.sidebar" input={{ session }} />}
</Show>
),
header: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.header.actions" input={{ session }} />}
</Show>
),
}
}
export type SessionExtensions = ReturnType<typeof useExtensionPanels>
+103
View File
@@ -0,0 +1,103 @@
import {
createComponent,
createMemo,
createRoot,
ErrorBoundary,
For,
getOwner,
onCleanup,
onMount,
Show,
type JSX,
type ParentProps,
} from "solid-js"
import { PanelProvider, PluginProvider, NativeSurfaceProvider } from "@opencode/plugin/desktop/solid"
import type { PanelInput, SlotMap, SlotPath } from "@opencode/plugin/desktop/context"
import { emptySlotted } from "@opencode/plugin/slots"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { useOptionalDesktopExtensions, type Contribution } from "./provider"
import { extensionTabKey } from "./keys"
import { ExtensionNativeSurface } from "./native-surface"
export function ExtensionSlot<Path extends SlotPath>(props: ParentProps<{ path: Path; input?: SlotMap[Path] }>) {
const host = useOptionalDesktopExtensions()
if (!host) return props.children
const language = useLanguage()
const slotted = createMemo(() => host.resolved().slotted.get(props.path) ?? emptySlotted<Contribution["render"]>())
const contribution = (claim: Contribution) => (
<ErrorBoundary
fallback={(error) => {
host.failed(claim.plugin)
onMount(() =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: `${claim.plugin}: ${String(error)}`,
}),
)
return null
}}
>
<PluginProvider value={claim.render.context}>
<NativeSurfaceProvider
render={(surface) => <ExtensionNativeSurface extensionID={claim.plugin} id={surface.id} />}
>
<Show
when={props.path === "session.panel"}
fallback={createComponent(claim.render.render as (input: object) => JSX.Element, props.input ?? {})}
>
<PanelProvider
value={{
get session() {
return (props.input as PanelInput).session
},
register(panel) {
const owner = getOwner()
const session = (props.input as PanelInput).session
const render = (value: () => JSX.Element) => {
const mounted = createRoot((dispose) => ({ dispose, view: value() }), owner)
onCleanup(mounted.dispose)
return mounted.view
}
onCleanup(
host.register({
key: panel.reference ?? extensionTabKey(claim.plugin, panel.id),
plugin: claim.plugin,
generation: claim.render.generation,
session,
props: panel,
render: () => render(() => panel.children),
icon: () => render(() => panel.icon),
}),
)
},
}}
>
{createComponent(claim.render.render as (input: object) => JSX.Element, props.input ?? {})}
</PanelProvider>
</Show>
</NativeSurfaceProvider>
</PluginProvider>
</ErrorBoundary>
)
return (
<>
<For each={slotted().before}>{contribution}</For>
<Show
when={slotted().replace}
keyed
fallback={
<>
<For each={slotted().prepend}>{contribution}</For>
{props.children}
<For each={slotted().append}>{contribution}</For>
</>
}
>
{contribution}
</Show>
<For each={slotted().after}>{contribution}</For>
</>
)
}
+75
View File
@@ -0,0 +1,75 @@
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
import type { SessionModel } from "@/session/model"
import { useFile } from "@/workspaces/files/model"
import { useComments } from "@/composer/comments"
import { useComposerState } from "@/composer/persistence"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLayout } from "@/shell/state/layout"
import { useSettings } from "@/settings/model"
import { useOptionalDesktopExtensions } from "./provider"
import { useServer } from "@/runtime/server/current"
export function createSessionServices(session: SessionModel): SessionServices {
const file = useFile()
const annotations = useComments()
const draft = useComposerState()
const location = useWorkspaceLocation()
const layout = useLayout()
const settings = useSettings()
const extensions = useOptionalDesktopExtensions()
const server = useServer()
return {
display: { wrapDiff: settings.general.mobileDiffWrap },
files: {
...file,
get directory() {
return location().directory
},
},
annotations,
draft: { context: draft.context },
view: {
auxiliary: {
opened: () => session.layout.view().terminal.opened(),
open: () => session.layout.view().terminal.open(),
close: () => session.layout.view().terminal.close(),
toggle: () => session.layout.view().terminal.toggle(),
height: () => session.layout.view().terminal.height(),
resize: (height) => session.layout.view().terminal.resize(height),
placement: settings.general.terminalPlacement,
},
ready: layout.ready,
desktop: session.isDesktop,
tabs: {
all: () => session.layout.tabs().all(),
active: session.tabs.activeTab,
open: (reference) => session.layout.tabs().open(reference),
close: (reference) => session.layout.tabs().close(reference),
canClose: (reference) =>
extensions?.state.panels.find(
(panel) =>
panel.key === reference &&
panel.session.sessionID === session.identity.sessionID() &&
panel.session.server.id === server.key,
)?.props.closable !== false,
setActive: (reference) => session.layout.tabs().setActive(reference),
preview: () => session.layout.tabs().preview(),
previewTab: (reference) => session.layout.tabs().previewTab(reference),
},
panel: {
opened: () => session.layout.view().reviewPanel.opened(),
open: (source) => session.layout.view().reviewPanel.open(source),
close: () => session.layout.view().reviewPanel.close(),
toggle: () => session.layout.view().reviewPanel.toggle(),
source: () => session.layout.view().reviewPanel.source(),
},
sidebar: { ...layout.fileTree, allowed: settings.visibility.fileTree },
scroll: (key) => session.layout.view().scroll(key),
setScroll: (key, value) => session.layout.view().setScroll(key, value),
},
get project() {
const project = session.project()
return project && { id: project.id, directory: project.worktree, name: project.name, vcs: project.vcs }
},
}
}
+1 -24
View File
@@ -1,24 +1 @@
export type FileManagerOS = "macos" | "windows" | "linux" | "unknown"
export function fileManagerApp(os: FileManagerOS): {
label: "session.header.open.finder" | "session.header.open.fileExplorer" | "session.header.open.fileManager"
actionLabel:
| "session.header.reveal.finder"
| "session.header.reveal.fileExplorer"
| "session.header.reveal.containingFolder"
icon: "finder" | "file-explorer"
} {
if (os === "macos")
return { label: "session.header.open.finder", actionLabel: "session.header.reveal.finder", icon: "finder" }
if (os === "windows")
return {
label: "session.header.open.fileExplorer",
actionLabel: "session.header.reveal.fileExplorer",
icon: "file-explorer",
}
return {
label: "session.header.open.fileManager",
actionLabel: "session.header.reveal.containingFolder",
icon: "finder",
}
}
export { fileManagerApp, type FileManagerOS } from "@opencode/util/file-manager"
+2 -26
View File
@@ -2,6 +2,8 @@
@import "@opencode/session-ui/styles";
@import "@opencode/ui/styles/tokens";
@import "tw-animate-css";
@source "../../plugin-review-desktop/src";
@source "../../plugin-context-desktop/src";
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
@@ -70,14 +72,6 @@
animation: side-terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
[data-component="terminal-panel"][data-size-animated="true"][data-opened="true"] {
animation: terminal-panel-size-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-component="terminal-panel"][data-size-animated="true"][data-opened="false"] {
animation: terminal-panel-size-out 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
[data-slot="terminal-panel-presence"],
[data-slot="side-terminal-panel-presence"],
@@ -147,24 +141,6 @@
}
}
@keyframes terminal-panel-size-in {
from {
height: 0;
}
to {
height: var(--terminal-panel-height);
}
}
@keyframes terminal-panel-size-out {
from {
height: var(--terminal-panel-height);
}
to {
height: 0;
}
}
::highlight(timeline-search-hit) {
background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent);
}
+31
View File
@@ -2,6 +2,37 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
export const dict = {
...DESKTOP_NATIVE_ENGLISH,
"settings.tab.tools": "Tools",
"settings.tools.description": "Manage tools available on this server",
"project.settings.tools.description": "View tools available to this project",
"settings.desktopExtensions.installTitle": "Install extensions",
"settings.desktopExtensions.drop": "Drop extension files",
"settings.desktopExtensions.choose": "Choose one or more .ocdx archives from your computer",
"settings.desktopExtensions.browse": "Browse",
"settings.desktopExtensions.files": "Extension files",
"settings.desktopExtensions.fromURL": "Or install from a URL",
"settings.desktopExtensions.placeholder": "https://example.com/my-extension.ocdx",
"settings.desktopExtensions.url": "Extension URL",
"settings.desktopExtensions.install": "Install",
"settings.desktopExtensions.installed": "Installed extensions",
"settings.desktopExtensions.empty": "No extensions installed.",
"settings.desktopExtensions.builtin": "Built-in",
"settings.desktopExtensions.main": "Main process",
"settings.desktopExtensions.enable": "Enable {{name}}",
"settings.desktopExtensions.reload": "Reload {{name}}",
"settings.desktopExtensions.error.files": "Choose one or more .ocdx files.",
"settings.desktopExtensions.error.archive": "The file is not a valid .ocdx archive",
"settings.desktopExtensions.error.manifest": "This archive must target @opencode/plugin/desktop.",
"settings.desktopExtensions.error.path": "The archive contains an invalid file path.",
"settings.desktopExtensions.error.size": "Extension is larger than 1 GiB",
"settings.desktopExtensions.error.reserved": "Extension ID is reserved by a built-in extension",
"settings.desktopExtensions.error.notFound": "Extension not found",
"settings.desktopExtensions.error.disabled": "This extension is disabled.",
"settings.desktopExtensions.error.module": "The extension entrypoint is invalid or uses an unsupported import.",
"settings.desktopExtensions.error.download": "Unable to download the extension.",
"settings.desktopExtensions.error.url": "Only HTTP and HTTPS URLs are supported",
"settings.desktopExtensions.error.storage": "Unable to update installed extensions.",
"settings.desktopExtensions.error.activation": "Unable to activate {{name}}.",
"session.location.unavailable": "Session location unavailable",
"session.location.description": "Choose another directory to continue this session.",
"session.location.choose": "Choose directory",
+11 -99
View File
@@ -1,99 +1,11 @@
export * as Persistence from "./schema"
import { Effect, Option, Predicate, Result, Schema, SchemaAST, SchemaGetter, SchemaParser, Struct } from "effect"
export type Migrated<S extends Schema.ConstraintCodec<object, unknown>> = {
current: S
read: Schema.ConstraintDecoder<unknown>
}
export function migrate<S extends Schema.ConstraintCodec<object, unknown>>(
current: S,
read: Schema.ConstraintDecoder<unknown>,
): Migrated<S> {
return { current, read }
}
function isMigrated<S extends Schema.ConstraintCodec<object, unknown>>(schema: S | Migrated<S>): schema is Migrated<S> {
return "current" in schema
}
export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
definition: S | Migrated<S>,
initial: NoInfer<S["Type"]>,
) {
const schema = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition)
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
: Result.succeed<unknown>
const encode = Schema.encodeUnknownSync(schema)
return Schema.Unknown.pipe(
Schema.decode<Schema.Unknown>({
decode: SchemaGetter.transformOrFail((value) =>
Effect.fromResult(Result.map(read(value), (stored) => merge(initial, recover(schema.ast, stored, initial)))),
),
encode: SchemaGetter.transform((value) => encode(value)),
}),
Schema.decodeTo(Schema.toType(schema)),
)
}
// Object-level codecs own their recovery. Plain structs can recover fields independently.
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if (ast._tag === "Objects" && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {
return Object.fromEntries(
ast.propertySignatures.flatMap((field) => {
const defaults = Predicate.isObject(initial) ? initial[field.name] : undefined
const next = recover(field.type, value[field.name], defaults)
if (next === undefined && !Object.hasOwn(value, field.name) && defaults === undefined) return []
return [[field.name, next]]
}),
)
}
const decoded = Schema.decodeUnknownOption(Schema.make<Schema.Codec<unknown, unknown>>(ast))(value)
return Option.isSome(decoded) ? decoded.value : initial
}
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial
if (!Predicate.isObject(initial) || !Predicate.isObject(value)) return value
return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
}
// Unlike a decoding default, a fallback also replaces invalid persisted values.
export function fallback<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S, value: () => S["Type"]) {
const defaulted = Schema.withDecodingDefaultType<S>(Effect.sync(value))(schema)
return Schema.catchDecoding<typeof defaulted>(() => Effect.sync(() => Option.some(value())))(defaulted)
}
export function optional<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const field = Schema.optional(schema)
return Schema.catchDecoding<typeof field>(() => Effect.succeed(Option.none()))(field)
}
export function struct<const Fields extends Schema.Struct.Fields>(fields: Fields) {
return Schema.Struct(fields).mapFields(Struct.map(Schema.mutableKey))
}
export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const entries = Schema.Record(Schema.String, Schema.mutableKey(schema))
return fallback(entries, () => Schema.decodeUnknownSync(entries)({}))
}
// Recover individual entries rather than discarding a whole history or collection.
export function array<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const decode = Schema.decodeUnknownOption(schema)
const encode = Schema.encodeSync(schema)
return fallback(
Schema.Array(Schema.Unknown).pipe(
Schema.decodeTo(Schema.mutable(Schema.Array(Schema.toType(schema))), {
decode: SchemaGetter.transform((items) => items.flatMap((item) => Option.toArray(decode(item)))),
encode: SchemaGetter.transform((items) => items.map((item) => encode(item))),
}),
),
() => [],
)
}
export {
Persistence,
type Migrated,
migrate,
withInitial,
fallback,
optional,
struct,
record,
array,
} from "@opencode/plugin/desktop/persistence"
@@ -24,6 +24,7 @@ type PersistTarget = {
scope?: "window"
workspaceStorageAliases?: string[]
previousKey?: string
previousKeys?: string[]
key: string
}
@@ -516,7 +517,11 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
if (!isDesktop && !draft) {
const current = currentStorage as SyncStorage
const sources = [
...(config.previousKeys ?? []).map((key) => ({ storage: current, key })),
...workspaceAliases.map((storage) => ({ storage: localStorageWithPrefix(storage) })),
...workspaceAliases.flatMap((storage) =>
(config.previousKeys ?? []).map((key) => ({ storage: localStorageWithPrefix(storage), key })),
),
...(config.previousKey ? [{ storage: localStorageDirect(), key: config.previousKey }] : []),
]
@@ -552,10 +557,17 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
: undefined
const previousStorage = config.previousKey ? (isDesktop ? platform.storage?.() : localStorageDirect()) : undefined
const relocationSources = [
...(config.previousKeys ?? []).map((key) => ({ storage: current, key })),
previousDraftStorage ? { storage: previousDraftStorage } : undefined,
...workspaceAliases.map((name) => ({
storage: isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name),
})),
...workspaceAliases.flatMap((name) =>
(config.previousKeys ?? []).map((key) => ({
storage: isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name),
key,
})),
),
previousStorage && config.previousKey ? { storage: previousStorage, key: config.previousKey } : undefined,
]
.filter((source): source is { storage: SyncStorage | AsyncStorage; key?: string } => !!source?.storage)
@@ -1,27 +0,0 @@
import type { Browser } from "@opencode/plugin-browser/rpc"
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
export type BrowserPaneLayout = {
tabID: Browser.TabID
visible: boolean
bounds?: { x: number; y: number; width: number; height: number }
background?: readonly [number, number, number, number]
radius?: number
}
export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
close(): void
}
export type BrowserPanePlatform = {
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
}
@@ -6,7 +6,9 @@ import { ServerConnection } from "@/runtime/server/registry"
import type { WslServersPlatform } from "@/servers/wsl/types"
import type { UpdaterPlatform } from "@/shell/updates/types"
import type { DraftStore } from "@/runtime/persistence/drafts"
import type { BrowserPanePlatform } from "./browser-pane"
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import type { Plugin } from "@opencode/plugin/desktop"
import type { ExtensionManager } from "@opencode/plugin/desktop/manager"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -120,8 +122,9 @@ type PlatformBase = {
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
extensions?: DesktopExtension.Transport
extensionPlugins?: readonly Plugin.Definition[]
extensionManager?: ExtensionManager.Transport
}
export type Platform = PlatformBase &
@@ -1,158 +0,0 @@
import { batch, createEffect, createMemo, getOwner, onCleanup, runWithOwner } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { useServer } from "@/runtime/server/current"
import { useSettings } from "@/settings/model"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
type Server = ReturnType<typeof useServer>
export type BrowserAttachment = {
registration?: BrowserPaneRegistration
browser: BrowserPaneState
error?: string
}
type Live = {
server: Server
sessionID: string
/** Shell tab that owns this attachment once seen; it may route to a child session later. */
tab?: string
registration?: BrowserPaneRegistration
retry?: ReturnType<typeof setTimeout>
attempts: number
dispose: () => void
}
// Attachments belong to the shell session tab, not the session route: native pages and the agent's
// browser survive visiting Settings or another tab and close when the session tab or the setting does.
export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider } = createSimpleContext({
name: "BrowserAttachments",
gate: false,
init: () => {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const shellTabs = useTabs()
const owner = getOwner()
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
const live = new Map<string, Live>()
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
const enabled = createMemo(
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
)
const close = (id: string) => {
live.get(id)?.dispose()
live.delete(id)
setStore(id, undefined)
}
createEffect(() => {
const on = enabled()
const tabs = shellTabs.store
// The store's keys mirror `live`, and reading them keeps this effect subscribed to new attachments.
Object.keys(store).forEach((id) => {
const entry = live.get(id)
if (!entry) return
// Tabs hydrate asynchronously, so the owner is learned when first seen rather than required up
// front. A tab keeps owning the attachment while it exists, even after routing back to its parent.
const current = findSessionTab(tabs, entry.server.key, entry.sessionID)
if (current) entry.tab = tabKey(current)
const owned = entry.tab === undefined || tabs.some((tab) => tabKey(tab) === entry.tab)
if (on && owned && !entry.server.health?.incompatible) return
close(id)
})
})
onCleanup(() => Array.from(live.keys()).forEach(close))
return {
enabled,
supported: (server: Server) => !unsupported[server.key],
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
attach(server: Server, sessionID: string) {
const id = key(server, sessionID)
if (live.has(id)) return
const pane = platform.browserPane
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
const entry: Live = { server, sessionID, attempts: 0, dispose: () => undefined }
live.set(id, entry)
setStore(id, { browser: null })
const register = () => {
if (entry.registration || live.get(id) !== entry) return
// The server's shared transport follows a restarted sidecar's port whether or not any route
// for this session is mounted; the connection captured at attach time may predate it.
const endpoint = { ...server.conn.http, url: server.ctx.sdk.url }
const registration = pane.register({ sessionID, endpoint }, (event) => {
if (live.get(id) !== entry) return
if (event.type === "focus") return focus.get(id)?.forEach((listener) => listener(event.tabID))
if (event.error === "browser.pane.unsupported") {
setUnsupported(server.key, true)
return close(id)
}
if (event.error === "browser.pane.replaced") {
registration.close()
entry.registration = undefined
return setStore(id, {
registration: undefined,
browser: null,
error: language.t("session.browser.replaced"),
})
}
// The desktop dropped the attachment (server restart, attach race). Re-register so the
// agent's browser tool comes back without a reload.
if (event.error === "browser.pane.registration.closed") {
registration.close()
entry.registration = undefined
setStore(id, { registration: undefined, browser: null, error: undefined })
entry.retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** entry.attempts++))
return
}
if (event.state) entry.attempts = 0
batch(() => {
setStore(id, "browser", reconcile(event.state))
setStore(id, "error", event.error ? language.t("common.requestFailed") : undefined)
})
})
entry.registration = registration
setStore(id, { registration, browser: null, error: undefined })
}
// A new session appears in the UI before its server-side creation finishes. The listener
// belongs to this provider, not to the route effect that happened to call attach().
const data = server.ctx.data
const unsubscribe = runWithOwner(owner, () =>
data.on("session.created", (event) => {
if (event.data.sessionID === sessionID) register()
}),
)
if (!data.session.creating(sessionID)) register()
entry.dispose = () => {
unsubscribe?.()
clearTimeout(entry.retry)
entry.registration?.close()
}
},
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
const id = key(server, sessionID)
const listeners = focus.get(id) ?? new Set()
listeners.add(listener)
focus.set(id, listeners)
return () => {
listeners.delete(listener)
if (!listeners.size) focus.delete(id)
}
},
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
const registration = live.get(key(server, sessionID))?.registration
if (!registration) return Promise.reject(new Error("browser.pane.unavailable"))
return registration.command(command)
},
}
},
})
-115
View File
@@ -1,115 +0,0 @@
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import { useServer } from "@/runtime/server/current"
import type { SessionModel } from "../model"
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
import { useBrowserAttachments } from "./attachments"
export function createSessionBrowser(session: SessionModel) {
const attachments = useBrowserAttachments()
const language = useLanguage()
const server = useServer()
const [local, setLocal] = createStore({ error: undefined as string | undefined })
const attachment = () => {
const sessionID = session.identity.sessionID()
return sessionID ? attachments.state(server, sessionID) : undefined
}
const available = createMemo(
() =>
attachments.enabled() &&
attachments.supported(server) &&
!!session.identity.sessionID() &&
!server.health?.incompatible &&
session.isDesktop(),
)
const attached = () => attachment()?.registration !== undefined
const browserTabs = createMemo(
() =>
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
[],
)
const focus = (tabID: Browser.TabID) => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
const key = sessionBrowserTab(tabID)
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
tabs.setActive(key)
}
const command = (command: BrowserPaneCommand) => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
setLocal("error", undefined)
const owner = session.ownership.capture()
void attachments.command(server, sessionID, command).catch(() => {
if (owner.current()) setLocal("error", language.t("common.requestFailed"))
})
}
createEffect(() => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
if (attachments.enabled()) attachments.attach(server, sessionID)
onCleanup(attachments.onFocus(server, sessionID, focus))
})
createEffect(
on(
() => session.layout.tabs().active(),
(active) => {
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active)
if (tab && tab.id !== attachment()?.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id })
},
),
)
createEffect(
on(
() => session.layout.tabs().all(),
(current, previous) => {
previous
?.filter((key) => isSessionBrowserTab(key) && !current.includes(key))
.forEach((key) => {
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key)
if (tab) command({ type: "tabs.close", tabID: tab.id })
})
},
),
)
// Mirror the desktop's tab inventory into this session's layout tabs. Only tabs new since the last
// inventory are added, so a layout tab the user just closed is not reopened before the desktop confirms.
createEffect(
on(
() => attachment()?.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)),
(ids, previous) => {
if (!ids) return
const known = new Set(previous ?? [])
batch(() => {
const tabs = session.layout.tabs()
tabs
.all()
.filter((key) => isSessionBrowserTab(key) && !ids.includes(key))
.forEach(tabs.close)
const current = tabs.all()
const added = ids.filter((key) => !known.has(key) && !current.includes(key))
if (added.length) tabs.setAll([...current, ...added])
})
},
),
)
return {
available,
attached,
opened: () => attached() && browserTabs().length > 0,
state: () => attachment()?.browser ?? null,
tabs: browserTabs,
active: () =>
browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ??
browserTabs().find((tab) => tab.id === attachment()?.browser?.focusedTabID) ??
browserTabs()[0],
error: () => local.error ?? attachment()?.error,
registration: () => attachment()?.registration,
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
open: () => command({ type: "tabs.open" }),
command,
}
}
-185
View File
@@ -1,185 +0,0 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Loader } from "@opencode/ui/loader"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, For, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: {
registration: BrowserPaneRegistration
browser: ReturnType<typeof createSessionBrowser>
visible: boolean
}) {
const platform = usePlatform()
const language = useLanguage()
const dialog = useDialog()
const state = props.browser.active
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
let surface: HTMLDivElement | undefined
let frame: number | undefined
let layout: string | undefined
let until = 0
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
// The native page always paints above the DOM, so hide it while a floating
// menu, select, or popover overlaps it. Tooltips are excluded.
const covered = (rect: DOMRect) =>
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
})
const measure = () => {
if (!surface) return
const tab = state()
if (!tab) {
props.registration.setLayout()
return
}
const rect = surface.getBoundingClientRect()
const zoom = platform.webviewZoom?.() ?? 1
const left = Math.round(rect.left * zoom)
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
).backgroundColor
const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
if (next !== layout) {
layout = next
// Let the browser resolve the semantic backdrop color, including custom
// themes using color formats that Electron's color parser cannot read.
if (paint) {
paint.clearRect(0, 0, 1, 1)
paint.fillStyle = color
paint.fillRect(0, 0, 1, 1)
}
const rgba = paint?.getImageData(0, 0, 1, 1).data
props.registration.setLayout({
tabID: tab.id,
visible,
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
radius: Math.round(10 * zoom),
})
}
}
const tick = () => {
frame = undefined
measure()
if (performance.now() < until) frame = requestAnimationFrame(tick)
}
const schedule = (duration = 0) => {
until = Math.max(until, performance.now() + duration)
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
createEffect(
on(
[
() => platform.webviewZoom?.(),
() => dialog.active,
() => store.visible,
() => props.visible,
() => state()?.id,
],
() => schedule(300),
),
)
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
// animation frame keeps the native view in step with a pane drag.
createResizeObserver(() => surface, measure)
createEventListener(window, "resize", () => schedule(300))
// Floating content portals directly into <body>; keep measuring briefly so
// the positioner has settled before the overlap check runs.
const portals = new MutationObserver(() => schedule(300))
portals.observe(document.body, { childList: true })
onCleanup(() => portals.disconnect())
const appearance = new MutationObserver(() => schedule(300))
appearance.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
onCleanup(() => appearance.disconnect())
createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300))
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
props.registration.setLayout()
})
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
<For each={["back", "forward"] as const}>
{(direction) => (
<IconButton
{...button}
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: direction, tabID: tab.id })
}}
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
/>
)}
</For>
<IconButton
{...button}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
}}
icon={
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
<Loader />
</Show>
}
/>
<form
class="min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault()
const tab = state()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
}}
>
<input
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
value={store.address}
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
onInput={(event) => setStore("address", event.currentTarget.value)}
/>
</form>
</div>
<Show when={props.browser.error()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
</aside>
)
}
+7 -68
View File
@@ -1,68 +1,7 @@
import type { SessionInfo, SessionMessageInfo } from "@opencode/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { Platform } from "@/runtime/platform/platform"
export type SessionExportData = {
info: SessionInfo
messages: SessionMessageInfo[]
}
export async function fetchSessionExport(input: {
sessionID: string
api: Pick<ServerApi, "session" | "message">
}): Promise<SessionExportData> {
const [info, first] = await Promise.all([
input.api.session.get({ sessionID: input.sessionID }),
input.api.message.list({ sessionID: input.sessionID, limit: 200, order: "asc" }),
])
const pages = [first]
while (pages.at(-1)?.cursor.next) {
pages.push(
await input.api.message.list({
sessionID: input.sessionID,
limit: 200,
cursor: pages.at(-1)!.cursor.next ?? undefined,
}),
)
}
return {
info,
messages: pages.flatMap((page) => page.data),
}
}
export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) {
const name = session.title || session.slug || session.id
const clean = name
.toLowerCase()
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/^-+|-+$/g, "")
return `${clean || session.id}.json`
}
export function downloadSessionExport(filename: string, data: unknown) {
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export async function saveSessionExport(
filename: string,
data: unknown,
platform: Pick<Platform, "saveFile">,
) {
if (!platform.saveFile) {
downloadSessionExport(filename, data)
return true
}
return platform.saveFile({ defaultPath: filename }, JSON.stringify(data, null, 2))
}
export {
fetchSessionExport,
saveSessionExport,
sessionExportFilename,
downloadSessionExport,
type SessionExportData,
} from "@opencode/plugin-context-desktop/export"
@@ -160,13 +160,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
}
const openFile = () => {
void openDialog(
() => import("@/shell/commands/dialog"),
(x) => dialog.show(() => <x.DialogCommandPalette onOpenFile={showAllFiles} />),
)
}
const closeTab = () => {
const tab = actions.session.tabs.closableTab()
if (!tab) return
@@ -311,14 +304,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const fileCmds = () => {
const tab = actions.session.tabs.closableTab()
return [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+p",
slash: "open",
onSelect: openFile,
}),
tab &&
fileCommand({
id: "file.close",
@@ -1,20 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
describe("open app preferences", () => {
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
expect(decode({ app })).toEqual({ app })
})
test.each([undefined, null, 42, "unknown", {}])("defaults invalid selection %p", (app) => {
expect(decode({ app })).toEqual({ app: "finder" })
})
test("defaults an absent selection", () => {
expect(decode({})).toEqual({ app: "finder" })
})
})
@@ -1,100 +0,0 @@
import { Button } from "@opencode/ui/button"
import { Tabs } from "@opencode/ui/tabs"
import { getFilename } from "@opencode/util/path"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionTabs, SESSION_OPEN_FILE_TAB } from "@/session/helpers"
import { useFile } from "@/workspaces/files/model"
import { SessionFileBrowserTab } from "./session-file-browser-tab"
import type { Kind } from "./file-tree-v2"
import "./session-mobile-files.css"
export function SessionMobileFiles() {
const file = useFile()
const language = useLanguage()
const layout = useSessionLayout()
const tabs = createSessionTabs({
tabs: layout.tabs,
pathFromTab: file.pathFromTab,
normalizeTab: file.tab,
})
const [store, setStore] = createStore({ browsing: !tabs.activeFileTab() })
const browsing = () => store.browsing || !tabs.activeFileTab()
const active = createMemo(() => file.pathFromTab(tabs.activeFileTab() ?? ""))
const kinds = new Map<string, Kind>()
const open = (path: string) => {
layout.tabs().open(file.tab(path))
void file.load(path)
setStore("browsing", false)
}
return (
<div data-slot="session-mobile-files" data-browsing={browsing()} class="flex h-full min-h-0 flex-col">
<div data-slot="session-mobile-files-header" class="relative flex h-10 shrink-0 items-center">
<Button
size="small"
variant="ghost"
class="shrink-0 mx-2"
onClick={() => setStore("browsing", true)}
aria-pressed={browsing()}
>
{language.t("session.files.all")}
</Button>
<Tabs
value={browsing() ? SESSION_OPEN_FILE_TAB : tabs.activeFileTab()}
onChange={(tab) => {
// Kobalte falls back to a file tab when the browse view has no trigger.
if (browsing()) return
const path = file.pathFromTab(tab)
if (path) open(path)
}}
variant="line"
class="min-w-0 flex-1 !h-auto"
>
<Tabs.List aria-label={language.t("session.files.openTabs")} class="!h-10 !px-0 overflow-x-auto">
<For each={tabs.openedTabs()}>
{(tab) => (
<Tabs.Trigger
value={tab}
onClick={() => open(file.pathFromTab(tab)!)}
class="shrink-0 max-w-48"
classes={{ button: "min-w-0" }}
closeButton={
<Tabs.CloseButton
aria-label={language.t("common.closeTab")}
onClick={() => layout.tabs().close(tab)}
/>
}
>
<span dir="ltr" class="truncate">
{getFilename(file.pathFromTab(tab) ?? tab)}
</span>
</Tabs.Trigger>
)}
</For>
</Tabs.List>
</Tabs>
</div>
<div class="min-h-0 flex-1">
<SessionFileBrowserTab
mobile
tab={tabs.activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={browsing()}
active={active()}
kinds={kinds}
state={{
sidebarOpened: browsing,
sidebarWidth: () => 240,
sidebarTransition: () => false,
resizeSidebar: () => undefined,
toggleSidebar: () => setStore("browsing", !browsing()),
}}
onSelect={open}
onSelectPermanent={open}
/>
</div>
</div>
)
}
@@ -1,4 +1,4 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { For, Show, createMemo, onCleanup } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable } from "@dnd-kit/solid/sortable"
@@ -8,694 +8,185 @@ import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { Tabs } from "@opencode/ui/tabs"
import { IconButton } from "@opencode/ui/icon-button"
import { Icon } from "@opencode/ui/icon"
import { Loader } from "@opencode/ui/loader"
import { ResizeHandle } from "@opencode/ui/resize-handle"
import { Mark } from "@opencode/ui/logo"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Menu } from "@opencode/ui/menu"
import type { FileDiffInfo } from "@opencode/client/promise"
import FileTree from "@/session/files/file-tree"
import { normalizeFileTreeV2Path } from "@/session/files/file-tree-v2-model"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
const browserTabID = "session-side-panel-browser-tab"
const browserTabPanelID = "session-side-panel-browser-tabpanel"
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab } from "@/session/files/session-context-tab"
import { SortableTab } from "@/session/files/tab"
import { OpenInAppButton } from "@/session/files/open-in-app-button"
import { useCommand } from "@/shell/commands/command"
import { useFile, type SelectedLineRange } from "@/workspaces/files/model"
import { Tooltip } from "@opencode/ui/tooltip"
import { SortableTab } from "./tab"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSettings } from "@/settings/model"
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
import {
SESSION_OPEN_FILE_TAB,
isSessionBrowserTab,
sessionBrowserTab,
createOpenSessionFileTab,
createSessionTabs,
shouldShowFileTree,
type Sizing,
} from "@/session/helpers"
import { setSessionHandoff } from "@/session/handoff"
import { createFileTabListSync } from "./file-tab-scroll"
import { useSessionLayout } from "@/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
import { SessionBrowserPane } from "@/session/browser/pane"
import type { createSessionBrowser } from "@/session/browser/model"
type ReviewDiff = FileDiffInfo
type RenderDiff = FileDiffInfo
const FILE_TREE_WIDTH_MIN = 240
function renderDiff(value: ReviewDiff): value is RenderDiff {
return typeof value.file === "string"
}
import { createSessionTabs, type Sizing } from "@/session/helpers"
import { useFile } from "@/workspaces/files/model"
import type { SessionExtensions } from "@/extensions/session"
import { ExtensionPanelContent } from "@/extensions/content"
export function SessionSidePanel(props: {
canReview: boolean
diffs: ReviewDiff[]
diffsReady: boolean
hasReview: boolean
reviewHasFocusableContent: boolean
reviewCount: number
reviewPanel: () => JSX.Element
reviewSidebarToggle: (disabled: boolean) => JSX.Element
fileBrowserState: SessionFileBrowserState
activeDiff?: string
focusReviewDiff: (path: string) => void
reviewPresent?: boolean
extensions: SessionExtensions
present?: boolean
size: Sizing
stacked?: boolean
browser: ReturnType<typeof createSessionBrowser>
mobile?: boolean
}) {
const language = useLanguage()
const layout = useLayout()
const settings = useSettings()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const sdk = useWorkspaceLocation()
const { sessionKey, tabs, view, params } = useSessionLayout()
const projectDirectory = createMemo(() => sdk().directory)
const session = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = settings.visibility.fileTree
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const reviewVisible = createMemo(() => reviewOpen() || !!props.reviewPresent)
const fileOpen = createMemo(
() =>
isDesktop() &&
shouldShowFileTree({
visible: shown(),
opened: layout.fileTree.opened(),
}),
)
const open = createMemo(() => reviewOpen() || fileOpen())
const visible = createMemo(() => reviewVisible() || fileOpen())
const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width()))
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
if (!visible()) return "0px"
if (reviewVisible()) return "auto"
return `${fileTreeWidth()}px`
})
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
const diffs = createMemo(() => props.diffs.filter(renderDiff))
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalizeFileTreeV2Path(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
for (const [idx] of parts.slice(0, -1).entries()) {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) continue
out.set(dir, merge(out.get(dir), kind))
}
}
return out
})
const empty = (msg: string) => (
<div class="h-full flex flex-col">
<div class="h-6 shrink-0" aria-hidden />
<div class="flex-1 pb-64 flex items-center justify-center text-center">
<div class="text-12-regular text-text-weak">{msg}</div>
</div>
</div>
)
const nofiles = createMemo(() => {
const state = file.tree.state("")
if (!state?.loaded) return false
return file.tree.children("").length === 0
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = createOpenSessionFileTab({
normalizeTab,
openTab: tabs().open,
const extensions = props.extensions
const opened = () => !!props.mobile || (isDesktop() && session.view().reviewPanel.opened())
const visible = () => opened() || !!props.present
const sidebar = () => !props.mobile && isDesktop() && settings.visibility.fileTree() && layout.fileTree.opened()
const width = () => Math.max(240, layout.fileTree.width())
const state = createSessionTabs({
tabs: session.tabs,
pathFromTab: file.pathFromTab,
loadFile: file.load,
openReviewPanel,
setActive: tabs().setActive,
normalizeTab: file.tab,
extensions: extensions.keys,
defaultPanel: extensions.defaultPanel,
canClose: extensions.canClose,
})
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview: () => props.canReview,
fileBrowser: () => true,
browser: props.browser.attached,
const panelTabs = createMemo(() => {
const keys = state.panelTabs()
const preferred = extensions.defaultPanel()
return preferred && !keys.includes(preferred) ? [preferred, ...keys] : keys
})
const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
layout.fileTree.setTab(value)
}
let fileFilter: HTMLInputElement | undefined
let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
tabs().previewTab(next)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
queueMicrotask(() => tabs().setActive(next))
}
const openFileBrowser = () => {
previewTab(SESSION_OPEN_FILE_TAB)
queueMicrotask(() => fileFilter?.focus())
}
const activateTab = (value: string) => {
const next = normalizeTab(value)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
tabs().setActive(next)
}
const fileTab = createMemo(() => {
const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
return activeFileTab()
})
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
return openedTabs().length > 0 || openFileOpen() || !!fileTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
return (
<Show when={isDesktop() && !!params.id}>
<Show when={(isDesktop() || props.mobile) && session.params.id}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
aria-hidden={!open()}
inert={!open()}
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
aria-hidden={!opened() && !sidebar()}
inert={!opened() && !sidebar()}
class="relative min-w-0 h-full flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
classList={{
"h-full shrink-0": !props.stacked,
"h-full min-h-0": props.stacked,
"pointer-events-none": !open(),
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
"flex-1": reviewVisible(),
"flex-1": visible(),
"min-h-0": !!props.stacked,
"shrink-0": !props.stacked,
"pointer-events-none": !opened() && !sidebar(),
}}
style={{ width: panelWidth() }}
style={{ width: visible() ? "auto" : sidebar() ? `${width()}px` : "0px" }}
>
<Show when={visible()}>
<div class="size-full flex">
<Show when={reviewVisible()}>
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-v2-background-bg-base">
<div class="size-full min-w-0 h-full bg-v2-background-bg-base">
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
event.target instanceof Element &&
(!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
!!event.target.closest(".session-review-v2-open-in-app-slot")),
}),
]}
modifiers={[
RestrictToHorizontalAxis,
RestrictToElement.configure({ element: () => tabList ?? null }),
]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
tabs().move(source.id.toString(), source.index)
<div class="relative min-w-0 h-full flex-1 overflow-hidden">
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
event.target instanceof Element &&
!!event.target.closest('[data-slot="tabs-trigger-close-button"]'),
}),
]}
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => tabList ?? null })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
session.tabs().move(source.id.toString(), source.index)
}}
>
<Tabs value={state.activeTab()} onChange={(value) => session.tabs().setActive(value)}>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
ref={(element: HTMLDivElement) => {
tabList = element
onCleanup(createFileTabListSync({ el: element, contextOpen: state.contextOpen }))
}}
>
<Tabs value={activeTab()} onChange={activateTab}>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
ref={(el: HTMLDivElement) => {
tabList = el
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{props.reviewSidebarToggle(activeTab() === SESSION_OPEN_FILE_TAB)}
</div>
<Show when={reviewTab() && props.canReview}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{extensions.toolbar()}
</div>
<For each={panelTabs()}>
{(key) => (
<Show when={extensions.panels().find((panel) => panel.key === key)}>
{(panel) => (
<SortableTab
tab={key}
index={session.tabs().all().indexOf(key)}
temporary={panel().props.temporary}
onTabClose={panel().props.closable === false ? undefined : session.tabs().close}
onTabDoubleClick={panel().props.onDoubleClick}
>
{props.hasReview
? language.t("session.review.filesChanged", { count: props.reviewCount })
: language.t("session.tab.review")}
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
onMiddleClick={() => tabs().close("context")}
closeButton={
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
<div class="flex items-center gap-1.5">
<Show when={panel().props.loading} fallback={panel().icon()}>
<Loader />
</Show>
<span class="max-w-40 truncate" dir="auto">
{panel().props.title}
</span>
<Show when={panel().props.badge}>{panel().props.badge}</Show>
</div>
</Tabs.Trigger>
</Show>
<For each={panelTabs()}>
{(tab) => (
<Switch
fallback={
<SortableTab
tab={tab}
index={tabs().all().indexOf(tab)}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
/>
}
>
<Match when={isSessionBrowserTab(tab)}>
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
{(item) => (
<SortableTab
tab={tab}
index={tabs().all().indexOf(tab)}
onTabClose={() => props.browser.close(item().id)}
id={`${browserTabID}-${item().id}`}
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<Icon name="window-cursor" size="small" />
<span class="max-w-40 truncate">
{item().title || language.t("session.tab.browser")}
</span>
</div>
</SortableTab>
)}
</Show>
</Match>
<Match when={tab === SESSION_OPEN_FILE_TAB}>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
class="group"
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
closeButton={
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<IconButton
size="small"
variant="ghost-muted"
class="hover-reveal relative z-10 group-hover:opacity-100"
classList={{ "opacity-100": activeTab() === SESSION_OPEN_FILE_TAB }}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
tabs().close(SESSION_OPEN_FILE_TAB)
}}
icon={<Icon name="xmark-small" />}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
>
<div class="flex items-center gap-1.5">
<Icon name="file-tree" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
</Match>
</Switch>
)}
</For>
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{/* With only files to add, the plus stays a one-click "Open file" button. */}
<Show
when={props.browser.available()}
fallback={
<Tooltip
value={
<>
{language.t("command.file.open")}
<Show when={openFileKeybind().length > 0}>
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButton
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
aria-label={language.t("command.file.open")}
/>
</Tooltip>
}
>
<Tooltip
value={language.t("session.tab.add")}
placement="bottom"
class="flex items-center"
>
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
aria-label={language.t("session.tab.add")}
// The tablist redirects focus entering it to the selected
// tab, which counts as focus-outside and closes the menu.
onPointerDown={(event: PointerEvent) => event.preventDefault()}
/>
<Menu.Portal>
<Menu.Content>
<Menu.Item
onSelect={openFileBrowser}
shortcut={
<Show when={openFileKeybind().length > 0}>
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
}
>
<div class="flex items-center gap-2">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Menu.Item>
<Menu.Item onSelect={props.browser.open}>
<div class="flex items-center gap-2">
<Icon name="window-cursor" size="small" />
<span>{language.t("session.tab.browser")}</span>
</div>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
</Show>
</div>
</Tabs.List>
<div
data-slot="session-side-panel-actions"
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
classList={{ "h-[51px]": props.stacked, "h-12": !props.stacked }}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppButton directory={projectDirectory} />
<Show when={reviewOpen()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</div>
</div>
<Show when={reviewTab() && props.canReview && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Tabs.Content>
</Show>
<Show when={props.browser.opened()}>
<div
id={browserTabPanelID}
role="tabpanel"
aria-labelledby={
props.browser.active() ? `${browserTabID}-${props.browser.active()?.id}` : undefined
}
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: !isSessionBrowserTab(activeTab()) }}
inert={!isSessionBrowserTab(activeTab()) || undefined}
>
<Show when={props.browser.registration()} keyed>
{(registration) => (
<SessionBrowserPane
registration={registration}
browser={props.browser}
visible={isSessionBrowserTab(activeTab())}
/>
)}
</Show>
</div>
</Show>
<Show when={fileBrowserMounted()}>
<div
id={fileBrowserTabPanelID}
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: !fileBrowserVisible() }}
inert={!fileBrowserVisible() || undefined}
>
<SessionFileBrowserTab
tab={fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={
(fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
}
active={file.pathFromTab(fileTab() ?? activeFileTab() ?? "")}
kinds={kinds()}
state={props.fileBrowserState}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</div>
</Show>
</Tabs>
</DragDropProvider>
</div>
</div>
</Show>
<Show when={fileOpen()}>
<div
id="file-tree-panel"
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
>
<Tabs
variant="surface"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.review.filesChanged", { count: props.reviewCount })}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Show when={fileTreeTab() === "changes"}>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview || !props.diffsReady}>
<Show
when={props.diffsReady}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
</Show>
<Show when={fileTreeTab() === "all"}>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
</SortableTab>
)}
</Show>
)}
</For>
<Show when={extensions.hasActions()}>
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
aria-label={language.t("session.tab.add")}
onPointerDown={(event: PointerEvent) => event.preventDefault()}
/>
</Match>
</Switch>
</Tabs.Content>
<Menu.Portal>
<Menu.Content>{extensions.actions()}</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
</div>
</Show>
</Tabs.List>
<div
data-slot="session-side-panel-actions"
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
classList={{ "h-[51px]": !!props.stacked, "h-12": !props.stacked }}
onPointerDown={(event) => event.stopPropagation()}
>
{extensions.tools()}
<Show when={opened() && !props.mobile}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</Tabs>
</div>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={fileTreeWidth()}
min={FILE_TREE_WIDTH_MIN}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</div>
</Show>
</div>
</Show>
</div>
<ExtensionPanelContent panels={extensions.panels()} active={state.activeTab()} />
</Tabs>
</DragDropProvider>
</div>
</Show>
<Show when={sidebar()}>
<div
id="file-tree-panel"
class="relative min-w-0 h-full shrink-0 overflow-hidden"
style={{ width: `${width()}px` }}
>
{extensions.sidebar()}
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={width()}
min={240}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</div>
</div>
</Show>
</aside>
+22 -20
View File
@@ -13,7 +13,7 @@ export function SortableTab(props: {
tab: string
index: number
temporary?: boolean
onTabClose: (tab: string) => void
onTabClose?: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
/** Replaces the file visual for non-file tabs such as the browser. */
children?: JSX.Element
@@ -46,27 +46,29 @@ export function SortableTab(props: {
value={props.tab}
id={props.id}
aria-controls={props.ariaControls}
onMiddleClick={() => props.onTabClose(props.tab)}
onMiddleClick={props.onTabClose ? () => props.onTabClose?.(props.tab) : undefined}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
closeButton={
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
<Show when={props.onTabClose}>
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose?.(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
</Show>
}
hideCloseButton
>
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { virtualScrollElement } from "./virtual-scroll"
import { virtualScrollElement } from "@opencode/plugin-review-desktop/files/virtual-scroll"
test("resolves the connected viewport that owns the virtual root", () => {
const stale = document.createElement("div")
@@ -1,4 +0,0 @@
export function virtualScrollElement(root: HTMLElement | undefined) {
if (!root?.isConnected) return null
return root.closest<HTMLDivElement>(".scroll-view__viewport")
}
+13 -13
View File
@@ -2,8 +2,6 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
SESSION_BROWSER_TAB,
sessionBrowserTab,
SESSION_OPEN_FILE_TAB,
createOpenReviewFile,
createOpenSessionFileTab,
@@ -12,6 +10,7 @@ import {
getTabReorderIndex,
shouldShowFileTree,
} from "./helpers"
import { extensionTabKey } from "@/extensions/keys"
describe("shouldShowFileTree", () => {
test("does not reserve space for a disabled file tree", () => {
@@ -236,35 +235,36 @@ describe("createSessionTabs", () => {
})
})
test("exposes one browser tab without treating it as a file tab", () => {
test("exposes one extension panel without treating it as a file tab", () => {
createRoot((dispose) => {
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
const panel = extensionTabKey("example", "panel")
const tabs = createMemo(() => ({ active: () => panel, all: () => [panel] }))
const result = createSessionTabs({
tabs,
pathFromTab: () => undefined,
normalizeTab: (tab) => tab,
browser: () => true,
extensions: () => [panel],
})
expect(result.panelTabs()).toEqual([SESSION_BROWSER_TAB])
expect(result.panelTabs()).toEqual([panel])
expect(result.openedTabs()).toEqual([])
expect(result.activeTab()).toBe(SESSION_BROWSER_TAB)
expect(result.activeTab()).toBe(panel)
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBe(SESSION_BROWSER_TAB)
expect(result.closableTab()).toBe(panel)
dispose()
})
})
test("keeps browser tabs in layout order beside file tabs, and drops them when the browser is detached", () => {
const first = sessionBrowserTab("first")
const second = sessionBrowserTab("second")
test("keeps extension panels in layout order beside files and drops unavailable contributions", () => {
const first = extensionTabKey("example", "first")
const second = extensionTabKey("example", "second")
const input = {
tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }),
pathFromTab: (tab: string) => (tab.startsWith("file://") ? tab.slice(7) : undefined),
normalizeTab: (tab: string) => tab,
}
createRoot((dispose) => {
const result = createSessionTabs({ ...input, browser: () => true })
const result = createSessionTabs({ ...input, extensions: () => [first, second] })
expect(result.panelTabs()).toEqual([first, "file://src/a.ts", second])
expect(result.openedTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe(second)
@@ -273,7 +273,7 @@ describe("createSessionTabs", () => {
dispose()
})
createRoot((dispose) => {
const result = createSessionTabs({ ...input, browser: () => false })
const result = createSessionTabs({ ...input, extensions: () => [] })
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe("file://src/a.ts")
expect(result.closableTab()).toBe("file://src/a.ts")
+29 -57
View File
@@ -1,15 +1,11 @@
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, createMemo, type Accessor } from "solid-js"
import { createSizing } from "@opencode/ui/resize-state"
export { createSizing }
import { same } from "@/runtime/persistence/equality"
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
import { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
import { isExtensionTab } from "@/extensions/keys"
export {
SESSION_BROWSER_TAB,
SESSION_OPEN_FILE_TAB,
sessionBrowserTab,
isSessionBrowserTab,
} from "@/shell/state/session-tabs"
export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
const emptyTabs: string[] = []
@@ -25,7 +21,9 @@ type TabsInput = {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: Accessor<boolean>
browser?: Accessor<boolean>
extensions?: Accessor<readonly string[]>
defaultPanel?: Accessor<string | undefined>
canClose?: (key: string) => boolean
}
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
@@ -36,7 +34,6 @@ export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const fileBrowser = input.fileBrowser ?? (() => false)
const browser = input.browser ?? (() => false)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openFileOpen = createMemo(
() =>
@@ -50,8 +47,10 @@ export const createSessionTabs = (input: TabsInput) => {
.tabs()
.all()
.flatMap((tab) => {
if (input.extensions?.().includes(tab)) return [tab]
if (tab === "context" || tab === "review") return []
if (isSessionBrowserTab(tab)) return browser() ? [tab] : []
if (isExtensionTab(tab)) return input.extensions?.().includes(tab) ? [tab] : []
if (tab !== SESSION_OPEN_FILE_TAB && !input.pathFromTab(tab)) return []
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
@@ -63,20 +62,33 @@ export const createSessionTabs = (input: TabsInput) => {
{ equals: same },
)
const openedTabs = createMemo(
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
() =>
Array.from(
new Set(
input
.tabs()
.all()
.filter((tab) => !!input.pathFromTab(tab))
.map(input.normalizeTab),
),
),
emptyTabs,
{ equals: same },
{
equals: same,
},
)
const activeTab = createMemo(() => {
const active = input.tabs().active()
if (active === "context") return active
if (active && input.extensions?.().includes(active)) return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (active === "review" && review()) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
const preferred = input.defaultPanel?.()
if (preferred) return preferred
if (contextOpen()) return "context"
if (review() && hasReview()) return "review"
return "empty"
@@ -88,9 +100,9 @@ export const createSessionTabs = (input: TabsInput) => {
})
const closableTab = createMemo<string | undefined>(() => {
const active = activeTab()
if (active && input.extensions?.().includes(active)) return input.canClose?.(active) === false ? undefined : active
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (!openedTabs().includes(active)) return
return active
})
@@ -172,44 +184,4 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st
return toIndex
}
export const createSizing = () => {
const [state, setState] = createStore({ active: false })
let t: number | undefined
const stop = () => {
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
setState("active", false)
}
const start = () => {
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
setState("active", true)
}
onMount(() => {
makeEventListener(window, "pointerup", stop)
makeEventListener(window, "pointercancel", stop)
makeEventListener(window, "blur", stop)
})
onCleanup(() => {
if (t !== undefined) clearTimeout(t)
})
return {
active: () => state.active,
start,
touch() {
start()
t = window.setTimeout(stop, 120)
},
}
}
export type Sizing = ReturnType<typeof createSizing>
@@ -0,0 +1,86 @@
import { For, Show, Suspense, lazy, createEffect, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode/ui/tabs"
import { Menu } from "@opencode/ui/menu"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { useLanguage } from "@/runtime/i18n/language"
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
return { default: StatusDrawer }
})
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
export function SessionMobileViewTabs(props: {
current: string
items: readonly { id: string; title: string; menu?: boolean }[]
onSelect(value: string): void
details?: (close: () => void) => JSX.Element
onDetailsOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ status: false, details: false })
createEffect(() => props.onDetailsOpenChange?.(store.details))
onCleanup(() => props.onDetailsOpenChange?.(false))
return (
<div class="relative flex shrink-0 items-center" data-slot="session-mobile-view-navigation">
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0">
<Tabs.Trigger value="session" onClick={() => props.onSelect("session")}>
{language.t("session.tab.session")}
</Tabs.Trigger>
<For each={props.items.filter((item) => !item.menu)}>
{(item) => (
<Tabs.Trigger value={item.id} onClick={() => props.onSelect(item.id)}>
{item.title}
</Tabs.Trigger>
)}
</For>
<Tabs.Trigger value="terminal" onClick={() => props.onSelect("terminal")}>
{language.t("terminal.title")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
icon={<Icon name="menu" />}
variant="ghost-muted"
size="normal"
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content>
<For each={props.items.filter((item) => item.menu)}>
{(item) => <Menu.Item onSelect={() => props.onSelect(item.id)}>{item.title}</Menu.Item>}
</For>
<Show when={props.details}>
<Menu.Item onSelect={() => setStore("details", true)}>{language.t("session.summary.title")}</Menu.Item>
</Show>
<Menu.Item onSelect={() => setStore("status", true)}>{language.t("status.popover.trigger")}</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.status}>
<Suspense>
<StatusDrawer open onOpenChange={(open) => setStore("status", open)} />
</Suspense>
</Show>
<Show when={store.details}>
<Suspense>
<MobilePanelDrawer
title={language.t("session.summary.title")}
open
onOpenChange={(open) => setStore("details", open)}
>
{props.details?.(() => setStore("details", false))}
</MobilePanelDrawer>
</Suspense>
</Show>
</div>
)
}
+16 -7
View File
@@ -7,7 +7,6 @@ import { useData } from "@/runtime/server/current"
import { same } from "@/runtime/persistence/equality"
import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { projectForSession } from "@/shell/layout/helpers"
import { useBrowserAttachments } from "./browser/attachments"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
@@ -19,6 +18,7 @@ import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useTabs } from "@/shell/tabs/tabs"
import { useServer } from "@/runtime/server/current"
import { useOptionalDesktopExtensions } from "@/extensions/provider"
const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
@@ -29,7 +29,7 @@ export function useSessionModel() {
const data = useData()
const server = useServer()
const shellTabs = useTabs()
const attachments = useBrowserAttachments()
const extensions = useOptionalDesktopExtensions()
const layout = useSessionLayout()
const location = useWorkspaceLocation()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -83,12 +83,21 @@ export function useSessionModel() {
normalizeTab,
review: isDesktop,
hasReview: canReview,
extensions: () =>
extensions?.state.panels
.filter((panel) => panel.session.sessionID === sessionID() && panel.session.server.id === server.key)
.map((panel) => panel.key) ?? [],
defaultPanel: () =>
extensions?.state.panels.find(
(panel) =>
panel.session.sessionID === sessionID() && panel.session.server.id === server.key && panel.props.default,
)?.key,
canClose: (key) =>
extensions?.state.panels.find(
(panel) =>
panel.session.sessionID === sessionID() && panel.session.server.id === server.key && panel.key === key,
)?.props.closable !== false,
fileBrowser: () => isDesktop() && !!sessionID(),
// Same flag the side panel uses, so keyboard tab commands see the browser tab the panel shows.
browser: () => {
const id = sessionID()
return !!id && attachments.state(server, id)?.registration !== undefined
},
})
return {
-430
View File
@@ -1,430 +0,0 @@
import type { FileDiffInfo } from "@opencode/client/promise"
import type { SessionReviewLineComment } from "@opencode/session-ui/session-review"
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
import { checksum } from "@opencode/util/encode"
import { createQuery, skipToken, useQueryClient } from "@tanstack/solid-query"
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, on, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useComments } from "@/composer/comments"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useComposerState } from "@/composer/persistence"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { createOpenReviewFile } from "../helpers"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { createReviewPanelState } from "./panel-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "./review-diff-kinds"
import type { DiffStyle } from "./review-tab"
export type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
export function createSessionReview(input: {
session: SessionModel
screen: SessionScreenLayout
deferRender: Accessor<boolean>
}) {
const data = input.session.shared.data
const queryClient = useQueryClient()
const comments = useComments()
const file = useFile()
const language = useLanguage()
const layout = useLayout()
const prompt = useComposerState()
const location = useWorkspaceLocation()
const server = useServerSDK()
const [state, setState] = createStore({
mobileTab: "session" as "session" | "changes" | "files" | "usage",
detailsOpen: false,
scroll: undefined as HTMLDivElement | undefined,
pendingFile: undefined as string | undefined,
})
const mode = () => input.session.layout.view().review.mode() ?? "git"
const selectedFile = () => input.session.layout.view().review.file()
createEffect(() => {
const tab = input.session.tabs.activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (path) void file.load(path)
})
const vcs = createMemo(() => data.location.vcs.info({ directory: location().directory }))
const options = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
const project = input.session.project()
if (project?.vcs) list.push("git")
if (
project?.vcs &&
vcs()?.branch.current &&
vcs()?.branch.default &&
vcs()?.branch.current !== vcs()?.branch.default
) {
list.push("branch")
}
return list
})
const mobileChanges = createMemo(
() => !input.session.isDesktop() && !input.screen.terminal.open() && state.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
const value = mode()
return value === "git" || value === "branch" ? value : undefined
})
const vcsKey = createMemo(
() =>
[
server.scope,
"session-vcs",
location().directory,
vcs()?.branch.current ?? "",
vcs()?.branch.default ?? "",
] as const,
)
const wantsReview = createMemo(() =>
input.session.isDesktop()
? input.screen.files.open() ||
(input.screen.review.open() &&
(input.session.tabs.activeTab() === "review" || !!input.session.tabs.activeFileTab()))
: mobileChanges(),
)
const vcsQuery = createQuery(() => {
const value = vcsMode()
return {
queryKey: [...vcsKey(), value] as const,
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
? () =>
server.api.vcs
.diff({
location: { directory: location().directory },
mode: value === "git" ? "working" : value,
})
.then((result) => result.data)
: skipToken,
}
})
const detailsQuery = createQuery(() => ({
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs,
queryFn: () =>
server.api.vcs
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
.then((result) => result.data)
.catch((error) => {
console.debug("[session-review] failed to load session details diff", { error })
return []
}),
}))
const refresh = debounce(() => {
void queryClient.invalidateQueries({ queryKey: vcsKey() })
void queryClient.invalidateQueries({
queryKey: [server.scope, "session-details", input.session.workspace.directory()],
})
}, 100)
createEffect(() => {
const stop = location().event.listen((event) => {
if (event.type === "filesystem.changed") refresh()
})
onCleanup(stop)
})
createEffect(
on(
() => input.screen.review.open() || mobileChanges(),
(open, previous) => {
if (!open || previous || !input.screen.files.open() || vcsQuery.isFetching) return
refresh()
},
{ defer: true },
),
)
const diffs = () => {
if (mode() === "git" || mode() === "branch") return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return []
}
const activeFile = () => {
const list = diffs()
const selected = selectedFile()
if (selected && list.some((diff) => diff.file === selected)) return selected
return list[0]?.file
}
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
}
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
const value = vcsMode()
if (!value) return undefined
const root = reviewRootDirectory(input.session.project()?.worktree ?? location().directory)
const directory = reviewDiffDirectory(root, path)
const source = diffs().find((diff) => diff.file === path)
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
if (!diff || !source) return undefined
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return undefined
if (reviewDiffNeedsLoad(diff)) return undefined
return diff
}
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
queryKey: [server.scope, ...vcsKey(), value, "directory", scope, context, version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
server.api.vcs
.diff({
location: { directory: scope },
mode: value === "git" ? "working" : value,
context,
})
.then((result) => result.data),
})
.then((result) => result.find((diff) => diff.file === path))
if (directory !== root) {
try {
const scoped = valid(await request(directory))
if (scoped) return scoped
} catch (error) {
console.debug("[session-review] failed to load scoped vcs diff", { mode: value, path, directory, error })
}
}
try {
const bounded = valid(await request(root, 3))
if (bounded) return bounded
} catch (error) {
console.debug("[session-review] failed to load bounded vcs diff", { mode: value, path, root, error })
}
return undefined
}
const selectionPreview = (path: string, selection: FileSelection): string | undefined => {
const content = file.get(path)?.content?.content
if (!content) return undefined
return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
}
const addComment = (comment: SessionReviewLineComment) => {
const selection = selectionFromLines(comment.selection)
const saved = comments.add({ file: comment.file, selection: comment.selection, comment: comment.comment })
prompt.context.add({
type: "file",
path: comment.file,
selection,
comment: comment.comment,
commentID: saved.id,
commentOrigin: "review",
preview: comment.preview ?? selectionPreview(comment.file, selection),
})
}
const updateComment = (comment: {
id: string
file: string
selection: SelectedLineRange
comment: string
preview?: string
}) => {
comments.update(comment.file, comment.id, comment.comment)
prompt.context.updateComment(comment.file, comment.id, {
comment: comment.comment,
...(comment.preview ? { preview: comment.preview } : {}),
})
}
const removeComment = (comment: { id: string; file: string }) => {
comments.remove(comment.file, comment.id)
prompt.context.removeComment(comment.file, comment.id)
}
const commentActions = createMemo(() => ({
moreLabel: language.t("common.moreOptions"),
editLabel: language.t("common.edit"),
deleteLabel: language.t("common.delete"),
saveLabel: language.t("common.save"),
}))
const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return
layout.fileTree.setTab("all")
}
const open = () => {
if (!input.session.layout.view().reviewPanel.opened()) input.session.layout.view().reviewPanel.open()
}
const openFile = createOpenReviewFile({
showAllFiles,
tabForPath: file.tab,
openTab: (tab) => input.session.layout.tabs().open(tab),
setActive: (tab) => input.session.layout.tabs().setActive(tab),
loadFile: file.load,
})
const reviewDiffId = (path: string): string | undefined => {
const sum = checksum(path)
if (!sum) return undefined
return `session-review-diff-${sum}`
}
const reviewDiffTop = (path: string): number | undefined => {
if (!state.scroll) return undefined
const id = reviewDiffId(path)
if (!id) return undefined
const element = document.getElementById(id)
if (!(element instanceof HTMLElement) || !state.scroll.contains(element)) return undefined
const target = element.getBoundingClientRect()
const root = state.scroll.getBoundingClientRect()
return target.top - root.top + state.scroll.scrollTop
}
const scrollToFile = (path: string) => {
if (!state.scroll) return false
const top = reviewDiffTop(path)
if (top === undefined) return false
input.session.layout.view().setScroll("review", { x: state.scroll.scrollLeft, y: top })
state.scroll.scrollTo({ top, behavior: "auto" })
return true
}
const focusFile = (path: string) => {
open()
input.session.layout.view().review.openPath(path)
input.session.layout.view().review.setFile(path)
setState("pendingFile", path)
}
createEffect(() => {
const pending = state.pendingFile
if (!pending || !state.scroll || !ready()) return
const attempt = (count: number) => {
if (state.pendingFile !== pending) return
if (count > 60) {
setState("pendingFile", undefined)
return
}
if (!state.scroll || !scrollToFile(pending)) {
requestAnimationFrame(() => attempt(count + 1))
return
}
const top = reviewDiffTop(pending)
if (top === undefined || Math.abs(state.scroll.scrollTop - top) > 1) {
requestAnimationFrame(() => attempt(count + 1))
return
}
setState("pendingFile", undefined)
}
requestAnimationFrame(() => attempt(0))
})
createEffect(
on(
input.session.tabs.activeFileTab,
(active) => {
if (!active || layout.fileTree.tab() !== "changes") return
showAllFiles()
},
{ defer: true },
),
)
let treeDirectory: string | undefined
createEffect(() => {
const directory = location().directory
if (!input.session.isDesktop() || !layout.fileTree.opened() || server.connection.status() !== "connected") return
layout.fileTree.tab()
const refreshTree = treeDirectory !== directory
treeDirectory = directory
void (refreshTree ? file.tree.refresh("") : file.tree.list(""))
})
createEffect(
on(
() => location().directory,
() => {
const tab = input.session.tabs.activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (path) void file.load(path, { force: true })
},
{ defer: true },
),
)
createEffect(() => {
if (!layout.ready() || server.connection.status() !== "connected" || !input.session.project()) return
const list = options()
const value = mode()
if (list.includes(value)) return
const next = list[0]
if (next) input.session.layout.view().review.setMode(next)
})
createEffect(
on(
() => data.session.status(input.session.identity.params.id ?? ""),
(next, previous) => {
if (next !== "idle" || previous === undefined || previous === "idle") return
refresh()
},
{ defer: true },
),
)
createEffect(
on(
input.session.identity.sessionKey,
() => {
setState("mobileTab", "session")
setState("scroll", undefined)
setState("pendingFile", undefined)
},
{ defer: true },
),
)
const panelState = createReviewPanelState()
const panelRendered = createMemo<boolean>((previous) => previous || !input.deferRender(), false)
return {
activeFile,
canReview: input.session.canReview,
comments: {
actions: commentActions,
add: addComment,
all: comments.all,
focus: comments.focus,
mentions: file.searchFilesAndDirectories,
remove: removeComment,
changeFocus: (focus: { file: string; id: string } | null) => {
if (!focus) {
const current = comments.focus()
if (current && diffs().some((diff) => diff.file === current.file)) focusFile(current.file)
}
comments.setFocus(focus)
},
setFocus: comments.setFocus,
update: updateComment,
},
count,
deferRender: input.deferRender,
details: {
diffs: () => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined),
setOpen: (open: boolean) => setState("detailsOpen", open),
},
diffVersion: () => vcsQuery.dataUpdatedAt,
diffStyle: {
current: layout.review.diffStyle,
set: (style: DiffStyle) => layout.review.setDiffStyle(style),
},
diffs,
focusFile,
hasChanges,
loadDiff,
mobile: {
changes: mobileChanges,
setTab: (tab: "session" | "changes" | "files" | "usage") => setState("mobileTab", tab),
tab: () => state.mobileTab,
},
mode,
noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs),
open,
openFile,
options,
panelState,
panelRendered,
ready,
screen: input.screen,
setMode: (value: ChangeMode) => input.session.layout.view().review.setMode(value),
setScroll: (element: HTMLDivElement | undefined) => setState("scroll", element),
view: input.session.layout.view,
}
}
export type SessionReviewModel = ReturnType<typeof createSessionReview>
@@ -1,49 +0,0 @@
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
type SessionReviewExpandMode,
} from "@opencode/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const ReviewPanel = Persistence.struct({
sidebarOpened: Schema.Boolean,
sidebarWidth: Schema.Finite.check(
Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }),
),
expandMode: Schema.Literals(["expand", "collapse"]),
})
export function createReviewPanelState(platform?: Platform) {
const [store, setStore, , ready] = persisted(
Persist.global("review-panel-v2"),
ReviewPanel,
{ sidebarOpened: true, sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT, expandMode: "collapse" },
platform,
)
// The filter is transient by design: a persisted filter would silently hide
// files after a reload.
const [filter, setFilter] = createSignal("")
return {
sidebarOpened: () => store.sidebarOpened,
sidebarWidth: () => store.sidebarWidth,
sidebarTransition: ready,
filter,
setFilter,
expandMode: () => store.expandMode,
setExpandMode: (mode: SessionReviewExpandMode) => setStore("expandMode", mode),
resizeSidebar: (width: number) =>
setStore(
"sidebarWidth",
Math.min(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, Math.max(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, width)),
),
toggleSidebar: () => setStore("sidebarOpened", (opened) => !opened),
}
}
export type ReviewPanelState = ReturnType<typeof createReviewPanelState>
@@ -1,180 +0,0 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileDiffInfo } from "@opencode/client/promise"
import { SessionReview } from "@opencode/session-ui/session-review"
import type {
SessionReviewCommentActions,
SessionReviewCommentDelete,
SessionReviewCommentUpdate,
} from "@opencode/session-ui/session-review"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useLayout } from "@/shell/state/layout"
import type { LineComment } from "@/composer/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = FileDiffInfo
export interface SessionReviewTabProps {
title?: JSX.Element
empty?: JSX.Element
diffs: ReviewDiff[]
view: ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle
changeSummary?: boolean
overflow?: "wrap" | "scroll"
disableLineNumbers?: boolean
onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
lineCommentActions?: SessionReviewCommentActions
comments?: LineComment[]
focusedComment?: { file: string; id: string } | null
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
focusedFile?: string
onScrollRef?: (el: HTMLDivElement | undefined) => void
commentMentions?: {
items: (query: string) => string[] | Promise<string[]>
}
classes?: {
root?: string
header?: string
container?: string
}
}
export function SessionReviewTab(props: SessionReviewTabProps) {
let scroll: HTMLDivElement | undefined
let restoreFrame: number | undefined
let userInteracted = false
let restored: { x: number; y: number } | undefined
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const layout = useLayout()
const readFile = async (path: string) => {
return serverSDK.api.file
.read({ path, location: { directory: sdk().directory } })
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
.catch((error) => {
console.debug("[session-review] failed to read file", { path, error })
return undefined
})
}
const handleInteraction = () => {
userInteracted = true
if (restoreFrame !== undefined) {
cancelAnimationFrame(restoreFrame)
restoreFrame = undefined
}
}
const doRestore = () => {
restoreFrame = undefined
const el = scroll
if (!el || !layout.ready() || userInteracted) return
if (el.clientHeight === 0 || el.clientWidth === 0) return
const s = props.view.scroll("review")
if (!s || (s.x === 0 && s.y === 0)) return
const maxY = Math.max(0, el.scrollHeight - el.clientHeight)
const maxX = Math.max(0, el.scrollWidth - el.clientWidth)
const targetY = Math.min(s.y, maxY)
const targetX = Math.min(s.x, maxX)
if (el.scrollTop === targetY && el.scrollLeft === targetX) return
if (el.scrollTop !== targetY) el.scrollTop = targetY
if (el.scrollLeft !== targetX) el.scrollLeft = targetX
restored = { x: el.scrollLeft, y: el.scrollTop }
}
const queueRestore = () => {
if (userInteracted || restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(doRestore)
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
const el = event.currentTarget
const prev = restored
if (prev && el.scrollTop === prev.y && el.scrollLeft === prev.x) {
restored = undefined
return
}
restored = undefined
handleInteraction()
if (!layout.ready()) return
if (el.clientHeight === 0 || el.clientWidth === 0) return
props.view.setScroll("review", {
x: el.scrollLeft,
y: el.scrollTop,
})
}
createEffect(() => {
props.diffs.length
props.diffStyle
props.overflow
if (!layout.ready()) return
queueRestore()
})
onCleanup(() => {
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
props.onScrollRef?.(undefined)
})
return (
<SessionReview
title={props.title}
empty={props.empty}
scrollRef={(el) => {
scroll = el
makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "keydown", handleInteraction, { capture: true })
props.onScrollRef?.(el)
queueRestore()
}}
onScroll={handleScroll}
onDiffRendered={queueRestore}
open={props.view.review.open()}
onOpenChange={props.view.review.setOpen}
classes={{
root: props.classes?.root ?? "pr-3",
header: props.classes?.header ?? "px-3",
container: props.classes?.container ?? "pl-3",
}}
diffs={props.diffs}
diffStyle={props.diffStyle}
changeSummary={props.changeSummary}
overflow={props.overflow}
disableLineNumbers={props.disableLineNumbers}
onDiffStyleChange={props.onDiffStyleChange}
onViewFile={props.onViewFile}
focusedFile={props.focusedFile}
readFile={readFile}
onLineComment={props.onLineComment}
onLineCommentUpdate={props.onLineCommentUpdate}
onLineCommentDelete={props.onLineCommentDelete}
lineCommentActions={props.lineCommentActions}
lineCommentMention={props.commentMentions}
comments={props.comments}
focusedComment={props.focusedComment}
onFocusedCommentChange={props.onFocusedCommentChange}
/>
)
}
-324
View File
@@ -1,324 +0,0 @@
import { SessionReviewEmptyChangesV2 } from "@opencode/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
import { Select } from "@opencode/ui/select"
import { Tabs } from "@opencode/ui/tabs"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { For, Match, Show, Suspense, Switch, lazy, createEffect, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SessionSidePanel } from "../files/session-side-panel"
import { ReviewPanel } from "./panel"
import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
import type { createSessionBrowser } from "../browser/model"
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
return { default: StatusDrawer }
})
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
export function SessionMobileViewTabs(props: {
current: "session" | "changes" | "files" | "usage" | "terminal"
onSelect: (view: "session" | "changes" | "files" | "usage" | "terminal") => void
details?: (close: () => void) => JSX.Element
onDetailsOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({
menu: false,
status: false,
statusLoaded: false,
details: false,
detailsLoaded: false,
pending: undefined as "status" | "details" | undefined,
})
createEffect(() => props.onDetailsOpenChange?.(store.details))
onCleanup(() => props.onDetailsOpenChange?.(false))
let trigger: HTMLButtonElement | undefined
return (
<div
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
data-slot="session-mobile-view-navigation"
>
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0 before:!hidden">
<For each={["session", "changes", "files", "terminal"] as const}>
{(view) => (
<Tabs.Trigger
value={view}
class="min-w-0 flex-1"
classes={{ button: "w-full justify-center" }}
onClick={() => props.onSelect(view)}
>
{view === "session"
? language.t("session.tab.session")
: view === "changes"
? language.plural("session.review.change", 0)
: view === "files"
? language.t("session.tab.files")
: language.t("terminal.title")}
</Tabs.Trigger>
)}
</For>
</Tabs.List>
</Tabs>
<Menu
appearance="standard"
modal={false}
placement="bottom-end"
gutter={4}
open={store.menu}
onOpenChange={(open) => setStore("menu", open)}
>
<Menu.Trigger
as={IconButton}
ref={(element: HTMLButtonElement) => {
trigger = element
}}
icon={<Icon name="menu" />}
variant="ghost-muted"
size="normal"
class="mx-1.5 shrink-0"
state={props.current === "usage" || store.menu ? "pressed" : undefined}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content
onCloseAutoFocus={(event) => {
if (!store.pending) return
event.preventDefault()
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
setStore("pending", undefined)
}}
>
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
<Show when={props.details}>
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
{language.t("session.summary.title")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
{language.t("status.popover.trigger")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.statusLoaded}>
<Suspense>
<StatusDrawer
open={store.status}
onOpenChange={(open) => setStore("status", open)}
returnFocus={() => trigger}
/>
</Suspense>
</Show>
<Show when={store.detailsLoaded}>
<Suspense>
<MobilePanelDrawer
title={language.t("session.summary.title")}
open={store.details}
onOpenChange={(open) => setStore("details", open)}
returnFocus={() => trigger}
>
{props.details?.(() => setStore("details", false))}
</MobilePanelDrawer>
</Suspense>
</Show>
</div>
)
}
export function SessionMobileReview(props: { review: SessionReviewModel }) {
return (
<div class="relative h-full overflow-hidden">
<ReviewContent review={props.review} />
</div>
)
}
export function SessionDesktopReview(props: {
review: SessionReviewModel
browser: ReturnType<typeof createSessionBrowser>
present?: boolean
}) {
return (
<Suspense>
<SessionSidePanel
canReview={props.review.canReview()}
diffs={props.review.diffs()}
diffsReady={props.review.ready()}
hasReview={props.review.hasChanges()}
reviewHasFocusableContent={props.review.hasChanges() || props.review.panelState.sidebarOpened()}
reviewCount={props.review.count()}
reviewPanel={() => <ReviewPanelContent review={props.review} />}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={props.review.panelState.sidebarOpened()}
disabled={disabled}
onToggle={props.review.panelState.toggleSidebar}
/>
)}
fileBrowserState={props.review.panelState}
activeDiff={props.review.activeFile()}
focusReviewDiff={props.review.focusFile}
reviewPresent={props.present}
size={props.review.screen.size}
stacked={props.review.screen.side.layout().stacked}
browser={props.browser}
/>
</Suspense>
)
}
function ReviewContent(props: { review: SessionReviewModel }) {
const settings = useSettings()
return (
<Show when={!props.review.deferRender()}>
<SessionReviewTab
title={<ReviewTitle review={props.review} />}
empty={<ReviewEmpty review={props.review} loadingClass="px-2 py-2 text-text-weak" />}
diffs={props.review.diffs()}
view={props.review.view()}
diffStyle="unified"
changeSummary
disableLineNumbers={false}
overflow={settings.general.mobileDiffWrap() ? "wrap" : "scroll"}
onViewFile={(file) => {
props.review.openFile(file)
props.review.mobile.setTab("files")
}}
onScrollRef={props.review.setScroll}
focusedFile={props.review.activeFile()}
onLineComment={props.review.comments.add}
onLineCommentUpdate={props.review.comments.update}
onLineCommentDelete={props.review.comments.remove}
lineCommentActions={props.review.comments.actions()}
commentMentions={{ items: props.review.comments.mentions }}
comments={props.review.comments.all()}
focusedComment={props.review.comments.focus()}
onFocusedCommentChange={props.review.comments.setFocus}
classes={{
root: "[&_[data-slot=session-review-list]]:pb-0 [&_[data-slot=accordion-trigger]]:!rounded-none [&_[data-slot=accordion-trigger]]:!border-x-0 [&_[data-slot=accordion-item]:first-child_[data-slot=accordion-trigger]]:!border-t-0 [&_[data-slot=accordion-item]:last-child:not([data-expanded])_[data-slot=accordion-trigger]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=accordion-content]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=session-review-diff-placeholder]]:!border-b-0 [&_[data-slot=accordion-content]]:!rounded-none [&_[data-slot=accordion-content]]:!border-x-0 [&_[data-slot=session-review-diff-placeholder]]:!rounded-none [&_[data-slot=session-review-diff-placeholder]]:!border-x-0",
header:
"!px-2 !h-10 !pb-0 relative before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']",
container: "!px-0",
}}
/>
</Show>
)
}
function ReviewPanelContent(props: { review: SessionReviewModel }) {
return (
<div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
<Show when={props.review.panelRendered()}>
<ReviewPanel
title={<ReviewTitle review={props.review} />}
empty={<ReviewPanelEmpty review={props.review} />}
diffs={props.review.diffs()}
diffsReady={props.review.ready()}
diffVersion={props.review.diffVersion()}
loadDiff={props.review.loadDiff}
activeFile={props.review.activeFile()}
onSelectFile={props.review.focusFile}
diffStyle={props.review.diffStyle.current()}
onDiffStyleChange={props.review.diffStyle.set}
state={props.review.panelState}
onLineComment={props.review.comments.add}
onLineCommentUpdate={props.review.comments.update}
onLineCommentDelete={props.review.comments.remove}
lineCommentActions={props.review.comments.actions()}
comments={props.review.comments.all()}
focusedComment={props.review.comments.focus()}
onFocusedCommentChange={props.review.comments.changeFocus}
/>
</Show>
</div>
)
}
function ReviewTitle(props: { review: SessionReviewModel }) {
const language = useLanguage()
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
return language.t("ui.sessionReview.title.lastTurn")
}
return (
<Show when={props.review.canReview()}>
<Select
options={props.review.options()}
current={props.review.mode()}
label={label}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && props.review.setMode(option)}
/>
</Show>
)
}
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
return language.t("session.review.noChanges")
}
return (
<Switch>
<Match when={loading()}>
<div class={props.loadingClass}>{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
</div>
</Match>
<Match when={true}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{text()}</div>
</div>
</Match>
</Switch>
)
}
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
return (
<Switch>
<Match when={loading()}>
<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
</Match>
<Match when={true}>
<SessionReviewEmptyChangesV2 />
</Match>
</Switch>
)
}
+60 -30
View File
@@ -23,9 +23,9 @@ import { TerminalPanel } from "@/session/terminal/panel"
import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
import { SessionErrorFallback } from "./route-error"
import { createSessionScreenLayout } from "./screen-layout"
import { createSessionReview } from "./review/model"
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
import { SessionContextTab } from "./files/session-context-tab"
import { createSessionSummary } from "./summary"
import { SessionMobileViewTabs } from "./mobile-view-tabs"
import { SessionSidePanel } from "./files/session-side-panel"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { createTimelineSearchController } from "./timeline/search-controller"
import { TimelineSearchBar } from "./timeline/search-bar"
@@ -33,12 +33,9 @@ import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./compos
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
return { default: SessionMobileFiles }
})
import { useExtensionPanels } from "@/extensions/session"
import { createSessionServices } from "@/extensions/workspace"
import { ExtensionPanelContent } from "@/extensions/content"
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
@@ -48,7 +45,14 @@ export function SessionScreen(props: { session: SessionModel }) {
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const browser = createSessionBrowser(session)
const extensions = useExtensionPanels({
services: createSessionServices(session),
active: session.tabs.activeTab,
serverID: () => server.key,
sessionID: session.identity.sessionID,
tabs: session.layout.tabs,
open: () => session.layout.view().reviewPanel.open(),
})
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
@@ -67,6 +71,7 @@ export function SessionScreen(props: { session: SessionModel }) {
sideTerminalPresent: false,
mobileTerminalCached: false,
mobileMoveDismissed: false,
mobileTab: "session",
})
const [elements, setElements] = createStore<{
side?: HTMLDivElement
@@ -135,8 +140,19 @@ export function SessionScreen(props: { session: SessionModel }) {
}
return key
})
const review = createSessionReview({ session, screen, deferRender: () => store.deferRender })
const mobileView = createMemo(() => (screen.terminal.open() ? "terminal" : review.mobile.tab()))
const summary = createSessionSummary(session)
const mobileView = createMemo(() => {
if (screen.terminal.open()) return "terminal"
if (store.mobileTab === "session") return store.mobileTab
const selected = extensions.panels().find((panel) => panel.key === session.tabs.activeTab())
return selected?.props.group ?? selected?.key ?? store.mobileTab
})
const mobileItems = createMemo(() =>
Array.from(new Set(extensions.panels().map((panel) => panel.props.group ?? panel.key))).map((id) => {
const panel = extensions.panels().find((panel) => (panel.props.group ?? panel.key) === id)!
return { id, title: panel.props.title, menu: panel.props.initial === "closed" && !panel.props.group }
}),
)
const conversationVisible = createMemo(() => isDesktop() || mobileView() === "session")
createEffect(() => {
if (!isDesktop() && screen.terminal.open()) setStore("mobileTerminalCached", true)
@@ -158,8 +174,9 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={session.identity.sessionKey()} keyed>
{(_key) => (
<SessionMobileViewTabs
items={mobileItems()}
current={mobileView()}
onDetailsOpenChange={review.details.setOpen}
onDetailsOpenChange={summary.setOpen}
details={
!session.data.isChild() && detailsProject()
? (close) => (
@@ -177,14 +194,18 @@ export function SessionScreen(props: { session: SessionModel }) {
baseBranch={
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
}
diffs={project().vcs ? review.details.diffs() : []}
diffs={project().vcs ? summary.diffs() : []}
sessionID={session.identity.params.id ?? ""}
moveEligible={composer.workspaceMoveEligible()}
moveDismissed={store.mobileMoveDismissed}
onMoveDismiss={() => setStore("mobileMoveDismissed", true)}
onReview={() => {
close()
review.mobile.setTab("changes")
const panel = extensions.defaultPanel()
if (panel) {
session.layout.tabs().setActive(panel)
setStore("mobileTab", panel)
}
session.layout.view().terminal.close()
}}
backgroundTasks={composer.requests.background.tasks()}
@@ -199,7 +220,13 @@ export function SessionScreen(props: { session: SessionModel }) {
session.layout.view().terminal.open()
return
}
review.mobile.setTab(view)
setStore("mobileTab", view)
const panel = extensions.panels().find((panel) => (panel.props.group ?? panel.key) === view)
if (panel) {
void session.layout.tabs().open(panel.key)
session.layout.tabs().setActive(panel.key)
session.layout.view().reviewPanel.open()
}
session.layout.view().terminal.close()
}}
/>
@@ -231,16 +258,8 @@ export function SessionScreen(props: { session: SessionModel }) {
<Match when={!isDesktop() && mobileView() === "terminal"}>
<></>
</Match>
<Match when={!isDesktop() && mobileView() === "usage"}>
<SessionContextTab />
</Match>
<Match when={!isDesktop() && mobileView() === "files"}>
<Suspense>
<SessionMobileFiles />
</Suspense>
</Match>
<Match when={session.identity.params.id && review.mobile.changes()}>
<SessionMobileReview review={review} />
<Match when={!isDesktop() && mobileItems().some((item) => item.id === mobileView())}>
<ExtensionPanelContent panels={extensions.panels()} active={session.tabs.activeTab()} />
</Match>
<Match when={session.identity.params.id}>
<Show when={isDesktop() && !messagesReady()}>
@@ -249,6 +268,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
headerActions={extensions.header()}
hideHeader={!isDesktop()}
session={session}
background={composer.requests.background}
@@ -265,10 +285,14 @@ export function SessionScreen(props: { session: SessionModel }) {
pinned={timeline.view.pinned()}
centered={screen.centered()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
diffs={summary.diffs}
onReview={() => {
session.layout.view().reviewPanel.open()
const panel = extensions.defaultPanel()
if (panel) session.layout.tabs().setActive(panel)
}}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
onSummaryOpenChange={summary.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
@@ -288,6 +312,7 @@ export function SessionScreen(props: { session: SessionModel }) {
return (
<>
{extensions.declarations()}
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
{/* Keep the control outside panel animations; the terminal's 52px header includes a 1px divider. */}
@@ -379,7 +404,12 @@ export function SessionScreen(props: { session: SessionModel }) {
setStore("sideReviewPresent", false)
}}
>
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
<SessionSidePanel
size={screen.size}
stacked={screen.side.layout().stacked}
extensions={extensions}
present={store.sideReviewPresent}
/>
</div>
</Show>
</div>
+3 -2
View File
@@ -20,8 +20,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { ReviewPanelView } from "@/session/review/panel"
import { createReviewPanelState } from "@/session/review/panel-state"
import { ReviewPanelView } from "@opencode/plugin-review-desktop/review/panel"
import { createReviewPanelState } from "@opencode/plugin-review-desktop/review/panel-state"
import { TerminalSurface } from "@/session/terminal/surface"
import type { WebSearchRequestModel } from "./requests/websearch"
@@ -365,6 +365,7 @@ function SessionReviewPane(props: { diffs: SessionDocument["diffs"] }) {
})
return (
<ReviewPanelView
labels={{ loading: language.t("common.loading"), empty: language.t("palette.empty") }}
title={language.t("ui.sessionReview.title.lastTurn")}
empty={<SessionReviewEmptyChangesV2 />}
diffs={props.diffs}
+30
View File
@@ -0,0 +1,30 @@
import { createQuery, useQueryClient } from "@tanstack/solid-query"
import { createStore } from "solid-js/store"
import { createEffect, onCleanup } from "solid-js"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { SessionModel } from "./model"
export function createSessionSummary(session: SessionModel) {
const server = useServerSDK()
const location = useWorkspaceLocation()
const queryClient = useQueryClient()
const [state, setState] = createStore({ open: false })
const key = () => [server.scope, "session-details", session.workspace.directory()]
const query = createQuery(() => ({
queryKey: key(),
enabled: state.open && server.connection.status() === "connected" && !!session.project()?.vcs,
queryFn: () =>
server.api.vcs
.diff({ location: { directory: session.workspace.directory() }, mode: "working" })
.then((result) => result.data),
}))
createEffect(() => {
onCleanup(
location().event.listen((event) => {
if (event.type === "filesystem.changed") void queryClient.invalidateQueries({ queryKey: key() })
}),
)
})
return { diffs: () => query.data, setOpen: (value: boolean) => setState("open", value) }
}
+4 -74
View File
@@ -1,76 +1,6 @@
import { ResizeHandle } from "@opencode/ui/resize-handle"
import type { ParentProps } from "solid-js"
import { AuxiliaryPanel } from "@opencode/ui/auxiliary-panel"
import type { ComponentProps } from "solid-js"
export function TerminalSurface(
props: ParentProps<{
label: string
opened: boolean
present?: boolean
framed?: boolean
embedded?: boolean
desktop: boolean
stacked: boolean
height: string
contentHeight: string
pane: number
max: number
resizing: boolean
animate?: boolean
onResizeStart: () => void
onResize: (height: number) => void
onCollapse: () => void
ref?: (element: HTMLElement) => void
}>,
) {
return (
<aside
ref={props.ref}
id="terminal-panel"
data-component="terminal-panel"
data-opened={props.opened}
data-size-animated={
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
}
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
inert={!props.opened}
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
classList={{
"w-full": !props.desktop || props.stacked,
"min-w-0 h-full flex-1": props.desktop && (props.present ?? props.opened) && !props.stacked,
"w-0 h-full pointer-events-none": props.desktop && !(props.present ?? props.opened),
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop && (props.framed ?? true),
"will-change-[height]": !props.embedded && !props.resizing && (!props.desktop || props.stacked),
}}
style={{ height: props.height, "--terminal-panel-height": props.contentHeight }}
>
<div
classList={{ "md:hidden": !props.stacked, hidden: props.stacked || props.embedded }}
onPointerDown={props.onResizeStart}
>
<ResizeHandle
class="-top-1"
direction="vertical"
size={props.pane}
min={100}
max={props.max}
collapseThreshold={50}
onResize={props.onResize}
onCollapse={props.onCollapse}
/>
</div>
<div
data-slot="terminal-panel-content"
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
classList={{
"border-t border-border-weak-base": props.opened && !props.desktop && !props.embedded,
"pointer-events-none": !props.opened,
}}
style={{ height: props.contentHeight }}
>
{props.children}
</div>
</aside>
)
export function TerminalSurface(props: ComponentProps<typeof AuxiliaryPanel>) {
return <AuxiliaryPanel {...props} id="terminal-panel" data-component="terminal-panel" />
}
@@ -14,7 +14,6 @@ import { ProjectAvatar } from "@opencode/ui/project-avatar"
import type { Project } from "@/runtime/server/types"
import { getFilename } from "@opencode/util/path"
import { Popover } from "@kobalte/core/popover"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -335,6 +334,7 @@ export function SessionSummaryPanel(props: {
}
type MessageTimelineProps = {
headerActions?: JSX.Element
hideHeader?: boolean
session: TimelineSessionSource
background: SessionBackground
@@ -794,7 +794,7 @@ function MessageTimelineView(
{(id) => (
<div class="shrink-0 flex items-center gap-2">
{props.search}
<SessionContextUsage placement="bottom" />
{props.headerActions}
<Show when={!parentID() && project()}>
{(project) => (
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
@@ -1,163 +0,0 @@
import { Show, createMemo, type ComponentProps, type JSX } from "solid-js"
import { ProgressCircle } from "@opencode/ui/progress-circle"
import { IconButton } from "@opencode/ui/icon-button"
import { Tooltip } from "@opencode/ui/tooltip"
import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/workspaces/files/model"
import { useLayout } from "@/shell/state/layout"
import { useData } from "@/runtime/server/current"
import { useLanguage } from "@/runtime/i18n/language"
import { useProviders } from "@/providers/catalog/providers"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionTabs } from "@/session/helpers"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
placement?: ComponentProps<typeof Tooltip>["placement"]
}
function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
return (
<div class="flex min-w-0 items-center gap-4">
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
</div>
)
}
function openSessionContext(args: {
view: ReturnType<ReturnType<typeof useLayout>["view"]>
layout: ReturnType<typeof useLayout>
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) {
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
void args.tabs.open("context").then(() => args.tabs.setActive("context"))
}
export function SessionContextUsage(props: SessionContextUsageProps) {
const data = useData()
const file = useFile()
const layout = useLayout()
const language = useLanguage()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const variant = createMemo(() => props.variant ?? "button")
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
fileBrowser: () => isDesktop() && !!params.id,
})
const messages = createMemo(() => (params.id ? data.session.message.list(params.id) : []))
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
const usd = createMemo(
() =>
new Intl.NumberFormat(language.intl(), {
style: "currency",
currency: "USD",
}),
)
const context = createMemo(() => {
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
if (message?.type !== "assistant" || !message.tokens) return
const model = providers.all().get(message.model.providerID)?.models[message.model.id]
const total =
message.tokens.input +
message.tokens.output +
message.tokens.reasoning +
message.tokens.cache.read +
message.tokens.cache.write
return {
total,
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0)
})
const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context")
const hasOtherTabs = createMemo(() =>
tabs()
.all()
.some((tab) => tab !== "context" && tab !== "review"),
)
const openContext = () => {
if (!params.id) return
const sessionView = view()
if (contextVisible()) {
tabs().close("context")
if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close()
return
}
openSessionContext({
view: sessionView,
layout,
tabs: tabs(),
})
}
const circle = () => (
<div class="flex items-center justify-center">
<ProgressCircle
appearance="indicator"
size={16}
strokeWidth={2}
percentage={context()?.usage ?? 0}
style={{
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
}}
/>
</div>
)
const compactCircle = () => (
<div class="flex items-center justify-center">
<ProgressCircle appearance="compact" percentage={context()?.usage ?? 0} />
</div>
)
const tooltipValue = () => (
<div class="flex w-[120px] flex-col gap-2">
<ContextTooltipRow name={language.t("context.usage.cost")} value={cost()} />
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
<ContextTooltipRow
name={language.t("context.usage.tokens")}
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
/>
</div>
)
return (
<Show when={params.id}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
<Show
when={variant() === "indicator"}
fallback={
<IconButton
type="button"
variant="ghost-muted"
size="large"
icon={compactCircle()}
onClick={openContext}
aria-label={language.t("context.usage.view")}
/>
}
>
{circle()}
</Show>
</Tooltip>
</Show>
)
}
@@ -2,18 +2,17 @@ import { Component, Show } from "solid-js"
import { Select } from "@opencode/ui/select"
import { Switch } from "@opencode/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsList } from "@/settings/list"
import { useSettings } from "@/settings/model"
import { SettingsRow } from "@/settings/row"
import "@/settings/settings.css"
import { ExtensionSlot } from "@/extensions/slot"
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
export const SettingsExperimental: Component = () => {
const language = useLanguage()
const settings = useSettings()
const platform = usePlatform()
return (
<>
@@ -31,22 +30,7 @@ export const SettingsExperimental: Component = () => {
<div class="settings-tab-body">
<div class="settings-section">
<SettingsList>
<Show when={platform.browserPane}>
<SettingsRow
title={language.t("settings.general.row.browserPane.title")}
description={language.t("settings.general.row.browserPane.description")}
>
<div data-action="settings-experimental-browser">
<Switch
checked={settings.general.experimentalBrowser()}
onChange={settings.general.setExperimentalBrowser}
hideLabel
>
{language.t("settings.general.row.browserPane.title")}
</Switch>
</div>
</SettingsRow>
</Show>
<ExtensionSlot path="settings.experimental" />
<SettingsRow
title={language.t("settings.appearance.row.tabs.title")}
description={language.t("settings.appearance.row.tabs.description")}
@@ -0,0 +1,71 @@
.desktop-extension-manager {
display: flex;
flex-direction: column;
gap: 32px;
}
.desktop-extension-install {
gap: 14px;
}
.desktop-extension-drop {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
background: var(--v2-background-bg-layer-01);
color: var(--v2-text-text-muted);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
transition: 120ms ease-out;
}
.desktop-extension-drop[data-dragging="true"] {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-text-text-base);
box-shadow: inset 0 0 0 1px var(--v2-border-border-focus);
}
.desktop-extension-drop > div {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 4px;
}
.desktop-extension-drop strong {
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
font-weight: 590;
}
.desktop-extension-drop span,
.desktop-extension-empty {
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-base);
}
.desktop-extension-url {
display: flex;
align-items: center;
gap: 8px;
}
.desktop-extension-url [data-component="text-input-v2"] {
min-width: 0;
flex: 1;
}
.desktop-extension-method-label {
color: var(--v2-text-text-faint);
font-size: 11px;
font-weight: 500;
}
.desktop-extension-error {
color: var(--v2-state-fg-danger);
font-size: 13px;
line-height: var(--line-height-base);
}
.desktop-extension-title {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.desktop-extension-empty {
padding: 20px;
}
@@ -0,0 +1,239 @@
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { Badge } from "@opencode/ui/badge"
import { Button } from "@opencode/ui/button"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { SettingsRow } from "@opencode/ui/layout"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Tooltip } from "@opencode/ui/tooltip"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { useDesktopExtensions } from "@/extensions/provider"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import "./extensions.css"
const errors = {
invalidArchive: "settings.desktopExtensions.error.archive",
invalidManifest: "settings.desktopExtensions.error.manifest",
invalidPath: "settings.desktopExtensions.error.path",
tooLarge: "settings.desktopExtensions.error.size",
reserved: "settings.desktopExtensions.error.reserved",
notFound: "settings.desktopExtensions.error.notFound",
disabled: "settings.desktopExtensions.error.disabled",
invalidModule: "settings.desktopExtensions.error.module",
download: "settings.desktopExtensions.error.download",
url: "settings.desktopExtensions.error.url",
storage: "settings.desktopExtensions.error.storage",
files: "settings.desktopExtensions.error.files",
} as const
/** Port of OCDX's manager, backed by the native Desktop extension registry. */
export function SettingsExtensions() {
const host = useDesktopExtensions()
const language = useLanguage()
const platform = usePlatform()
const [state, setState] = createStore({
url: "",
busy: false,
dragging: false,
error: undefined as keyof typeof errors | undefined,
})
let picker: HTMLInputElement | undefined
const entries = createMemo(() =>
[
...host.builtins().map((plugin) => ({
id: plugin.id,
name: plugin.name ? (language.t(plugin.name as Parameters<typeof language.t>[0]) ?? plugin.name) : plugin.id,
version: plugin.version ?? platform.version ?? "0.0.0",
enabled: true,
builtin: true,
hasMain: plugin.main ?? false,
})),
...host.state.installed
.filter((entry) => !host.builtins().some((plugin) => plugin.id === entry.id))
.map((entry) => ({ ...entry, builtin: false })),
].toSorted((a, b) => Number(b.builtin) - Number(a.builtin) || a.name.localeCompare(b.name)),
)
const error = () => state.error ?? host.state.managerError
const failed = () => host.state.installed.find((entry) => host.state.failures[entry.id])
const perform = async (action: (manager: ExtensionManager.Transport) => Promise<unknown>) => {
if (state.busy || !host.manager) return false
setState({ busy: true, error: undefined })
try {
await action(host.manager)
return true
} catch (error) {
setState("error", error instanceof ExtensionManager.ManagerError ? error.code : "storage")
return false
} finally {
setState("busy", false)
}
}
const installFiles = async (files: FileList | File[]) => {
const archives = Array.from(files).filter((file) => file.name.toLowerCase().endsWith(".ocdx"))
if (!archives.length) {
setState("error", "files")
return
}
await perform(async (manager) => {
for (const file of archives) await manager.install(new Uint8Array(await file.arrayBuffer()))
})
}
const installURL = async () => {
const url = state.url.trim()
if (!url) return
if (await perform((manager) => manager.installURL(url))) setState("url", "")
}
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
</div>
</div>
<div class="settings-tab-body desktop-extension-manager" data-component="desktop-extension-manager">
<section class="settings-section desktop-extension-install">
<h3 class="settings-section-title">{language.t("settings.desktopExtensions.installTitle")}</h3>
<div
class="desktop-extension-drop"
data-dragging={state.dragging}
onDragEnter={(event) => {
event.preventDefault()
setState("dragging", true)
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return
setState("dragging", false)
}}
onDrop={(event) => {
event.preventDefault()
setState("dragging", false)
if (event.dataTransfer) void installFiles(event.dataTransfer.files)
}}
>
<div>
<strong>{language.t("settings.desktopExtensions.drop")}</strong>
<span>{language.t("settings.desktopExtensions.choose")}</span>
</div>
<Button size="small" variant="neutral" disabled={state.busy} onClick={() => picker?.click()}>
{language.t("settings.desktopExtensions.browse")}
</Button>
<input
ref={picker}
type="file"
accept=".ocdx"
multiple
hidden
aria-label={language.t("settings.desktopExtensions.files")}
onChange={(event) => {
if (event.currentTarget.files) void installFiles(event.currentTarget.files)
event.currentTarget.value = ""
}}
/>
</div>
<div class="desktop-extension-method-label">{language.t("settings.desktopExtensions.fromURL")}</div>
<div class="desktop-extension-url">
<TextInput
appearance="large"
dir="ltr"
value={state.url}
placeholder={language.t("settings.desktopExtensions.placeholder")}
aria-label={language.t("settings.desktopExtensions.url")}
disabled={state.busy}
onInput={(event) => setState("url", event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void installURL()
}}
/>
<Button
size="normal"
variant="neutral"
disabled={state.busy || !state.url.trim()}
onClick={() => void installURL()}
>
{language.t("settings.desktopExtensions.install")}
</Button>
</div>
</section>
<Show when={error()}>
{(error) => (
<div role="alert" class="desktop-extension-error">
{language.t(errors[error()])}
</div>
)}
</Show>
<Show when={failed()}>
{(entry) => (
<div role="alert" class="desktop-extension-error">
{language.t("settings.desktopExtensions.error.activation", { name: entry().name })}
</div>
)}
</Show>
<section class="settings-section">
<h3 class="settings-section-title">{language.t("settings.desktopExtensions.installed")}</h3>
<div data-component="settings-list">
<Show
when={host.state.managerReady}
fallback={
<div role="status" class="desktop-extension-empty">
{language.t("common.loading")}
</div>
}
>
<Show
when={entries().length}
fallback={<div class="desktop-extension-empty">{language.t("settings.desktopExtensions.empty")}</div>}
>
<For each={entries()}>
{(entry) => (
<SettingsRow
title={
<span class="desktop-extension-title">
<bdi dir="auto">{entry.name}</bdi>
<Badge variant="neutral">v{entry.version}</Badge>
<Show when={entry.builtin}>
<Badge variant="accent">{language.t("settings.desktopExtensions.builtin")}</Badge>
</Show>
<Show when={entry.hasMain}>
<Badge variant="neutral">{language.t("settings.desktopExtensions.main")}</Badge>
</Show>
</span>
}
description={<bdi dir="ltr">{entry.id}</bdi>}
>
<div class="flex items-center gap-3">
<Show when={!entry.builtin}>
<Tooltip value={language.t("settings.desktopExtensions.reload", { name: entry.name })}>
<IconButton
icon={<Icon name="reset" />}
variant="ghost-muted"
size="normal"
disabled={state.busy || !entry.enabled}
aria-label={language.t("settings.desktopExtensions.reload", { name: entry.name })}
onClick={() => void perform((manager) => manager.reload(entry.id))}
/>
</Tooltip>
</Show>
<Switch
checked={entry.enabled}
disabled={entry.builtin || state.busy}
onChange={(enabled) => void perform((manager) => manager.enable(entry.id, enabled))}
hideLabel
>
{language.t("settings.desktopExtensions.enable", { name: entry.name })}
</Switch>
</div>
</SettingsRow>
)}
</For>
</Show>
</Show>
</div>
</section>
</div>
</>
)
}
@@ -20,7 +20,7 @@ interface PluginRowItem {
name: string
}
export const SettingsExtensions: Component = () => {
export const SettingsTools: Component = () => {
const language = useLanguage()
const serverSdk = useServerSDK()
const data = useData()
@@ -60,8 +60,8 @@ export const SettingsExtensions: Component = () => {
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
<h2 class="settings-tab-title">{language.t("settings.tab.tools")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.tools.description")}</span>
</div>
<InlineServerSelect />
</div>
+1 -20
View File
@@ -1,20 +1 @@
import type { Component, JSX } from "solid-js"
import "@/settings/settings.css"
export interface SettingsRowProps {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
export const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div data-component="settings-row">
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title">{props.title}</div>
<div data-slot="settings-row-description">{props.description}</div>
</div>
<div data-slot="settings-row-control">{props.children}</div>
</div>
)
}
export { SettingsRow } from "@opencode/ui/layout"
+28 -13
View File
@@ -15,7 +15,9 @@ import { SettingsModels } from "./models/models"
import { SettingsServers } from "./servers/servers"
import { SettingsWorkspaces } from "./workspaces/workspaces"
import { SettingsProjects } from "./workspaces/projects"
import { SettingsExtensions } from "./providers/extensions"
import { SettingsTools } from "./providers/tools"
import { SettingsExtensions } from "./extensions/extensions"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsAbout } from "./about/about"
import { SettingsServerScope } from "./server-scope"
import { useDialog } from "@opencode/ui/context/dialog"
@@ -41,14 +43,21 @@ const sections = [
[
{ value: "providers", icon: "providers", label: "settings.providers.title" },
{ value: "models", icon: "models", label: "settings.models.title" },
{ value: "tools", icon: "extensions", label: "settings.tab.tools" },
],
[
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
{ value: "experimental", icon: "flask", label: "settings.tab.experimental" },
],
[{ value: "experimental", icon: "flask", label: "settings.tab.experimental" }],
[{ value: "about", icon: "info", label: "settings.tab.about" }],
] as const
export const SettingsScreen: Component = () => {
const language = useLanguage()
const platform = usePlatform()
const visibleSections = createMemo(() =>
sections.map((group) => group.filter((section) => section.value !== "extensions" || !!platform.extensionManager)),
)
const dialog = useDialog()
const surface = useSettingsSurface()
const layout = useLayout()
@@ -56,6 +65,9 @@ export const SettingsScreen: Component = () => {
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
createEffect(() => {
if (!platform.extensionManager && surface.tab() === "extensions") surface.open("tools")
})
let root: HTMLDivElement | undefined
onMount(() => {
@@ -128,8 +140,9 @@ export const SettingsScreen: Component = () => {
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>
{language.t(
sections.flat().find((section) => section.value === surface.tab())?.label ??
"settings.tab.preferences",
visibleSections()
.flat()
.find((section) => section.value === surface.tab())?.label ?? "settings.tab.preferences",
)}
</span>
<Icon name="chevron-down" size="small" />
@@ -140,7 +153,7 @@ export const SettingsScreen: Component = () => {
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
>
<For each={sections}>
<For each={visibleSections()}>
{(group, index) => (
<>
<Show when={index() > 0}>
@@ -176,9 +189,9 @@ export const SettingsScreen: Component = () => {
<span>{language.t("settings.backToApp")}</span>
</button>
<div class="flex flex-col gap-4 w-full">
<For each={sections}>
<For each={visibleSections()}>
{(group) => (
<div class="flex flex-col gap-1 w-full">
<div data-slot="settings-nav-group" class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger
@@ -214,6 +227,11 @@ export const SettingsScreen: Component = () => {
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
<Show when={platform.extensionManager}>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</Show>
<Tabs.Content value="servers" class="settings-panel">
<SettingsServers />
</Tabs.Content>
@@ -222,10 +240,7 @@ export const SettingsScreen: Component = () => {
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={directory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
<SettingsWorkspaces activeDirectory={directory()} resetProjectFilter={() => state.worktreeFilterReset} />
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
@@ -233,8 +248,8 @@ export const SettingsScreen: Component = () => {
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
<Tabs.Content value="tools" class="settings-panel">
<SettingsTools />
</Tabs.Content>
</SettingsServerScope>
<Tabs.Content value="about" class="settings-panel settings-about">
@@ -67,7 +67,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
</Tabs.Trigger>
<Tabs.Trigger value="extensions">
<Icon name="extensions" size="small" />
{language.t("settings.tab.extensions")}
{language.t("settings.tab.tools")}
</Tabs.Trigger>
</div>
</Tabs.List>
@@ -147,8 +147,8 @@ export const ProjectSettingsExtensions: Component = () => {
return (
<div class="project-settings-extensions">
<div class="project-settings-page-header">
<h2>{language.t("settings.tab.extensions")}</h2>
<span>{language.t("project.settings.extensions.description")}</span>
<h2>{language.t("settings.tab.tools")}</h2>
<span>{language.t("project.settings.tools.description")}</span>
</div>
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
@@ -458,6 +458,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const config = keybindConfig(id)
return config ? formatKeybindParts(config, language.t) : []
},
matches(id: string, event: KeyboardEvent) {
const config = keybindConfig(id)
return !!config && matchKeybind(parseKeybind(config), event)
},
show: showPalette,
keybinds(enabled: boolean) {
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
+7 -4
View File
@@ -4,7 +4,8 @@ import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
import { DesktopExtensionsProvider } from "@/extensions/provider"
import { ExtensionSlot } from "@/extensions/slot"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
@@ -79,9 +80,11 @@ function AppLayout(props: ParentProps) {
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
<DesktopExtensionsProvider>
<ExtensionSlot path="app">
<Shell>{props.children}</Shell>
</ExtensionSlot>
</DesktopExtensionsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
</Show>
+1 -1
View File
@@ -52,7 +52,7 @@ export type HomeProjectSelection = typeof layoutSchema.Type.home.selection
export type ReviewDiffStyle = typeof layoutSchema.Type.review.diffStyle
export type ReviewChangeMode = NonNullable<(typeof layoutSchema.Type.sessionView)[string]["reviewMode"]>
export type ReviewPanelSource = "context-button" | "other"
export type ReviewPanelSource = string
export type TabPanes = {
terminalOpened: Accessor<boolean>
setTerminalOpened(opened: boolean): void
@@ -1,8 +1,4 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export const SESSION_BROWSER_TAB = "browser"
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
export const isSessionBrowserTab = (tab: string | undefined) =>
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
export type SessionTabs = {
active?: string
@@ -1,6 +1,7 @@
import { createContext, onCleanup, onMount, Show, useContext, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { ExtensionSlot } from "@/extensions/slot"
type Registration = {
active: () => boolean
@@ -45,7 +46,9 @@ export function TitlebarRightMount(props: { vertical?: boolean }) {
ref={slot.setMount}
id="opencode-titlebar-right"
class={props.vertical ? "flex w-full shrink-0 flex-col" : "flex shrink-0 items-center justify-end gap-0"}
/>
>
<ExtensionSlot path="titlebar.actions" />
</div>
)
}
+1 -1
View File
@@ -257,7 +257,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return state
}
function withPath(input: string, action: (file: string) => unknown) {
function withPath<Value>(input: string, action: (file: string) => Value): Value {
return action(path.normalize(input))
}
const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
@@ -48,6 +48,40 @@ function desktop() {
}
describe("schema-backed persistence", () => {
for (const mode of ["web", "desktop"] as const) {
test(`relocates an extracted workspace key from an alias in ${mode} storage`, async () => {
const native = desktop()
const target = {
...Persist.workspace(`C:\\extension-relocation-${mode}`, "extension.example.document"),
previousKeys: ["workspace:legacy"],
}
const previous = `${target.workspaceStorageAliases![0]}:workspace:legacy`
const current = `${target.storage}:${target.key}`
const read = (key: string) => (mode === "web" ? localStorage.getItem(key) : native.values.get(key))
const raw = JSON.stringify({ enabled: false, label: "retained" })
if (mode === "web") localStorage.setItem(previous, raw)
if (mode === "desktop") native.values.set(previous, raw)
const mounted = createRoot((dispose) => {
const state = persisted(target, Current, initial, mode === "web" ? web : native.platform)
const ready = new Promise<void>((resolve) =>
createComputed(() => {
if (state[3]()) resolve()
}),
)
return { dispose, state, ready }
})
try {
await mounted.ready
expect(mounted.state[3]()).toBe(true)
expect(mounted.state[0]).toEqual({ enabled: false, label: "retained" })
expect(read(current)).toBe(raw)
expect(read(previous)).toBeFalsy()
} finally {
mounted.dispose()
}
})
}
test("clears the recent tab after restoring it from storage", () => {
const target = Persist.global("schema-recent-clear")
const key = `${target.storage}:${target.key}`
+2
View File
@@ -38,8 +38,10 @@
"@lydell/node-pty": "catalog:",
"@opencode/app": "workspace:*",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin-browser-desktop": "workspace:*",
"@opencode/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -0,0 +1,78 @@
import { Uint8ArrayReader, Uint8ArrayWriter, TextWriter, ZipReader } from "@zip.js/zip.js"
import { Schema } from "effect"
import { createHash } from "node:crypto"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
export const archiveLimit = 1_073_741_824
export function archivePath(value: string) {
if (
!value ||
value.includes("\\") ||
value.includes(":") ||
/[\u0000-\u001f]/.test(value) ||
value.startsWith("/") ||
value.split("/").some((part) => !part || part === "." || part === "..")
) {
throw new ExtensionManager.ManagerError("invalidPath")
}
return value
}
export async function readArchive(data: Uint8Array) {
if (data.byteLength > archiveLimit) throw new ExtensionManager.ManagerError("tooLarge")
if (data[0] !== 0x50 || data[1] !== 0x4b) throw new ExtensionManager.ManagerError("invalidArchive")
// zip.js expects slice() to copy; Node Buffer.slice() returns a view instead.
const reader = new ZipReader(new Uint8ArrayReader(Buffer.isBuffer(data) ? new Uint8Array(data) : data), {
useWebWorkers: false,
})
try {
const entries = await reader.getEntries()
if (entries.length > 1024 || entries.reduce((total, entry) => total + entry.uncompressedSize, 0) > archiveLimit)
throw new ExtensionManager.ManagerError("tooLarge")
const paths = entries.map((entry) =>
archivePath(entry.directory ? entry.filename.replace(/\/$/, "") : entry.filename),
)
if (new Set(paths).size !== paths.length) throw new ExtensionManager.ManagerError("invalidPath")
const metadata = entries.find((entry) => entry.filename === "manifest.json" && !entry.directory)
if (!metadata || metadata.directory || !metadata.getData || metadata.uncompressedSize > 65536)
throw new ExtensionManager.ManagerError("invalidManifest")
const manifest = Schema.decodeUnknownOption(Schema.fromJsonString(ExtensionManager.Manifest))(
await metadata.getData(new TextWriter()),
)
if (manifest._tag === "None") throw new ExtensionManager.ManagerError("invalidManifest")
const value = manifest.value
archivePath(value.entry)
if (value.main) archivePath(value.main)
if (value.style) archivePath(value.style)
const files = await Promise.all(
entries
.filter((entry) => !entry.directory)
.map(async (entry) => {
if (!entry.getData) throw new ExtensionManager.ManagerError("invalidArchive")
return { path: entry.filename, data: Buffer.from(await entry.getData(new Uint8ArrayWriter())) }
}),
)
if (files.reduce((total, file) => total + file.data.byteLength, 0) > archiveLimit)
throw new ExtensionManager.ManagerError("tooLarge")
if (value.style && !files.some((file) => file.path === value.style))
throw new ExtensionManager.ManagerError("invalidManifest")
for (const entry of [value.entry, value.main].filter((entry) => entry !== undefined)) {
const file = files.find((file) => file.path === entry)
if (!file) throw new ExtensionManager.ManagerError("invalidManifest")
// Reject syntax errors before replacing a working installation. Execution
// remains in the renderer/main host with their shared module identities.
try {
new Function("require", "module", "exports", file.data.toString("utf8"))
} catch {
throw new ExtensionManager.ManagerError("invalidModule")
}
}
return { manifest: value, revision: createHash("sha256").update(data).digest("hex"), files }
} catch (error) {
if (error instanceof ExtensionManager.ManagerError) throw error
throw new ExtensionManager.ManagerError("invalidArchive", { cause: error })
} finally {
await reader.close()
}
}
@@ -0,0 +1,75 @@
import { and, eq, sql } from "drizzle-orm"
import type { Database } from "../storage/database"
import { extensions, extensionFiles } from "../storage/schema"
const types: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
svg: "image/svg+xml",
mp4: "video/mp4",
webm: "video/webm",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
woff2: "font/woff2",
woff: "font/woff",
json: "application/json",
css: "text/css",
txt: "text/plain",
pdf: "application/pdf",
wasm: "application/wasm",
}
export function extensionAssetResponse(
db: Database,
input: { id: string; revision: string; path: string; range?: string | null; head?: boolean },
) {
const where = and(
eq(extensionFiles.extension_id, input.id),
eq(extensionFiles.path, input.path),
eq(extensions.revision, input.revision),
eq(extensions.enabled, true),
)
const metadata = db
.select({ size: sql<number>`length(${extensionFiles.data})` })
.from(extensionFiles)
.innerJoin(extensions, eq(extensionFiles.extension_id, extensions.id))
.where(where)
.get()
if (!metadata) return new Response(null, { status: 404 })
const headers = new Headers({
"Content-Type": types[input.path.split(".").at(-1)?.toLowerCase() ?? ""] ?? "application/octet-stream",
"Cache-Control": "no-store",
"Accept-Ranges": "bytes",
})
const match = input.range?.match(/^bytes=(\d*)-(\d*)$/)
const start = match?.[1] ? Number(match[1]) : match?.[2] ? Math.max(0, metadata.size - Number(match[2])) : 0
const end = match?.[1] && match[2] ? Math.min(Number(match[2]), metadata.size - 1) : metadata.size - 1
if (
input.range &&
(!match ||
(!match[1] && !match[2]) ||
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start > end ||
start >= metadata.size)
) {
headers.set("Content-Range", `bytes */${metadata.size}`)
return new Response(null, { status: 416, headers })
}
headers.set("Content-Length", String(Math.max(0, end - start + 1)))
if (input.range) headers.set("Content-Range", `bytes ${start}-${end}/${metadata.size}`)
const data = input.head
? undefined
: db
.select({
data: sql<Uint8Array>`substr(${extensionFiles.data}, ${start + 1}, ${Math.max(0, end - start + 1)})`,
})
.from(extensionFiles)
.innerJoin(extensions, eq(extensionFiles.extension_id, extensions.id))
.where(where)
.get()?.data
return new Response(data ? new Uint8Array(data) : null, { status: input.range ? 206 : 200, headers })
}
@@ -0,0 +1,4 @@
import type { MainPlugin } from "@opencode/plugin/desktop/main"
import BrowserExtension from "@opencode/plugin-browser-desktop/main"
export const mainExtensions: readonly MainPlugin.Entry[] = [BrowserExtension]
@@ -0,0 +1,182 @@
import type { BrowserWindow } from "electron"
import { NodeHttpClient } from "@effect/platform-node"
import { OpenCode } from "@opencode/client/effect"
import type { MainPlugin } from "@opencode/plugin/desktop/main"
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import { createLifecycle } from "@opencode/plugin/desktop/lifecycle"
import { CallError, decode, encode } from "@opencode/plugin/desktop/rpc"
import { Effect, ManagedRuntime, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { SidecarCredentials } from "../service/sidecar-credentials"
import { createSurfaces } from "./surfaces"
export function createMainExtensionHost(
plugins: readonly MainPlugin.Entry[],
publish: (win: BrowserWindow, event: DesktopExtension.Event) => void,
load?: (id: string) => Promise<MainPlugin.Entry | undefined>,
) {
const windows = new Map<BrowserWindow, ReturnType<typeof windowHost>>()
const host = (win: BrowserWindow) => {
const previous = windows.get(win)
if (previous) return previous
const result = windowHost(win)
windows.set(win, result)
win.once("closed", () => {
result.dispose()
windows.delete(win)
})
return result
}
const runtime = ManagedRuntime.make(NodeHttpClient.layerNodeHttp)
return {
configure(win: BrowserWindow, servers: readonly DesktopExtension.Endpoint[]) {
host(win).configure(servers)
},
call(win: BrowserWindow, input: DesktopExtension.Call) {
return host(win).call(input)
},
cancel(win: BrowserWindow, extensionID: string, requestID: string) {
host(win).cancel(extensionID, requestID)
},
surface(win: BrowserWindow, extensionID: string, surfaceID: string, layout?: DesktopExtension.Layout) {
host(win).surfaces.layout(extensionID, surfaceID, layout)
},
release(win: BrowserWindow, extensionID: string) {
host(win).release(extensionID)
},
releaseAll(extensionID: string) {
windows.forEach((host) => host.release(extensionID))
},
async dispose() {
windows.forEach((value) => value.dispose())
windows.clear()
await runtime.dispose()
},
}
function windowHost(win: BrowserWindow) {
const servers = new Map<string, DesktopExtension.Endpoint>()
const instances = new Map<
string,
{
lifecycle: ReturnType<typeof createLifecycle>
handlers: ReturnType<MainPlugin.Entry["setup"]>
definition: MainPlugin.Entry
}
>()
const calls = new Map<string, AbortController>()
const surfaces = createSurfaces(win)
const release = (extensionID: string) => {
calls.forEach((call, key) => {
if (key.startsWith(`${extensionID}/`)) call.abort()
})
const instance = instances.get(extensionID)
instances.delete(extensionID)
try {
instance?.lifecycle.dispose()
} finally {
surfaces.release(extensionID)
}
}
const instance = async (id: string) => {
const previous = instances.get(id)
if (previous) return previous
const definition = plugins.find((plugin) => plugin.id === id) ?? (await load?.(id))
if (!definition) throw new CallError("rpc.unavailable", `Desktop extension unavailable: ${id}`)
const loaded = instances.get(id)
if (loaded) return loaded
const lifecycle = createLifecycle()
const context: MainPlugin.Context = {
window: win,
lifecycle,
client(serverID) {
const endpoint = servers.get(serverID)
if (!endpoint) return Promise.reject(new Error("Desktop server is unavailable"))
return runtime.runPromise(
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const authorization = endpoint.password
? `Basic ${Buffer.from(`${endpoint.username ?? "opencode"}:${endpoint.password}`).toString("base64")}`
: SidecarCredentials.authorization(SidecarCredentials.get(), endpoint.url)
return yield* OpenCode.make({ baseUrl: endpoint.url }).pipe(
Effect.provideService(
HttpClient.HttpClient,
authorization
? HttpClient.mapRequest(http, HttpClientRequest.setHeader("authorization", authorization))
: http,
),
)
}),
)
},
surfaces: { register: (view) => surfaces.register(id, view) },
async emit(contract, name, data) {
if (lifecycle.signal.aborted) return
const event = contract.events[name]
if (!event) throw new Error(`Unknown desktop event: ${name}`)
publish(win, { extensionID: id, rpcID: contract.id, name, data: await encode(event.schema, data) })
},
}
try {
const value = { lifecycle, definition, handlers: definition.setup(context) }
instances.set(id, value)
return value
} catch (error) {
lifecycle.dispose()
throw error
}
}
return {
surfaces,
release,
configure(values: readonly DesktopExtension.Endpoint[]) {
servers.clear()
values.forEach((server) => servers.set(server.id, server))
},
cancel(id: string, requestID: string) {
calls.get(`${id}/${requestID}`)?.abort()
},
async call(input: DesktopExtension.Call): Promise<Schema.Json> {
const key = `${input.extensionID}/${input.requestID}`
const controller = new AbortController()
calls.set(key, controller)
try {
const current = await instance(input.extensionID)
controller.signal.throwIfAborted()
const method =
current.definition.rpc.id === input.rpcID ? current.definition.rpc.methods[input.method] : undefined
const handler = current.handlers[input.method]
if (!method || !handler) throw new CallError("rpc.method_not_found", "Unknown desktop extension method")
const value = await decode(method.input, input.input).catch((error) => {
throw new CallError("rpc.invalid_input", String(error))
})
const output = await handler(value, {
signal: AbortSignal.any([controller.signal, current.lifecycle.signal]),
error(type, message, data): never {
throw new CallError(type, message, data)
},
})
return { ok: true, output: await encode(method.output, output) }
} catch (error) {
return Schema.decodeUnknownSync(Schema.Json)({
ok: false,
error: {
type: error instanceof CallError ? error.type : "rpc.internal",
message: error instanceof Error ? error.message : String(error),
...(error instanceof CallError && error.data !== undefined
? { data: Schema.decodeUnknownSync(Schema.Json)(error.data) }
: {}),
},
})
} finally {
calls.delete(key)
}
},
dispose() {
calls.forEach((call) => call.abort())
Array.from(instances.keys()).forEach(release)
surfaces.dispose()
},
}
}
}
@@ -0,0 +1,97 @@
import { expect, test } from "bun:test"
import { openDatabase } from "../storage/database"
import { createExtensionManager, readExtensionAsset } from "./manager"
import { readArchive } from "./archive"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { extensionArchive } from "../../../test/extensions/fixture"
import { extensionAssetResponse } from "./assets"
test("installation, replacement, enable state and same-build reload persist in SQLite", async () => {
const database = openDatabase(":memory:")
const changed: string[] = []
const manager = createExtensionManager({ db: database.db, fetch, changed: (id) => changed.push(id) })
const archive = await extensionArchive({ files: { "assets/value.txt": "one" } })
const [first] = await manager.install(archive)
expect(first).toMatchObject({ id: "test.lifecycle", name: "File utilities", enabled: true, generation: 1 })
expect(readExtensionAsset(database.db, first.id, first.revision, "assets/value.txt")?.toString()).toBe("one")
const [reloaded] = manager.reload(first.id)
expect(reloaded).toMatchObject({ revision: first.revision, generation: 2 })
manager.enable(first.id, false)
expect(() => manager.source(first.id, first.revision)).toThrow("disabled")
const restored = createExtensionManager({ db: database.db, fetch, changed() {} })
expect(restored.list()[0].enabled).toBe(false)
expect(readExtensionAsset(database.db, first.id, first.revision, "assets/value.txt")).toBeUndefined()
manager.enable(first.id, true)
const [updated] = await manager.install(
await extensionArchive({ version: "2.0.0", files: { "assets/value.txt": "two" } }),
)
expect(updated).toMatchObject({ version: "2.0.0", generation: 5 })
expect(updated.revision).not.toBe(first.revision)
expect(readExtensionAsset(database.db, first.id, updated.revision, "assets/value.txt")?.toString()).toBe("two")
expect(readExtensionAsset(database.db, first.id, first.revision, "assets/value.txt")).toBeUndefined()
expect(changed).toEqual(Array(5).fill(first.id))
database.close()
})
test("a rejected update preserves the working archive", async () => {
const database = openDatabase(":memory:")
const manager = createExtensionManager({ db: database.db, fetch, changed() {} })
const [first] = await manager.install(await extensionArchive())
await expect(manager.install(await extensionArchive({ renderer: "export const broken =" }))).rejects.toThrow(
"invalidModule",
)
await expect(manager.install(await extensionArchive({ id: "opencode.browser" }))).rejects.toThrow("reserved")
expect(manager.list()).toEqual([first])
expect(manager.source(first.id, first.revision).source).toContain("setup()")
database.close()
})
test("archive assets retain byte ranges, HEAD and disabled-state behavior", async () => {
const database = openDatabase(":memory:")
const manager = createExtensionManager({ db: database.db, fetch, changed() {} })
const [entry] = await manager.install(await extensionArchive({ files: { "assets/movie.webm": "0123456789" } }))
const input = { id: entry.id, revision: entry.revision, path: "assets/movie.webm" }
const range = extensionAssetResponse(database.db, { ...input, range: "bytes=2-5" })
expect(range.status).toBe(206)
expect(range.headers.get("Content-Range")).toBe("bytes 2-5/10")
expect(await range.text()).toBe("2345")
expect(await extensionAssetResponse(database.db, { ...input, range: "bytes=-3" }).text()).toBe("789")
expect(extensionAssetResponse(database.db, { ...input, range: "bytes=20-" }).status).toBe(416)
const head = extensionAssetResponse(database.db, { ...input, head: true })
expect(head.headers.get("Content-Length")).toBe("10")
expect(await head.text()).toBe("")
manager.enable(entry.id, false)
expect(extensionAssetResponse(database.db, input).status).toBe(404)
database.close()
})
test.each([
{ manifest: { schema: 1 } },
{ manifest: { entry: "missing.cjs" } },
{ manifest: { main: "missing.cjs" } },
{ manifest: { entry: "../renderer.cjs" } },
{ files: { "../escape.txt": "bad" } },
{ files: { "C:/escape.txt": "bad" } },
])("validates the archive boundary: %j", async (input) => {
await expect(readArchive(await extensionArchive(input))).rejects.toBeInstanceOf(ExtensionManager.ManagerError)
})
test("downloads archives over HTTP and rejects unsupported URLs or failed responses", async () => {
const archive = await extensionArchive()
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: (request) =>
new URL(request.url).pathname === "/extension.ocdx" ? new Response(archive) : new Response(null, { status: 404 }),
})
const database = openDatabase(":memory:")
const manager = createExtensionManager({ db: database.db, fetch, changed() {} })
try {
expect(await manager.installURL(new URL("extension.ocdx", server.url).href)).toHaveLength(1)
await expect(manager.installURL(new URL("missing.ocdx", server.url).href)).rejects.toThrow("download")
await expect(manager.installURL("file:///extension.ocdx")).rejects.toThrow("url")
} finally {
server.stop(true)
database.close()
}
})
@@ -0,0 +1,147 @@
import { and, eq, sql } from "drizzle-orm"
import { Schema } from "effect"
import type { Database } from "../storage/database"
import { extensions, extensionFiles } from "../storage/schema"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { readArchive, archiveLimit } from "./archive"
const decodeManifest = Schema.decodeUnknownSync(Schema.fromJsonString(ExtensionManager.Manifest))
/** Archive bytes and enable state are committed together in Desktop's SQLite store. */
export function createExtensionManager(input: {
db: Database
fetch: (url: string) => Promise<Response>
changed(id: string, entries: readonly ExtensionManager.Installed[]): void
reserved?: readonly string[]
}) {
const list = (): ExtensionManager.Installed[] =>
input.db
.select()
.from(extensions)
.all()
.map((row) => {
const manifest = decodeManifest(row.manifest)
return {
id: row.id,
name: manifest.name,
version: manifest.version,
revision: row.revision,
generation: row.generation,
enabled: row.enabled,
hasMain: !!manifest.main,
}
})
const get = (id: string, revision?: string) => {
const row = input.db.select().from(extensions).where(eq(extensions.id, id)).get()
if (!row || (revision && row.revision !== revision)) throw new ExtensionManager.ManagerError("notFound")
if (!row.enabled) throw new ExtensionManager.ManagerError("disabled")
return { ...row, manifest: decodeManifest(row.manifest) }
}
const source = (id: string, revision?: string, main = false): ExtensionManager.Source => {
const row = get(id, revision)
const entry = main ? row.manifest.main : row.manifest.entry
const file =
entry &&
input.db
.select()
.from(extensionFiles)
.where(and(eq(extensionFiles.extension_id, id), eq(extensionFiles.path, entry)))
.get()
if (!file) throw new ExtensionManager.ManagerError("notFound")
return { manifest: row.manifest, revision: row.revision, source: file.data.toString("utf8") }
}
const install = async (bytes: Uint8Array) => {
const archive = await readArchive(bytes)
if (archive.manifest.id.startsWith("opencode.") || input.reserved?.includes(archive.manifest.id))
throw new ExtensionManager.ManagerError("reserved")
input.db.transaction((db) => {
db.delete(extensionFiles).where(eq(extensionFiles.extension_id, archive.manifest.id)).run()
db.insert(extensions)
.values({
id: archive.manifest.id,
manifest: JSON.stringify(archive.manifest),
revision: archive.revision,
generation: 1,
enabled: true,
})
.onConflictDoUpdate({
target: extensions.id,
set: {
manifest: JSON.stringify(archive.manifest),
revision: archive.revision,
generation: sql`${extensions.generation} + 1`,
enabled: true,
},
})
.run()
archive.files.forEach((file) =>
db.insert(extensionFiles).values({ extension_id: archive.manifest.id, path: file.path, data: file.data }).run(),
)
})
const entries = list()
input.changed(archive.manifest.id, entries)
return entries
}
return {
list,
source,
install,
enable(id: string, enabled: boolean) {
const row = input.db.select().from(extensions).where(eq(extensions.id, id)).get()
if (!row) throw new ExtensionManager.ManagerError("notFound")
input.db
.update(extensions)
.set({ enabled, generation: row.generation + 1 })
.where(eq(extensions.id, id))
.run()
const entries = list()
input.changed(id, entries)
return entries
},
reload(id: string) {
const row = get(id)
input.db
.update(extensions)
.set({ generation: row.generation + 1 })
.where(eq(extensions.id, id))
.run()
const entries = list()
input.changed(id, entries)
return entries
},
async installURL(value: string) {
if (!URL.canParse(value) || !["http:", "https:"].includes(new URL(value).protocol))
throw new ExtensionManager.ManagerError("url")
const response = await input.fetch(value).catch(() => {
throw new ExtensionManager.ManagerError("download")
})
if (!response.ok || !response.body) throw new ExtensionManager.ManagerError("download")
if (Number(response.headers.get("content-length")) > archiveLimit) {
await response.body.cancel()
throw new ExtensionManager.ManagerError("tooLarge")
}
const chunks: Uint8Array[] = []
let size = 0
for await (const chunk of response.body) {
size += chunk.byteLength
if (size > archiveLimit) throw new ExtensionManager.ManagerError("tooLarge")
chunks.push(chunk)
}
return install(Buffer.concat(chunks))
},
}
}
export function readExtensionAsset(db: Database, id: string, revision: string, path: string) {
const row = db
.select()
.from(extensions)
.where(and(eq(extensions.id, id), eq(extensions.revision, revision), eq(extensions.enabled, true)))
.get()
if (!row) return
return db
.select({ data: extensionFiles.data })
.from(extensionFiles)
.where(and(eq(extensionFiles.extension_id, id), eq(extensionFiles.path, path)))
.get()?.data
}
@@ -0,0 +1,40 @@
import { createRequire, isBuiltin } from "node:module"
import { Schema } from "effect"
import { MainPlugin } from "@opencode/plugin/desktop/main"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { evaluateBundle } from "@opencode/plugin/desktop/bundle"
import type { createExtensionManager } from "./manager"
const native = createRequire(import.meta.url)
const shared = new Map<string, () => Promise<unknown>>([
["effect", () => import("effect")],
["@opencode/plugin/desktop/main", () => import("@opencode/plugin/desktop/main")],
["@opencode/schema/rpc", () => import("@opencode/schema/rpc")],
["@opencode/client/effect", () => import("@opencode/client/effect")],
["@opencode/client", () => import("@opencode/client")],
])
export async function loadMainPlugin(manager: ReturnType<typeof createExtensionManager>, id: string) {
const entry = manager.list().find((entry) => entry.id === id && entry.enabled && entry.hasMain)
if (!entry) return
const input = manager.source(id, entry.revision, true)
const modules = new Map(
await Promise.all(
(input.manifest.mainImports ?? []).map(async (name) => {
if (name === "electron" || isBuiltin(name)) return [name, native(name)] as const
const load = shared.get(name)
if (!load) throw new ExtensionManager.ManagerError("invalidModule")
return [name, await load()] as const
}),
),
)
// Re-read after module loading so disabling/replacing an archive takes effect
// before a new main instance can start.
const current = manager.source(id, entry.revision, true)
const decoded = Schema.decodeUnknownOption(Schema.Struct({ default: MainPlugin.Entry }))(
evaluateBundle(current.source, modules),
)
if (decoded._tag === "None" || decoded.value.default.id !== id)
throw new ExtensionManager.ManagerError("invalidModule")
return decoded.value.default
}
@@ -0,0 +1,71 @@
import { ImageView, screen, type BrowserWindow, type View } from "electron"
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import { createCornerImages } from "../native/corners"
export function createSurfaces(win: BrowserWindow) {
const entries = new Map<string, { extensionID: string; view: View; corners: ImageView[]; key: string }>()
const remove = (id: string) => {
const entry = entries.get(id)
if (!entry) return
entries.delete(id)
if (win.isDestroyed()) return
entry.view.setVisible(false)
entry.corners.forEach((corner) => win.contentView.removeChildView(corner))
win.contentView.removeChildView(entry.view)
}
return {
register(extensionID: string, view: View) {
const id = crypto.randomUUID()
view.setBounds({ x: 0, y: 0, width: 1000, height: 700 })
view.setVisible(false)
win.contentView.addChildView(view)
const corners = [new ImageView(), new ImageView()]
corners.forEach((corner) => {
corner.setVisible(false)
win.contentView.addChildView(corner)
})
entries.set(id, { extensionID, view, corners, key: "" })
return { id, dispose: () => remove(id) }
},
layout(extensionID: string, id: string, layout?: DesktopExtension.Layout) {
const entry = entries.get(id)
if (!entry || entry.extensionID !== extensionID || win.isDestroyed()) return
const bounds = layout?.bounds
if (!layout?.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) {
entry.view.setVisible(false)
entry.corners.forEach((corner) => corner.setVisible(false))
return
}
entry.view.setBounds(bounds)
const size = Math.min(layout.radius ?? 10, Math.floor(bounds.width / 2), Math.floor(bounds.height / 2))
const scale = screen.getDisplayMatching(win.getBounds()).scaleFactor
const key = layout.background && size > 0 ? `${layout.background}:${size}:${scale}` : ""
if (key && key !== entry.key && layout.background)
createCornerImages(layout.background, size, scale).forEach((image, index) =>
entry.corners[index].setImage(image),
)
entry.key = key
entry.corners.forEach((corner, index) => {
corner.setBounds(
{
x: bounds.x + (index ? bounds.width - size : 0),
y: bounds.y + bounds.height - size,
width: size,
height: size,
},
{ animate: { duration: 0 } },
)
corner.setVisible(!!key)
})
entry.view.setVisible(true)
},
release(extensionID: string) {
entries.forEach((entry, id) => {
if (entry.extensionID === extensionID) remove(id)
})
},
dispose() {
Array.from(entries.keys()).forEach(remove)
},
}
}
@@ -1,35 +1,103 @@
import { BrowserWindow } from "electron"
import { BrowserWindow, net } from "electron"
import { Effect } from "effect"
import { EventRpcs } from "../../shared/ipc-rpc"
import { createBrowserPane } from "../browser-pane"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
import { isRendererUrl } from "../windows/protocol"
import { sender } from "./context"
import { createMainExtensionHost } from "../extensions/host"
import { mainExtensions } from "../extensions/builtins"
import { emitIpcEvent } from "../ipc-events"
import { ExtensionEvent } from "../../shared/ipc-rpc/events"
import { ExtensionsChanged } from "../../shared/ipc-rpc/events"
import { ExtensionManagerRpcs } from "../../shared/ipc-rpc/extension-manager"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { DesktopStorage } from "../storage"
import { createExtensionManager } from "../extensions/manager"
import { loadMainPlugin } from "../extensions/module"
export const eventHandlers = EventRpcs.toLayer(
const rpcs = EventRpcs.merge(ExtensionManagerRpcs)
export const eventHandlers = rpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const shutdown = yield* Shutdown.Service
const browser = createBrowserPane()
const stop = Effect.promise(() => browser.dispose())
const storage = yield* DesktopStorage.Service
const extensions = createMainExtensionHost(
mainExtensions,
(win, event) => emitIpcEvent(win.webContents, new ExtensionEvent({ event })),
(id) => loadMainPlugin(manager, id),
)
const manager = createExtensionManager({
db: storage.db,
fetch: net.fetch,
reserved: mainExtensions.map((plugin) => plugin.id),
changed(id, entries) {
extensions.releaseAll(id)
BrowserWindow.getAllWindows().forEach((win) => {
if (!win.isDestroyed() && isRendererUrl(win.webContents.getURL()))
emitIpcEvent(win.webContents, new ExtensionsChanged({ entries }))
})
},
})
const authorized = (context: Parameters<typeof sender>[1]) => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL()))
throw new ExtensionManager.ManagerError("notFound")
}
const operation = <Value>(context: Parameters<typeof sender>[1], run: () => Value | Promise<Value>) =>
Effect.tryPromise(async () => {
try {
authorized(context)
return { ok: true as const, entries: await run() }
} catch (error) {
return {
ok: false as const,
error: { code: error instanceof ExtensionManager.ManagerError ? error.code : ("storage" as const) },
}
}
}).pipe(Effect.orDie)
const stop = Effect.promise(() => extensions.dispose())
const remove = yield* shutdown.add(stop)
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
BrowserPane: ({ request }, context) =>
return rpcs.of({
ExtensionManagerList: (_request, context) =>
Effect.sync(() => {
authorized(context)
return manager.list()
}),
ExtensionManagerInstall: ({ data }, context) => operation(context, () => manager.install(data)),
ExtensionManagerInstallURL: ({ url }, context) => operation(context, () => manager.installURL(url)),
ExtensionManagerEnable: ({ id, enabled }, context) => operation(context, () => manager.enable(id, enabled)),
ExtensionManagerReload: ({ id }, context) => operation(context, () => manager.reload(id)),
ExtensionManagerSource: ({ id, revision }, context) =>
Effect.sync(() => {
try {
authorized(context)
return { ok: true as const, value: manager.source(id, revision) }
} catch (error) {
return {
ok: false as const,
error: { code: error instanceof ExtensionManager.ManagerError ? error.code : ("storage" as const) },
}
}
}),
DesktopExtension: ({ request }, context) =>
Effect.tryPromise(async () => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
throw new Error("browser.pane.owner.invalid")
}
if (request.type === "register") return browser.register(win, request.bindingID, request.target)
if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout)
if (request.type === "command") return browser.command(win, request.bindingID, request.command)
return browser.close(win, request.bindingID)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL()))
throw new Error("Desktop extension owner is unavailable")
if (request.type === "call") return extensions.call(win, request.call)
if (request.type === "cancel") extensions.cancel(win, request.extensionID, request.requestID)
if (request.type === "servers") extensions.configure(win, request.servers)
if (request.type === "surface")
extensions.surface(win, request.extensionID, request.surfaceID, request.layout)
if (request.type === "release") extensions.release(win, request.extensionID)
return null
}).pipe(Effect.orDie),
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
})
}),
)
@@ -1,6 +1,6 @@
import { nativeImage } from "electron"
// Native browser surfaces ignore a parent View's clip path. Cover only the
// Native surfaces ignore a parent View's clip path. Cover only the
// pixels outside the bottom arcs; never resize or style the page itself.
export function createCornerImages(color: readonly [number, number, number, number], radius: number, scale: number) {
const size = Math.max(1, Math.round(radius * scale))
@@ -14,7 +14,14 @@ const tables = (db: ReturnType<typeof drizzle>) =>
describe("database", () => {
test("bootstraps every table on a fresh database and is idempotent", () => {
const database = openDatabase(":memory:")
expect(tables(database.db)).toEqual(["blob", "document", "migration", "state"])
expect(tables(database.db)).toEqual([
"blob",
"desktop_extension",
"desktop_extension_file",
"document",
"migration",
"state",
])
expect(migrate(database.db)).toEqual([])
database.close()
})
@@ -26,7 +33,14 @@ describe("database", () => {
)
const db = drizzle({ client: native })
expect(migrate(db)).toEqual(migrations.map((migration) => migration.id))
expect(tables(db)).toEqual(["blob", "document", "migration", "state"])
expect(tables(db)).toEqual([
"blob",
"desktop_extension",
"desktop_extension_file",
"document",
"migration",
"state",
])
expect(db.all<{ value: string }>(sql`SELECT value FROM document`)).toEqual([{ value: "v" }])
expect(migrate(db)).toEqual([])
})
@@ -18,4 +18,11 @@ export const migrations = [
id: "20260907031611_blob-touched",
statements: ["ALTER TABLE `blob` ADD `touched_at` integer DEFAULT 0 NOT NULL;"],
},
{
id: "20260908211404_desktop-extensions",
statements: [
"CREATE TABLE `desktop_extension_file` (\n\t`extension_id` text NOT NULL,\n\t`path` text NOT NULL,\n\t`data` blob NOT NULL,\n\tCONSTRAINT `desktop_extension_file_pk` PRIMARY KEY(`extension_id`, `path`)\n);",
"CREATE TABLE `desktop_extension` (\n\t`id` text PRIMARY KEY,\n\t`manifest` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`generation` integer NOT NULL,\n\t`enabled` integer NOT NULL\n);",
],
},
]
@@ -0,0 +1,14 @@
CREATE TABLE `desktop_extension_file` (
`extension_id` text NOT NULL,
`path` text NOT NULL,
`data` blob NOT NULL,
CONSTRAINT `desktop_extension_file_pk` PRIMARY KEY(`extension_id`, `path`)
);
--> statement-breakpoint
CREATE TABLE `desktop_extension` (
`id` text PRIMARY KEY,
`manifest` text NOT NULL,
`revision` text NOT NULL,
`generation` integer NOT NULL,
`enabled` integer NOT NULL
);
@@ -0,0 +1,248 @@
{
"version": "7",
"dialect": "sqlite",
"id": "373d581e-0c79-4a9d-9a31-616c3a734892",
"prevIds": [
"53c65132-8703-42d6-8464-64356145dfb4"
],
"ddl": [
{
"name": "blob",
"entityType": "tables"
},
{
"name": "document",
"entityType": "tables"
},
{
"name": "desktop_extension_file",
"entityType": "tables"
},
{
"name": "desktop_extension",
"entityType": "tables"
},
{
"name": "state",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "blob"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "blob"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "touched_at",
"entityType": "columns",
"table": "blob"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "extension_id",
"entityType": "columns",
"table": "desktop_extension_file"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "path",
"entityType": "columns",
"table": "desktop_extension_file"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "desktop_extension_file"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "desktop_extension"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "manifest",
"entityType": "columns",
"table": "desktop_extension"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "desktop_extension"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "generation",
"entityType": "columns",
"table": "desktop_extension"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "enabled",
"entityType": "columns",
"table": "desktop_extension"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "state"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "state"
},
{
"columns": [
"extension_id",
"path"
],
"nameExplicit": false,
"name": "desktop_extension_file_pk",
"entityType": "pks",
"table": "desktop_extension_file"
},
{
"columns": [
"name",
"key"
],
"nameExplicit": false,
"name": "state_pk",
"entityType": "pks",
"table": "state"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "blob_pk",
"table": "blob",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "document_pk",
"table": "document",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "desktop_extension_pk",
"table": "desktop_extension",
"entityType": "pks"
}
],
"renames": []
}
@@ -27,3 +27,21 @@ export const state = sqliteTable(
},
(table) => [primaryKey({ columns: [table.name, table.key] })],
)
export const extensions = sqliteTable("desktop_extension", {
id: text().primaryKey(),
manifest: text().notNull(),
revision: text().notNull(),
generation: integer().notNull(),
enabled: integer({ mode: "boolean" }).notNull(),
})
export const extensionFiles = sqliteTable(
"desktop_extension_file",
{
extension_id: text().notNull(),
path: text().notNull(),
data: blob({ mode: "buffer" }).notNull(),
},
(table) => [primaryKey({ columns: [table.extension_id, table.path] })],
)
@@ -5,6 +5,8 @@ import { Effect, Path } from "effect"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
import { DesktopStorage } from "../storage"
import { extensionAssetResponse } from "../extensions/assets"
const rendererProtocol = "oc"
const rendererHost = "renderer"
@@ -16,6 +18,7 @@ protocol.registerSchemesAsPrivileged([
secure: true,
standard: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
@@ -24,11 +27,26 @@ protocol.registerSchemesAsPrivileged([
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const storage = yield* DesktopStorage.Service
const runFork = Effect.runForkWith(yield* Effect.context<never>())
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host === "extensions") {
const [id, revision, ...parts] = url.pathname.slice(1).split("/").map(decodeURIComponent)
if (!id || !revision) return new Response(null, { status: 404 })
const response = extensionAssetResponse(storage.db, {
id,
revision,
path: parts.join("/"),
range: request.headers.get("range"),
head: request.method === "HEAD",
})
const origin = request.headers.get("origin")
if (origin && isRendererUrl(origin)) response.headers.set("Access-Control-Allow-Origin", origin)
return response
}
if (url.host !== rendererHost) {
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
return new Response("Not found", { status: 404 })
+4 -7
View File
@@ -1,9 +1,9 @@
import type { BrowserPaneEvent } from "@opencode/app/desktop"
import type { DesktopMenuAction } from "@opencode/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode/app/updater"
import type { WslServersPlatform } from "@opencode/app/wsl/types"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import type { ExtensionManager } from "@opencode/plugin/desktop/manager"
import type {
ClipboardImage,
DirectoryPickerOptions,
@@ -23,13 +23,10 @@ export type UpdaterAPI = {
}
export type ElectronAPI = {
extensions: DesktopExtension.Transport
extensionManager: ExtensionManager.Transport
awaitInitialization(): Promise<ServerReadyData>
reconnectService(): Promise<ServerReadyData>
browserPane: {
request(request: BrowserPaneRequest): Promise<void>
send(request: BrowserPaneRequest): void
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
}
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks(): Promise<string[]>
+47 -5
View File
@@ -1,6 +1,7 @@
import type { ElectronAPI } from "./api-types"
import type { UpdaterState } from "@opencode/app/updater"
import { invoke, listen, send } from "./ipc-client"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
type Mutable<Value> =
Value extends ReadonlyArray<unknown>
@@ -23,13 +24,45 @@ const updaterHandler = (state: UpdaterState) => {
}
export const api: ElectronAPI = {
extensionManager: {
list: () => invoke("ExtensionManagerList"),
install: (data) => invoke("ExtensionManagerInstall", { data }).then(extensionResult),
installURL: (url) => invoke("ExtensionManagerInstallURL", { url }).then(extensionResult),
enable: (id, enabled) => invoke("ExtensionManagerEnable", { id, enabled }).then(extensionResult),
reload: (id) => invoke("ExtensionManagerReload", { id }).then(extensionResult),
source: (id, revision) =>
invoke("ExtensionManagerSource", { id, revision }).then((result) => {
if (!result.ok) throw new ExtensionManager.ManagerError(result.error.code)
return result.value
}),
onChange: (callback) => listen("ExtensionsChanged", (event) => callback(event.entries)),
assetURL: (id, revision, path) =>
`oc://extensions/${encodeURIComponent(id)}/${revision}/${path.split("/").map(encodeURIComponent).join("/")}`,
},
extensions: {
call(input, signal) {
if (signal?.aborted) return Promise.reject(signal.reason)
return new Promise((resolve, reject) => {
const cancel = () => {
send("DesktopExtension", {
request: { type: "cancel", extensionID: input.extensionID, requestID: input.requestID },
})
reject(signal?.reason)
}
signal?.addEventListener("abort", cancel, { once: true })
void invoke("DesktopExtension", { request: { type: "call", call: input } })
.then(resolve, reject)
.finally(() => signal?.removeEventListener("abort", cancel))
})
},
onEvent: (callback) => listen("ExtensionEvent", ({ event }) => callback(event)),
surface: (extensionID, surfaceID, layout) =>
send("DesktopExtension", { request: { type: "surface", extensionID, surfaceID, layout } }),
configure: (servers) => send("DesktopExtension", { request: { type: "servers", servers } }),
release: (extensionID) => send("DesktopExtension", { request: { type: "release", extensionID } }),
},
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
browserPane: {
request: (request) => invoke("BrowserPane", { request }),
send: (request) => send("BrowserPane", { request }),
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(value)),
},
wslServers: {
getState: () => invoke("WslGetState").then(mutable),
subscribe: (cb) => {
@@ -131,3 +164,12 @@ export const api: ElectronAPI = {
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
}
function extensionResult(
result:
| { readonly ok: true; readonly entries: readonly ExtensionManager.Installed[] }
| { readonly ok: false; readonly error: { readonly code: ExtensionManager.ErrorCode } },
) {
if (!result.ok) throw new ExtensionManager.ManagerError(result.error.code)
return result.entries
}
@@ -1,9 +1,4 @@
import {
ACCEPTED_FILE_EXTENSIONS,
ServerConnection,
type Platform,
type UpdaterPlatform,
} from "@opencode/app/desktop"
import { ACCEPTED_FILE_EXTENSIONS, ServerConnection, type Platform, type UpdaterPlatform } from "@opencode/app/desktop"
import type { ElectronAPI } from "../api-types"
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
import { windowFullscreen } from "../window/fullscreen"
@@ -11,6 +6,7 @@ import { DragCancelEvent } from "../../shared/ipc-transport"
import { createDesktopFiles } from "./files"
import { createDesktopMenuAction } from "./menu"
import { createDesktopNotify } from "./notifications"
import BrowserExtension from "@opencode/plugin-browser-desktop"
import { createDesktopStorage } from "./storage"
export type DesktopWindowState = {
@@ -29,37 +25,11 @@ export function createDesktopPlatform(
os,
version: windowState.version,
windowID: windowState.id,
extensions: api.extensions,
extensionManager: api.extensionManager,
extensionPlugins: [BrowserExtension],
...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS),
...createDesktopStorage(api),
browserPane: {
register(target, onEvent) {
const bindingID = crypto.randomUUID()
let closed = false
const dispose = api.browserPane.onEvent((value) => {
if (!closed && value.bindingID === bindingID) onEvent(value.event)
})
const ready = api.browserPane.request({ type: "register", bindingID, target })
// Failures reach the owner through the closed-state event; keep the bare promise handled.
void ready.catch(() => undefined)
return {
setLayout(layout) {
if (!closed)
void ready
.then(() =>
api.browserPane.send({ type: "layout", bindingID, ...(layout === undefined ? {} : { layout }) }),
)
.catch(() => undefined)
},
command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })),
close() {
if (closed) return
closed = true
dispose()
void ready.then(() => api.browserPane.request({ type: "close", bindingID })).catch(() => undefined)
},
}
},
},
updater,
exportDebugLogs: () => api.exportDebugLogs(),
setForceFocus: (enabled) => api.setForceFocus(enabled),
+11 -1
View File
@@ -7,6 +7,7 @@ import { StorageRpcs } from "./ipc-rpc/storage"
import { UpdaterRpcs } from "./ipc-rpc/updater"
import { WindowRpcs } from "./ipc-rpc/window"
import { WslRpcs } from "./ipc-rpc/wsl"
import { ExtensionManagerRpcs } from "./ipc-rpc/extension-manager"
export { AppRpcs } from "./ipc-rpc/app"
export { EventRpcs } from "./ipc-rpc/events"
@@ -17,5 +18,14 @@ export { UpdaterRpcs } from "./ipc-rpc/updater"
export { WindowRpcs } from "./ipc-rpc/window"
export { WslRpcs } from "./ipc-rpc/wsl"
export const DesktopRpcs = AppRpcs.merge(StorageRpcs, FileRpcs, WindowRpcs, MenuRpcs, UpdaterRpcs, WslRpcs, EventRpcs)
export const DesktopRpcs = AppRpcs.merge(
StorageRpcs,
FileRpcs,
WindowRpcs,
MenuRpcs,
UpdaterRpcs,
WslRpcs,
EventRpcs,
ExtensionManagerRpcs,
)
export type DesktopRpcClient = RpcClient.FromGroup<typeof DesktopRpcs, RpcClientError.RpcClientError>
@@ -1,38 +0,0 @@
import { Browser } from "@opencode/plugin-browser/rpc"
import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum))
const bindingID = text(128)
const endpoint = Schema.Struct({
url: text(16_384),
username: Schema.optionalKey(text(1_024)),
password: Schema.optionalKey(text(4_096)),
})
const target = Schema.Struct({ sessionID: text(256).check(Schema.isStartsWith("ses")), endpoint })
const bounds = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite })
const channel = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 255 }))
const layout = Schema.Struct({
tabID: Browser.TabID,
visible: Schema.Boolean,
bounds: Schema.optionalKey(bounds),
background: Schema.optionalKey(Schema.Tuple([channel, channel, channel, channel])),
radius: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
})
export const BrowserPaneRequestSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("register"), bindingID, target }),
Schema.Struct({ type: Schema.Literal("layout"), bindingID, layout: Schema.optionalKey(layout) }),
Schema.Struct({ type: Schema.Literal("command"), bindingID, command: Browser.Action }),
Schema.Struct({ type: Schema.Literal("close"), bindingID }),
])
export type BrowserPaneRequest = Schema.Schema.Type<typeof BrowserPaneRequestSchema>
export const BrowserPaneEventSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("focus"), tabID: Browser.TabID }),
Schema.Struct({
type: Schema.Literal("state"),
state: Schema.NullOr(Browser.State),
error: Schema.optionalKey(Schema.String),
}),
])
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
+12 -6
View File
@@ -1,12 +1,17 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import { ExtensionRpc } from "./extensions"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
export class BrowserPaneEvent extends Schema.TaggedClass<BrowserPaneEvent>()("BrowserPaneEvent", {
bindingID: Schema.String,
event: BrowserPaneEventSchema,
export class ExtensionsChanged extends Schema.TaggedClass<ExtensionsChanged>()("ExtensionsChanged", {
entries: Schema.Array(ExtensionManager.Installed),
}) {}
export class ExtensionEvent extends Schema.TaggedClass<ExtensionEvent>()("ExtensionEvent", {
event: DesktopExtension.Event,
}) {}
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
@@ -46,7 +51,8 @@ export class StorageChanged extends Schema.TaggedClass<StorageChanged>()("Storag
}) {}
export const DesktopEvent = Schema.Union([
BrowserPaneEvent,
ExtensionsChanged,
ExtensionEvent,
DeepLinksOpened,
MenuCommandTriggered,
UpdaterStateChanged,
@@ -59,4 +65,4 @@ export const DesktopEvent = Schema.Union([
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
export const EventRpcs = RpcGroup.make(DesktopEvents, ExtensionRpc)
@@ -0,0 +1,23 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
const Inventory = Schema.Array(ExtensionManager.Installed)
const Result = Schema.Union([
Schema.Struct({ ok: Schema.Literal(true), entries: Inventory }),
Schema.Struct({ ok: Schema.Literal(false), error: ExtensionManager.Failure }),
])
export const ExtensionManagerRpcs = RpcGroup.make(
Rpc.make("ExtensionManagerList", { success: Inventory }),
Rpc.make("ExtensionManagerInstall", { payload: { data: Schema.Uint8Array }, success: Result }),
Rpc.make("ExtensionManagerInstallURL", { payload: { url: Schema.String }, success: Result }),
Rpc.make("ExtensionManagerEnable", { payload: { id: Schema.String, enabled: Schema.Boolean }, success: Result }),
Rpc.make("ExtensionManagerReload", { payload: { id: Schema.String }, success: Result }),
Rpc.make("ExtensionManagerSource", {
payload: { id: Schema.String, revision: Schema.String },
success: Schema.Union([
Schema.Struct({ ok: Schema.Literal(true), value: ExtensionManager.Source }),
Schema.Struct({ ok: Schema.Literal(false), error: ExtensionManager.Failure }),
]),
}),
)
@@ -0,0 +1,21 @@
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
export const ExtensionRequest = Schema.Union([
Schema.Struct({ type: Schema.Literal("call"), call: DesktopExtension.Call }),
Schema.Struct({ type: Schema.Literal("cancel"), extensionID: Schema.String, requestID: Schema.String }),
Schema.Struct({ type: Schema.Literal("servers"), servers: Schema.Array(DesktopExtension.Endpoint) }),
Schema.Struct({
type: Schema.Literal("surface"),
extensionID: Schema.String,
surfaceID: Schema.String,
layout: Schema.optionalKey(DesktopExtension.Layout),
}),
Schema.Struct({ type: Schema.Literal("release"), extensionID: Schema.String }),
])
export type ExtensionRequest = typeof ExtensionRequest.Type
export const ExtensionRpc = Rpc.make("DesktopExtension", {
payload: { request: ExtensionRequest },
success: Schema.Json,
})

Some files were not shown because too many files have changed in this diff Show More