mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 10:26:25 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4539ff55c | ||
|
|
7acdea1437 | ||
|
|
890f8fe863 | ||
|
|
fa4bce6f7a | ||
|
|
d8804ffd65 | ||
|
|
7bb391582b | ||
|
|
07dcde43c6 | ||
|
|
66b16d40f0 | ||
|
|
de7ab3b082 | ||
|
|
29a05fa343 | ||
|
|
0dc7aa2411 | ||
|
|
b5222ef904 | ||
|
|
30c25008ac | ||
|
|
7466567c51 | ||
|
|
39310ee609 | ||
|
|
cc40f5844c |
@@ -63,6 +63,7 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
@@ -426,6 +427,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode/app": "workspace:*",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
@@ -597,8 +599,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:*",
|
||||
|
||||
@@ -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>
|
||||
@@ -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")!,
|
||||
)
|
||||
@@ -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") {
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import { createMockServerHandler, mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("serves an empty config document list for composer defaults", async () => {
|
||||
const server = createMockServerHandler({
|
||||
provider: {},
|
||||
directory: "C:/OpenCode",
|
||||
project: {},
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
try {
|
||||
const response = await server.handler(
|
||||
new Request("http://localhost/api/config?location%5Bdirectory%5D=C%3A%2FOpenCode"),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual([])
|
||||
} finally {
|
||||
await server.dispose()
|
||||
}
|
||||
})
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("applies message latency after a list response gate is released", async () => {
|
||||
const events: string[] = []
|
||||
|
||||
@@ -342,7 +342,6 @@ async function mockServers(
|
||||
if (route.request().method() === "GET" && sessionPermission)
|
||||
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/config") return json(route, [])
|
||||
if (url.pathname === "/api/provider")
|
||||
return json(route, {
|
||||
location: { directory },
|
||||
|
||||
@@ -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",
|
||||
]) {
|
||||
|
||||
@@ -602,7 +602,6 @@ async function mockServer(page: Page) {
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/config") return json(route, [])
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -39,7 +39,6 @@ const Group = HttpApiGroup.make("mock")
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("config", "/api/config", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
|
||||
|
||||
@@ -207,7 +207,6 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
)
|
||||
.handleAll({
|
||||
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
|
||||
config: () => Effect.succeed([]),
|
||||
reference: () =>
|
||||
Effect.succeed({
|
||||
location: {
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/session-ui": "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`,
|
||||
)
|
||||
@@ -1,5 +1,4 @@
|
||||
import { batch, type Accessor, createEffect, createMemo, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
|
||||
import type { ComposerControls } from "./adapter"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
|
||||
@@ -10,7 +9,6 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
|
||||
import { useComposerState } from "./persistence"
|
||||
import { useConfiguredModel } from "@/providers/models/configured"
|
||||
|
||||
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
|
||||
const layout = useLayout()
|
||||
@@ -33,7 +31,6 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
|
||||
selection: input.model ?? local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading:
|
||||
!(input.model ?? local.model).ready() ||
|
||||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
|
||||
!providers.ready(),
|
||||
},
|
||||
@@ -46,31 +43,15 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
|
||||
}
|
||||
|
||||
export function createComposerModelSelection(input: {
|
||||
agent: () => { name: string; model?: ModelKey; variant?: string } | undefined
|
||||
agent: () => { model?: ModelKey; variant?: string } | undefined
|
||||
}) {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const models = useModels()
|
||||
const local = useLocal()
|
||||
const prompt = useComposerState()
|
||||
const configuredModel = useConfiguredModel()
|
||||
const [remembered, setRemembered] = createStore<Record<string, ModelKey | undefined>>({})
|
||||
createEffect(
|
||||
on(
|
||||
() => input.agent()?.name,
|
||||
(name, previous) => {
|
||||
if (!name || !previous || name === previous) return
|
||||
batch(() => {
|
||||
const model = prompt.model.current()
|
||||
setRemembered(previous, model ? { providerID: model.providerID, modelID: model.modelID } : undefined)
|
||||
prompt.model.set(remembered[name] ? { ...remembered[name] } : undefined)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
|
||||
const valid = (model: ModelKey) => {
|
||||
const provider = providers.all().get(model.providerID)
|
||||
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||
}
|
||||
@@ -81,8 +62,7 @@ export function createComposerModelSelection(input: {
|
||||
return modelID ? [{ providerID: provider.id, modelID }] : []
|
||||
})[0]
|
||||
const current = () => {
|
||||
if (!configuredModel.ready()) return
|
||||
const key = [prompt.model.current(), input.agent()?.model, configuredModel(), recent(), fallback()].find(
|
||||
const key = [prompt.model.current(), input.agent()?.model, recent(), fallback()].find(
|
||||
(item): item is ModelKey => !!item && valid(item),
|
||||
)
|
||||
return key ? models.find(key) : undefined
|
||||
@@ -94,9 +74,7 @@ export function createComposerModelSelection(input: {
|
||||
.filter((item): item is NonNullable<typeof item> => !!item),
|
||||
)
|
||||
const selection = {
|
||||
trackSessionCommit: local.model.trackSessionCommit,
|
||||
remembered: () => Object.fromEntries(Object.entries(remembered).map(([name, model]) => [name, { model }])),
|
||||
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
|
||||
ready: models.ready,
|
||||
current,
|
||||
recent: recentModels,
|
||||
list: models.list,
|
||||
@@ -105,24 +83,19 @@ export function createComposerModelSelection(input: {
|
||||
const item = current()
|
||||
if (!item) return
|
||||
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
|
||||
const next =
|
||||
items[
|
||||
index === -1 ? (direction === 1 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
|
||||
]
|
||||
if (index === -1) return
|
||||
const next = items[(index + direction + items.length) % items.length]
|
||||
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
|
||||
},
|
||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
if (item && !valid(item)) return
|
||||
const previous = current()
|
||||
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
|
||||
prompt.model.set(
|
||||
item ? { ...item, variant: same ? (selection.variant.current() ?? null) : undefined } : undefined,
|
||||
)
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (options?.recent) models.recent.push(item)
|
||||
})
|
||||
void startTransition(() =>
|
||||
batch(() => {
|
||||
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (options?.recent) models.recent.push(item)
|
||||
}),
|
||||
)
|
||||
},
|
||||
visible: models.visible,
|
||||
setVisibility: models.setVisibility,
|
||||
@@ -131,41 +104,38 @@ export function createComposerModelSelection(input: {
|
||||
const item = input.agent()
|
||||
const model = current()
|
||||
if (!item || !model) return
|
||||
const global = configuredModel()
|
||||
return (
|
||||
getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
}) ??
|
||||
getConfiguredAgentVariant({
|
||||
agent: { model: global, variant: global?.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
})
|
||||
)
|
||||
return getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
})
|
||||
},
|
||||
selected() {
|
||||
const model = prompt.model.current()
|
||||
return model && valid(model) ? model.variant : undefined
|
||||
return prompt.model.current()?.variant
|
||||
},
|
||||
current() {
|
||||
const model = current()
|
||||
return resolveModelVariant({
|
||||
const resolved = resolveModelVariant({
|
||||
variants: this.list(),
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
|
||||
})
|
||||
if (resolved) return resolved
|
||||
const model = current()
|
||||
if (!model) return
|
||||
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
|
||||
if (saved && this.list().includes(saved)) return saved
|
||||
},
|
||||
list() {
|
||||
return Object.keys(current()?.variants ?? {})
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
batch(() => {
|
||||
const model = current()
|
||||
if (!model) return
|
||||
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
|
||||
})
|
||||
void startTransition(() =>
|
||||
batch(() => {
|
||||
const model = current()
|
||||
if (!model) return
|
||||
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
|
||||
}),
|
||||
)
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
@@ -173,13 +143,13 @@ export function createComposerModelSelection(input: {
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
variants,
|
||||
selected: this.current() ?? null,
|
||||
configured: undefined,
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
} satisfies ModelSelection
|
||||
|
||||
return selection satisfies ModelSelection
|
||||
return selection
|
||||
}
|
||||
|
||||
@@ -91,8 +91,6 @@ function session(input: {
|
||||
admitted?: (messageID: string) => boolean
|
||||
shell?: () => Promise<unknown>
|
||||
command?: ComposerSession["api"]["command"]
|
||||
switchAgent?: ComposerSession["api"]["switchAgent"]
|
||||
switchModel?: ComposerSession["api"]["switchModel"]
|
||||
}): ComposerSession {
|
||||
return {
|
||||
id: "session-1",
|
||||
@@ -101,16 +99,12 @@ function session(input: {
|
||||
current: input.current ?? (() => undefined),
|
||||
admitted: input.admitted ?? (() => false),
|
||||
api: {
|
||||
switchAgent:
|
||||
input.switchAgent ??
|
||||
(async () => {
|
||||
input.calls.push("switch-agent")
|
||||
}),
|
||||
switchModel:
|
||||
input.switchModel ??
|
||||
(async () => {
|
||||
input.calls.push("switch-model")
|
||||
}),
|
||||
switchAgent: async () => {
|
||||
input.calls.push("switch-agent")
|
||||
},
|
||||
switchModel: async () => {
|
||||
input.calls.push("switch-model")
|
||||
},
|
||||
shell: input.shell ?? (async () => undefined),
|
||||
command: input.command ?? (async () => undefined),
|
||||
},
|
||||
@@ -128,146 +122,6 @@ function session(input: {
|
||||
}
|
||||
|
||||
describe("Composer submission", () => {
|
||||
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
|
||||
const calls: string[] = []
|
||||
const selected = controls()
|
||||
const agent = Promise.withResolvers<void>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
const committed = Promise.withResolvers<void>()
|
||||
const completed = Promise.withResolvers<void>()
|
||||
const target = session({
|
||||
calls,
|
||||
prompt: async () => {
|
||||
throw new Error("command must not call prompt")
|
||||
},
|
||||
switchAgent: async (request) => {
|
||||
expect(request.agent).toBe("build")
|
||||
calls.push("agent")
|
||||
started.resolve()
|
||||
await agent.promise
|
||||
},
|
||||
switchModel: async (request) => {
|
||||
expect(request.model).toEqual({ providerID: "provider-1", id: "model-1", variant: "balanced" })
|
||||
calls.push("model")
|
||||
await committed.promise
|
||||
},
|
||||
command: async (request) => {
|
||||
expect(request).toMatchObject({ command: "review", text: "changes", delivery: "steer" })
|
||||
expect(request).not.toHaveProperty("model")
|
||||
expect(request).not.toHaveProperty("agent")
|
||||
calls.push("command")
|
||||
completed.resolve()
|
||||
},
|
||||
})
|
||||
selected.model.selection = {
|
||||
...selection,
|
||||
trackSessionCommit: (_id, value) => {
|
||||
expect(value).toEqual({
|
||||
agent: "build",
|
||||
model: { providerID: "provider-1", modelID: "model-1" },
|
||||
variant: "balanced",
|
||||
})
|
||||
calls.push("track")
|
||||
return () => calls.push("cancel")
|
||||
},
|
||||
}
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls: () => selected,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
|
||||
await started.promise
|
||||
expect(calls).toEqual(["track", "agent"])
|
||||
selected.agents.current = "plan"
|
||||
selected.model.selection = { ...selection, variant: { ...selection.variant, current: () => "high" } }
|
||||
agent.resolve()
|
||||
committed.resolve()
|
||||
await completed.promise
|
||||
expect(calls).toEqual(["track", "agent", "model", "command"])
|
||||
})
|
||||
|
||||
test("commits the model even when cached session state already matches", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "continue" }).capture()
|
||||
const calls: string[] = []
|
||||
const done = Promise.withResolvers<void>()
|
||||
const target = session({
|
||||
calls,
|
||||
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
|
||||
prompt: async () => done.resolve(),
|
||||
})
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
await done.promise
|
||||
expect(calls).toEqual(["switch-model", "prompt"])
|
||||
})
|
||||
|
||||
test("cancels selection tracking and does not execute a command when selection fails", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
|
||||
const calls: string[] = []
|
||||
const selected = controls()
|
||||
const failed = Promise.withResolvers<unknown>()
|
||||
const error = new Error("model unavailable")
|
||||
const target = session({
|
||||
calls,
|
||||
prompt: async () => {
|
||||
calls.push("prompt")
|
||||
},
|
||||
switchModel: async () => {
|
||||
throw error
|
||||
},
|
||||
command: async () => {
|
||||
calls.push("command")
|
||||
},
|
||||
})
|
||||
selected.model.selection = {
|
||||
...selection,
|
||||
trackSessionCommit: () => {
|
||||
calls.push("track")
|
||||
return () => {
|
||||
calls.push("cancel")
|
||||
}
|
||||
},
|
||||
}
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls: () => selected,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
await submitInput(
|
||||
adapter,
|
||||
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
|
||||
"normal",
|
||||
() => [{ name: "review" }],
|
||||
).submit(new Event("submit"))
|
||||
expect(await failed.promise).toBe(error)
|
||||
expect(calls).toEqual(["track", "switch-agent", "cancel"])
|
||||
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
|
||||
})
|
||||
|
||||
test("submits a slash skill with its trailing text and attachments", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
|
||||
state.set([
|
||||
|
||||
@@ -11,7 +11,6 @@ import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
|
||||
const submitting = new WeakSet<object>()
|
||||
|
||||
@@ -89,7 +88,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
const sending = sendPrompt(session, value).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
@@ -125,12 +124,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
// Commands always steer: the server applies a command's configured
|
||||
// agent and model immediately at admission, so queueing one would
|
||||
// reconfigure the turn it is supposed to wait behind.
|
||||
void sendCommand(
|
||||
session,
|
||||
{ ...value, delivery: "steer" },
|
||||
command,
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
|
||||
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
|
||||
failSubmission(input, session, "command", error, restore, value.id),
|
||||
)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
@@ -319,10 +315,8 @@ async function sendCommand(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
command: { command: string; arguments: string },
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
await applySelection(session, value.selection, track)
|
||||
await session.api.command({
|
||||
sessionID: session.id,
|
||||
command: command.command,
|
||||
@@ -334,33 +328,7 @@ async function sendCommand(
|
||||
})
|
||||
}
|
||||
|
||||
async function applySelection(
|
||||
session: ComposerSession,
|
||||
selection: ComposerSelection,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const cancel = track?.(session.id, selection)
|
||||
try {
|
||||
const current = session.current()
|
||||
if (current?.agent !== selection.agent) {
|
||||
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
|
||||
}
|
||||
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
|
||||
await session.api.switchModel({
|
||||
sessionID: session.id,
|
||||
model: { id: selection.model.modelID, providerID: selection.model.providerID, variant: selection.variant },
|
||||
})
|
||||
} catch (error) {
|
||||
cancel?.()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
// the remainder of a running turn. A steer targets that turn, so its
|
||||
@@ -368,7 +336,24 @@ async function sendPrompt(
|
||||
// waits behind, so it runs with the session selection at delivery time (the
|
||||
// intended selection stays recorded in its metadata).
|
||||
if (value.delivery === "steer") {
|
||||
await applySelection(session, value.selection, track)
|
||||
const current = session.current()
|
||||
if (current?.agent !== value.selection.agent) {
|
||||
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
|
||||
}
|
||||
if (
|
||||
current?.model?.providerID !== value.selection.model.providerID ||
|
||||
current.model.id !== value.selection.model.modelID ||
|
||||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
|
||||
) {
|
||||
await session.api.switchModel({
|
||||
sessionID: session.id,
|
||||
model: {
|
||||
id: value.selection.model.modelID,
|
||||
providerID: value.selection.model.providerID,
|
||||
variant: value.selection.variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const admission = {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const extensionTabKey = (plugin: string, id: string) => `extension:${plugin}:${id}`
|
||||
export const isExtensionTab = (id: string | undefined) => !!id?.startsWith("extension:")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
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 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 = () => 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),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -70,14 +70,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 +139,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);
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ export function createNewSessionComposerAdapter(props: {
|
||||
agent: selection.agent,
|
||||
model: selection.model,
|
||||
variant: selection.variant ?? null,
|
||||
choices: model.remembered(),
|
||||
})
|
||||
if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id })
|
||||
submission.retarget(
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createEffect, createMemo, on } from "solid-js"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
|
||||
export function useConfiguredModel() {
|
||||
const data = useData()
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServerSDK()
|
||||
createEffect(
|
||||
on(
|
||||
() => [location().directory, server.connection.status()] as const,
|
||||
([directory]) => {
|
||||
void data.location.config.sync({ directory }).catch(() => undefined)
|
||||
},
|
||||
),
|
||||
)
|
||||
const documents = () => data.location.config.list({ directory: location().directory })
|
||||
const model = createMemo(() => {
|
||||
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
|
||||
const model = entry?.type === "document" ? entry.info.model : undefined
|
||||
if (!model) return
|
||||
if (typeof model !== "string") return { providerID: model.providerID, modelID: model.model, variant: model.variant }
|
||||
const [providerID, ...parts] = model.split("/")
|
||||
return { providerID, modelID: parts.join("/"), variant: undefined }
|
||||
})
|
||||
return Object.assign(model, { ready: () => documents() !== undefined })
|
||||
}
|
||||
@@ -122,10 +122,10 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const setVariant = (model: ModelKey, value: string | undefined) => {
|
||||
const key = variantKey(model)
|
||||
if (!store.variant) {
|
||||
setStore("variant", { [key]: value ?? "default" })
|
||||
setStore("variant", { [key]: value })
|
||||
return
|
||||
}
|
||||
setStore("variant", key, value ?? "default")
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { popularProviders } from "@/providers/catalog/providers"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -27,7 +27,7 @@ import "@/settings/settings.css"
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
type ModelState = ModelSelection
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
type ModelItem = ReturnType<ModelState["list"]>[number]
|
||||
|
||||
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
@@ -16,7 +16,6 @@ import { useData } from "@/runtime/server/current"
|
||||
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
import { useConfiguredModel } from "./configured"
|
||||
|
||||
const ModelKeySchema = Schema.Struct({
|
||||
providerID: Schema.String,
|
||||
@@ -25,15 +24,11 @@ const ModelKeySchema = Schema.Struct({
|
||||
})
|
||||
export type ModelKey = typeof ModelKeySchema.Type
|
||||
|
||||
const ChoiceSchema = Schema.Struct({
|
||||
const StateSchema = Schema.Struct({
|
||||
agent: Persistence.optional(Schema.String),
|
||||
model: Persistence.optional(ModelKeySchema),
|
||||
variant: Persistence.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
const StateSchema = Schema.Struct({
|
||||
...ChoiceSchema.fields,
|
||||
agent: Persistence.optional(Schema.String),
|
||||
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
|
||||
})
|
||||
type State = typeof StateSchema.Type
|
||||
|
||||
const SessionsSchema = Schema.Record(
|
||||
@@ -83,7 +78,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const models = useModels()
|
||||
const settings = useSettings()
|
||||
const configuredModel = useConfiguredModel()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() =>
|
||||
@@ -104,9 +98,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
current?: string
|
||||
draft?: State
|
||||
promoting?: State
|
||||
last?: {
|
||||
type: "agent" | "model" | "variant"
|
||||
agent?: string
|
||||
model?: ModelKey | null
|
||||
variant?: string | null
|
||||
}
|
||||
}>({
|
||||
current: list()[0]?.name,
|
||||
draft: undefined,
|
||||
last: undefined,
|
||||
})
|
||||
|
||||
const validModel = (model: ModelKey) => {
|
||||
@@ -175,17 +176,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel))
|
||||
const durable = () => {
|
||||
const session = id()
|
||||
return session ? data.session.get(session) : undefined
|
||||
}
|
||||
const fallback = createMemo<ModelKey | undefined>(() => recentModel() ?? defaultModel())
|
||||
|
||||
const agent = {
|
||||
list,
|
||||
visible: agentsVisible,
|
||||
current() {
|
||||
return pickAgent(scope()?.agent ?? durable()?.agent ?? (agentsVisible() ? store.current : "build"))
|
||||
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const item = pickAgent(name)
|
||||
@@ -195,24 +192,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
const previous = snapshot()
|
||||
if (previous.agent === item.name) return
|
||||
const prev = scope()
|
||||
const choices = {
|
||||
...prev?.choices,
|
||||
...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}),
|
||||
}
|
||||
setStore("current", item.name)
|
||||
setStore("last", {
|
||||
type: "agent",
|
||||
agent: item.name,
|
||||
model: item.model,
|
||||
variant: item.variant ?? null,
|
||||
})
|
||||
const prev = scope()
|
||||
const next = {
|
||||
agent: item.name,
|
||||
model: choices[item.name]?.model,
|
||||
variant: choices[item.name]?.variant,
|
||||
choices,
|
||||
model: item.model ?? prev?.model,
|
||||
variant: item.variant ?? prev?.variant,
|
||||
} satisfies State
|
||||
write(next)
|
||||
// Pin both choices while the agent and model acknowledgments arrive separately.
|
||||
const selected = current()
|
||||
if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id })
|
||||
const session = id()
|
||||
if (session) {
|
||||
setSaved("session", session, next)
|
||||
return
|
||||
}
|
||||
setStore("draft", next)
|
||||
})
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
@@ -232,15 +230,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const current = createMemo(() => {
|
||||
if (!configuredModel.ready()) return
|
||||
const item = firstModel(
|
||||
() => scope()?.model,
|
||||
() => {
|
||||
const session = durable()
|
||||
if (session?.agent && session.agent !== agent.current()?.name) return
|
||||
const model = session?.model
|
||||
return model && { providerID: model.providerID, modelID: model.id }
|
||||
},
|
||||
() => agent.current()?.model,
|
||||
fallback,
|
||||
)
|
||||
@@ -252,41 +243,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const item = agent.current()
|
||||
const model = current()
|
||||
if (!item || !model) return
|
||||
const global = configuredModel()
|
||||
return (
|
||||
getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
}) ??
|
||||
getConfiguredAgentVariant({
|
||||
agent: { model: global, variant: global?.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
})
|
||||
)
|
||||
return getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||
})
|
||||
}
|
||||
|
||||
const selected = () => {
|
||||
const draft = scope()
|
||||
if (draft?.model && validModel(draft.model)) return draft.variant
|
||||
const session = durable()
|
||||
if (session?.agent && session.agent !== agent.current()?.name) return
|
||||
const value = session?.model
|
||||
if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null
|
||||
}
|
||||
const selected = () => scope()?.variant
|
||||
|
||||
const snapshot = () => {
|
||||
const selected = current()
|
||||
const model = current()
|
||||
return {
|
||||
agent: agent.current()?.name,
|
||||
model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined,
|
||||
variant: selected ? (model.variant.current() ?? null) : undefined,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
|
||||
variant: selected(),
|
||||
} satisfies State
|
||||
}
|
||||
|
||||
const write = (next: Partial<State>) => {
|
||||
const state = {
|
||||
...scope(),
|
||||
agent: agent.current()?.name,
|
||||
...(scope() ?? { agent: agent.current()?.name }),
|
||||
...next,
|
||||
} satisfies State
|
||||
|
||||
@@ -298,59 +274,22 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setStore("draft", state)
|
||||
}
|
||||
|
||||
const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean))
|
||||
const pending = new Map<string, State>()
|
||||
const sameSelection = (a: State, b: State) =>
|
||||
a.agent === b.agent &&
|
||||
a.model?.providerID === b.model?.providerID &&
|
||||
a.model?.modelID === b.model?.modelID &&
|
||||
(a.variant ?? "default") === (b.variant ?? "default")
|
||||
|
||||
const reconcile = (sessionID: string) => {
|
||||
const expected = pending.get(sessionID)
|
||||
const session = data.session.get(sessionID)
|
||||
if (!expected || !session?.model) return
|
||||
if (
|
||||
!sameSelection(expected, {
|
||||
agent: session.agent,
|
||||
model: { providerID: session.model.providerID, modelID: session.model.id },
|
||||
variant: session.model.variant,
|
||||
})
|
||||
)
|
||||
return
|
||||
pending.delete(sessionID)
|
||||
const draft = saved.session[sessionID]
|
||||
if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return
|
||||
setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined })
|
||||
}
|
||||
onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID)))
|
||||
onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID)))
|
||||
onCleanup(
|
||||
serverSDK.event.on("session.deleted", (event) => {
|
||||
pending.delete(event.data.sessionID)
|
||||
setSaved("session", event.data.sessionID, undefined)
|
||||
}),
|
||||
)
|
||||
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
|
||||
|
||||
const model = {
|
||||
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
|
||||
ready: models.ready,
|
||||
current,
|
||||
recent,
|
||||
list: models.list,
|
||||
trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) {
|
||||
pending.set(sessionID, selection)
|
||||
reconcile(sessionID)
|
||||
return () => {
|
||||
if (pending.get(sessionID) === selection) pending.delete(sessionID)
|
||||
}
|
||||
},
|
||||
cycle(direction: 1 | -1) {
|
||||
const items = recent()
|
||||
const item = current()
|
||||
if (!item) return
|
||||
|
||||
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
|
||||
let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction
|
||||
if (index === -1) return
|
||||
|
||||
let next = index + direction
|
||||
if (next < 0) next = items.length - 1
|
||||
if (next >= items.length) next = 0
|
||||
|
||||
@@ -359,18 +298,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
||||
},
|
||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
if (item && !validModel(item)) return
|
||||
const previous = current()
|
||||
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
|
||||
write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined })
|
||||
if (!item) return
|
||||
// A session draft owns its variant even when preferences change in another session.
|
||||
if (id() && !same) write({ variant: model.variant.current() ?? null })
|
||||
models.setVisibility(item, true)
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
})
|
||||
startTransition(() =>
|
||||
batch(() => {
|
||||
setStore("last", {
|
||||
type: "model",
|
||||
agent: agent.current()?.name,
|
||||
model: item ?? null,
|
||||
variant: selected(),
|
||||
})
|
||||
write({ model: item })
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
}),
|
||||
)
|
||||
},
|
||||
visible(item: ModelKey) {
|
||||
return models.visible(item)
|
||||
@@ -382,13 +324,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
configured,
|
||||
selected,
|
||||
current() {
|
||||
const model = current()
|
||||
return resolveModelVariant({
|
||||
const resolved = resolveModelVariant({
|
||||
variants: this.list(),
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
|
||||
})
|
||||
if (resolved) return resolved
|
||||
const model = current()
|
||||
if (!model) return
|
||||
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
|
||||
if (saved && this.list().includes(saved)) return saved
|
||||
},
|
||||
list() {
|
||||
const item = current()
|
||||
@@ -396,12 +341,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return Object.keys(item.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
batch(() => {
|
||||
const model = current()
|
||||
if (!model) return
|
||||
write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null })
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
|
||||
})
|
||||
startTransition(() =>
|
||||
batch(() => {
|
||||
const model = current()
|
||||
setStore("last", {
|
||||
type: "variant",
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
||||
variant: value ?? null,
|
||||
})
|
||||
write({ variant: value ?? null })
|
||||
if (model) {
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
cycle() {
|
||||
const items = this.list()
|
||||
@@ -409,8 +363,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
variants: items,
|
||||
selected: this.current() ?? null,
|
||||
configured: undefined,
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -429,34 +383,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
promote(dir: string, session: string, state?: State) {
|
||||
const next = clone(state ?? snapshot())
|
||||
if (!next) return
|
||||
// Creation already owns the active selection; keep only agent memory once it is in the read model.
|
||||
// Otherwise a first-message command's configured overrides would stay hidden behind this handoff.
|
||||
const created = data.session.get(session)
|
||||
const selection = created?.model
|
||||
const committed =
|
||||
selection &&
|
||||
sameSelection(next, {
|
||||
agent: created.agent,
|
||||
model: { providerID: selection.providerID, modelID: selection.id },
|
||||
variant: selection.variant,
|
||||
})
|
||||
? { choices: next.choices }
|
||||
: next
|
||||
const key = handoffKey(serverSDK.scope, dir, session)
|
||||
handoff.set(key, committed)
|
||||
handoff.set(key, next)
|
||||
|
||||
if (dir === sdk().directory) {
|
||||
setSaved("session", session, committed)
|
||||
setSaved("session", session, next)
|
||||
}
|
||||
|
||||
setStore("promoting", committed)
|
||||
setStore("promoting", next)
|
||||
setStore("draft", undefined)
|
||||
},
|
||||
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||
const session = id()
|
||||
if (!session) return
|
||||
if (msg.sessionID !== session) return
|
||||
if (durable()?.model) return
|
||||
if (saved.session[session] !== undefined) return
|
||||
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
|
||||
|
||||
@@ -472,5 +412,4 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
})
|
||||
|
||||
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit"> &
|
||||
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit">>
|
||||
export type ModelSelection = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
@@ -6,13 +6,13 @@ import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useTheme } from "@opencode/ui/theme"
|
||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ModelTooltip } from "./tooltip"
|
||||
|
||||
type ModelState = ModelSelection
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
|
||||
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
||||
|
||||
|
||||
@@ -64,14 +64,14 @@ describe("model variant", () => {
|
||||
expect(value).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("cycles from configured last variant to default", () => {
|
||||
test("wraps from configured last variant to first", () => {
|
||||
const value = cycleModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
selected: undefined,
|
||||
configured: "xhigh",
|
||||
})
|
||||
|
||||
expect(value).toBeUndefined()
|
||||
expect(value).toBe("low")
|
||||
})
|
||||
|
||||
test("cycles from an explicit default to the first variant", () => {
|
||||
@@ -83,22 +83,4 @@ describe("model variant", () => {
|
||||
|
||||
expect(value).toBe("low")
|
||||
})
|
||||
|
||||
test("prefers a saved variant to configuration, including explicit Default", () => {
|
||||
const input = { variants: ["low", "high"], selected: undefined, configured: "high" }
|
||||
expect(resolveModelVariant({ ...input, preferred: "low" })).toBe("low")
|
||||
expect(resolveModelVariant({ ...input, preferred: "default" })).toBeUndefined()
|
||||
expect(resolveModelVariant({ ...input, preferred: "low", selected: null })).toBeUndefined()
|
||||
expect(resolveModelVariant({ ...input, preferred: "low", selected: "high" })).toBe("high")
|
||||
expect(cycleModelVariant({ ...input, preferred: "high" })).toBeUndefined()
|
||||
expect(cycleModelVariant({ ...input, preferred: "default" })).toBe("low")
|
||||
})
|
||||
|
||||
test("normalizes unavailable selections instead of silently applying another variant", () => {
|
||||
expect(resolveModelVariant({ variants: ["low"], selected: "high", configured: "low" })).toBeUndefined()
|
||||
expect(
|
||||
resolveModelVariant({ variants: ["low"], selected: undefined, preferred: "high", configured: "low" }),
|
||||
).toBeUndefined()
|
||||
expect(cycleModelVariant({ variants: [], selected: undefined, configured: undefined })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,6 @@ type VariantInput = {
|
||||
variants: string[]
|
||||
selected: string | null | undefined
|
||||
configured: string | undefined
|
||||
preferred?: string
|
||||
}
|
||||
|
||||
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
|
||||
@@ -25,16 +24,29 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
|
||||
if (!input.model?.variants) return undefined
|
||||
if (input.agent.model.providerID !== input.model.providerID) return undefined
|
||||
if (input.agent.model.modelID !== input.model.modelID) return undefined
|
||||
if (!(input.agent.variant in input.model.variants)) return undefined
|
||||
return input.agent.variant
|
||||
}
|
||||
|
||||
export function resolveModelVariant(input: VariantInput) {
|
||||
if (input.selected === null) return undefined
|
||||
const value = input.selected ?? input.preferred ?? input.configured
|
||||
return value && value !== "default" && input.variants.includes(value) ? value : undefined
|
||||
if (input.selected && input.variants.includes(input.selected)) return input.selected
|
||||
if (input.configured && input.variants.includes(input.configured)) return input.configured
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function cycleModelVariant(input: VariantInput) {
|
||||
const current = resolveModelVariant(input)
|
||||
return input.variants[current ? input.variants.indexOf(current) + 1 : 0]
|
||||
if (input.variants.length === 0) return undefined
|
||||
if (input.selected === null) return input.variants[0]
|
||||
if (input.selected && input.variants.includes(input.selected)) {
|
||||
const index = input.variants.indexOf(input.selected)
|
||||
if (index === input.variants.length - 1) return undefined
|
||||
return input.variants[index + 1]
|
||||
}
|
||||
if (input.configured && input.variants.includes(input.configured)) {
|
||||
const index = input.variants.indexOf(input.configured)
|
||||
if (index === input.variants.length - 1) return input.variants[0]
|
||||
return input.variants[index + 1]
|
||||
}
|
||||
return input.variants[0]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -230,72 +230,3 @@ describe("draft store text externalization", () => {
|
||||
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("draft store image retention", () => {
|
||||
const image = (byte: number) => new Blob([new Uint8Array(6).fill(byte)], { type: "image/png" })
|
||||
const fresh = (grace = 0) => {
|
||||
const memory = memoryDriver()
|
||||
return { memory, store: createDraftStore(memory.driver, { grace }) }
|
||||
}
|
||||
// Release timers fire on the macrotask queue; a zero grace has fired after one tick.
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 5))
|
||||
// An image with no object URL left gets a new one when its bytes are uploaded again.
|
||||
const released = async (store: ReturnType<typeof createDraftStore>, byte: number, url: string) =>
|
||||
(await store.putBlob(image(byte))).url !== url
|
||||
|
||||
test("an uploaded image no document references is released after the grace", async () => {
|
||||
const { store } = fresh()
|
||||
const orphan = await store.putBlob(image(1))
|
||||
await tick()
|
||||
expect(await released(store, 1, orphan.url)).toBe(true)
|
||||
})
|
||||
|
||||
test("an image referenced within the grace is kept", async () => {
|
||||
const { store } = fresh(50)
|
||||
const pasted = await store.putBlob(image(2))
|
||||
await store.setDocument("pinned", { prompt: [{ type: "image", blob: pasted }] })
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
expect(await released(store, 2, pasted.url)).toBe(false)
|
||||
})
|
||||
|
||||
test("saving a document without an image or removing the document releases it", async () => {
|
||||
const { store } = fresh()
|
||||
const dropped = await store.putBlob(image(3))
|
||||
const removed = await store.putBlob(image(4))
|
||||
await store.setDocument("edited", { prompt: [{ type: "image", blob: dropped }] })
|
||||
await store.setDocument("closed", { prompt: [{ type: "image", blob: removed }] })
|
||||
await tick()
|
||||
expect(await released(store, 3, dropped.url)).toBe(false)
|
||||
expect(await released(store, 4, removed.url)).toBe(false)
|
||||
await store.setDocument("edited", { prompt: [{ type: "text", content: "typed over it" }] })
|
||||
await store.removeItem("closed")
|
||||
await tick()
|
||||
expect(await released(store, 3, dropped.url)).toBe(true)
|
||||
expect(await released(store, 4, removed.url)).toBe(true)
|
||||
})
|
||||
|
||||
test("an image referenced by two documents survives until both drop it", async () => {
|
||||
const { store } = fresh()
|
||||
const shared = await store.putBlob(image(5))
|
||||
await store.setDocument("composer", { prompt: [{ type: "image", blob: shared }] })
|
||||
await store.setDocument("history", { entries: [{ prompt: [{ type: "image", blob: shared }] }] })
|
||||
await store.setDocument("composer", { prompt: [] })
|
||||
await tick()
|
||||
expect(await released(store, 5, shared.url)).toBe(false)
|
||||
await store.setDocument("history", { entries: [] })
|
||||
await tick()
|
||||
expect(await released(store, 5, shared.url)).toBe(true)
|
||||
})
|
||||
|
||||
test("loading a document pins the images it references", async () => {
|
||||
const { memory, store } = fresh()
|
||||
const id = await memory.driver.putBlob(image(6))
|
||||
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
|
||||
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(false)
|
||||
await store.removeItem("loaded")
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,77 +28,23 @@ export const draftTextThreshold = 16 * 1024
|
||||
export const draftTextChunk = 64 * 1024
|
||||
const textCacheLimit = 64
|
||||
|
||||
// Decoded image bytes the renderer pins through object URLs. Every consumer of a `blob.url` is a
|
||||
// persisted draft document (composer prompt, prompt history), so an image is pinned exactly while a
|
||||
// stored document references it. Once the last reference disappears (removed from a draft, sent, or
|
||||
// a discarded duplicate paste) the URL is revoked after this grace, which covers the persist delay
|
||||
// between a paste and the save that references it, and the submit → history handoff.
|
||||
export const retainedBlobGrace = 30_000
|
||||
|
||||
type Retained = { blob: Blob; url: string; release: ReturnType<typeof setTimeout> | undefined }
|
||||
const retained = new Map<string, Retained>()
|
||||
// Document keys that reference each image id; an id with no keys is released after the grace.
|
||||
const refs = new Map<string, Set<string>>()
|
||||
const urls = new Map<string, string>()
|
||||
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
|
||||
// collected image be uploaded again without fetching the URL.
|
||||
const held = new Map<string, Blob>()
|
||||
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
|
||||
// ones); live references still carry the original.
|
||||
const aliases = new Map<string, string>()
|
||||
|
||||
function blobUrl(id: string, blob: Blob, grace?: number) {
|
||||
const existing = retained.get(id)
|
||||
if (existing) return existing.url
|
||||
function blobUrl(id: string, blob: Blob) {
|
||||
const existing = urls.get(id)
|
||||
if (existing) return existing
|
||||
const url = URL.createObjectURL(blob)
|
||||
// Without a grace the image has no store to reference it from and stays for the page's lifetime.
|
||||
const release = grace === undefined || refs.get(id)?.size ? undefined : setTimeout(() => revoke(id), grace)
|
||||
retained.set(id, { blob, url, release })
|
||||
urls.set(id, url)
|
||||
held.set(id, blob)
|
||||
return url
|
||||
}
|
||||
|
||||
// Record which image ids `key` now references; ids it dropped are released once no other document
|
||||
// references them, ids it gained stay pinned.
|
||||
function retain(key: string, ids: ReadonlySet<string>, grace: number) {
|
||||
for (const [id, keys] of refs) {
|
||||
if (ids.has(id) || !keys.delete(key) || keys.size) continue
|
||||
refs.delete(id)
|
||||
const entry = retained.get(id)
|
||||
if (entry) entry.release = setTimeout(() => revoke(id), grace)
|
||||
}
|
||||
for (const id of ids) {
|
||||
const keys = refs.get(id) ?? new Set<string>()
|
||||
keys.add(key)
|
||||
refs.set(id, keys)
|
||||
const entry = retained.get(id)
|
||||
if (!entry) continue
|
||||
clearTimeout(entry.release)
|
||||
entry.release = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function revoke(id: string) {
|
||||
const entry = retained.get(id)
|
||||
if (!entry) return
|
||||
URL.revokeObjectURL(entry.url)
|
||||
retained.delete(id)
|
||||
for (const [from, to] of aliases) if (to === id) aliases.delete(from)
|
||||
}
|
||||
|
||||
// Image ids a document references: `{ blob: { id } }` parts, not text chunk lists.
|
||||
function imageIDs(value: unknown, into = new Set<string>()): Set<string> {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => imageIDs(entry, into))
|
||||
return into
|
||||
}
|
||||
if (!value || typeof value !== "object") return into
|
||||
const item = value as Record<string, unknown>
|
||||
const blob = item.blob
|
||||
if (blob && typeof blob === "object" && !("kind" in blob)) {
|
||||
const id = (blob as Record<string, unknown>).id
|
||||
if (typeof id === "string") into.add(id)
|
||||
return into
|
||||
}
|
||||
Object.values(item).forEach((entry) => imageIDs(entry, into))
|
||||
return into
|
||||
}
|
||||
|
||||
async function blobID(blob: Blob) {
|
||||
const bytes = crypto.subtle
|
||||
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
|
||||
@@ -114,25 +60,24 @@ export async function createBlobReference(blob: Blob): Promise<BlobReference> {
|
||||
return { id, url: blobUrl(id, blob) }
|
||||
}
|
||||
|
||||
export function createDraftStore(driver: Driver, options: { grace?: number } = {}): DraftStore {
|
||||
const grace = options.grace ?? retainedBlobGrace
|
||||
export function createDraftStore(driver: Driver): DraftStore {
|
||||
const versions = new Map<string, number>()
|
||||
const loading = new Map<string, Promise<string | undefined>>()
|
||||
const loadBlobUrl = (id: string) => {
|
||||
const existing = retained.get(id)
|
||||
if (existing) return existing.url
|
||||
const existing = urls.get(id)
|
||||
if (existing) return existing
|
||||
const pending = loading.get(id)
|
||||
if (pending) return pending
|
||||
const next = driver
|
||||
.getBlob(id)
|
||||
.then((blob) => (blob ? blobUrl(id, blob, grace) : undefined))
|
||||
.then((blob) => (blob ? blobUrl(id, blob) : undefined))
|
||||
.finally(() => loading.delete(id))
|
||||
loading.set(id, next)
|
||||
return next
|
||||
}
|
||||
const putBlob = async (blob: Blob) => {
|
||||
const id = await driver.putBlob(blob)
|
||||
return { id, url: blobUrl(id, blob, grace) }
|
||||
return { id, url: blobUrl(id, blob) }
|
||||
}
|
||||
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
|
||||
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
|
||||
@@ -200,8 +145,8 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
if (typeof blob.id === "string") {
|
||||
// A live reference keeps the id it was created with; publish the id its bytes now live under.
|
||||
const id = aliases.get(blob.id) ?? blob.id
|
||||
const kept = retained.get(id)?.blob
|
||||
const url = typeof blob.url === "string" ? blob.url : retained.get(id)?.url
|
||||
const kept = held.get(id)
|
||||
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
|
||||
if (kept) sources.set(id, { blob: async () => kept })
|
||||
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
|
||||
return { ...item, blob: { id } }
|
||||
@@ -247,7 +192,8 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
remember(chunks, next, source.chunk)
|
||||
return
|
||||
}
|
||||
blobUrl(next, blob, grace)
|
||||
held.set(next, blob)
|
||||
blobUrl(next, blob)
|
||||
if (next === id) return
|
||||
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
|
||||
// earlier alias chain so lookups stay one step.
|
||||
@@ -279,19 +225,14 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
|
||||
// or the composer's history still held its id.
|
||||
const missing = await driver.set(key, JSON.stringify(encoded), true)
|
||||
if (missing.length === 0) {
|
||||
retain(key, imageIDs(encoded), grace)
|
||||
return
|
||||
}
|
||||
if (missing.length === 0) return
|
||||
const renamed = await restore(missing, sources)
|
||||
if (versions.get(key) !== version) return
|
||||
const unrestored = missing.filter((id) => !renamed.has(id))
|
||||
if (unrestored.length)
|
||||
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
|
||||
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
|
||||
const final = rename(encoded, renamed)
|
||||
await driver.set(key, JSON.stringify(final), false)
|
||||
retain(key, imageIDs(final), grace)
|
||||
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
|
||||
}
|
||||
return {
|
||||
getItem: async (key) => {
|
||||
@@ -300,8 +241,6 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
|
||||
// Let the owning persistence codec apply its invalid-document policy.
|
||||
if (Option.isNone(parsed)) return value
|
||||
// A loaded document is live in the composer: pin its images before decode mints their URLs.
|
||||
retain(key, imageIDs(parsed.value), grace)
|
||||
return JSON.stringify(await decode(parsed.value))
|
||||
},
|
||||
setItem: (key, value) => setDocument(key, JSON.parse(value)),
|
||||
@@ -309,7 +248,6 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
removeItem: async (key) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
await driver.remove(key)
|
||||
retain(key, new Set(), grace)
|
||||
},
|
||||
putBlob,
|
||||
}
|
||||
@@ -422,8 +360,7 @@ function referenced(json: string) {
|
||||
}
|
||||
|
||||
export async function blobDataUrl(blob: BlobReference, mime: string) {
|
||||
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
|
||||
const data = await fetch(blob.url).then((response) => response.blob())
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => reject(reader.error))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,9 @@ 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 }
|
||||
@@ -122,6 +125,9 @@ type PlatformBase = {
|
||||
|
||||
/** 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 &
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useComposerCommands } from "@/composer/commands"
|
||||
import { useSessionCommands } from "../commands/use-session-commands"
|
||||
import type { SessionModel } from "../model"
|
||||
import type { SessionScreenLayout } from "../screen-layout"
|
||||
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
|
||||
import { restorePromptModel, syncPromptModel, syncSessionModel } from "../session-model-helpers"
|
||||
import type { SessionTimelineInteraction } from "../timeline/interaction"
|
||||
import { createSessionRevert } from "../revert"
|
||||
import { SessionComposerRegion } from "./session-composer-region"
|
||||
@@ -62,10 +62,14 @@ export function createActiveSessionRegion(input: {
|
||||
},
|
||||
),
|
||||
)
|
||||
let restoredModelSession: string | undefined
|
||||
createEffect(() => {
|
||||
const id = input.session.identity.params.id
|
||||
if (!id || !prompt.ready() || !local.session.ready()) return
|
||||
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
|
||||
if (restoredModelSession !== id) {
|
||||
restoredModelSession = id
|
||||
if (restorePromptModel(local, prompt)) return
|
||||
}
|
||||
syncPromptModel(local, prompt)
|
||||
})
|
||||
createEffect(
|
||||
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
@@ -48,6 +49,9 @@ 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"
|
||||
import type { SessionExtensions } from "@/extensions/session"
|
||||
import { isExtensionTab } from "@/extensions/keys"
|
||||
import { ExtensionPanelContent } from "@/extensions/content"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type RenderDiff = FileDiffInfo
|
||||
@@ -58,6 +62,7 @@ function renderDiff(value: ReviewDiff): value is RenderDiff {
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
extensions: SessionExtensions
|
||||
canReview: boolean
|
||||
diffs: ReviewDiff[]
|
||||
diffsReady: boolean
|
||||
@@ -81,6 +86,7 @@ export function SessionSidePanel(props: {
|
||||
const command = useCommand()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||
const extensions = props.extensions
|
||||
const projectDirectory = createMemo(() => sdk().directory)
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -174,6 +180,9 @@ export function SessionSidePanel(props: {
|
||||
hasReview: () => props.canReview,
|
||||
fileBrowser: () => true,
|
||||
browser: props.browser.attached,
|
||||
extensions: extensions.keys,
|
||||
defaultPanel: extensions.defaultPanel,
|
||||
canClose: extensions.canClose,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openFileOpen = tabState.openFileOpen
|
||||
@@ -225,7 +234,13 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const fileBrowserVisible = createMemo(() => {
|
||||
const active = activeTab()
|
||||
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
|
||||
return (
|
||||
active !== "review" &&
|
||||
active !== "context" &&
|
||||
active !== "empty" &&
|
||||
!isSessionBrowserTab(active) &&
|
||||
!extensions.keys().includes(active)
|
||||
)
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
@@ -365,6 +380,27 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match when={extensions.keys().includes(tab)}>
|
||||
<Show when={extensions.panels().find((panel) => panel.key === tab)}>
|
||||
{(panel) => (
|
||||
<SortableTab
|
||||
tab={tab}
|
||||
index={tabs().all().indexOf(tab)}
|
||||
onTabClose={panel().props.closable === false ? undefined : tabs().close}
|
||||
>
|
||||
<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>
|
||||
</SortableTab>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={isSessionBrowserTab(tab)}>
|
||||
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
|
||||
{(item) => (
|
||||
@@ -436,7 +472,7 @@ export function SessionSidePanel(props: {
|
||||
<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()}
|
||||
when={props.browser.available() || extensions.hasActions()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
value={
|
||||
@@ -491,12 +527,15 @@ export function SessionSidePanel(props: {
|
||||
<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>
|
||||
<Show when={props.browser.available()}>
|
||||
<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>
|
||||
</Show>
|
||||
{extensions.actions()}
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
@@ -552,6 +591,8 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<ExtensionPanelContent panels={extensions.panels()} active={activeTab()} />
|
||||
|
||||
<Show when={props.browser.opened()}>
|
||||
<div
|
||||
id={browserTabPanelID}
|
||||
|
||||
@@ -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,8 +1,9 @@
|
||||
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 { isExtensionTab } from "@/extensions/keys"
|
||||
|
||||
export {
|
||||
SESSION_BROWSER_TAB,
|
||||
@@ -26,6 +27,9 @@ type TabsInput = {
|
||||
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 }) {
|
||||
@@ -50,7 +54,9 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
.tabs()
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
if (input.extensions?.().includes(tab)) return [tab]
|
||||
if (tab === "context" || tab === "review") return []
|
||||
if (isExtensionTab(tab)) return input.extensions?.().includes(tab) ? [tab] : []
|
||||
if (isSessionBrowserTab(tab)) return browser() ? [tab] : []
|
||||
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
|
||||
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
|
||||
@@ -63,13 +69,25 @@ 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
|
||||
@@ -77,6 +95,8 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
|
||||
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,6 +108,7 @@ 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
|
||||
@@ -172,44 +193,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>
|
||||
|
||||
@@ -19,6 +19,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[] = []
|
||||
@@ -30,6 +31,7 @@ export function useSessionModel() {
|
||||
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,6 +85,20 @@ 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: () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ReviewPanel } from "./panel"
|
||||
import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
import type { SessionExtensions } from "@/extensions/session"
|
||||
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
@@ -148,11 +149,13 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
export function SessionDesktopReview(props: {
|
||||
review: SessionReviewModel
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
extensions: SessionExtensions
|
||||
present?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
extensions={props.extensions}
|
||||
canReview={props.review.canReview()}
|
||||
diffs={props.review.diffs()}
|
||||
diffsReady={props.review.ready()}
|
||||
|
||||
@@ -34,6 +34,8 @@ import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
import { useExtensionPanels } from "@/extensions/session"
|
||||
import { createSessionServices } from "@/extensions/workspace"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -49,6 +51,14 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
})
|
||||
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({
|
||||
@@ -249,6 +259,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}
|
||||
@@ -288,6 +299,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={isDesktop()}>{extensions.declarations()}</Show>
|
||||
<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 +391,12 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
|
||||
<SessionDesktopReview
|
||||
review={review}
|
||||
browser={browser}
|
||||
extensions={extensions}
|
||||
present={store.sideReviewPresent}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resetSessionModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
|
||||
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
|
||||
|
||||
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
|
||||
sessionID: "session",
|
||||
@@ -54,7 +54,8 @@ describe("syncPromptModel", () => {
|
||||
{
|
||||
model: {
|
||||
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
|
||||
variant: { current: () => "high" },
|
||||
set() {},
|
||||
variant: { current: () => "high", set() {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -76,7 +77,8 @@ describe("syncPromptModel", () => {
|
||||
{
|
||||
model: {
|
||||
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
|
||||
variant: { current: () => model.variant },
|
||||
set() {},
|
||||
variant: { current: () => model.variant, set() {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -91,26 +93,54 @@ describe("syncPromptModel", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("stale prompt model", () => {
|
||||
test("replaces the submission mirror without changing the effective selection", () => {
|
||||
describe("restorePromptModel", () => {
|
||||
test("restores the persisted prompt model into session selection", () => {
|
||||
const calls: unknown[] = []
|
||||
syncPromptModel(
|
||||
const restored = restorePromptModel(
|
||||
{
|
||||
model: {
|
||||
current: () => ({ id: "gpt", provider: { id: "openai" } }),
|
||||
set: (model) => calls.push(model),
|
||||
variant: {
|
||||
current: () => undefined,
|
||||
set: (variant) => calls.push(variant),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: {
|
||||
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
|
||||
set: (value) => calls.push(value),
|
||||
set() {},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ providerID: "openai", modelID: "gpt", variant: undefined }])
|
||||
expect(restored).toBe(true)
|
||||
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
|
||||
})
|
||||
|
||||
test("does nothing without a persisted prompt model", () => {
|
||||
const calls: unknown[] = []
|
||||
const restored = restorePromptModel(
|
||||
{
|
||||
model: {
|
||||
current: () => ({ id: "gpt", provider: { id: "openai" } }),
|
||||
set: (model) => calls.push(model),
|
||||
variant: {
|
||||
current: () => undefined,
|
||||
set: (variant) => calls.push(variant),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: {
|
||||
current: () => undefined,
|
||||
set() {},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(restored).toBe(false)
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,8 +12,10 @@ type Local = {
|
||||
type ModelSelection = {
|
||||
model: {
|
||||
current(): { id: string; provider: { id: string } } | undefined
|
||||
set(model: { providerID: string; modelID: string }): void
|
||||
variant: {
|
||||
current(): string | undefined
|
||||
set(variant: string | undefined): void
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,3 +51,18 @@ export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
|
||||
return
|
||||
prompt.model.set(next)
|
||||
}
|
||||
|
||||
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
|
||||
const model = prompt.model.current()
|
||||
if (!model) return false
|
||||
const current = local.model.current()
|
||||
if (
|
||||
current?.provider.id === model.providerID &&
|
||||
current.id === model.modelID &&
|
||||
local.model.variant.current() === (model.variant ?? undefined)
|
||||
)
|
||||
return true
|
||||
local.model.set({ providerID: model.providerID, modelID: model.modelID })
|
||||
local.model.variant.set(model.variant ?? undefined)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -335,6 +335,7 @@ export function SessionSummaryPanel(props: {
|
||||
}
|
||||
|
||||
type MessageTimelineProps = {
|
||||
headerActions?: JSX.Element
|
||||
hideHeader?: boolean
|
||||
session: TimelineSessionSource
|
||||
background: SessionBackground
|
||||
@@ -795,6 +796,7 @@ function MessageTimelineView(
|
||||
<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}>
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"]
|
||||
|
||||
@@ -31,6 +32,7 @@ export const SettingsExperimental: Component = () => {
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<ExtensionSlot path="settings.experimental" />
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -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 +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"
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -5,6 +5,8 @@ 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"
|
||||
@@ -80,7 +82,11 @@ function AppLayout(props: ParentProps) {
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
<DesktopExtensionsProvider>
|
||||
<ExtensionSlot path="app">
|
||||
<Shell>{props.children}</Shell>
|
||||
</ExtensionSlot>
|
||||
</DesktopExtensionsProvider>
|
||||
</BrowserAttachmentsProvider>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
import { afterEach, expect, mock, test } from "bun:test"
|
||||
import { createRequire } from "node:module"
|
||||
import { createComponent, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Agent } from "@/runtime/server/types"
|
||||
import type { ModelKey } from "@/providers/models/selection"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
|
||||
// Bun does not compile Solid JSX. Compile the real context provider with the
|
||||
// same presets as Vite instead of replacing LocalProvider's implementation.
|
||||
const require = createRequire(import.meta.url)
|
||||
const solid = createRequire(require.resolve("vite-plugin-solid"))
|
||||
const { transformSync } = solid("@babel/core")
|
||||
Bun.plugin({
|
||||
name: "selection-solid-context",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /[\\/]ui[\\/]src[\\/]context[\\/]helper\.tsx$/ }, async (args) => ({
|
||||
contents: transformSync(await Bun.file(args.path).text(), {
|
||||
filename: args.path,
|
||||
presets: [solid.resolve("babel-preset-solid"), solid.resolve("@babel/preset-typescript")],
|
||||
}).code,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
type Commit = { agent?: string; model?: { providerID: string; id: string; variant?: string } }
|
||||
type Event = { data: { sessionID: string } }
|
||||
type ConfigModel = string | { providerID: string; model: string; variant?: string }
|
||||
const key = (modelID: string): ModelKey => ({ providerID: "provider", modelID })
|
||||
const durable = (modelID: string, variant?: string, agent = "build"): Commit => ({
|
||||
agent,
|
||||
model: { providerID: "provider", id: modelID, variant },
|
||||
})
|
||||
const agent = (name: string, model?: ModelKey, variant?: string): Agent => ({
|
||||
name,
|
||||
mode: "primary",
|
||||
permission: [],
|
||||
options: {},
|
||||
model,
|
||||
variant,
|
||||
})
|
||||
|
||||
let active: ReturnType<typeof fixture>
|
||||
mock.module("@solidjs/router", () => ({ useParams: () => active.state.route }))
|
||||
mock.module("@/runtime/server/current", () => ({ useData: () => active.data }))
|
||||
mock.module("@/runtime/server/client", () => ({ useServerSDK: () => active.sdk }))
|
||||
mock.module("@/runtime/server/runtime", () => ({ useGlobal: () => ({ models: active.preferences }) }))
|
||||
mock.module("@/workspaces/location", () => ({ useWorkspaceLocation: () => () => ({ directory: active.directory }) }))
|
||||
mock.module("@/settings/model", () => ({
|
||||
useSettings: () => ({ visibility: { customAgents: () => active.state.visible } }),
|
||||
}))
|
||||
mock.module("@/composer/persistence", () => ({ useComposerState: () => active.prompt }))
|
||||
mock.module("@/shell/state/layout", () => ({ useLayout: () => undefined }))
|
||||
mock.module("@/runtime/platform/platform", () => ({
|
||||
usePlatform: () => ({
|
||||
platform: "web",
|
||||
openExternal() {},
|
||||
restart: async () => {},
|
||||
notify: async () => {},
|
||||
}),
|
||||
}))
|
||||
|
||||
const { LocalProvider, useLocal } = await import("@/providers/models/selection")
|
||||
const { ModelsProvider } = await import("@/providers/models/models")
|
||||
const { Persist } = await import("@/runtime/persistence/storage")
|
||||
const { createMemoryComposerState } = await import("@/composer/state")
|
||||
const { createComposerModelSelection } = await import("@/composer/selection")
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
afterEach(() =>
|
||||
cleanups
|
||||
.splice(0)
|
||||
.reverse()
|
||||
.forEach((dispose) => dispose()),
|
||||
)
|
||||
|
||||
function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigModel; preferred?: string } = {}) {
|
||||
const directory = `/selection-test/${crypto.randomUUID()}`
|
||||
const [state, set] = createStore({
|
||||
visible: true,
|
||||
configLoaded: true,
|
||||
connection: "connected",
|
||||
route: { id: "ses_a" as string | undefined },
|
||||
agents: input.agents ?? [agent("build"), agent("plan")],
|
||||
config: input.config as ConfigModel | undefined,
|
||||
sessions: { ses_a: input.session } as Record<string, Commit | undefined>,
|
||||
providers: [{ id: "provider", name: "Provider", package: "@ai-sdk/test", activation: "enabled" }],
|
||||
models: ["a", "b", "c"].map((id) => ({
|
||||
id,
|
||||
modelID: id,
|
||||
providerID: "provider",
|
||||
name: `Model ${id}`,
|
||||
settings: {},
|
||||
headers: {},
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: ["low", "high"].map((id) => ({ id, settings: {} })),
|
||||
time: { released: 1 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 8192 },
|
||||
})),
|
||||
})
|
||||
const [preferences, setPreferences] = createStore({
|
||||
user: [] as Array<ModelKey & { visibility: "show" | "hide" }>,
|
||||
recent: [] as ModelKey[],
|
||||
variant: (input.preferred ? { "provider/a": input.preferred } : {}) as Record<string, string>,
|
||||
})
|
||||
const events = new Map<string, Set<(event: Event) => void>>()
|
||||
const configLoads: string[] = []
|
||||
const result = {
|
||||
prompt: createMemoryComposerState(),
|
||||
directory,
|
||||
state,
|
||||
set,
|
||||
setPreferences,
|
||||
preferences: { store: preferences, set: setPreferences, ready: () => true, recent: () => preferences.recent },
|
||||
data: {
|
||||
session: { get: (id: string) => state.sessions[id] },
|
||||
location: {
|
||||
agent: { list: () => state.agents },
|
||||
config: {
|
||||
list: () => (state.configLoaded ? [{ type: "document", info: { model: state.config } }] : undefined),
|
||||
sync: async () => {
|
||||
configLoads.push(state.connection)
|
||||
},
|
||||
},
|
||||
provider: { list: () => state.providers },
|
||||
model: { list: () => state.models },
|
||||
integration: { list: () => [] },
|
||||
},
|
||||
},
|
||||
sdk: {
|
||||
scope: ServerScope.local,
|
||||
connection: { status: () => state.connection },
|
||||
event: {
|
||||
on(type: string, handler: (event: Event) => void) {
|
||||
const handlers = events.get(type) ?? new Set()
|
||||
events.set(type, handlers)
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
},
|
||||
},
|
||||
emit(type: string, sessionID = "ses_a") {
|
||||
events.get(type)?.forEach((handler) => handler({ data: { sessionID } }))
|
||||
},
|
||||
configLoads,
|
||||
mount(draft = false) {
|
||||
active = result
|
||||
let local!: ReturnType<typeof useLocal>
|
||||
let composer: ReturnType<typeof createComposerModelSelection> | undefined
|
||||
const dispose = createRoot((dispose) => {
|
||||
createComponent(ModelsProvider, {
|
||||
directory,
|
||||
get children() {
|
||||
return createComponent(LocalProvider, {
|
||||
get children() {
|
||||
local = useLocal()
|
||||
if (draft) composer = createComposerModelSelection({ agent: local.agent.current })
|
||||
return null
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
return dispose
|
||||
})
|
||||
cleanups.push(dispose)
|
||||
return { local, composer, dispose }
|
||||
},
|
||||
}
|
||||
const target = Persist.serverWorkspace(ServerScope.local, directory, "model-selection")
|
||||
cleanups.push(() => localStorage.removeItem(`${target.storage}:${target.key}`))
|
||||
return result
|
||||
}
|
||||
|
||||
function selection(local: ReturnType<typeof useLocal>) {
|
||||
return {
|
||||
agent: local.agent.current()?.name,
|
||||
model: local.model.current()?.id,
|
||||
variant: local.model.variant.current(),
|
||||
}
|
||||
}
|
||||
|
||||
test("restores durable agents even when the agent selector is hidden", () => {
|
||||
const f = fixture({ session: durable("b", "high", "plan") })
|
||||
f.set("visible", false)
|
||||
const { local } = f.mount()
|
||||
expect(local.agent.visible()).toBe(false)
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
|
||||
})
|
||||
|
||||
test("waits for initial configuration and reloads it after reconnecting", () => {
|
||||
const f = fixture({ config: "provider/b" })
|
||||
f.set("configLoaded", false)
|
||||
const { local } = f.mount()
|
||||
expect(local.model.ready()).toBe(false)
|
||||
expect(local.model.current()).toBeUndefined()
|
||||
f.set("connection", "reconnecting")
|
||||
f.set("connection", "connected")
|
||||
expect(f.configLoads).toEqual(["connected", "reconnecting", "connected"])
|
||||
f.set("configLoaded", true)
|
||||
expect(local.model.ready()).toBe(true)
|
||||
expect(local.model.current()?.id).toBe("b")
|
||||
})
|
||||
|
||||
test("new-session promotion does not mask a command's durable overrides", () => {
|
||||
const f = fixture({ session: durable("a", "low") })
|
||||
const { local } = f.mount()
|
||||
local.session.promote(f.directory, "ses_a", { agent: "build", model: key("a"), variant: "low" })
|
||||
f.set("sessions", "ses_a", durable("b", "high", "plan"))
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
|
||||
})
|
||||
|
||||
test("new-session drafts remember each agent's model and hand off inactive choices", () => {
|
||||
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
|
||||
f.set("route", "id", undefined)
|
||||
const { local, composer } = f.mount(true)
|
||||
if (!composer) throw new Error("missing draft composer")
|
||||
composer.set(key("c"))
|
||||
composer.variant.set("high")
|
||||
local.agent.set("plan")
|
||||
expect(composer.current()?.id).toBe("b")
|
||||
expect(composer.variant.current()).toBeUndefined()
|
||||
local.agent.set("build")
|
||||
expect(composer.current()?.id).toBe("c")
|
||||
expect(composer.variant.current()).toBe("high")
|
||||
local.agent.set("plan")
|
||||
const choices = composer.remembered()
|
||||
f.set("sessions", "ses_a", durable("b", undefined, "plan"))
|
||||
local.session.promote(f.directory, "ses_a", { agent: "plan", model: key("b"), choices })
|
||||
f.set("route", "id", "ses_a")
|
||||
local.agent.set("build")
|
||||
expect(local.model.current()?.id).toBe("c")
|
||||
})
|
||||
|
||||
test("session model picks snapshot the variant rather than following another session's preferences", () => {
|
||||
const f = fixture({ session: durable("a") })
|
||||
const { local } = f.mount()
|
||||
f.setPreferences("variant", "provider/b", "low")
|
||||
local.model.set(key("b"))
|
||||
f.setPreferences("variant", "provider/b", "high")
|
||||
expect(local.model.variant.current()).toBe("low")
|
||||
})
|
||||
|
||||
test("remembers distinct variants for agents using the same model, scoped to the session", () => {
|
||||
const f = fixture({ session: durable("a", "low"), agents: [agent("build", key("a")), agent("plan", key("a"))] })
|
||||
const { local } = f.mount()
|
||||
local.model.variant.set("high")
|
||||
local.agent.set("plan")
|
||||
local.model.variant.set("low")
|
||||
local.agent.set("build")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
|
||||
local.agent.set("plan")
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
|
||||
|
||||
f.set("sessions", "ses_b", durable("b", "high"))
|
||||
f.set("route", "id", "ses_b")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
|
||||
f.set("route", "id", "ses_a")
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
|
||||
})
|
||||
|
||||
test("restores each agent's model and variant after provider remount", () => {
|
||||
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
|
||||
const first = f.mount()
|
||||
first.local.model.variant.set("high")
|
||||
first.local.agent.set("plan")
|
||||
first.local.model.set(key("c"))
|
||||
first.local.model.variant.set("low")
|
||||
first.dispose()
|
||||
const { local } = f.mount()
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
|
||||
local.agent.set("build")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
|
||||
local.agent.set("plan")
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
|
||||
})
|
||||
|
||||
test("changing models drops the old variant even when both models support it", () => {
|
||||
const f = fixture({ session: durable("a", "high") })
|
||||
const { local } = f.mount()
|
||||
local.model.set(key("b"))
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
|
||||
f.setPreferences("variant", "provider/c", "low")
|
||||
local.model.set(key("c"))
|
||||
expect(local.model.variant.current()).toBe("low")
|
||||
})
|
||||
|
||||
test("restores durable selection ahead of agent, global, and saved variant defaults", () => {
|
||||
const f = fixture({
|
||||
session: durable("a", "low", "plan"),
|
||||
agents: [agent("build"), agent("plan", key("b"), "high")],
|
||||
config: { providerID: "provider", model: "c", variant: "high" },
|
||||
preferred: "high",
|
||||
})
|
||||
const { local } = f.mount()
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
|
||||
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
|
||||
})
|
||||
|
||||
test("uses historical message selection only when durable and local selection are absent", () => {
|
||||
const f = fixture()
|
||||
const { local } = f.mount()
|
||||
local.session.restore({ sessionID: "ses_b", agent: "plan", model: key("b") })
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: undefined })
|
||||
local.session.restore({ sessionID: "ses_a", agent: "plan", model: { ...key("b"), variant: "low" } })
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "low" })
|
||||
local.model.variant.set("high")
|
||||
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
|
||||
})
|
||||
|
||||
test("invalid durable models fall through agent, global, recent, and connected defaults", () => {
|
||||
const f = fixture({
|
||||
session: durable("removed", "high"),
|
||||
agents: [agent("build", key("b"), "low")],
|
||||
config: "provider/c",
|
||||
})
|
||||
const { local } = f.mount()
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
|
||||
f.set("agents", [agent("build", key("removed"))])
|
||||
expect(local.model.current()?.id).toBe("c")
|
||||
f.setPreferences("recent", [key("removed"), key("b")])
|
||||
f.set("config", "provider/removed")
|
||||
expect(local.model.current()?.id).toBe("b")
|
||||
f.setPreferences("recent", [key("removed")])
|
||||
expect(local.model.current()?.id).toBe("a")
|
||||
f.set("providers", [])
|
||||
expect(local.model.current()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("global and agent model/variant defaults react to configuration replacement", () => {
|
||||
const f = fixture({ config: { providerID: "provider", model: "a", variant: "low" } })
|
||||
const { local } = f.mount()
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
|
||||
f.set("config", { providerID: "provider", model: "b", variant: "high" })
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
|
||||
f.set("agents", [agent("build", key("a"), "low")])
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
|
||||
f.set("agents", [agent("build", key("c"), "high")])
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
|
||||
f.set("agents", [agent("build")])
|
||||
f.set("config", "provider/b")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
|
||||
})
|
||||
|
||||
test("durable and explicitly selected Default override a saved variant preference", () => {
|
||||
const f = fixture({
|
||||
session: durable("a"),
|
||||
preferred: "high",
|
||||
config: { providerID: "provider", model: "a", variant: "low" },
|
||||
})
|
||||
const { local } = f.mount()
|
||||
expect(local.model.variant.current()).toBeUndefined()
|
||||
local.model.variant.set(undefined)
|
||||
expect(f.preferences.store.variant["provider/a"]).toBe("default")
|
||||
f.set("route", "id", "ses_b")
|
||||
expect(local.model.variant.current()).toBeUndefined()
|
||||
f.set("route", "id", "ses_a")
|
||||
f.setPreferences("variant", "provider/a", "high")
|
||||
expect(local.model.variant.current()).toBeUndefined()
|
||||
f.set("route", "id", "ses_b")
|
||||
expect(local.model.variant.current()).toBe("high")
|
||||
f.set("route", "id", "ses_a")
|
||||
expect(local.model.variant.current()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("waits for both commit acknowledgments, then releases only the matching draft", () => {
|
||||
const f = fixture({ session: durable("b", "low") })
|
||||
const { local } = f.mount()
|
||||
local.agent.set("plan")
|
||||
local.model.set(key("a"))
|
||||
local.model.variant.set("high")
|
||||
local.model.trackSessionCommit("ses_a", { agent: "plan", model: key("a"), variant: "high" })
|
||||
f.set("sessions", "ses_a", durable("b", "low", "plan"))
|
||||
f.emit("session.agent.selected")
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
|
||||
f.set("sessions", "ses_a", durable("a", "high", "plan"))
|
||||
f.emit("session.model.selected")
|
||||
f.set("sessions", "ses_a", durable("c", "low", "plan"))
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
|
||||
})
|
||||
|
||||
test.each(["a", "b"])("a delayed commit preserves the newer %s/low selection", (model) => {
|
||||
const f = fixture()
|
||||
const { local } = f.mount()
|
||||
local.model.set(key("a"))
|
||||
local.model.variant.set("high")
|
||||
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
|
||||
local.model.set(key(model))
|
||||
local.model.variant.set("low")
|
||||
f.set("sessions", "ses_a", durable("a", "high"))
|
||||
f.emit("session.model.selected")
|
||||
expect(selection(local)).toEqual({ agent: "build", model, variant: "low" })
|
||||
})
|
||||
|
||||
test("a delayed commit preserves a newer agent even when model and variant match", () => {
|
||||
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("a"))] })
|
||||
const { local } = f.mount()
|
||||
local.model.variant.set("high")
|
||||
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
|
||||
local.agent.set("plan")
|
||||
f.set("sessions", "ses_a", durable("a", "high"))
|
||||
f.emit("session.model.selected")
|
||||
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
|
||||
})
|
||||
|
||||
test("a commit received while its session is inactive does not discard its local selection", () => {
|
||||
const f = fixture()
|
||||
const { local } = f.mount()
|
||||
local.model.set(key("a"))
|
||||
local.model.variant.set("high")
|
||||
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
|
||||
f.set("sessions", "ses_b", durable("b", "low"))
|
||||
f.set("route", "id", "ses_b")
|
||||
f.set("sessions", "ses_a", durable("a", "high"))
|
||||
f.emit("session.model.selected")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
|
||||
f.set("sessions", "ses_a", durable("c", "low"))
|
||||
f.set("route", "id", "ses_a")
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
|
||||
})
|
||||
|
||||
test("cancelling a failed commit retains the draft and does not cancel a newer commit", () => {
|
||||
const f = fixture()
|
||||
const { local } = f.mount()
|
||||
local.model.set(key("a"))
|
||||
local.model.variant.set("high")
|
||||
const cancel = local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
|
||||
cancel()
|
||||
f.set("sessions", "ses_a", durable("a", "high"))
|
||||
f.emit("session.model.selected")
|
||||
f.set("sessions", "ses_a", durable("c", "low"))
|
||||
expect(local.model.current()?.id).toBe("a")
|
||||
|
||||
local.model.set(key("b"))
|
||||
local.model.variant.set("low")
|
||||
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("b"), variant: "low" })
|
||||
cancel()
|
||||
f.set("sessions", "ses_a", durable("b", "low"))
|
||||
f.emit("session.model.selected")
|
||||
f.set("sessions", "ses_a", durable("c", "high"))
|
||||
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
|
||||
})
|
||||
|
||||
test.each([1, -1] as const)("cycles %p from outside recents to the correct end and wraps", (direction) => {
|
||||
const f = fixture({ session: durable("a") })
|
||||
f.setPreferences("recent", [key("removed"), key("b"), key("c")])
|
||||
const { local } = f.mount()
|
||||
local.model.cycle(direction)
|
||||
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
|
||||
local.model.cycle(direction)
|
||||
expect(local.model.current()?.id).toBe(direction === 1 ? "c" : "b")
|
||||
local.model.cycle(direction)
|
||||
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
test("LocalProvider selection integration", async () => {
|
||||
// Isolate provider module substitutions from the rest of the browser suite.
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
"test",
|
||||
"--conditions=browser",
|
||||
"--preload",
|
||||
"./happydom.ts",
|
||||
"./test-browser/fixtures/model-selection.ts",
|
||||
],
|
||||
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [status, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
expect(status, stdout + stderr).toBe(0)
|
||||
}, 30_000)
|
||||
@@ -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}`
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode/schema/prompt"
|
||||
import type { Skill } from "@opencode/schema/skill"
|
||||
import type { Event } from "@opencode/schema/event"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
import type { Schema } from "effect"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
@@ -37,6 +36,7 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
|
||||
import type { Reference } from "@opencode/schema/reference"
|
||||
import type { Worktree } from "@opencode/schema/worktree"
|
||||
import type { Vcs } from "@opencode/schema/vcs"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode/schema/websearch"
|
||||
import type { Config } from "@opencode/schema/config"
|
||||
|
||||
@@ -360,15 +360,6 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
|
||||
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID?: SessionMessage.ID | undefined
|
||||
readonly to?: SessionMessage.ID | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
|
||||
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: Session.ID }
|
||||
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (
|
||||
@@ -1142,7 +1133,6 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly diff: SessionDiffOperation<E>
|
||||
readonly inbox: {
|
||||
readonly list: SessionInboxListOperation<E>
|
||||
readonly cancel: SessionInboxCancelOperation<E>
|
||||
|
||||
@@ -68,8 +68,6 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -596,17 +594,6 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
|
||||
preserveEffect<SessionDiffOutput>()(
|
||||
raw["session.diff"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
|
||||
preserveEffect<SessionInboxListOutput>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
@@ -757,7 +744,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
commit: EndpointSessionRevertCommit(raw),
|
||||
},
|
||||
context: EndpointSessionContext(raw),
|
||||
diff: EndpointSessionDiff(raw),
|
||||
inbox: {
|
||||
list: EndpointSessionInboxList(raw),
|
||||
cancel: EndpointSessionInboxCancel(raw),
|
||||
|
||||
@@ -62,8 +62,6 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -846,18 +844,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionDiffOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
inbox: {
|
||||
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionInboxListOutput }>(
|
||||
|
||||
@@ -147,14 +147,6 @@ export type SessionProviderContextProvenance = {
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionMessageIdle = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
type: "idle"
|
||||
outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -2185,7 +2177,6 @@ export type SessionMessageInfo =
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
| SessionMessageIdle
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
@@ -3131,13 +3122,6 @@ export type SessionImportInput = {
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["info"]
|
||||
@@ -3429,13 +3413,6 @@ export type SessionImportInput = {
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["messages"]
|
||||
@@ -3727,13 +3704,6 @@ export type SessionImportInput = {
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["location"]
|
||||
@@ -4223,27 +4193,6 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
|
||||
|
||||
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly messageID?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["messageID"]
|
||||
readonly to?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["to"]
|
||||
readonly context?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
|
||||
|
||||
@@ -1032,18 +1032,6 @@ export function createData(config: CreateDataInput) {
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
|
||||
// Mirror the projected idle marker so turn boundaries match before the next message read.
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome:
|
||||
event.type === "session.execution.succeeded"
|
||||
? "succeeded"
|
||||
: event.type === "session.execution.failed"
|
||||
? "failed"
|
||||
: "interrupted",
|
||||
time: { created: event.created },
|
||||
})
|
||||
// An event can overtake the first read; queue a revalidation when that read is still active.
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
|
||||
+64
-75
@@ -9,7 +9,6 @@ import { AppProcess } from "@opencode/util/process"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { File } from "./file.js"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { VcsPatch } from "./vcs/patch.js"
|
||||
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
@@ -309,7 +308,7 @@ const layer = Layer.effect(
|
||||
operationName: OperationError["operation"],
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
|
||||
options?: { stdin?: string; env?: Record<string, string> },
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
@@ -318,7 +317,7 @@ const layer = Layer.effect(
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
|
||||
{ stdin: options?.stdin },
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
@@ -332,8 +331,7 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const text = result.stdout.toString("utf8")
|
||||
if (result.exitCode === 0)
|
||||
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
|
||||
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
|
||||
return yield* new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
@@ -387,7 +385,9 @@ const layer = Layer.effect(
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) {
|
||||
const list = (args: string[]) =>
|
||||
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
|
||||
repositoryOperation("refresh", input.repository, args).pipe(
|
||||
Effect.map((result) => result.text.split("\0").filter(Boolean)),
|
||||
)
|
||||
const [tracked, untracked] = yield* Effect.all(
|
||||
[
|
||||
list(["diff-files", "--name-only", "-z", "--", input.scope]),
|
||||
@@ -464,7 +464,13 @@ const layer = Layer.effect(
|
||||
directory: input.repository.worktree,
|
||||
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
|
||||
})
|
||||
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
|
||||
return new Set(
|
||||
result.stdout
|
||||
.toString("utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file)),
|
||||
)
|
||||
})
|
||||
|
||||
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
|
||||
@@ -493,23 +499,19 @@ const layer = Layer.effect(
|
||||
to: TreeID
|
||||
}) {
|
||||
// Undo needs both paths of a rename, not only its destination.
|
||||
return nuls(
|
||||
(yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text,
|
||||
).map((file) => RelativePath.make(file))
|
||||
return (yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file))
|
||||
})
|
||||
|
||||
/**
|
||||
* Three batched invocations over the tree pair instead of three per file. An
|
||||
* explicit empty selection diffs nothing; an absent one diffs every changed path.
|
||||
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
|
||||
*/
|
||||
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
@@ -517,57 +519,49 @@ const layer = Layer.effect(
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) {
|
||||
if (input.paths?.length === 0) return []
|
||||
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
|
||||
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
|
||||
const [names, numbers, patch] = yield* Effect.all(
|
||||
[
|
||||
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
|
||||
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
|
||||
repositoryOperation(
|
||||
const paths = input.paths ?? (yield* treeFiles(input))
|
||||
return yield* Effect.forEach(paths, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const statusText = (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
input.repository,
|
||||
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
|
||||
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
|
||||
),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
const statuses = nuls(names.text)
|
||||
const files = statuses.flatMap((code, index) => {
|
||||
const file = statuses[index + 1]
|
||||
if (index % 2 !== 0 || !file) return []
|
||||
return [
|
||||
{
|
||||
file: RelativePath.make(file),
|
||||
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
|
||||
} as const,
|
||||
]
|
||||
})
|
||||
const stats = new Map(
|
||||
nuls(numbers.text).flatMap((line) => {
|
||||
const [additions, deletions, ...file] = line.split("\t")
|
||||
if (!additions || !deletions || file.length === 0) return []
|
||||
return [
|
||||
[
|
||||
file.join("\t"),
|
||||
additions === "-" || deletions === "-"
|
||||
? { binary: true, additions: 0, deletions: 0 }
|
||||
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
|
||||
] as const,
|
||||
]
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.trim()
|
||||
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
|
||||
const stats = (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.split("\t")
|
||||
const binary = stats[0] === "-" || stats[1] === "-"
|
||||
const patch = binary
|
||||
? ""
|
||||
: (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
`--unified=${input.context ?? 3}`,
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
patch,
|
||||
} satisfies File.Diff
|
||||
}),
|
||||
)
|
||||
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
|
||||
return files.map((entry) => {
|
||||
const stat = stats.get(entry.file)
|
||||
return {
|
||||
...entry,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
|
||||
} satisfies File.Diff
|
||||
})
|
||||
})
|
||||
|
||||
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
@@ -739,11 +733,6 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Split NUL-terminated git output into its records. */
|
||||
function nuls(text: string) {
|
||||
return text.split("\0").filter(Boolean)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
|
||||
@@ -57,11 +57,8 @@ import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { llmClient } from "./effect/app-node-platform.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { Session } from "./session/session.js"
|
||||
import { SessionDiff, TurnRangeError } from "./session/diff.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
@@ -116,7 +113,6 @@ export {
|
||||
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
|
||||
|
||||
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
|
||||
export { TurnRangeError }
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
@@ -146,13 +142,6 @@ export interface Interface {
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
|
||||
readonly diff: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
|
||||
/**
|
||||
* Durable admitted session work not yet visible in projected history,
|
||||
* ordered by admission. Includes unpromoted user and synthetic inputs and
|
||||
@@ -241,7 +230,6 @@ const layer = Layer.effect(
|
||||
const moves = yield* SessionMove.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const sessions = yield* Session.make()
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
@@ -374,17 +362,6 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
diff: Effect.fn("Session.diff")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const active = yield* execution.isActive(input.sessionID)
|
||||
return yield* SessionDiff.turn(db, locations, {
|
||||
session,
|
||||
active,
|
||||
messageID: input.messageID,
|
||||
to: input.to,
|
||||
context: input.context,
|
||||
})
|
||||
}),
|
||||
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
|
||||
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
|
||||
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
|
||||
@@ -473,7 +450,6 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionInbox.node,
|
||||
SessionMove.node,
|
||||
SessionProjector.node,
|
||||
LocationServiceMap.node,
|
||||
FSUtil.node,
|
||||
App.node,
|
||||
],
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
export * as SessionDiff from "./diff.js"
|
||||
|
||||
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Schema } from "effect"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { Database } from "../database/database.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
|
||||
import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
|
||||
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
field: Schema.Literals(["messageID", "to"]),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
|
||||
|
||||
/**
|
||||
* Diff the files changed by the turn containing a user message. A turn runs from
|
||||
* the first prompt after the Session was last idle until the next idle marker, so
|
||||
* prompts steered in while it was busy belong to the same turn; `to` extends the
|
||||
* range through the turn containing a later user message. Compares the range's
|
||||
* first recorded start snapshot with its last recorded end snapshot; only a step
|
||||
* still running in the active Session compares against the working copy. Like VCS
|
||||
* diffs, an omitted `context` yields full-file patches.
|
||||
*
|
||||
* A Session without any idle marker predates them, so its prompts span until the
|
||||
* next user message instead.
|
||||
*
|
||||
* Snapshot trees live in the repository of the Location that captured them, so a
|
||||
* range spanning a location switch is rejected rather than diffed wrongly.
|
||||
*/
|
||||
export const turn = Effect.fn("SessionDiff.turn")(function* (
|
||||
db: Database.Interface["db"],
|
||||
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
|
||||
input: {
|
||||
readonly session: SessionSchema.Info
|
||||
/** The process is currently executing this Session. */
|
||||
readonly active: boolean
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
},
|
||||
) {
|
||||
const sessionID = input.session.id
|
||||
const rows = yield* db
|
||||
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
or(
|
||||
inArray(SessionMessageTable.type, ["user", "idle"]),
|
||||
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
|
||||
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const users = rows.filter((row) => row.type === "user")
|
||||
const markers = rows.filter((row) => row.type === "idle")
|
||||
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
|
||||
const row = rows.find((row) => row.id === id)
|
||||
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
|
||||
if (row.type !== "user")
|
||||
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
|
||||
return row
|
||||
})
|
||||
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
|
||||
if (!anchor) return []
|
||||
const last = input.to ? yield* resolve("to", input.to) : anchor
|
||||
if (last.seq < anchor.seq)
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
|
||||
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
|
||||
const legacy = markers.length === 0
|
||||
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
|
||||
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
|
||||
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
|
||||
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
|
||||
const steps = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
|
||||
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
|
||||
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, start),
|
||||
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const first = steps[0]
|
||||
const final = steps[steps.length - 1]
|
||||
const from = steps.find((step) => step.start)?.start
|
||||
if (!first || !final || !from) return []
|
||||
const switches = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
|
||||
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
|
||||
const before = switches.findLast((row) => row.seq < first.seq)?.location
|
||||
const after = switches.find((row) => row.seq > first.seq)?.previous
|
||||
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
|
||||
const recorded = steps.findLast((step) => step.end)?.end
|
||||
return yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const running = input.active && final.completed === null
|
||||
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
|
||||
if (!to) return []
|
||||
return yield* snapshot.diff({
|
||||
from: Snapshot.ID.make(from),
|
||||
to: Snapshot.ID.make(to),
|
||||
context: input.context ?? PATCH_CONTEXT_LINES,
|
||||
})
|
||||
}).pipe(Effect.provide(locations.get(location)))
|
||||
})
|
||||
@@ -60,21 +60,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
})
|
||||
|
||||
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
|
||||
clearCurrentRetry.pipe(
|
||||
Effect.andThen(
|
||||
adapter.appendMessage(
|
||||
SessionMessage.Idle.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome,
|
||||
metadata: event.metadata,
|
||||
time: { created },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const project = pipe(
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
@@ -138,11 +123,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.inbox.cancelled": () => Effect.void,
|
||||
"session.inbox.delivery.changed": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => idle("succeeded"),
|
||||
"session.execution.failed": () => idle("failed"),
|
||||
// Shutdown keeps the execution claim and the resumed drain continues the turn.
|
||||
"session.execution.interrupted": (event) =>
|
||||
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
"session.execution.interrupted": () => clearCurrentRetry,
|
||||
"session.instructions.updated": (event) => {
|
||||
if (event.data.text === undefined) return Effect.void
|
||||
return adapter.appendMessage(
|
||||
|
||||
@@ -226,7 +226,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
case "idle":
|
||||
return []
|
||||
case "location-switched":
|
||||
return [
|
||||
|
||||
@@ -131,55 +131,38 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
source: repo.source,
|
||||
const comparison = {
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
})
|
||||
|
||||
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
|
||||
const ignored = Effect.fnUntraced(function* (
|
||||
operation: "files" | "diff",
|
||||
source: Git.Repository,
|
||||
paths: readonly RelativePath[],
|
||||
) {
|
||||
return yield* git.index
|
||||
.ignored({ repository: source, paths })
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const compared = yield* comparison("files", input)
|
||||
const changed = yield* git.tree
|
||||
.files({ repository: compared.repository, from: compared.from, to: compared.to })
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
const skipped = yield* ignored("files", compared.source, changed)
|
||||
return changed.filter((file) => !skipped.has(file))
|
||||
const comparison = yield* compare("files", input)
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
if (input.paths?.length === 0) return []
|
||||
const compared = yield* comparison("diff", input)
|
||||
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
|
||||
const diffs = yield* git.tree
|
||||
const comparison = yield* compare("diff", input)
|
||||
return yield* git.tree
|
||||
.diff({
|
||||
repository: compared.repository,
|
||||
from: compared.from,
|
||||
to: compared.to,
|
||||
...comparison.input,
|
||||
context: input.context,
|
||||
paths: input.paths,
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
const skipped = yield* ignored(
|
||||
"diff",
|
||||
compared.source,
|
||||
diffs.map((file) => RelativePath.make(file.file)),
|
||||
)
|
||||
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Git } from "@opencode/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
|
||||
import { VcsPatch } from "@opencode/core/vcs/patch"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -197,42 +196,6 @@ describe("Git trees", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const git = yield* Git.Service
|
||||
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!repository) throw new Error("Repository not found")
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
|
||||
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
|
||||
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
|
||||
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
|
||||
})
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
|
||||
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
|
||||
["a-caf\u00e9.txt", "added", 1, 0],
|
||||
["a-small.txt", "added", 1, 0],
|
||||
["b-large.txt", "added", lines, 0],
|
||||
["c-binary.bin", "added", 0, 0],
|
||||
])
|
||||
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
|
||||
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
|
||||
expect(diffs[1]?.patch).toContain("+small\n")
|
||||
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
|
||||
expect(diffs[3]?.patch).toBe("")
|
||||
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionDiff } from "@opencode/core/session/diff"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionInbox } from "@opencode/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { SessionProjector } from "@opencode/core/session/projector"
|
||||
import { Snapshot } from "@opencode/core/snapshot"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
|
||||
),
|
||||
)
|
||||
|
||||
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
|
||||
file.file,
|
||||
file.status,
|
||||
file.additions,
|
||||
file.deletions,
|
||||
]
|
||||
|
||||
describe("Session.diff", () => {
|
||||
it.live(
|
||||
"diffs the busy period containing a user message and ranges across later turns",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await write("first.txt", "first\n")()
|
||||
await write("second.txt", "second\n")()
|
||||
await write("manual.txt", "manual\n")()
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
|
||||
sessions
|
||||
.diff({ sessionID: created.id, context: 0, ...input })
|
||||
.pipe(Effect.map((files) => files.map(summarize)))
|
||||
expect(yield* diff()).toEqual([])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const usage = {
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
const prompt = Effect.fn(function* (text: string) {
|
||||
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
return admitted.id
|
||||
})
|
||||
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Start snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* Effect.promise(edit)
|
||||
if (end === "running") return assistantMessageID
|
||||
const after = end === "recorded" ? yield* snapshot.capture() : undefined
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
...usage,
|
||||
snapshot: after,
|
||||
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
|
||||
})
|
||||
return assistantMessageID
|
||||
})
|
||||
|
||||
const idle = (outcome: "succeeded" | "failed") =>
|
||||
outcome === "succeeded"
|
||||
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
: bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
|
||||
// Before any idle marker exists, a prompt's turn ends at the next prompt.
|
||||
const first = yield* prompt("Edit the first file")
|
||||
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
|
||||
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
|
||||
yield* Effect.promise(write("manual.txt", "manual edited\n"))
|
||||
const second = yield* prompt("Edit the second file")
|
||||
yield* step(write("second.txt", "second edited\n"), "recorded")
|
||||
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
|
||||
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
|
||||
yield* idle("succeeded")
|
||||
const third = yield* prompt("Add a third file")
|
||||
yield* step(write("third.txt", "third\n"), "recorded")
|
||||
const steer = yield* prompt("Also add a fourth file")
|
||||
yield* step(write("fourth.txt", "fourth\n"), "recorded")
|
||||
yield* idle("failed")
|
||||
const busy = [
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["third.txt", "added", 1, 0],
|
||||
]
|
||||
expect(yield* diff()).toEqual(busy)
|
||||
expect(yield* diff({ messageID: steer })).toEqual(busy)
|
||||
expect(yield* diff({ messageID: second })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
])
|
||||
expect(yield* diff({ messageID: first, to: third })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
|
||||
expect(full[0]?.patch).toContain("-first\n+first edited\n")
|
||||
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "to",
|
||||
})
|
||||
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "messageID",
|
||||
})
|
||||
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
|
||||
// A completed step without an end snapshot falls back to the last recorded end.
|
||||
yield* prompt("Edit both files again")
|
||||
yield* step(write("first.txt", "first edited twice\n"), "recorded")
|
||||
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
|
||||
yield* idle("succeeded")
|
||||
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Only a step still running in the active session compares against the working copy.
|
||||
yield* prompt("Delete the manual file")
|
||||
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
|
||||
expect(yield* diff()).toEqual([])
|
||||
const session = yield* sessions.get(created.id)
|
||||
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
|
||||
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
|
||||
|
||||
// Reverting removes later history, markers included; a fork keeps the copied turns.
|
||||
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
|
||||
yield* sessions.revert.commit(created.id)
|
||||
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
|
||||
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
|
||||
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}),
|
||||
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
@@ -561,9 +561,7 @@ describe("SessionRestart background recovery", () => {
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
// Recovery ends a busy period, so an idle marker follows the notification.
|
||||
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
|
||||
expect(messages).toMatchObject([
|
||||
expect(yield* sessions.messages({ sessionID })).toMatchObject([
|
||||
{
|
||||
id: background.notificationID,
|
||||
type: "synthetic",
|
||||
@@ -571,6 +569,7 @@ describe("SessionRestart background recovery", () => {
|
||||
metadata: { state: "completed" },
|
||||
},
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode/app": "workspace:*",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createCdp, abortError, waitFor } from "./browser/cdp"
|
||||
import { createBrowserFiles } from "./browser/files"
|
||||
import { createDiagnostics } from "./browser/diagnostics"
|
||||
import { createProfiling } from "./browser/profiling"
|
||||
import { createCornerImages } from "./browser/corners"
|
||||
import { createCornerImages } from "./native/corners"
|
||||
import type { BrowserNetwork } from "./browser/network"
|
||||
import { destinationOrigin, normalizeURL } from "./browser/policy"
|
||||
|
||||
|
||||
@@ -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,3 @@
|
||||
import type { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
|
||||
export const mainExtensions: readonly MainPlugin.Entry[] = []
|
||||
@@ -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,4 +1,4 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { BrowserWindow, net } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { EventRpcs } from "../../shared/ipc-rpc"
|
||||
import { createBrowserPane } from "../browser-pane"
|
||||
@@ -7,16 +7,99 @@ 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 storage = yield* DesktopStorage.Service
|
||||
const browser = createBrowserPane()
|
||||
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)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => extensions.dispose()))
|
||||
const stop = Effect.promise(() => browser.dispose())
|
||||
const remove = yield* shutdown.add(stop)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
|
||||
return EventRpcs.of({
|
||||
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("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),
|
||||
BrowserPane: ({ request }, context) =>
|
||||
Effect.tryPromise(async () => {
|
||||
|
||||
+1
-1
@@ -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))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user