Compare commits

...
60 changed files with 2257 additions and 72 deletions
+3
View File
@@ -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,6 +599,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"electron": "42.10.1",
"solid-js": "catalog:",
"typescript": "catalog:",
},
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="UTF-8" /><style>html,body,#root{height:100%;margin:0}#root{display:flex;flex-direction:column}</style></head><body><div id="root"></div><script type="module" src="./fixture.tsx"></script></body></html>
+81
View File
@@ -0,0 +1,81 @@
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider } from "../../src/runtime/platform/platform"
import { createWebPlatform } from "../../src/runtime/platform/web"
import { ServerConnection } from "../../src/runtime/server/registry"
import { Plugin } from "@opencode/plugin/desktop"
import { Panel } from "@opencode/plugin/desktop/solid"
import { Button } from "@opencode/ui/button"
import { TextInput } from "@opencode/ui/text-input"
import { Stack, Text } from "@opencode/ui/layout"
import { createMemoryHistory, MemoryRouter } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { base64Encode } from "@opencode/util/encode"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
const plugin = Plugin.define({
id: "example.inspector",
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,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:example.inspector: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")
})
+1
View File
@@ -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:*",
+56
View File
@@ -0,0 +1,56 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import type { RegisteredPanel } from "./provider"
/** Grouped content stays mounted while any declaration in its group is available. */
export function ExtensionPanelContent(props: { panels: readonly RegisteredPanel[]; active: string | undefined }) {
const selected = createMemo(() => props.panels.find((panel) => panel.key === props.active))
const groups = createMemo(() => Array.from(new Set(props.panels.filter((panel) => panel.props.group).map(groupKey))))
const single = createMemo(() => {
const panel = selected()
return panel && !panel.props.group ? panel : undefined
})
return (
<>
<For each={groups()}>
{(key) => {
const [mounted, setMounted] = createSignal(false)
const active = () => !!selected() && groupKey(selected()!) === key
const declaration = props.panels.find((panel) => groupKey(panel) === key)!
createEffect(() => {
if (active()) setMounted(true)
})
return (
<Show when={mounted()}>
<div
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden flex flex-col"
classList={{ hidden: !active() }}
inert={!active()}
aria-label={selected()?.props.title}
>
{declaration.render()}
</div>
</Show>
)
}}
</For>
<Show when={single()} keyed>
{(panel) => (
<div
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden flex flex-col"
aria-label={panel.props.title}
>
{panel.render()}
</div>
)}
</Show>
</>
)
}
function groupKey(panel: RegisteredPanel) {
return `${panel.session.key}/${panel.plugin}/${panel.props.group ?? panel.key}`
}
+2
View File
@@ -0,0 +1,2 @@
export const extensionTabKey = (plugin: string, id: string) => `extension:${plugin}:${id}`
export const isExtensionTab = (id: string | undefined) => !!id?.startsWith("extension:")
@@ -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" />
}
+344
View File
@@ -0,0 +1,344 @@
import {
createContext,
createEffect,
createMemo,
createRoot,
getOwner,
onCleanup,
runWithOwner,
useContext,
type ParentProps,
type JSX,
} from "solid-js"
import { createStore, produce, type Store, type SetStoreFunction } from "solid-js/store"
import { Schema } from "effect"
import type { Context, PanelProps, SessionContext, SlotClaim } from "@opencode/plugin/desktop"
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 } from "@/runtime/persistence/storage"
import { extensionTabKey } from "./keys"
import { showToast } from "@/shell/notifications/toast"
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
export type Contribution = Claim<{ context: Context; when?: () => boolean; render: SlotClaim["render"] }>
export type RegisteredPanel = {
key: string
plugin: string
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 owner = getOwner()
const [state, setState] = createStore({
claims: [] as Contribution[],
panels: [] as RegisteredPanel[],
sessions: [] as SessionContext[],
services: {} as Record<string, SessionServices | 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 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",
]),
claims: state.claims.filter((claim) => claim.render.when?.() ?? true),
}),
)
createEffect(() => {
const all = global.servers.list()
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 = {
id: tab.server,
local: ServerConnection.local(connection),
get client() {
return data.sdk.api
},
data: data.data,
get compatible() {
return !global.servers.health[tab.server]?.incompatible
},
}
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 activate = (definition: NonNullable<typeof platform.extensionPlugins>[number]) =>
createRoot((dispose) => {
const lifecycle = createLifecycle()
const id = definition.id
let nextClaim = 0
const context: Context = {
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 },
storage: {
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}/${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,
onSelect: () => {
void command.run()
},
})),
)
return dispose
}),
)
},
dispatch: (commandID) => commands.trigger(`${id}.${commandID}`),
},
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}/${nextClaim++}`,
plugin: id,
placement: { kind, target: claim[kind]! },
render: { context, 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 {
platform.extensions?.release(id)
setState("panels", (items) => items.filter((panel) => panel.plugin !== id))
}
})
const cleanup = definition.setup(context)
if (cleanup) lifecycle.own(cleanup)
return dispose
})
createEffect(() => {
const definitions = platform.extensionPlugins ?? []
Array.from(instances).forEach(([id, instance]) => {
if (definitions.find((definition) => definition.id === id) === instance.definition) return
instance.dispose()
instances.delete(id)
})
definitions.forEach((definition) => {
if (instances.has(definition.id)) return
instances.set(definition.id, { definition, dispose: activate(definition) })
})
})
onCleanup(() => instances.forEach((instance) => instance.dispose()))
return {
state,
resolved,
current,
transport: platform.extensions,
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),
)
},
}
}
+136
View File
@@ -0,0 +1,136 @@
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"
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 {
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>
+101
View File
@@ -0,0 +1,101 @@
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) => {
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,
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>
</>
)
}
+66
View File
@@ -0,0 +1,66 @@
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: {
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 }
},
}
}
@@ -7,6 +7,8 @@ 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"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -122,6 +124,8 @@ type PlatformBase = {
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
extensions?: DesktopExtension.Transport
extensionPlugins?: readonly Plugin.Definition[]
}
export type Platform = PlatformBase &
@@ -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}
+22 -20
View File
@@ -13,7 +13,7 @@ export function SortableTab(props: {
tab: string
index: number
temporary?: boolean
onTabClose: (tab: string) => void
onTabClose?: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
/** Replaces the file visual for non-file tabs such as the browser. */
children?: JSX.Element
@@ -46,27 +46,29 @@ export function SortableTab(props: {
value={props.tab}
id={props.id}
aria-controls={props.ariaControls}
onMiddleClick={() => props.onTabClose(props.tab)}
onMiddleClick={props.onTabClose ? () => props.onTabClose?.(props.tab) : undefined}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
closeButton={
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
<Show when={props.onTabClose}>
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose?.(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
</Show>
}
hideCloseButton
>
+13 -5
View File
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
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
@@ -62,14 +68,13 @@ export const createSessionTabs = (input: TabsInput) => {
emptyTabs,
{ equals: same },
)
const openedTabs = createMemo(
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
emptyTabs,
{ equals: same },
)
const openedTabs = createMemo(() => Array.from(new Set(input.tabs().all().filter((tab) => !!input.pathFromTab(tab)).map(input.normalizeTab))), emptyTabs, {
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 +82,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 +95,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
+16
View File
@@ -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: () => {
+3
View File
@@ -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()}
+18 -1
View File
@@ -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>
@@ -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")}
+1 -20
View File
@@ -1,20 +1 @@
import type { Component, JSX } from "solid-js"
import "@/settings/settings.css"
export interface SettingsRowProps {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
export const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div data-component="settings-row">
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title">{props.title}</div>
<div data-slot="settings-row-description">{props.description}</div>
</div>
<div data-slot="settings-row-control">{props.children}</div>
</div>
)
}
export { SettingsRow } from "@opencode/ui/layout"
+7 -1
View File
@@ -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>
+1 -1
View File
@@ -52,7 +52,7 @@ export type HomeProjectSelection = typeof layoutSchema.Type.home.selection
export type ReviewDiffStyle = typeof layoutSchema.Type.review.diffStyle
export type ReviewChangeMode = NonNullable<(typeof layoutSchema.Type.sessionView)[string]["reviewMode"]>
export type ReviewPanelSource = "context-button" | "other"
export type ReviewPanelSource = string
export type TabPanes = {
terminalOpened: Accessor<boolean>
setTerminalOpened(opened: boolean): void
@@ -1,6 +1,7 @@
import { createContext, onCleanup, onMount, Show, useContext, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { ExtensionSlot } from "@/extensions/slot"
type Registration = {
active: () => boolean
@@ -45,7 +46,9 @@ export function TitlebarRightMount(props: { vertical?: boolean }) {
ref={slot.setMount}
id="opencode-titlebar-right"
class={props.vertical ? "flex w-full shrink-0 flex-col" : "flex shrink-0 items-center justify-end gap-0"}
/>
>
<ExtensionSlot path="titlebar.actions" />
</div>
)
}
+1 -1
View File
@@ -257,7 +257,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return state
}
function withPath(input: string, action: (file: string) => unknown) {
function withPath<Value>(input: string, action: (file: string) => Value): Value {
return action(path.normalize(input))
}
const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
+1
View File
@@ -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,3 @@
import type { MainPlugin } from "@opencode/plugin/desktop/main"
export const mainExtensions: readonly MainPlugin.Entry[] = []
@@ -0,0 +1,172 @@
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,
) {
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)
},
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) => {
const instance = instances.get(extensionID)
instances.delete(extensionID)
try {
instance?.lifecycle.dispose()
} finally {
surfaces.release(extensionID)
}
}
const instance = (id: string) => {
const previous = instances.get(id)
if (previous) return previous
const definition = plugins.find((plugin) => plugin.id === id)
if (!definition) throw new CallError("rpc.unavailable", `Desktop extension unavailable: ${id}`)
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 = instance(input.extensionID)
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,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)
},
}
}
@@ -7,16 +7,38 @@ 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"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const shutdown = yield* Shutdown.Service
const browser = createBrowserPane()
const extensions = createMainExtensionHost(mainExtensions, (win, event) =>
emitIpcEvent(win.webContents, new ExtensionEvent({ event })),
)
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({
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,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))
@@ -4,6 +4,7 @@ import type { DesktopNativeBundle } from "@opencode/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode/app/updater"
import type { WslServersPlatform } from "@opencode/app/wsl/types"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import type {
ClipboardImage,
DirectoryPickerOptions,
@@ -23,6 +24,7 @@ export type UpdaterAPI = {
}
export type ElectronAPI = {
extensions: DesktopExtension.Transport
awaitInitialization(): Promise<ServerReadyData>
reconnectService(): Promise<ServerReadyData>
browserPane: {
+22
View File
@@ -23,6 +23,28 @@ const updaterHandler = (state: UpdaterState) => {
}
export const api: ElectronAPI = {
extensions: {
call(input, signal) {
if (signal?.aborted) return Promise.reject(signal.reason)
return new Promise((resolve, reject) => {
const cancel = () => {
send("DesktopExtension", {
request: { type: "cancel", extensionID: input.extensionID, requestID: input.requestID },
})
reject(signal?.reason)
}
signal?.addEventListener("abort", cancel, { once: true })
void invoke("DesktopExtension", { request: { type: "call", call: input } })
.then(resolve, reject)
.finally(() => signal?.removeEventListener("abort", cancel))
})
},
onEvent: (callback) => listen("ExtensionEvent", ({ event }) => callback(event)),
surface: (extensionID, surfaceID, layout) =>
send("DesktopExtension", { request: { type: "surface", extensionID, surfaceID, layout } }),
configure: (servers) => send("DesktopExtension", { request: { type: "servers", servers } }),
release: (extensionID) => send("DesktopExtension", { request: { type: "release", extensionID } }),
},
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
browserPane: {
@@ -1,9 +1,4 @@
import {
ACCEPTED_FILE_EXTENSIONS,
ServerConnection,
type Platform,
type UpdaterPlatform,
} from "@opencode/app/desktop"
import { ACCEPTED_FILE_EXTENSIONS, ServerConnection, type Platform, type UpdaterPlatform } from "@opencode/app/desktop"
import type { ElectronAPI } from "../api-types"
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
import { windowFullscreen } from "../window/fullscreen"
@@ -29,6 +24,7 @@ export function createDesktopPlatform(
os,
version: windowState.version,
windowID: windowState.id,
extensions: api.extensions,
...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS),
...createDesktopStorage(api),
browserPane: {
@@ -3,6 +3,12 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import { ExtensionRpc } from "./extensions"
export class ExtensionEvent extends Schema.TaggedClass<ExtensionEvent>()("ExtensionEvent", {
event: DesktopExtension.Event,
}) {}
export class BrowserPaneEvent extends Schema.TaggedClass<BrowserPaneEvent>()("BrowserPaneEvent", {
bindingID: Schema.String,
@@ -46,6 +52,7 @@ export class StorageChanged extends Schema.TaggedClass<StorageChanged>()("Storag
}) {}
export const DesktopEvent = Schema.Union([
ExtensionEvent,
BrowserPaneEvent,
DeepLinksOpened,
MenuCommandTriggered,
@@ -59,4 +66,4 @@ export const DesktopEvent = Schema.Union([
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc, ExtensionRpc)
@@ -0,0 +1,21 @@
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
export const ExtensionRequest = Schema.Union([
Schema.Struct({ type: Schema.Literal("call"), call: DesktopExtension.Call }),
Schema.Struct({ type: Schema.Literal("cancel"), extensionID: Schema.String, requestID: Schema.String }),
Schema.Struct({ type: Schema.Literal("servers"), servers: Schema.Array(DesktopExtension.Endpoint) }),
Schema.Struct({
type: Schema.Literal("surface"),
extensionID: Schema.String,
surfaceID: Schema.String,
layout: Schema.optionalKey(DesktopExtension.Layout),
}),
Schema.Struct({ type: Schema.Literal("release"), extensionID: Schema.String }),
])
export type ExtensionRequest = typeof ExtensionRequest.Type
export const ExtensionRpc = Rpc.make("DesktopExtension", {
payload: { request: ExtensionRequest },
success: Schema.Json,
})
+1 -1
View File
@@ -3,7 +3,7 @@ import type { BrowserWindow } from "electron"
import { Browser } from "@opencode/plugin-browser/rpc"
import { Schema } from "effect"
import { createBrowserPage } from "../../src/main/browser-chromium"
import { createCornerImages } from "../../src/main/browser/corners"
import { createCornerImages } from "../../src/main/native/corners"
export async function verifyTargets(win: BrowserWindow, url: string) {
const children = win.contentView.children.length
+72
View File
@@ -0,0 +1,72 @@
# Desktop extensions — exploratory API
This draft adds a renderer entrypoint at `@opencode/plugin/desktop` and a trusted main entrypoint at `@opencode/plugin/desktop/main`. Built-in/static registrations run through the same contracts external packages can import. Installing and resolving arbitrary third-party renderer packages is a follow-up; this draft does not add a second package manager or a sandbox.
## Contributions
Active routes expose `session.services`: workspace file caches and selection,
draft attachments, annotations, tab references, scroll state, and panel/sidebar
layout controls. These services exist while the session route is mounted. Stable
session identity and the public client remain available while its shell tab is open.
Feature queries stay in the extension and use TanStack Query.
Panels can use a shared `reference` (for example a `file://` resource) so existing
document producers can select them. `initial: "closed"` separates availability from
opening, `default` selects a fallback, and `closable: false` declares a pinned panel.
`view.tabs.canClose(reference)` lets a feature count user-opened tabs without knowing
which extensions supply pinned defaults. The host retains activated `group` content
while a declaration in that group exists, so replacing a file preview preserves its
surrounding sidebar. Group identity includes the server and session.
Session controls can use `session.header.actions`, `session.panel.toolbar`,
`session.panel.tools`, and `session.sidebar`. Each receives the session input and
uses the shared placement rules. Renderer contexts also expose shared translations,
notifications, file export, and available native path actions.
```tsx
import { Plugin } from "@opencode/plugin/desktop"
import { Panel } from "@opencode/plugin/desktop/solid"
import { Button } from "@opencode/ui/button"
export default Plugin.define({
id: "example.inspector",
setup(ctx) {
ctx.ui.slot({
append: "session.panel",
when: () => available(),
render: ({ session }) => (
<Panel id="inspector" title={title()} onClose={close}>
<Inspector session={session} />
</Panel>
),
})
},
})
```
Slots share the TUI resolver and its `append`, `prepend`, `before`, `after`, and `replace` rules. The plugin controls `when`, commands, and opening. Panel IDs are scoped to the plugin. Multiple reactive `Panel` instances share the host's ordered, closable tab strip. Closing, hiding, and disposing content are separate operations. Panel declarations live with the session route, even when the panel is closed.
Use the host's UI components directly. `@opencode/ui/layout` supplies shared layout, toolbar, form, text, and settings-row components. The browser companion in the next layer uses these components rather than shipping private CSS. Solid and TanStack remain normal libraries; the SDK adds no query framework.
## Lifetimes and data
- `ctx.sessions.list()` contains sessions visited by each open shell tab, retaining child-session ownership across navigation. `current()` is the currently routed session.
- Every session carries stable server, shell-tab, and session identities. Its server provides the existing client and reactive data APIs.
- `ctx.lifecycle.own` owns custom cleanup; its signal aborts on unload. Slots, commands and main RPC subscriptions are owned automatically.
- `storage.store` uses the host's persistence and cross-window synchronization. `storage.memory` retains window-local values across extension reloads. Schema-specific migrations remain an open API design item.
- `commands.register` accepts a reactive command list and registers with the existing command palette, keyboard, and slash-command host. IDs are plugin-scoped.
- `i18n` resolves existing host keys through the active language. Extension-owned translation catalogs are a follow-up.
## Local main entrypoint
`MainPlugin.define({ id, rpc, setup })` uses a public `Rpc.define` contract. Inputs, outputs and events are decoded/encoded at the bridge. Effect codecs can carry bytes over the JSON envelope, and Standard Schema/JSON Schema are supported. Methods receive a cancellation signal. Null is the void wire value.
Main context exposes the owning Electron window, a lifecycle, authenticated Node-side OpenCode clients for host-known server IDs, and a native surface registrar. It does not import Core. A renderer calls `ctx.main.rpc(contract)` and subscribes to its events.
`ctx.surfaces.register(view)` returns an opaque, window/extension-owned ID. `<NativeSurface id={id} />` presents that view. The host owns bounds, zoom conversion, corner composition, and menu/dialog occlusion. The extension owns the view's domain behavior and disposal.
## Verification
The pre-change production benchmark is `desktop-extensions-before`, at base `c3f1bdaf97`. Two samples per scenario completed: cold/closed first-correct median 223.60 ms, cold/open 255.75 ms, warm/closed 57.70 ms, warm/open 95.55 ms, warm/resized 92.85 ms. These are exploratory measurements, not machine-independent thresholds.
Focused tests cover lifecycle teardown, codec validation and binary round trips, shared slot ordering, and actual application panel behavior through an independent fixture plugin. The dependent browser extraction exercises native surfaces and server RPC.
+3
View File
@@ -14,6 +14,8 @@
"./effect": "./src/effect/index.ts",
"./host": "./src/host.ts",
"./tui": "./src/tui/index.ts",
"./desktop": "./src/desktop/index.ts",
"./desktop/solid": "./src/desktop/solid.ts",
"./*": "./src/*.ts"
},
"imports": {
@@ -66,6 +68,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"solid-js": "catalog:",
"electron": "42.10.1",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
+141
View File
@@ -0,0 +1,141 @@
import type { OpenCodeClient, LocationRef } from "@opencode/client"
import type { Data } from "../tui/context.js"
import type { Accessor, JSX } from "solid-js"
import type { Store } from "solid-js/store"
import type { Rpc } from "@opencode/schema/rpc"
import type { RpcClient } from "./rpc.js"
import type { SessionServices } from "./workspace.js"
export type Dispose = () => void
export interface Lifecycle {
readonly signal: AbortSignal
own(dispose: Dispose): Dispose
}
export interface Server {
readonly id: string
readonly client: OpenCodeClient
readonly data: Data
readonly compatible: boolean
readonly local: boolean
}
/** A visited session remains owned by its shell tab, including while another route is shown. */
export interface SessionContext {
readonly key: string
readonly ownerID: string
readonly sessionID: string
readonly server: Server
readonly creating: boolean
readonly location: LocationRef | undefined
readonly services?: SessionServices
}
export interface PanelInput {
readonly session: SessionContext
}
export interface SlotMap {
readonly app: Readonly<Record<string, never>>
readonly "titlebar.actions": Readonly<Record<string, never>>
readonly "settings.experimental": Readonly<Record<string, never>>
readonly "session.panel": PanelInput
readonly "session.panel.actions": PanelInput
readonly "session.composer.top": PanelInput
readonly "session.header.actions": PanelInput
readonly "session.panel.toolbar": PanelInput
readonly "session.panel.tools": PanelInput
readonly "session.sidebar": PanelInput
}
export type SlotPath = keyof SlotMap
type Placement<Path extends string> = {
[Kind in "append" | "prepend" | "before" | "after" | "replace"]: { readonly [Key in Kind]: Path } & {
readonly [Key in Exclude<"append" | "prepend" | "before" | "after" | "replace", Kind>]?: never
}
}["append" | "prepend" | "before" | "after" | "replace"]
export type SlotClaim<Path extends SlotPath = SlotPath> = Path extends SlotPath
? Placement<Path> & { readonly when?: Accessor<boolean>; readonly render: (input: SlotMap[Path]) => JSX.Element }
: never
export interface Command {
readonly id: string
/** Optional shared command reference, e.g. a document opener used by other UI. */
readonly reference?: string
readonly title: string
readonly description?: string
readonly group?: string
readonly bind?: string
readonly slash?: string
readonly enabled?: boolean
readonly palette?: boolean
readonly run: () => void | Promise<void>
}
export interface Storage {
store<Value extends object>(
key: string,
options: { initial: Value },
): readonly [Store<Value>, (update: (draft: Value) => void) => void]
memory<Value extends object>(
key: string,
options: { initial: Value },
): readonly [Store<Value>, (update: (draft: Value) => void) => void]
}
export interface Context {
readonly app: { readonly version?: string; readonly windowID?: string; readonly native: boolean }
readonly lifecycle: Lifecycle
readonly sessions: { list(): readonly SessionContext[]; current(): SessionContext | undefined }
readonly storage: Storage
readonly commands: { register(commands: Accessor<readonly Command[]>): Dispose; dispatch(id: string): void }
readonly main: { rpc<D extends Rpc.Definition>(definition: D): RpcClient<D> }
readonly ui: {
slot(claim: SlotClaim): Dispose
readonly toast: {
show(options: { title: string; message?: string; variant?: "error" | "success" | "default" | "loading" }): void
}
readonly panel: {
open(id: string, session: SessionContext): boolean
close(id: string, session: SessionContext): boolean
selected(id: string, session: SessionContext): boolean
visible(id: string, session: SessionContext): boolean
}
}
readonly platform: {
readonly platform: "web" | "desktop"
readonly os?: "macos" | "windows" | "linux"
openPath?(path: string, app?: string): Promise<void>
revealPath?(path: string): Promise<boolean>
checkAppExists?(app: string): Promise<boolean>
saveFile(options: { defaultPath?: string }, content: string): Promise<boolean>
writeClipboardText?(text: string): Promise<void>
}
/** Host copy uses the host language; extension-specific copy can be supplied as a fallback. */
readonly i18n: {
locale(): string
intl(): string
t(key: string, params?: Record<string, string | number>): string
plural(key: string, count: number, params?: Record<string, string | number>): string
}
}
export interface PanelProps {
readonly id: string
/** Shared resource reference understood by existing document/command producers. */
readonly reference?: string
readonly closable?: boolean
readonly default?: boolean
/** Reuse the content owner across related panel instances, such as file previews. */
readonly group?: string
/** Available declarations can start closed until another UI opens them. */
readonly initial?: "open" | "closed"
readonly onDoubleClick?: () => void
readonly temporary?: boolean
readonly title: string
readonly icon?: JSX.Element
readonly badge?: string | number
readonly loading?: boolean
readonly onClose?: () => void
readonly onSelect?: () => void
readonly children: JSX.Element
}
+3
View File
@@ -0,0 +1,3 @@
export * as Plugin from "./plugin.js"
export { usePlugin } from "./solid.js"
export type { Context, SessionContext, Server, SlotClaim, SlotMap, PanelProps, Lifecycle } from "./context.js"
+35
View File
@@ -0,0 +1,35 @@
import type { Lifecycle } from "./context.js"
export function createLifecycle(): Lifecycle & { dispose(): void } {
const controller = new AbortController()
const owned = new Set<() => void>()
return {
signal: controller.signal,
own(dispose) {
if (controller.signal.aborted) {
dispose()
return () => {}
}
const cleanup = () => {
if (owned.delete(cleanup)) dispose()
}
owned.add(cleanup)
return cleanup
},
dispose() {
if (controller.signal.aborted) return
controller.abort()
const failures: unknown[] = []
Array.from(owned)
.reverse()
.forEach((dispose) => {
try {
dispose()
} catch (error) {
failures.push(error)
}
})
if (failures.length) throw new AggregateError(failures, "Extension cleanup failed")
},
}
}
+52
View File
@@ -0,0 +1,52 @@
export * as MainPlugin from "./main.js"
import type { BrowserWindow, View } from "electron"
import type { OpenCodeClient } from "@opencode/client/effect"
import type { Rpc } from "@opencode/schema/rpc"
import type { Lifecycle } from "./context.js"
export interface Context {
readonly window: BrowserWindow
readonly lifecycle: Lifecycle
/** Returns an authenticated Node-side client for a host-known server. */
client(serverID: string): Promise<OpenCodeClient>
readonly surfaces: {
register(view: View): { readonly id: string; dispose(): void }
}
emit<D extends Rpc.Definition, Name extends keyof D["events"] & string>(
definition: D,
name: Name,
data: Rpc.EventInputData<D["events"][Name]["schema"]>,
): Promise<void>
}
export type Handlers<D extends Rpc.Definition> = {
[Name in keyof D["methods"]]: (
input: Rpc.Output<D["methods"][Name]["input"]>,
call: { signal: AbortSignal; error: Rpc.ErrorFactory<D["methods"][Name]> },
) => Rpc.HandlerOutput<D["methods"][Name]["output"]> | Promise<Rpc.HandlerOutput<D["methods"][Name]["output"]>>
}
export interface Definition<D extends Rpc.Definition = Rpc.Definition> {
readonly id: string
readonly rpc: D
readonly setup: (context: Context) => Handlers<D>
}
/** Type-erased host entrypoint; authors register through define to retain method correlations. */
export interface Entry {
readonly id: string
readonly rpc: Rpc.Definition
readonly setup: (
context: Context,
) => Record<
string,
(
input: unknown,
call: { signal: AbortSignal; error: (type: string, message: string, data?: unknown) => never },
) => unknown
>
}
export function define<const D extends Rpc.Definition>(definition: Definition<D>): Entry {
return definition as Entry
}
+10
View File
@@ -0,0 +1,10 @@
import type { Context, Dispose } from "./context.js"
export interface Definition {
readonly id: string
readonly setup: (context: Context) => void | Dispose
}
export function define<const T extends Definition>(plugin: T) {
return plugin
}
+32
View File
@@ -0,0 +1,32 @@
export * as DesktopExtension from "./protocol.js"
import { Schema } from "effect"
const id = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256))
export const Endpoint = Schema.Struct({
id,
url: Schema.String,
username: Schema.optionalKey(Schema.String),
password: Schema.optionalKey(Schema.String),
})
export type Endpoint = typeof Endpoint.Type
export const Layout = Schema.Struct({
visible: Schema.Boolean,
bounds: Schema.optionalKey(
Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite }),
),
background: Schema.optionalKey(Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number])),
radius: Schema.optionalKey(Schema.Number),
})
export type Layout = typeof Layout.Type
export const Call = Schema.Struct({ extensionID: id, rpcID: id, method: id, requestID: id, input: Schema.Json })
export type Call = typeof Call.Type
export const Event = Schema.Struct({ extensionID: id, rpcID: id, name: id, data: Schema.Json })
export type Event = typeof Event.Type
export interface Transport {
call(input: Call, signal?: AbortSignal): Promise<unknown>
onEvent(listener: (event: Event) => void): () => void
surface(extensionID: string, surfaceID: string, layout?: Layout): void
configure(servers: readonly Endpoint[]): void
release(extensionID: string): void
}
+122
View File
@@ -0,0 +1,122 @@
import { Effect, JsonSchema, Schema, SchemaRepresentation } from "effect"
import type { Rpc } from "@opencode/schema/rpc"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import type { DesktopExtension } from "./protocol.js"
type Input<S extends Rpc.Method["input"]> = S extends Schema.Top ? S["Type"] : Rpc.Input<S>
export type RpcClient<D extends Rpc.Definition> = {
readonly [Name in keyof D["methods"]]: (
input: Input<D["methods"][Name]["input"]>,
options?: { signal?: AbortSignal },
) => Promise<Rpc.Output<D["methods"][Name]["output"]>>
} & {
readonly events: {
on<Name extends keyof D["events"] & string>(
name: Name,
listener: (data: Rpc.EventData<D["events"][Name]["schema"]>) => void,
): () => void
}
}
export class CallError extends Error {
constructor(
readonly type: string,
message: string,
readonly data?: unknown,
) {
super(message)
}
}
const codecs = new WeakMap<object, Schema.Codec<unknown>>()
export async function decode(schema: Rpc.Method["input"], input: unknown): Promise<unknown> {
if (Schema.isSchema(schema)) return Effect.runPromise(Schema.decodeUnknownEffect(schema)(input))
if ("~standard" in schema) {
const result = await (schema as StandardSchemaV1)["~standard"].validate(input)
if (result.issues) throw new Error(result.issues.map((issue) => issue.message).join("; "))
return result.value
}
const codec =
codecs.get(schema) ??
Schema.make<Schema.Codec<unknown>>(
SchemaRepresentation.fromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema)).ast,
)
codecs.set(schema, codec)
return Effect.runPromise(Schema.decodeUnknownEffect(codec)(input))
}
export async function encode(schema: Rpc.Method["output"], value: unknown) {
const encoded = Schema.isSchema(schema)
? await Effect.runPromise(Schema.encodeUnknownEffect(schema)(value))
: await decode(schema, value)
return Schema.decodeUnknownSync(Schema.Json)(encoded)
}
const Outcome = Schema.Union([
Schema.Struct({ ok: Schema.Literal(true), output: Schema.Json }),
Schema.Struct({
ok: Schema.Literal(false),
error: Schema.Struct({ type: Schema.String, message: Schema.String, data: Schema.optionalKey(Schema.Json) }),
}),
])
export function client<D extends Rpc.Definition>(
extensionID: string,
definition: D,
transport: DesktopExtension.Transport,
signal: AbortSignal,
own: (dispose: () => void) => unknown,
): RpcClient<D> {
const methods = Object.fromEntries(
Object.entries(definition.methods).map(([name, method]) => [
name,
async (input: unknown, options?: { signal?: AbortSignal }) => {
const result = Schema.decodeUnknownSync(Outcome)(
await transport.call(
{
extensionID,
rpcID: definition.id,
method: name,
requestID: crypto.randomUUID(),
input: await encode(method.input, input),
},
options?.signal ? AbortSignal.any([signal, options.signal]) : signal,
),
)
if (!result.ok) throw new CallError(result.error.type, result.error.message, result.error.data)
return Schema.isSchema(method.output) ? decode(method.output, result.output) : result.output
},
]),
)
// The definition supplies every method name and the codecs preserve its input/output correlation.
return Object.assign(methods, {
events: {
on(name: keyof D["events"] & string, listener: (data: unknown) => void) {
const event = definition.events[name]
if (!event) throw new Error(`Unknown extension event: ${name}`)
let live = true
const stop = transport.onEvent((message) => {
if (
!live ||
signal.aborted ||
message.extensionID !== extensionID ||
message.rpcID !== definition.id ||
message.name !== name
)
return
void decode(event.schema, message.data)
.then((data) => {
if (live && !signal.aborted) listener(data)
})
.catch(console.error)
})
const dispose = () => {
live = false
stop()
}
own(dispose)
return dispose
},
},
}) as RpcClient<D>
}
+60
View File
@@ -0,0 +1,60 @@
import { createComponent, createContext, useContext, type JSX, type ParentProps } from "solid-js"
import type { Context, PanelProps, SessionContext } from "./context.js"
const PluginContext = createContext<Context>()
const SurfaceContext = createContext<(props: { id: string }) => JSX.Element>()
const PanelContext = createContext<{
session: SessionContext
register(props: PanelProps): void
}>()
export function PluginProvider(props: ParentProps<{ value: Context }>) {
return createComponent(PluginContext.Provider, {
value: props.value,
get children() {
return props.children
},
})
}
export function PanelProvider(props: ParentProps<{ value: NonNullable<ReturnType<typeof usePanel>> }>) {
return createComponent(PanelContext.Provider, {
value: props.value,
get children() {
return props.children
},
})
}
function usePanel() {
return useContext(PanelContext)
}
export function usePlugin() {
const value = useContext(PluginContext)
if (!value) throw new Error("Desktop plugin context is unavailable")
return value
}
export function NativeSurfaceProvider(props: ParentProps<{ render: (props: { id: string }) => JSX.Element }>) {
return createComponent(SurfaceContext.Provider, {
value: props.render,
get children() {
return props.children
},
})
}
export function NativeSurface(props: { id: string }) {
const render = useContext(SurfaceContext)
if (!render) throw new Error("Native surfaces require a desktop extension host")
return render(props)
}
/** Declare a host-owned panel instance in the session.panel slot. */
export function Panel(props: PanelProps): JSX.Element {
const context = usePanel()
if (!context) throw new Error("Panel must be contributed to session.panel")
context.register(props)
return null
}
+139
View File
@@ -0,0 +1,139 @@
export interface LineRange {
start: number
end: number
side?: "additions" | "deletions"
endSide?: "additions" | "deletions"
}
export interface TextSelection {
startLine: number
endLine: number
startChar: number
endChar: number
}
export interface FileNode {
name: string
path: string
absolute: string
type: "file" | "directory"
ignored: boolean
}
export interface FileState {
path: string
name: string
loaded?: boolean
loading?: boolean
error?: string
content?: { type: "text" | "binary"; content: string; encoding?: "base64"; mimeType?: string }
}
export interface Files {
readonly directory: string
ready(): boolean
normalize(path: string): string
tab(path: string): string
pathFromTab(tab: string): string | undefined
get(path: string): FileState | undefined
load(path: string, options?: { force?: boolean }): Promise<void>
selectedLines(path: string): LineRange | null | undefined
setSelectedLines(path: string, range: LineRange | null): unknown
scrollTop(path: string): number | undefined
scrollLeft(path: string): number | undefined
setScrollTop(path: string, top: number): unknown
setScrollLeft(path: string, left: number): unknown
searchFiles(query: string, options?: { limit?: number; signal?: AbortSignal }): Promise<string[]>
searchFilesAndDirectories(query: string): Promise<string[]>
readonly tree: {
list(path: string): Promise<void>
refresh(path: string): Promise<void>
state(path: string): { expanded: boolean; loaded?: boolean; loading?: boolean; error?: string } | undefined
children(path: string): FileNode[]
expand(path: string, options?: { list?: boolean }): unknown
collapse(path: string): unknown
}
}
export interface Annotation {
id: string
time: number
file: string
selection: LineRange
comment: string
}
export interface Annotations {
all(): Annotation[]
list(file: string): Annotation[]
add(input: Omit<Annotation, "id" | "time">): Annotation
update(file: string, id: string, comment: string): void
remove(file: string, id: string): void
focus(): { file: string; id: string } | null
setFocus(value: { file: string; id: string } | null): unknown
clearFocus(): void
}
export interface DraftFile {
type: "file"
path: string
selection?: TextSelection
preview?: string
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
}
export interface Draft {
readonly context: {
add(input: DraftFile): unknown
updateComment(path: string, id: string, input: { comment?: string; preview?: string }): unknown
removeComment(path: string, id: string): unknown
}
}
export interface SessionView {
ready(): boolean
desktop(): boolean
readonly tabs: {
all(): string[]
active(): string | undefined
open(reference: string): Promise<void>
setActive(reference: string): void
close(reference: string): void
canClose(reference: string): boolean
preview(): string | undefined
previewTab(reference: string): void
}
readonly panel: {
opened(): boolean
open(source?: string): void
close(): void
toggle(): void
source(): string
}
readonly sidebar: {
allowed(): boolean
opened(): boolean
width(): number
resize(width: number): void
toggle(): void
tab(): string
setTab(value: "changes" | "all"): void
}
scroll(key: string): { x: number; y: number } | undefined
setScroll(key: string, value: { x: number; y: number }): void
}
/** Shared workspace/draft capabilities. Feature queries and presentation remain extension-owned. */
export interface SessionServices {
/** Shared diff presentation preference, also used by built-in session surfaces. */
readonly display: { wrapDiff(): boolean }
readonly files: Files
readonly annotations: Annotations
readonly draft: Draft
readonly view: SessionView
readonly project: { id: string; directory: string; name?: string; vcs?: string } | undefined
}
export function selectionFromLines(range: LineRange): TextSelection {
return {
startLine: Math.min(range.start, range.end),
endLine: Math.max(range.start, range.end),
startChar: 0,
endChar: 0,
}
}
@@ -1,4 +1,4 @@
// Pure resolution of the slot tree: the mounted slot paths plus plugin claims
// Shared resolution of the slot tree: the mounted slot paths plus plugin claims
// in, per-path placement buckets plus diagnostics out. No solid, no I/O —
// every policy rule (replacement takeover, hierarchy-beats-timeline,
// last-enabled-wins, missing-target degradation) is testable as a data
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { z } from "zod"
import { createLifecycle } from "../src/desktop/lifecycle"
import { decode, encode } from "../src/desktop/rpc"
import { resolveSlots } from "../src/slots"
test("extension teardown cancels work and disposes every resource in reverse order", () => {
const scope = createLifecycle()
const called: string[] = []
scope.signal.addEventListener("abort", () => called.push("abort"))
scope.own(() => called.push("first"))
scope.own(() => {
called.push("second")
throw new Error("cleanup")
})
expect(() => scope.dispose()).toThrow(AggregateError)
expect(called).toEqual(["abort", "second", "first"])
scope.dispose()
scope.own(() => called.push("late"))
expect(called).toEqual(["abort", "second", "first", "late"])
})
test("main RPC codecs transfer bytes and reject incompatible values", async () => {
const schema = Schema.Struct({ bytes: Schema.Uint8ArrayFromBase64 })
const input = { bytes: new Uint8Array([0, 1, 127, 255]) }
expect(await decode(schema, await encode(schema, input))).toEqual(input)
await expect(decode(schema, { bytes: 17 })).rejects.toThrow()
await expect(encode(schema, { bytes: "wrong" })).rejects.toThrow()
})
test("main RPC accepts Standard Schema and JSON Schema contracts", async () => {
expect(await decode(z.object({ name: z.string().min(1) }), { name: "inspector" })).toEqual({ name: "inspector" })
await expect(decode({ type: "integer", minimum: 1 }, 0)).rejects.toThrow()
expect(await decode({ type: "integer", minimum: 1 }, 2)).toBe(2)
})
test("shared TUI/Desktop slots compose replacements and preserve neighboring contributions", () => {
const result = resolveSlots({
paths: new Set(["app", "app.panel"]),
claims: [
{ key: "a", plugin: "one", placement: { kind: "append", target: "app.panel" }, render: "A" },
{ key: "b", plugin: "two", placement: { kind: "replace", target: "app.panel" }, render: "B" },
{ key: "c", plugin: "three", placement: { kind: "after", target: "app.panel" }, render: "C" },
],
})
expect(result.slotted.get("app.panel")?.replace?.render).toBe("B")
expect(result.slotted.get("app.panel")?.after.map((claim) => claim.render)).toEqual(["C"])
expect(result.suppressed.map((item) => item.claim.key)).toEqual(["a"])
})
@@ -1,5 +1,6 @@
import { Show, type JSX, type ParentProps } from "solid-js"
import "./session-review-v2.css"
import "./session-mobile-file-panel.css"
export function SessionFilePanelV2(props: {
sidebar?: JSX.Element
@@ -37,3 +38,14 @@ export function SessionFilePanelV2(props: {
export function SessionFilePanelV2Empty(props: ParentProps) {
return <div data-slot="session-review-v2-empty">{props.children}</div>
}
export function SessionMobileFilePanel(props: ParentProps<{ browsing: boolean; header: JSX.Element }>) {
return (
<div data-slot="session-mobile-files" data-browsing={props.browsing} class="flex h-full min-h-0 flex-col">
<div data-slot="session-mobile-files-header" class="relative flex h-10 shrink-0 items-center">
{props.header}
</div>
<div class="min-h-0 flex-1">{props.children}</div>
</div>
)
}
@@ -0,0 +1,33 @@
[data-slot="session-mobile-files"]
[data-slot="session-mobile-files-header"]
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"]
[data-slot="tabs-v2-list"] {
position: static;
&::before {
inset-inline-start: 0;
width: 100%;
}
}
[data-slot="session-mobile-files"] [data-component="line-comment-v2"] {
max-width: none;
}
[data-slot="session-mobile-files"][data-browsing="true"] {
[data-component="session-review-v2-sidebar-root"] {
width: 100%;
}
[data-slot="session-review-v2-sidebar"] {
width: 100% !important;
border-inline-end: 0;
}
[data-slot="session-review-v2-preview"] {
display: none;
}
}
[data-slot="session-mobile-files"] [data-slot="tabs-v2-trigger-close-button"] [data-slot="tabs-close-button"] {
width: 32px;
height: 36px;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { PluginContextProvider } from "@opencode/plugin/tui"
import type { JSX } from "solid-js"
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode/plugin/tui/context"
import type { Placement, PlacementKind } from "./structure"
import type { Placement, PlacementKind } from "@opencode/plugin/slots"
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { useClient } from "../context/client"
+1 -1
View File
@@ -18,7 +18,7 @@ import { stat } from "fs/promises"
import { fileURLToPath } from "url"
import type { Page } from "@opencode/plugin/tui/context"
import { Host } from "@opencode/plugin/host"
import { resolveSlots, type Claim } from "./structure"
import { resolveSlots, type Claim } from "@opencode/plugin/slots"
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
+1 -1
View File
@@ -15,7 +15,7 @@ import {
import { isShallowEqual } from "remeda"
import type { SlotMap, SlotPath } from "@opencode/plugin/tui/context"
import type { SlotRender } from "./api"
import { contains, emptySlotted, type Claim } from "./structure"
import { contains, emptySlotted, type Claim } from "@opencode/plugin/slots"
import { useRoute } from "../context/route"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
+2
View File
@@ -38,11 +38,13 @@
"./avatar": "./src/data-display/avatar/avatar.tsx",
"./badge": "./src/data-display/badge/badge.tsx",
"./button": "./src/actions/button/button.tsx",
"./file-tree-item": "./src/components/file-tree-item.tsx",
"./button.css": "./src/actions/button/button.css",
"./checkbox": "./src/forms/checkbox/checkbox.tsx",
"./dialog": "./src/overlays/dialog/dialog.tsx",
"./diff-changes": "./src/data-display/diff-changes/diff-changes.tsx",
"./divider": "./src/layout/divider/divider.tsx",
"./layout": "./src/layout/extension-layout.tsx",
"./field": "./src/forms/field/field.tsx",
"./file-tree.css": "./src/styles/file-tree.css",
"./icon": "./src/icons/icon/icon.tsx",
@@ -0,0 +1,8 @@
import { Root } from "@kobalte/core/button"
import type { ComponentProps } from "solid-js"
import "../styles/file-tree.css"
/** Interactive row for the shared file-tree and file-search presentation. */
export function FileTreeItem(props: ComponentProps<typeof Root>) {
return <Root {...props} data-slot="file-tree-v2-row" />
}
@@ -0,0 +1,78 @@
import type { JSX, ParentProps } from "solid-js"
/** Shared layout primitives for composed extension UI. Visual choices remain semantic. */
export function Stack(
props: ParentProps<{ gap?: "none" | "small" | "medium"; padding?: "none" | "small" | "medium" }>,
) {
return (
<div
data-component="stack"
class="min-w-0 min-h-0 h-full flex flex-col bg-v2-background-bg-base"
classList={{
"gap-2": props.gap === "small",
"gap-4": props.gap === "medium",
"p-2": props.padding === "small",
"p-4": props.padding === "medium",
}}
>
{props.children}
</div>
)
}
export function Toolbar(props: ParentProps) {
return (
<div
data-component="toolbar"
class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02"
>
{props.children}
</div>
)
}
export function InlineForm(props: ParentProps<{ onSubmit: () => void }>) {
return (
<form
class="min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault()
props.onSubmit()
}}
>
{props.children}
</form>
)
}
export function Text(props: ParentProps<{ tone?: "default" | "muted" | "error" }>) {
return (
<span
data-component="text"
class="text-12-regular"
classList={{
"text-v2-text-text-base": !props.tone || props.tone === "default",
"text-v2-text-text-muted": props.tone === "muted",
"text-text-danger-base": props.tone === "error",
}}
>
{props.children}
</span>
)
}
export function SettingsRow(props: {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}) {
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>
)
}