Compare commits

..
Author SHA1 Message Date
LukeParkerDev df69e7a171 refactor(app): extract context usage into an extension 2026-09-08 20:52:22 +10:00
LukeParkerDev be066806ea refactor(review): compose shared file navigation controls 2026-09-08 20:52:18 +10:00
LukeParkerDev 375e6efb45 fix(review): retain mobile diff presentation and wrapping 2026-09-08 20:51:11 +10:00
LukeParkerDev 7619f8b27e refactor(app): extract review and file viewers into an extension 2026-09-08 20:51:11 +10:00
LukeParkerDev 3e1ef11cac refactor(desktop): extract the browser extension package 2026-09-08 20:51:09 +10:00
LukeParkerDev 66b16d40f0 feat(ui): share file navigation controls and mobile panel layout 2026-09-08 20:51:07 +10:00
LukeParkerDev de7ab3b082 feat(plugin): share the configured diff presentation preference 2026-09-08 20:48:23 +10:00
LukeParkerDev 29a05fa343 fix(app): render extension icons with their declaring context 2026-09-08 20:46:42 +10:00
LukeParkerDev 0dc7aa2411 feat(plugin): expose tab closing policy to session contributions 2026-09-08 20:42:14 +10:00
LukeParkerDev b5222ef904 fix(app): retain active panel groups across preview replacement 2026-09-08 20:38:05 +10:00
LukeParkerDev 30c25008ac fix(app): preserve grouped panel content and shared translations 2026-09-08 20:35:27 +10:00
LukeParkerDev 7466567c51 feat(plugin): expose shared session capabilities and panel references 2026-09-08 20:20:25 +10:00
LukeParkerDev 39310ee609 fix(plugin): retain panel order across route remounts 2026-09-08 19:31:55 +10:00
LukeParkerDev cc40f5844c feat(plugin): explore a desktop extension host 2026-09-08 19:04:39 +10:00
232 changed files with 5319 additions and 7277 deletions
+72 -1
View File
@@ -63,7 +63,9 @@
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-context-desktop": "workspace:*",
"@opencode/plugin-review-desktop": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
@@ -426,7 +428,9 @@
"@lydell/node-pty": "catalog:",
"@opencode/app": "workspace:*",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin-browser-desktop": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/ui": "workspace:*",
"@sentry/solid": "catalog:",
@@ -597,6 +601,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"electron": "42.10.1",
"solid-js": "catalog:",
"typescript": "catalog:",
},
@@ -628,6 +633,66 @@
"typescript": "catalog:",
},
},
"packages/plugin-browser-desktop": {
"name": "@opencode/plugin-browser-desktop",
"version": "0.0.0",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/ui": "workspace:*",
"effect": "catalog:",
"lighthouse": "13.4.1",
"solid-js": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"devtools-protocol": "0.0.1687809",
"electron": "42.10.1",
"puppeteer-core": "25.9.0",
},
},
"packages/plugin-context-desktop": {
"name": "@opencode/plugin-context-desktop",
"version": "0.0.0",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
"@opencode/util": "workspace:*",
"luxon": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/plugin-review-desktop": {
"name": "@opencode/plugin-review-desktop",
"version": "0.0.0",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
"@opencode/util": "workspace:*",
"@solid-primitives/event-listener": "catalog:",
"@solid-primitives/scheduled": "1.5.3",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/posts": {
"name": "@opencode/posts",
"dependencies": {
@@ -2215,6 +2280,12 @@
"@opencode/plugin-browser": ["@opencode/plugin-browser@workspace:packages/plugin-browser"],
"@opencode/plugin-browser-desktop": ["@opencode/plugin-browser-desktop@workspace:packages/plugin-browser-desktop"],
"@opencode/plugin-context-desktop": ["@opencode/plugin-context-desktop@workspace:packages/plugin-context-desktop"],
"@opencode/plugin-review-desktop": ["@opencode/plugin-review-desktop@workspace:packages/plugin-review-desktop"],
"@opencode/posts": ["@opencode/posts@workspace:packages/posts"],
"@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"],
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test"
import { readFile } from "node:fs/promises"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test("context opens independently, exports its session, and restores the closed panel", async ({ page }, info) => {
await page.setViewportSize({ width: 1440, height: 900 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
const toggle = page.getByRole("button", { name: "View context usage", exact: true })
await toggle.click()
const context = page.getByRole("tabpanel", { name: "Context", exact: true })
await expect(context.getByText("Total Tokens", { exact: true })).toBeVisible()
await expect(context.getByText(fixture.expected.sourceTitle, { exact: true })).toBeVisible()
await expect(context.getByText("Raw messages", { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath("context-extension.png") })
const pending = page.waitForEvent("download")
await context.getByRole("button", { name: "Export session", exact: true }).click()
const download = await pending
const exported: { info: { id: string }; messages: { id: string }[] } = JSON.parse(
await readFile((await download.path())!, "utf8"),
)
expect(exported.info.id).toBe(fixture.sourceID)
expect(exported.messages.length).toBeGreaterThan(0)
expect(new Set(exported.messages.map((message) => message.id)).size).toBe(exported.messages.length)
await toggle.click()
await expect(page.getByRole("tab", { name: "Context", exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toHaveAttribute(
"aria-expanded",
"false",
)
await toggle.click()
await expect(context.getByText(fixture.expected.sourceTitle, { exact: true })).toBeVisible()
})
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="UTF-8" /><style>html,body,#root{height:100%;margin:0}#root{display:flex;flex-direction:column}</style></head><body><div id="root"></div><script type="module" src="./fixture.tsx"></script></body></html>
+81
View File
@@ -0,0 +1,81 @@
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider } from "../../src/runtime/platform/platform"
import { createWebPlatform } from "../../src/runtime/platform/web"
import { ServerConnection } from "../../src/runtime/server/registry"
import { Plugin } from "@opencode/plugin/desktop"
import { Panel } from "@opencode/plugin/desktop/solid"
import { Button } from "@opencode/ui/button"
import { TextInput } from "@opencode/ui/text-input"
import { Stack, Text } from "@opencode/ui/layout"
import { createMemoryHistory, MemoryRouter } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { base64Encode } from "@opencode/util/encode"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
const plugin = Plugin.define({
id: "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,25 +1,6 @@
import { expect, test } from "bun:test"
import type { Page, Route } from "@playwright/test"
import { createMockServerHandler, mockOpenCodeServer } from "../../utils/mock-server"
test("serves an empty config document list for composer defaults", async () => {
const server = createMockServerHandler({
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
})
try {
const response = await server.handler(
new Request("http://localhost/api/config?location%5Bdirectory%5D=C%3A%2FOpenCode"),
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual([])
} finally {
await server.dispose()
}
})
import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
@@ -59,6 +59,9 @@ test("keeps the file-browser sidebar mounted when switching file tabs", async ({
expect(await readProbe(page)).toBe(PROBE)
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
})
test.afterEach(async ({ page }, info) => {
if (info.status === "passed") await page.screenshot({ path: info.outputPath("review-files-extension.png") })
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -342,7 +342,6 @@ async function mockServers(
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
@@ -602,7 +602,6 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback()
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
-1
View File
@@ -39,7 +39,6 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("config", "/api/config", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
-1
View File
@@ -207,7 +207,6 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
config: () => Effect.succeed([]),
reference: () =>
Effect.succeed({
location: {
+3 -1
View File
@@ -64,8 +64,10 @@
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-review-desktop": "workspace:*",
"@opencode/plugin-context-desktop": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
"@opencode/util": "workspace:*",
+38 -68
View File
@@ -1,5 +1,4 @@
import { batch, type Accessor, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
import type { ComposerControls } from "./adapter"
import { useLayout } from "@/shell/state/layout"
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
@@ -10,7 +9,6 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useModels } from "@/providers/models/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
import { useComposerState } from "./persistence"
import { useConfiguredModel } from "@/providers/models/configured"
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
const layout = useLayout()
@@ -33,7 +31,6 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
!(input.model ?? local.model).ready() ||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
!providers.ready(),
},
@@ -46,31 +43,15 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
}
export function createComposerModelSelection(input: {
agent: () => { name: string; model?: ModelKey; variant?: string } | undefined
agent: () => { model?: ModelKey; variant?: string } | undefined
}) {
const sdk = useWorkspaceLocation()
const models = useModels()
const local = useLocal()
const prompt = useComposerState()
const configuredModel = useConfiguredModel()
const [remembered, setRemembered] = createStore<Record<string, ModelKey | undefined>>({})
createEffect(
on(
() => input.agent()?.name,
(name, previous) => {
if (!name || !previous || name === previous) return
batch(() => {
const model = prompt.model.current()
setRemembered(previous, model ? { providerID: model.providerID, modelID: model.modelID } : undefined)
prompt.model.set(remembered[name] ? { ...remembered[name] } : undefined)
})
},
),
)
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
const valid = (model: ModelKey) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
@@ -81,8 +62,7 @@ export function createComposerModelSelection(input: {
return modelID ? [{ providerID: provider.id, modelID }] : []
})[0]
const current = () => {
if (!configuredModel.ready()) return
const key = [prompt.model.current(), input.agent()?.model, configuredModel(), recent(), fallback()].find(
const key = [prompt.model.current(), input.agent()?.model, recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
return key ? models.find(key) : undefined
@@ -94,9 +74,7 @@ export function createComposerModelSelection(input: {
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
trackSessionCommit: local.model.trackSessionCommit,
remembered: () => Object.fromEntries(Object.entries(remembered).map(([name, model]) => [name, { model }])),
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
ready: models.ready,
current,
recent: recentModels,
list: models.list,
@@ -105,24 +83,19 @@ export function createComposerModelSelection(input: {
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
const next =
items[
index === -1 ? (direction === 1 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
]
if (index === -1) return
const next = items[(index + direction + items.length) % items.length]
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
if (item && !valid(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
prompt.model.set(
item ? { ...item, variant: same ? (selection.variant.current() ?? null) : undefined } : undefined,
)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
})
void startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
}),
)
},
visible: models.visible,
setVisibility: models.setVisibility,
@@ -131,41 +104,38 @@ export function createComposerModelSelection(input: {
const item = input.agent()
const model = current()
if (!item || !model) return
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
},
selected() {
const model = prompt.model.current()
return model && valid(model) ? model.variant : undefined
return prompt.model.current()?.variant
},
current() {
const model = current()
return resolveModelVariant({
const resolved = resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
void startTransition(() =>
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
}),
)
},
cycle() {
const variants = this.list()
@@ -173,13 +143,13 @@ export function createComposerModelSelection(input: {
this.set(
cycleModelVariant({
variants,
selected: this.current() ?? null,
configured: undefined,
selected: this.selected(),
configured: this.configured(),
}),
)
},
},
}
} satisfies ModelSelection
return selection satisfies ModelSelection
return selection
}
+6 -152
View File
@@ -91,8 +91,6 @@ function session(input: {
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
command?: ComposerSession["api"]["command"]
switchAgent?: ComposerSession["api"]["switchAgent"]
switchModel?: ComposerSession["api"]["switchModel"]
}): ComposerSession {
return {
id: "session-1",
@@ -101,16 +99,12 @@ function session(input: {
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
switchAgent:
input.switchAgent ??
(async () => {
input.calls.push("switch-agent")
}),
switchModel:
input.switchModel ??
(async () => {
input.calls.push("switch-model")
}),
switchAgent: async () => {
input.calls.push("switch-agent")
},
switchModel: async () => {
input.calls.push("switch-model")
},
shell: input.shell ?? (async () => undefined),
command: input.command ?? (async () => undefined),
},
@@ -128,146 +122,6 @@ function session(input: {
}
describe("Composer submission", () => {
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const agent = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const committed = Promise.withResolvers<void>()
const completed = Promise.withResolvers<void>()
const target = session({
calls,
prompt: async () => {
throw new Error("command must not call prompt")
},
switchAgent: async (request) => {
expect(request.agent).toBe("build")
calls.push("agent")
started.resolve()
await agent.promise
},
switchModel: async (request) => {
expect(request.model).toEqual({ providerID: "provider-1", id: "model-1", variant: "balanced" })
calls.push("model")
await committed.promise
},
command: async (request) => {
expect(request).toMatchObject({ command: "review", text: "changes", delivery: "steer" })
expect(request).not.toHaveProperty("model")
expect(request).not.toHaveProperty("agent")
calls.push("command")
completed.resolve()
},
})
selected.model.selection = {
...selection,
trackSessionCommit: (_id, value) => {
expect(value).toEqual({
agent: "build",
model: { providerID: "provider-1", modelID: "model-1" },
variant: "balanced",
})
calls.push("track")
return () => calls.push("cancel")
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
await started.promise
expect(calls).toEqual(["track", "agent"])
selected.agents.current = "plan"
selected.model.selection = { ...selection, variant: { ...selection.variant, current: () => "high" } }
agent.resolve()
committed.resolve()
await completed.promise
expect(calls).toEqual(["track", "agent", "model", "command"])
})
test("commits the model even when cached session state already matches", async () => {
const state = createMemoryComposerState({ prompt: "continue" }).capture()
const calls: string[] = []
const done = Promise.withResolvers<void>()
const target = session({
calls,
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
prompt: async () => done.resolve(),
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await done.promise
expect(calls).toEqual(["switch-model", "prompt"])
})
test("cancels selection tracking and does not execute a command when selection fails", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const failed = Promise.withResolvers<unknown>()
const error = new Error("model unavailable")
const target = session({
calls,
prompt: async () => {
calls.push("prompt")
},
switchModel: async () => {
throw error
},
command: async () => {
calls.push("command")
},
})
selected.model.selection = {
...selection,
trackSessionCommit: () => {
calls.push("track")
return () => {
calls.push("cancel")
}
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
"normal",
() => [{ name: "review" }],
).submit(new Event("submit"))
expect(await failed.promise).toBe(error)
expect(calls).toEqual(["track", "switch-agent", "cancel"])
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
})
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
+23 -38
View File
@@ -11,7 +11,6 @@ import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -89,7 +88,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
const sending = sendPrompt(session, value).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -125,12 +124,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -319,10 +315,8 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -334,33 +328,7 @@ async function sendCommand(
})
}
async function applySelection(
session: ComposerSession,
selection: ComposerSelection,
track?: ModelSelection["trackSessionCommit"],
) {
const cancel = track?.(session.id, selection)
try {
const current = session.current()
if (current?.agent !== selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
}
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
await session.api.switchModel({
sessionID: session.id,
model: { id: selection.model.modelID, providerID: selection.model.providerID, variant: selection.variant },
})
} catch (error) {
cancel?.()
throw error
}
}
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track?: ModelSelection["trackSessionCommit"],
) {
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
@@ -368,7 +336,24 @@ async function sendPrompt(
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
await applySelection(session, value.selection, track)
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
}
const admission = {
-10
View File
@@ -4,16 +4,6 @@ export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export type {
BrowserPaneCommand,
BrowserPaneEndpoint,
BrowserPaneEvent,
BrowserPaneLayout,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneState,
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
+56
View File
@@ -0,0 +1,56 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import type { RegisteredPanel } from "./provider"
/** Grouped content stays mounted while any declaration in its group is available. */
export function ExtensionPanelContent(props: { panels: readonly RegisteredPanel[]; active: string | undefined }) {
const selected = createMemo(() => props.panels.find((panel) => panel.key === props.active))
const groups = createMemo(() => Array.from(new Set(props.panels.filter((panel) => panel.props.group).map(groupKey))))
const single = createMemo(() => {
const panel = selected()
return panel && !panel.props.group ? panel : undefined
})
return (
<>
<For each={groups()}>
{(key) => {
const [mounted, setMounted] = createSignal(false)
const active = () => !!selected() && groupKey(selected()!) === key
const declaration = props.panels.find((panel) => groupKey(panel) === key)!
createEffect(() => {
if (active()) setMounted(true)
})
return (
<Show when={mounted()}>
<div
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden flex flex-col"
classList={{ hidden: !active() }}
inert={!active()}
aria-label={selected()?.props.title}
>
{declaration.render()}
</div>
</Show>
)
}}
</For>
<Show when={single()} keyed>
{(panel) => (
<div
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden flex flex-col"
aria-label={panel.props.title}
>
{panel.render()}
</div>
)}
</Show>
</>
)
}
function groupKey(panel: RegisteredPanel) {
return `${panel.session.key}/${panel.plugin}/${panel.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" />
}
+346
View File
@@ -0,0 +1,346 @@
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 { ReviewDesktop } from "@opencode/plugin-review-desktop"
import { ContextDesktop } from "@opencode/plugin-context-desktop"
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
export type Contribution = Claim<{ context: Context; 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 = [ReviewDesktop, ContextDesktop, ...(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 }
},
}
}
+1 -24
View File
@@ -1,24 +1 @@
export type FileManagerOS = "macos" | "windows" | "linux" | "unknown"
export function fileManagerApp(os: FileManagerOS): {
label: "session.header.open.finder" | "session.header.open.fileExplorer" | "session.header.open.fileManager"
actionLabel:
| "session.header.reveal.finder"
| "session.header.reveal.fileExplorer"
| "session.header.reveal.containingFolder"
icon: "finder" | "file-explorer"
} {
if (os === "macos")
return { label: "session.header.open.finder", actionLabel: "session.header.reveal.finder", icon: "finder" }
if (os === "windows")
return {
label: "session.header.open.fileExplorer",
actionLabel: "session.header.reveal.fileExplorer",
icon: "file-explorer",
}
return {
label: "session.header.open.fileManager",
actionLabel: "session.header.reveal.containingFolder",
icon: "finder",
}
}
export { fileManagerApp, type FileManagerOS } from "@opencode/util/file-manager"
+2
View File
@@ -2,6 +2,8 @@
@import "@opencode/session-ui/styles";
@import "@opencode/ui/styles/tokens";
@import "tw-animate-css";
@source "../../plugin-review-desktop/src";
@source "../../plugin-context-desktop/src";
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
@@ -110,7 +110,6 @@ export function createNewSessionComposerAdapter(props: {
agent: selection.agent,
model: selection.model,
variant: selection.variant ?? null,
choices: model.remembered(),
})
if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id })
submission.retarget(
@@ -1,28 +0,0 @@
import { createEffect, createMemo, on } from "solid-js"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
export function useConfiguredModel() {
const data = useData()
const location = useWorkspaceLocation()
const server = useServerSDK()
createEffect(
on(
() => [location().directory, server.connection.status()] as const,
([directory]) => {
void data.location.config.sync({ directory }).catch(() => undefined)
},
),
)
const documents = () => data.location.config.list({ directory: location().directory })
const model = createMemo(() => {
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
const model = entry?.type === "document" ? entry.info.model : undefined
if (!model) return
if (typeof model !== "string") return { providerID: model.providerID, modelID: model.model, variant: model.variant }
const [providerID, ...parts] = model.split("/")
return { providerID, modelID: parts.join("/"), variant: undefined }
})
return Object.assign(model, { ready: () => documents() !== undefined })
}
+2 -2
View File
@@ -122,10 +122,10 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
setStore("variant", { [key]: value ?? "default" })
setStore("variant", { [key]: value })
return
}
setStore("variant", key, value ?? "default")
setStore("variant", key, value)
}
return {
@@ -1,7 +1,7 @@
import { Popover } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useLocal } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
import { Button } from "@opencode/ui/button"
@@ -27,7 +27,7 @@ import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ModelSelection
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelItem = ReturnType<ModelState["list"]>[number]
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
+83 -144
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { base64Encode } from "@opencode/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { useModels } from "@/providers/models/models"
@@ -16,7 +16,6 @@ import { useData } from "@/runtime/server/current"
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
@@ -25,15 +24,11 @@ const ModelKeySchema = Schema.Struct({
})
export type ModelKey = typeof ModelKeySchema.Type
const ChoiceSchema = Schema.Struct({
const StateSchema = Schema.Struct({
agent: Persistence.optional(Schema.String),
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
const StateSchema = Schema.Struct({
...ChoiceSchema.fields,
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Schema.Record(
@@ -83,7 +78,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
const configuredModel = useConfiguredModel()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() =>
@@ -104,9 +98,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
current?: string
draft?: State
promoting?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
}>({
current: list()[0]?.name,
draft: undefined,
last: undefined,
})
const validModel = (model: ModelKey) => {
@@ -175,17 +176,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel))
const durable = () => {
const session = id()
return session ? data.session.get(session) : undefined
}
const fallback = createMemo<ModelKey | undefined>(() => recentModel() ?? defaultModel())
const agent = {
list,
visible: agentsVisible,
current() {
return pickAgent(scope()?.agent ?? durable()?.agent ?? (agentsVisible() ? store.current : "build"))
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
},
set(name: string | undefined) {
const item = pickAgent(name)
@@ -195,24 +192,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
batch(() => {
const previous = snapshot()
if (previous.agent === item.name) return
const prev = scope()
const choices = {
...prev?.choices,
...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}),
}
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
variant: item.variant ?? null,
})
const prev = scope()
const next = {
agent: item.name,
model: choices[item.name]?.model,
variant: choices[item.name]?.variant,
choices,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
} satisfies State
write(next)
// Pin both choices while the agent and model acknowledgments arrive separately.
const selected = current()
if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id })
const session = id()
if (session) {
setSaved("session", session, next)
return
}
setStore("draft", next)
})
},
move(direction: 1 | -1) {
@@ -232,15 +230,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const current = createMemo(() => {
if (!configuredModel.ready()) return
const item = firstModel(
() => scope()?.model,
() => {
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const model = session?.model
return model && { providerID: model.providerID, modelID: model.id }
},
() => agent.current()?.model,
fallback,
)
@@ -252,41 +243,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const item = agent.current()
const model = current()
if (!item || !model) return
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
}
const selected = () => {
const draft = scope()
if (draft?.model && validModel(draft.model)) return draft.variant
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const value = session?.model
if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null
}
const selected = () => scope()?.variant
const snapshot = () => {
const selected = current()
const model = current()
return {
agent: agent.current()?.name,
model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined,
variant: selected ? (model.variant.current() ?? null) : undefined,
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
variant: selected(),
} satisfies State
}
const write = (next: Partial<State>) => {
const state = {
...scope(),
agent: agent.current()?.name,
...(scope() ?? { agent: agent.current()?.name }),
...next,
} satisfies State
@@ -298,59 +274,22 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setStore("draft", state)
}
const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean))
const pending = new Map<string, State>()
const sameSelection = (a: State, b: State) =>
a.agent === b.agent &&
a.model?.providerID === b.model?.providerID &&
a.model?.modelID === b.model?.modelID &&
(a.variant ?? "default") === (b.variant ?? "default")
const reconcile = (sessionID: string) => {
const expected = pending.get(sessionID)
const session = data.session.get(sessionID)
if (!expected || !session?.model) return
if (
!sameSelection(expected, {
agent: session.agent,
model: { providerID: session.model.providerID, modelID: session.model.id },
variant: session.model.variant,
})
)
return
pending.delete(sessionID)
const draft = saved.session[sessionID]
if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return
setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined })
}
onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(
serverSDK.event.on("session.deleted", (event) => {
pending.delete(event.data.sessionID)
setSaved("session", event.data.sessionID, undefined)
}),
)
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const model = {
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
ready: models.ready,
current,
recent,
list: models.list,
trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) {
pending.set(sessionID, selection)
reconcile(sessionID)
return () => {
if (pending.get(sessionID) === selection) pending.delete(sessionID)
}
},
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction
if (index === -1) return
let next = index + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
@@ -359,18 +298,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
if (item && !validModel(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined })
if (!item) return
// A session draft owns its variant even when preferences change in another session.
if (id() && !same) write({ variant: model.variant.current() ?? null })
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
})
startTransition(() =>
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}),
)
},
visible(item: ModelKey) {
return models.visible(item)
@@ -382,13 +324,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
configured,
selected,
current() {
const model = current()
return resolveModelVariant({
const resolved = resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
const item = current()
@@ -396,12 +341,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants)
},
set(value: string | undefined) {
batch(() => {
const model = current()
if (!model) return
write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
startTransition(() =>
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
}),
)
},
cycle() {
const items = this.list()
@@ -409,8 +363,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
this.set(
cycleModelVariant({
variants: items,
selected: this.current() ?? null,
configured: undefined,
selected: this.selected(),
configured: this.configured(),
}),
)
},
@@ -429,34 +383,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
// Creation already owns the active selection; keep only agent memory once it is in the read model.
// Otherwise a first-message command's configured overrides would stay hidden behind this handoff.
const created = data.session.get(session)
const selection = created?.model
const committed =
selection &&
sameSelection(next, {
agent: created.agent,
model: { providerID: selection.providerID, modelID: selection.id },
variant: selection.variant,
})
? { choices: next.choices }
: next
const key = handoffKey(serverSDK.scope, dir, session)
handoff.set(key, committed)
handoff.set(key, next)
if (dir === sdk().directory) {
setSaved("session", session, committed)
setSaved("session", session, next)
}
setStore("promoting", committed)
setStore("promoting", next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (durable()?.model) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
@@ -472,5 +412,4 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
})
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit"> &
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit">>
export type ModelSelection = ReturnType<typeof useLocal>["model"]
+2 -2
View File
@@ -6,13 +6,13 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { useTheme } from "@opencode/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useLocal } from "@/providers/models/selection"
import { useIntegrations } from "@/providers/catalog/integrations"
import { decode64 } from "@/runtime/persistence/base64"
import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
type ModelState = ModelSelection
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
@@ -64,14 +64,14 @@ describe("model variant", () => {
expect(value).toBe("xhigh")
})
test("cycles from configured last variant to default", () => {
test("wraps from configured last variant to first", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "xhigh",
})
expect(value).toBeUndefined()
expect(value).toBe("low")
})
test("cycles from an explicit default to the first variant", () => {
@@ -83,22 +83,4 @@ describe("model variant", () => {
expect(value).toBe("low")
})
test("prefers a saved variant to configuration, including explicit Default", () => {
const input = { variants: ["low", "high"], selected: undefined, configured: "high" }
expect(resolveModelVariant({ ...input, preferred: "low" })).toBe("low")
expect(resolveModelVariant({ ...input, preferred: "default" })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: null })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: "high" })).toBe("high")
expect(cycleModelVariant({ ...input, preferred: "high" })).toBeUndefined()
expect(cycleModelVariant({ ...input, preferred: "default" })).toBe("low")
})
test("normalizes unavailable selections instead of silently applying another variant", () => {
expect(resolveModelVariant({ variants: ["low"], selected: "high", configured: "low" })).toBeUndefined()
expect(
resolveModelVariant({ variants: ["low"], selected: undefined, preferred: "high", configured: "low" }),
).toBeUndefined()
expect(cycleModelVariant({ variants: [], selected: undefined, configured: undefined })).toBeUndefined()
})
})
+17 -5
View File
@@ -16,7 +16,6 @@ type VariantInput = {
variants: string[]
selected: string | null | undefined
configured: string | undefined
preferred?: string
}
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
@@ -25,16 +24,29 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}
export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
const value = input.selected ?? input.preferred ?? input.configured
return value && value !== "default" && input.variants.includes(value) ? value : undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
}
export function cycleModelVariant(input: VariantInput) {
const current = resolveModelVariant(input)
return input.variants[current ? input.variants.indexOf(current) + 1 : 0]
if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
if (input.configured && input.variants.includes(input.configured)) {
const index = input.variants.indexOf(input.configured)
if (index === input.variants.length - 1) return input.variants[0]
return input.variants[index + 1]
}
return input.variants[0]
}
@@ -230,72 +230,3 @@ describe("draft store text externalization", () => {
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
})
})
describe("draft store image retention", () => {
const image = (byte: number) => new Blob([new Uint8Array(6).fill(byte)], { type: "image/png" })
const fresh = (grace = 0) => {
const memory = memoryDriver()
return { memory, store: createDraftStore(memory.driver, { grace }) }
}
// Release timers fire on the macrotask queue; a zero grace has fired after one tick.
const tick = () => new Promise((resolve) => setTimeout(resolve, 5))
// An image with no object URL left gets a new one when its bytes are uploaded again.
const released = async (store: ReturnType<typeof createDraftStore>, byte: number, url: string) =>
(await store.putBlob(image(byte))).url !== url
test("an uploaded image no document references is released after the grace", async () => {
const { store } = fresh()
const orphan = await store.putBlob(image(1))
await tick()
expect(await released(store, 1, orphan.url)).toBe(true)
})
test("an image referenced within the grace is kept", async () => {
const { store } = fresh(50)
const pasted = await store.putBlob(image(2))
await store.setDocument("pinned", { prompt: [{ type: "image", blob: pasted }] })
await new Promise((resolve) => setTimeout(resolve, 80))
expect(await released(store, 2, pasted.url)).toBe(false)
})
test("saving a document without an image or removing the document releases it", async () => {
const { store } = fresh()
const dropped = await store.putBlob(image(3))
const removed = await store.putBlob(image(4))
await store.setDocument("edited", { prompt: [{ type: "image", blob: dropped }] })
await store.setDocument("closed", { prompt: [{ type: "image", blob: removed }] })
await tick()
expect(await released(store, 3, dropped.url)).toBe(false)
expect(await released(store, 4, removed.url)).toBe(false)
await store.setDocument("edited", { prompt: [{ type: "text", content: "typed over it" }] })
await store.removeItem("closed")
await tick()
expect(await released(store, 3, dropped.url)).toBe(true)
expect(await released(store, 4, removed.url)).toBe(true)
})
test("an image referenced by two documents survives until both drop it", async () => {
const { store } = fresh()
const shared = await store.putBlob(image(5))
await store.setDocument("composer", { prompt: [{ type: "image", blob: shared }] })
await store.setDocument("history", { entries: [{ prompt: [{ type: "image", blob: shared }] }] })
await store.setDocument("composer", { prompt: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(false)
await store.setDocument("history", { entries: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(true)
})
test("loading a document pins the images it references", async () => {
const { memory, store } = fresh()
const id = await memory.driver.putBlob(image(6))
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
await tick()
expect(await released(store, 6, url)).toBe(false)
await store.removeItem("loaded")
await tick()
expect(await released(store, 6, url)).toBe(true)
})
})
+21 -84
View File
@@ -28,77 +28,23 @@ export const draftTextThreshold = 16 * 1024
export const draftTextChunk = 64 * 1024
const textCacheLimit = 64
// Decoded image bytes the renderer pins through object URLs. Every consumer of a `blob.url` is a
// persisted draft document (composer prompt, prompt history), so an image is pinned exactly while a
// stored document references it. Once the last reference disappears (removed from a draft, sent, or
// a discarded duplicate paste) the URL is revoked after this grace, which covers the persist delay
// between a paste and the save that references it, and the submit → history handoff.
export const retainedBlobGrace = 30_000
type Retained = { blob: Blob; url: string; release: ReturnType<typeof setTimeout> | undefined }
const retained = new Map<string, Retained>()
// Document keys that reference each image id; an id with no keys is released after the grace.
const refs = new Map<string, Set<string>>()
const urls = new Map<string, string>()
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
// collected image be uploaded again without fetching the URL.
const held = new Map<string, Blob>()
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
// ones); live references still carry the original.
const aliases = new Map<string, string>()
function blobUrl(id: string, blob: Blob, grace?: number) {
const existing = retained.get(id)
if (existing) return existing.url
function blobUrl(id: string, blob: Blob) {
const existing = urls.get(id)
if (existing) return existing
const url = URL.createObjectURL(blob)
// Without a grace the image has no store to reference it from and stays for the page's lifetime.
const release = grace === undefined || refs.get(id)?.size ? undefined : setTimeout(() => revoke(id), grace)
retained.set(id, { blob, url, release })
urls.set(id, url)
held.set(id, blob)
return url
}
// Record which image ids `key` now references; ids it dropped are released once no other document
// references them, ids it gained stay pinned.
function retain(key: string, ids: ReadonlySet<string>, grace: number) {
for (const [id, keys] of refs) {
if (ids.has(id) || !keys.delete(key) || keys.size) continue
refs.delete(id)
const entry = retained.get(id)
if (entry) entry.release = setTimeout(() => revoke(id), grace)
}
for (const id of ids) {
const keys = refs.get(id) ?? new Set<string>()
keys.add(key)
refs.set(id, keys)
const entry = retained.get(id)
if (!entry) continue
clearTimeout(entry.release)
entry.release = undefined
}
}
function revoke(id: string) {
const entry = retained.get(id)
if (!entry) return
URL.revokeObjectURL(entry.url)
retained.delete(id)
for (const [from, to] of aliases) if (to === id) aliases.delete(from)
}
// Image ids a document references: `{ blob: { id } }` parts, not text chunk lists.
function imageIDs(value: unknown, into = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
value.forEach((entry) => imageIDs(entry, into))
return into
}
if (!value || typeof value !== "object") return into
const item = value as Record<string, unknown>
const blob = item.blob
if (blob && typeof blob === "object" && !("kind" in blob)) {
const id = (blob as Record<string, unknown>).id
if (typeof id === "string") into.add(id)
return into
}
Object.values(item).forEach((entry) => imageIDs(entry, into))
return into
}
async function blobID(blob: Blob) {
const bytes = crypto.subtle
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
@@ -114,25 +60,24 @@ export async function createBlobReference(blob: Blob): Promise<BlobReference> {
return { id, url: blobUrl(id, blob) }
}
export function createDraftStore(driver: Driver, options: { grace?: number } = {}): DraftStore {
const grace = options.grace ?? retainedBlobGrace
export function createDraftStore(driver: Driver): DraftStore {
const versions = new Map<string, number>()
const loading = new Map<string, Promise<string | undefined>>()
const loadBlobUrl = (id: string) => {
const existing = retained.get(id)
if (existing) return existing.url
const existing = urls.get(id)
if (existing) return existing
const pending = loading.get(id)
if (pending) return pending
const next = driver
.getBlob(id)
.then((blob) => (blob ? blobUrl(id, blob, grace) : undefined))
.then((blob) => (blob ? blobUrl(id, blob) : undefined))
.finally(() => loading.delete(id))
loading.set(id, next)
return next
}
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob, grace) }
return { id, url: blobUrl(id, blob) }
}
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
@@ -200,8 +145,8 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
if (typeof blob.id === "string") {
// A live reference keeps the id it was created with; publish the id its bytes now live under.
const id = aliases.get(blob.id) ?? blob.id
const kept = retained.get(id)?.blob
const url = typeof blob.url === "string" ? blob.url : retained.get(id)?.url
const kept = held.get(id)
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
if (kept) sources.set(id, { blob: async () => kept })
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
return { ...item, blob: { id } }
@@ -247,7 +192,8 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
remember(chunks, next, source.chunk)
return
}
blobUrl(next, blob, grace)
held.set(next, blob)
blobUrl(next, blob)
if (next === id) return
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
// earlier alias chain so lookups stay one step.
@@ -279,19 +225,14 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
// or the composer's history still held its id.
const missing = await driver.set(key, JSON.stringify(encoded), true)
if (missing.length === 0) {
retain(key, imageIDs(encoded), grace)
return
}
if (missing.length === 0) return
const renamed = await restore(missing, sources)
if (versions.get(key) !== version) return
const unrestored = missing.filter((id) => !renamed.has(id))
if (unrestored.length)
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
const final = rename(encoded, renamed)
await driver.set(key, JSON.stringify(final), false)
retain(key, imageIDs(final), grace)
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
}
return {
getItem: async (key) => {
@@ -300,8 +241,6 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed.value), grace)
return JSON.stringify(await decode(parsed.value))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
@@ -309,7 +248,6 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
removeItem: async (key) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
await driver.remove(key)
retain(key, new Set(), grace)
},
putBlob,
}
@@ -422,8 +360,7 @@ function referenced(json: string) {
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
const data = await fetch(blob.url).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
@@ -1,27 +0,0 @@
import type { Browser } from "@opencode/plugin-browser/rpc"
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
export type BrowserPaneLayout = {
tabID: Browser.TabID
visible: boolean
bounds?: { x: number; y: number; width: number; height: number }
background?: readonly [number, number, number, number]
radius?: number
}
export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
close(): void
}
export type BrowserPanePlatform = {
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
}
@@ -6,7 +6,8 @@ import { ServerConnection } from "@/runtime/server/registry"
import type { WslServersPlatform } from "@/servers/wsl/types"
import type { UpdaterPlatform } from "@/shell/updates/types"
import type { DraftStore } from "@/runtime/persistence/drafts"
import type { BrowserPanePlatform } from "./browser-pane"
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import type { Plugin } from "@opencode/plugin/desktop"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -120,8 +121,8 @@ type PlatformBase = {
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
extensions?: DesktopExtension.Transport
extensionPlugins?: readonly Plugin.Definition[]
}
export type Platform = PlatformBase &
@@ -1,158 +0,0 @@
import { batch, createEffect, createMemo, getOwner, onCleanup, runWithOwner } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { useServer } from "@/runtime/server/current"
import { useSettings } from "@/settings/model"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
type Server = ReturnType<typeof useServer>
export type BrowserAttachment = {
registration?: BrowserPaneRegistration
browser: BrowserPaneState
error?: string
}
type Live = {
server: Server
sessionID: string
/** Shell tab that owns this attachment once seen; it may route to a child session later. */
tab?: string
registration?: BrowserPaneRegistration
retry?: ReturnType<typeof setTimeout>
attempts: number
dispose: () => void
}
// Attachments belong to the shell session tab, not the session route: native pages and the agent's
// browser survive visiting Settings or another tab and close when the session tab or the setting does.
export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider } = createSimpleContext({
name: "BrowserAttachments",
gate: false,
init: () => {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const shellTabs = useTabs()
const owner = getOwner()
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
const live = new Map<string, Live>()
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
const enabled = createMemo(
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
)
const close = (id: string) => {
live.get(id)?.dispose()
live.delete(id)
setStore(id, undefined)
}
createEffect(() => {
const on = enabled()
const tabs = shellTabs.store
// The store's keys mirror `live`, and reading them keeps this effect subscribed to new attachments.
Object.keys(store).forEach((id) => {
const entry = live.get(id)
if (!entry) return
// Tabs hydrate asynchronously, so the owner is learned when first seen rather than required up
// front. A tab keeps owning the attachment while it exists, even after routing back to its parent.
const current = findSessionTab(tabs, entry.server.key, entry.sessionID)
if (current) entry.tab = tabKey(current)
const owned = entry.tab === undefined || tabs.some((tab) => tabKey(tab) === entry.tab)
if (on && owned && !entry.server.health?.incompatible) return
close(id)
})
})
onCleanup(() => Array.from(live.keys()).forEach(close))
return {
enabled,
supported: (server: Server) => !unsupported[server.key],
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
attach(server: Server, sessionID: string) {
const id = key(server, sessionID)
if (live.has(id)) return
const pane = platform.browserPane
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
const entry: Live = { server, sessionID, attempts: 0, dispose: () => undefined }
live.set(id, entry)
setStore(id, { browser: null })
const register = () => {
if (entry.registration || live.get(id) !== entry) return
// The server's shared transport follows a restarted sidecar's port whether or not any route
// for this session is mounted; the connection captured at attach time may predate it.
const endpoint = { ...server.conn.http, url: server.ctx.sdk.url }
const registration = pane.register({ sessionID, endpoint }, (event) => {
if (live.get(id) !== entry) return
if (event.type === "focus") return focus.get(id)?.forEach((listener) => listener(event.tabID))
if (event.error === "browser.pane.unsupported") {
setUnsupported(server.key, true)
return close(id)
}
if (event.error === "browser.pane.replaced") {
registration.close()
entry.registration = undefined
return setStore(id, {
registration: undefined,
browser: null,
error: language.t("session.browser.replaced"),
})
}
// The desktop dropped the attachment (server restart, attach race). Re-register so the
// agent's browser tool comes back without a reload.
if (event.error === "browser.pane.registration.closed") {
registration.close()
entry.registration = undefined
setStore(id, { registration: undefined, browser: null, error: undefined })
entry.retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** entry.attempts++))
return
}
if (event.state) entry.attempts = 0
batch(() => {
setStore(id, "browser", reconcile(event.state))
setStore(id, "error", event.error ? language.t("common.requestFailed") : undefined)
})
})
entry.registration = registration
setStore(id, { registration, browser: null, error: undefined })
}
// A new session appears in the UI before its server-side creation finishes. The listener
// belongs to this provider, not to the route effect that happened to call attach().
const data = server.ctx.data
const unsubscribe = runWithOwner(owner, () =>
data.on("session.created", (event) => {
if (event.data.sessionID === sessionID) register()
}),
)
if (!data.session.creating(sessionID)) register()
entry.dispose = () => {
unsubscribe?.()
clearTimeout(entry.retry)
entry.registration?.close()
}
},
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
const id = key(server, sessionID)
const listeners = focus.get(id) ?? new Set()
listeners.add(listener)
focus.set(id, listeners)
return () => {
listeners.delete(listener)
if (!listeners.size) focus.delete(id)
}
},
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
const registration = live.get(key(server, sessionID))?.registration
if (!registration) return Promise.reject(new Error("browser.pane.unavailable"))
return registration.command(command)
},
}
},
})
-115
View File
@@ -1,115 +0,0 @@
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import { useServer } from "@/runtime/server/current"
import type { SessionModel } from "../model"
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
import { useBrowserAttachments } from "./attachments"
export function createSessionBrowser(session: SessionModel) {
const attachments = useBrowserAttachments()
const language = useLanguage()
const server = useServer()
const [local, setLocal] = createStore({ error: undefined as string | undefined })
const attachment = () => {
const sessionID = session.identity.sessionID()
return sessionID ? attachments.state(server, sessionID) : undefined
}
const available = createMemo(
() =>
attachments.enabled() &&
attachments.supported(server) &&
!!session.identity.sessionID() &&
!server.health?.incompatible &&
session.isDesktop(),
)
const attached = () => attachment()?.registration !== undefined
const browserTabs = createMemo(
() =>
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
[],
)
const focus = (tabID: Browser.TabID) => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
const key = sessionBrowserTab(tabID)
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
tabs.setActive(key)
}
const command = (command: BrowserPaneCommand) => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
setLocal("error", undefined)
const owner = session.ownership.capture()
void attachments.command(server, sessionID, command).catch(() => {
if (owner.current()) setLocal("error", language.t("common.requestFailed"))
})
}
createEffect(() => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
if (attachments.enabled()) attachments.attach(server, sessionID)
onCleanup(attachments.onFocus(server, sessionID, focus))
})
createEffect(
on(
() => session.layout.tabs().active(),
(active) => {
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active)
if (tab && tab.id !== attachment()?.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id })
},
),
)
createEffect(
on(
() => session.layout.tabs().all(),
(current, previous) => {
previous
?.filter((key) => isSessionBrowserTab(key) && !current.includes(key))
.forEach((key) => {
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key)
if (tab) command({ type: "tabs.close", tabID: tab.id })
})
},
),
)
// Mirror the desktop's tab inventory into this session's layout tabs. Only tabs new since the last
// inventory are added, so a layout tab the user just closed is not reopened before the desktop confirms.
createEffect(
on(
() => attachment()?.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)),
(ids, previous) => {
if (!ids) return
const known = new Set(previous ?? [])
batch(() => {
const tabs = session.layout.tabs()
tabs
.all()
.filter((key) => isSessionBrowserTab(key) && !ids.includes(key))
.forEach(tabs.close)
const current = tabs.all()
const added = ids.filter((key) => !known.has(key) && !current.includes(key))
if (added.length) tabs.setAll([...current, ...added])
})
},
),
)
return {
available,
attached,
opened: () => attached() && browserTabs().length > 0,
state: () => attachment()?.browser ?? null,
tabs: browserTabs,
active: () =>
browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ??
browserTabs().find((tab) => tab.id === attachment()?.browser?.focusedTabID) ??
browserTabs()[0],
error: () => local.error ?? attachment()?.error,
registration: () => attachment()?.registration,
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
open: () => command({ type: "tabs.open" }),
command,
}
}
-185
View File
@@ -1,185 +0,0 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Loader } from "@opencode/ui/loader"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, For, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: {
registration: BrowserPaneRegistration
browser: ReturnType<typeof createSessionBrowser>
visible: boolean
}) {
const platform = usePlatform()
const language = useLanguage()
const dialog = useDialog()
const state = props.browser.active
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
let surface: HTMLDivElement | undefined
let frame: number | undefined
let layout: string | undefined
let until = 0
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
// The native page always paints above the DOM, so hide it while a floating
// menu, select, or popover overlaps it. Tooltips are excluded.
const covered = (rect: DOMRect) =>
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
})
const measure = () => {
if (!surface) return
const tab = state()
if (!tab) {
props.registration.setLayout()
return
}
const rect = surface.getBoundingClientRect()
const zoom = platform.webviewZoom?.() ?? 1
const left = Math.round(rect.left * zoom)
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
).backgroundColor
const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
if (next !== layout) {
layout = next
// Let the browser resolve the semantic backdrop color, including custom
// themes using color formats that Electron's color parser cannot read.
if (paint) {
paint.clearRect(0, 0, 1, 1)
paint.fillStyle = color
paint.fillRect(0, 0, 1, 1)
}
const rgba = paint?.getImageData(0, 0, 1, 1).data
props.registration.setLayout({
tabID: tab.id,
visible,
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
radius: Math.round(10 * zoom),
})
}
}
const tick = () => {
frame = undefined
measure()
if (performance.now() < until) frame = requestAnimationFrame(tick)
}
const schedule = (duration = 0) => {
until = Math.max(until, performance.now() + duration)
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
createEffect(
on(
[
() => platform.webviewZoom?.(),
() => dialog.active,
() => store.visible,
() => props.visible,
() => state()?.id,
],
() => schedule(300),
),
)
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
// animation frame keeps the native view in step with a pane drag.
createResizeObserver(() => surface, measure)
createEventListener(window, "resize", () => schedule(300))
// Floating content portals directly into <body>; keep measuring briefly so
// the positioner has settled before the overlap check runs.
const portals = new MutationObserver(() => schedule(300))
portals.observe(document.body, { childList: true })
onCleanup(() => portals.disconnect())
const appearance = new MutationObserver(() => schedule(300))
appearance.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
onCleanup(() => appearance.disconnect())
createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300))
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
props.registration.setLayout()
})
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
<For each={["back", "forward"] as const}>
{(direction) => (
<IconButton
{...button}
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: direction, tabID: tab.id })
}}
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
/>
)}
</For>
<IconButton
{...button}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
}}
icon={
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
<Loader />
</Show>
}
/>
<form
class="min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault()
const tab = state()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
}}
>
<input
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
value={store.address}
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
onInput={(event) => setStore("address", event.currentTarget.value)}
/>
</form>
</div>
<Show when={props.browser.error()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
</aside>
)
}
+7 -68
View File
@@ -1,68 +1,7 @@
import type { SessionInfo, SessionMessageInfo } from "@opencode/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { Platform } from "@/runtime/platform/platform"
export type SessionExportData = {
info: SessionInfo
messages: SessionMessageInfo[]
}
export async function fetchSessionExport(input: {
sessionID: string
api: Pick<ServerApi, "session" | "message">
}): Promise<SessionExportData> {
const [info, first] = await Promise.all([
input.api.session.get({ sessionID: input.sessionID }),
input.api.message.list({ sessionID: input.sessionID, limit: 200, order: "asc" }),
])
const pages = [first]
while (pages.at(-1)?.cursor.next) {
pages.push(
await input.api.message.list({
sessionID: input.sessionID,
limit: 200,
cursor: pages.at(-1)!.cursor.next ?? undefined,
}),
)
}
return {
info,
messages: pages.flatMap((page) => page.data),
}
}
export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) {
const name = session.title || session.slug || session.id
const clean = name
.toLowerCase()
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/^-+|-+$/g, "")
return `${clean || session.id}.json`
}
export function downloadSessionExport(filename: string, data: unknown) {
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export async function saveSessionExport(
filename: string,
data: unknown,
platform: Pick<Platform, "saveFile">,
) {
if (!platform.saveFile) {
downloadSessionExport(filename, data)
return true
}
return platform.saveFile({ defaultPath: filename }, JSON.stringify(data, null, 2))
}
export {
fetchSessionExport,
saveSessionExport,
sessionExportFilename,
downloadSessionExport,
type SessionExportData,
} from "@opencode/plugin-context-desktop/export"
@@ -160,13 +160,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
}
const openFile = () => {
void openDialog(
() => import("@/shell/commands/dialog"),
(x) => dialog.show(() => <x.DialogCommandPalette onOpenFile={showAllFiles} />),
)
}
const closeTab = () => {
const tab = actions.session.tabs.closableTab()
if (!tab) return
@@ -311,14 +304,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const fileCmds = () => {
const tab = actions.session.tabs.closableTab()
return [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+p",
slash: "open",
onSelect: openFile,
}),
tab &&
fileCommand({
id: "file.close",
+6 -2
View File
@@ -20,7 +20,7 @@ import { useComposerCommands } from "@/composer/commands"
import { useSessionCommands } from "../commands/use-session-commands"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "../session-model-helpers"
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
@@ -62,10 +62,14 @@ export function createActiveSessionRegion(input: {
},
),
)
let restoredModelSession: string | undefined
createEffect(() => {
const id = input.session.identity.params.id
if (!id || !prompt.ready() || !local.session.ready()) return
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
}
syncPromptModel(local, prompt)
})
createEffect(
@@ -1,20 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
describe("open app preferences", () => {
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
expect(decode({ app })).toEqual({ app })
})
test.each([undefined, null, 42, "unknown", {}])("defaults invalid selection %p", (app) => {
expect(decode({ app })).toEqual({ app: "finder" })
})
test("defaults an absent selection", () => {
expect(decode({})).toEqual({ app: "finder" })
})
})
@@ -1,100 +0,0 @@
import { Button } from "@opencode/ui/button"
import { Tabs } from "@opencode/ui/tabs"
import { getFilename } from "@opencode/util/path"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionTabs, SESSION_OPEN_FILE_TAB } from "@/session/helpers"
import { useFile } from "@/workspaces/files/model"
import { SessionFileBrowserTab } from "./session-file-browser-tab"
import type { Kind } from "./file-tree-v2"
import "./session-mobile-files.css"
export function SessionMobileFiles() {
const file = useFile()
const language = useLanguage()
const layout = useSessionLayout()
const tabs = createSessionTabs({
tabs: layout.tabs,
pathFromTab: file.pathFromTab,
normalizeTab: file.tab,
})
const [store, setStore] = createStore({ browsing: !tabs.activeFileTab() })
const browsing = () => store.browsing || !tabs.activeFileTab()
const active = createMemo(() => file.pathFromTab(tabs.activeFileTab() ?? ""))
const kinds = new Map<string, Kind>()
const open = (path: string) => {
layout.tabs().open(file.tab(path))
void file.load(path)
setStore("browsing", false)
}
return (
<div data-slot="session-mobile-files" data-browsing={browsing()} class="flex h-full min-h-0 flex-col">
<div data-slot="session-mobile-files-header" class="relative flex h-10 shrink-0 items-center">
<Button
size="small"
variant="ghost"
class="shrink-0 mx-2"
onClick={() => setStore("browsing", true)}
aria-pressed={browsing()}
>
{language.t("session.files.all")}
</Button>
<Tabs
value={browsing() ? SESSION_OPEN_FILE_TAB : tabs.activeFileTab()}
onChange={(tab) => {
// Kobalte falls back to a file tab when the browse view has no trigger.
if (browsing()) return
const path = file.pathFromTab(tab)
if (path) open(path)
}}
variant="line"
class="min-w-0 flex-1 !h-auto"
>
<Tabs.List aria-label={language.t("session.files.openTabs")} class="!h-10 !px-0 overflow-x-auto">
<For each={tabs.openedTabs()}>
{(tab) => (
<Tabs.Trigger
value={tab}
onClick={() => open(file.pathFromTab(tab)!)}
class="shrink-0 max-w-48"
classes={{ button: "min-w-0" }}
closeButton={
<Tabs.CloseButton
aria-label={language.t("common.closeTab")}
onClick={() => layout.tabs().close(tab)}
/>
}
>
<span dir="ltr" class="truncate">
{getFilename(file.pathFromTab(tab) ?? tab)}
</span>
</Tabs.Trigger>
)}
</For>
</Tabs.List>
</Tabs>
</div>
<div class="min-h-0 flex-1">
<SessionFileBrowserTab
mobile
tab={tabs.activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={browsing()}
active={active()}
kinds={kinds}
state={{
sidebarOpened: browsing,
sidebarWidth: () => 240,
sidebarTransition: () => false,
resizeSidebar: () => undefined,
toggleSidebar: () => setStore("browsing", !browsing()),
}}
onSelect={open}
onSelectPermanent={open}
/>
</div>
</div>
)
}
@@ -1,4 +1,4 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { For, Show, createMemo, onCleanup } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable } from "@dnd-kit/solid/sortable"
@@ -8,694 +8,185 @@ import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { Tabs } from "@opencode/ui/tabs"
import { IconButton } from "@opencode/ui/icon-button"
import { Icon } from "@opencode/ui/icon"
import { Loader } from "@opencode/ui/loader"
import { ResizeHandle } from "@opencode/ui/resize-handle"
import { Mark } from "@opencode/ui/logo"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Menu } from "@opencode/ui/menu"
import type { FileDiffInfo } from "@opencode/client/promise"
import FileTree from "@/session/files/file-tree"
import { normalizeFileTreeV2Path } from "@/session/files/file-tree-v2-model"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
const browserTabID = "session-side-panel-browser-tab"
const browserTabPanelID = "session-side-panel-browser-tabpanel"
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab } from "@/session/files/session-context-tab"
import { SortableTab } from "@/session/files/tab"
import { OpenInAppButton } from "@/session/files/open-in-app-button"
import { useCommand } from "@/shell/commands/command"
import { useFile, type SelectedLineRange } from "@/workspaces/files/model"
import { Tooltip } from "@opencode/ui/tooltip"
import { SortableTab } from "./tab"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSettings } from "@/settings/model"
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
import {
SESSION_OPEN_FILE_TAB,
isSessionBrowserTab,
sessionBrowserTab,
createOpenSessionFileTab,
createSessionTabs,
shouldShowFileTree,
type Sizing,
} from "@/session/helpers"
import { setSessionHandoff } from "@/session/handoff"
import { createFileTabListSync } from "./file-tab-scroll"
import { useSessionLayout } from "@/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
import { SessionBrowserPane } from "@/session/browser/pane"
import type { createSessionBrowser } from "@/session/browser/model"
type ReviewDiff = FileDiffInfo
type RenderDiff = FileDiffInfo
const FILE_TREE_WIDTH_MIN = 240
function renderDiff(value: ReviewDiff): value is RenderDiff {
return typeof value.file === "string"
}
import { createSessionTabs, type Sizing } from "@/session/helpers"
import { useFile } from "@/workspaces/files/model"
import type { SessionExtensions } from "@/extensions/session"
import { ExtensionPanelContent } from "@/extensions/content"
export function SessionSidePanel(props: {
canReview: boolean
diffs: ReviewDiff[]
diffsReady: boolean
hasReview: boolean
reviewHasFocusableContent: boolean
reviewCount: number
reviewPanel: () => JSX.Element
reviewSidebarToggle: (disabled: boolean) => JSX.Element
fileBrowserState: SessionFileBrowserState
activeDiff?: string
focusReviewDiff: (path: string) => void
reviewPresent?: boolean
extensions: SessionExtensions
present?: boolean
size: Sizing
stacked?: boolean
browser: ReturnType<typeof createSessionBrowser>
mobile?: boolean
}) {
const language = useLanguage()
const layout = useLayout()
const settings = useSettings()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const sdk = useWorkspaceLocation()
const { sessionKey, tabs, view, params } = useSessionLayout()
const projectDirectory = createMemo(() => sdk().directory)
const session = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = settings.visibility.fileTree
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const reviewVisible = createMemo(() => reviewOpen() || !!props.reviewPresent)
const fileOpen = createMemo(
() =>
isDesktop() &&
shouldShowFileTree({
visible: shown(),
opened: layout.fileTree.opened(),
}),
)
const open = createMemo(() => reviewOpen() || fileOpen())
const visible = createMemo(() => reviewVisible() || fileOpen())
const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width()))
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
if (!visible()) return "0px"
if (reviewVisible()) return "auto"
return `${fileTreeWidth()}px`
})
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
const diffs = createMemo(() => props.diffs.filter(renderDiff))
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalizeFileTreeV2Path(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
for (const [idx] of parts.slice(0, -1).entries()) {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) continue
out.set(dir, merge(out.get(dir), kind))
}
}
return out
})
const empty = (msg: string) => (
<div class="h-full flex flex-col">
<div class="h-6 shrink-0" aria-hidden />
<div class="flex-1 pb-64 flex items-center justify-center text-center">
<div class="text-12-regular text-text-weak">{msg}</div>
</div>
</div>
)
const nofiles = createMemo(() => {
const state = file.tree.state("")
if (!state?.loaded) return false
return file.tree.children("").length === 0
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = createOpenSessionFileTab({
normalizeTab,
openTab: tabs().open,
const extensions = props.extensions
const opened = () => !!props.mobile || (isDesktop() && session.view().reviewPanel.opened())
const visible = () => opened() || !!props.present
const sidebar = () => !props.mobile && isDesktop() && settings.visibility.fileTree() && layout.fileTree.opened()
const width = () => Math.max(240, layout.fileTree.width())
const state = createSessionTabs({
tabs: session.tabs,
pathFromTab: file.pathFromTab,
loadFile: file.load,
openReviewPanel,
setActive: tabs().setActive,
normalizeTab: file.tab,
extensions: extensions.keys,
defaultPanel: extensions.defaultPanel,
canClose: extensions.canClose,
})
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview: () => props.canReview,
fileBrowser: () => true,
browser: props.browser.attached,
const panelTabs = createMemo(() => {
const keys = state.panelTabs()
const preferred = extensions.defaultPanel()
return preferred && !keys.includes(preferred) ? [preferred, ...keys] : keys
})
const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
layout.fileTree.setTab(value)
}
let fileFilter: HTMLInputElement | undefined
let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
tabs().previewTab(next)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
queueMicrotask(() => tabs().setActive(next))
}
const openFileBrowser = () => {
previewTab(SESSION_OPEN_FILE_TAB)
queueMicrotask(() => fileFilter?.focus())
}
const activateTab = (value: string) => {
const next = normalizeTab(value)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
tabs().setActive(next)
}
const fileTab = createMemo(() => {
const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
return activeFileTab()
})
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
return openedTabs().length > 0 || openFileOpen() || !!fileTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
return (
<Show when={isDesktop() && !!params.id}>
<Show when={(isDesktop() || props.mobile) && session.params.id}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
aria-hidden={!open()}
inert={!open()}
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
aria-hidden={!opened() && !sidebar()}
inert={!opened() && !sidebar()}
class="relative min-w-0 h-full flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
classList={{
"h-full shrink-0": !props.stacked,
"h-full min-h-0": props.stacked,
"pointer-events-none": !open(),
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
"flex-1": reviewVisible(),
"flex-1": visible(),
"min-h-0": !!props.stacked,
"shrink-0": !props.stacked,
"pointer-events-none": !opened() && !sidebar(),
}}
style={{ width: panelWidth() }}
style={{ width: visible() ? "auto" : sidebar() ? `${width()}px` : "0px" }}
>
<Show when={visible()}>
<div class="size-full flex">
<Show when={reviewVisible()}>
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-v2-background-bg-base">
<div class="size-full min-w-0 h-full bg-v2-background-bg-base">
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
event.target instanceof Element &&
(!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
!!event.target.closest(".session-review-v2-open-in-app-slot")),
}),
]}
modifiers={[
RestrictToHorizontalAxis,
RestrictToElement.configure({ element: () => tabList ?? null }),
]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
tabs().move(source.id.toString(), source.index)
<div class="relative min-w-0 h-full flex-1 overflow-hidden">
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
event.target instanceof Element &&
!!event.target.closest('[data-slot="tabs-trigger-close-button"]'),
}),
]}
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => tabList ?? null })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
session.tabs().move(source.id.toString(), source.index)
}}
>
<Tabs value={state.activeTab()} onChange={(value) => session.tabs().setActive(value)}>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
ref={(element: HTMLDivElement) => {
tabList = element
onCleanup(createFileTabListSync({ el: element, contextOpen: state.contextOpen }))
}}
>
<Tabs value={activeTab()} onChange={activateTab}>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
ref={(el: HTMLDivElement) => {
tabList = el
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{props.reviewSidebarToggle(activeTab() === SESSION_OPEN_FILE_TAB)}
</div>
<Show when={reviewTab() && props.canReview}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{extensions.toolbar()}
</div>
<For each={panelTabs()}>
{(key) => (
<Show when={extensions.panels().find((panel) => panel.key === key)}>
{(panel) => (
<SortableTab
tab={key}
index={session.tabs().all().indexOf(key)}
temporary={panel().props.temporary}
onTabClose={panel().props.closable === false ? undefined : session.tabs().close}
onTabDoubleClick={panel().props.onDoubleClick}
>
{props.hasReview
? language.t("session.review.filesChanged", { count: props.reviewCount })
: language.t("session.tab.review")}
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
onMiddleClick={() => tabs().close("context")}
closeButton={
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
<div class="flex items-center gap-1.5">
<Show when={panel().props.loading} fallback={panel().icon()}>
<Loader />
</Show>
<span class="max-w-40 truncate" dir="auto">
{panel().props.title}
</span>
<Show when={panel().props.badge}>{panel().props.badge}</Show>
</div>
</Tabs.Trigger>
</Show>
<For each={panelTabs()}>
{(tab) => (
<Switch
fallback={
<SortableTab
tab={tab}
index={tabs().all().indexOf(tab)}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
/>
}
>
<Match when={isSessionBrowserTab(tab)}>
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
{(item) => (
<SortableTab
tab={tab}
index={tabs().all().indexOf(tab)}
onTabClose={() => props.browser.close(item().id)}
id={`${browserTabID}-${item().id}`}
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<Icon name="window-cursor" size="small" />
<span class="max-w-40 truncate">
{item().title || language.t("session.tab.browser")}
</span>
</div>
</SortableTab>
)}
</Show>
</Match>
<Match when={tab === SESSION_OPEN_FILE_TAB}>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
class="group"
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
closeButton={
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<IconButton
size="small"
variant="ghost-muted"
class="hover-reveal relative z-10 group-hover:opacity-100"
classList={{ "opacity-100": activeTab() === SESSION_OPEN_FILE_TAB }}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
tabs().close(SESSION_OPEN_FILE_TAB)
}}
icon={<Icon name="xmark-small" />}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
>
<div class="flex items-center gap-1.5">
<Icon name="file-tree" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
</Match>
</Switch>
)}
</For>
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{/* With only files to add, the plus stays a one-click "Open file" button. */}
<Show
when={props.browser.available()}
fallback={
<Tooltip
value={
<>
{language.t("command.file.open")}
<Show when={openFileKeybind().length > 0}>
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButton
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
aria-label={language.t("command.file.open")}
/>
</Tooltip>
}
>
<Tooltip
value={language.t("session.tab.add")}
placement="bottom"
class="flex items-center"
>
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
aria-label={language.t("session.tab.add")}
// The tablist redirects focus entering it to the selected
// tab, which counts as focus-outside and closes the menu.
onPointerDown={(event: PointerEvent) => event.preventDefault()}
/>
<Menu.Portal>
<Menu.Content>
<Menu.Item
onSelect={openFileBrowser}
shortcut={
<Show when={openFileKeybind().length > 0}>
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
}
>
<div class="flex items-center gap-2">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Menu.Item>
<Menu.Item onSelect={props.browser.open}>
<div class="flex items-center gap-2">
<Icon name="window-cursor" size="small" />
<span>{language.t("session.tab.browser")}</span>
</div>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
</Show>
</div>
</Tabs.List>
<div
data-slot="session-side-panel-actions"
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
classList={{ "h-[51px]": props.stacked, "h-12": !props.stacked }}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppButton directory={projectDirectory} />
<Show when={reviewOpen()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</div>
</div>
<Show when={reviewTab() && props.canReview && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Tabs.Content>
</Show>
<Show when={props.browser.opened()}>
<div
id={browserTabPanelID}
role="tabpanel"
aria-labelledby={
props.browser.active() ? `${browserTabID}-${props.browser.active()?.id}` : undefined
}
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: !isSessionBrowserTab(activeTab()) }}
inert={!isSessionBrowserTab(activeTab()) || undefined}
>
<Show when={props.browser.registration()} keyed>
{(registration) => (
<SessionBrowserPane
registration={registration}
browser={props.browser}
visible={isSessionBrowserTab(activeTab())}
/>
)}
</Show>
</div>
</Show>
<Show when={fileBrowserMounted()}>
<div
id={fileBrowserTabPanelID}
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: !fileBrowserVisible() }}
inert={!fileBrowserVisible() || undefined}
>
<SessionFileBrowserTab
tab={fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={
(fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
}
active={file.pathFromTab(fileTab() ?? activeFileTab() ?? "")}
kinds={kinds()}
state={props.fileBrowserState}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</div>
</Show>
</Tabs>
</DragDropProvider>
</div>
</div>
</Show>
<Show when={fileOpen()}>
<div
id="file-tree-panel"
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
>
<Tabs
variant="surface"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.review.filesChanged", { count: props.reviewCount })}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Show when={fileTreeTab() === "changes"}>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview || !props.diffsReady}>
<Show
when={props.diffsReady}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
</Show>
<Show when={fileTreeTab() === "all"}>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
</SortableTab>
)}
</Show>
)}
</For>
<Show when={extensions.hasActions()}>
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
aria-label={language.t("session.tab.add")}
onPointerDown={(event: PointerEvent) => event.preventDefault()}
/>
</Match>
</Switch>
</Tabs.Content>
<Menu.Portal>
<Menu.Content>{extensions.actions()}</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
</div>
</Show>
</Tabs.List>
<div
data-slot="session-side-panel-actions"
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
classList={{ "h-[51px]": !!props.stacked, "h-12": !props.stacked }}
onPointerDown={(event) => event.stopPropagation()}
>
{extensions.tools()}
<Show when={opened() && !props.mobile}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</Tabs>
</div>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={fileTreeWidth()}
min={FILE_TREE_WIDTH_MIN}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</div>
</Show>
</div>
</Show>
</div>
<ExtensionPanelContent panels={extensions.panels()} active={state.activeTab()} />
</Tabs>
</DragDropProvider>
</div>
</Show>
<Show when={sidebar()}>
<div
id="file-tree-panel"
class="relative min-w-0 h-full shrink-0 overflow-hidden"
style={{ width: `${width()}px` }}
>
{extensions.sidebar()}
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={width()}
min={240}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</div>
</div>
</Show>
</aside>
+22 -20
View File
@@ -13,7 +13,7 @@ export function SortableTab(props: {
tab: string
index: number
temporary?: boolean
onTabClose: (tab: string) => void
onTabClose?: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
/** Replaces the file visual for non-file tabs such as the browser. */
children?: JSX.Element
@@ -46,27 +46,29 @@ export function SortableTab(props: {
value={props.tab}
id={props.id}
aria-controls={props.ariaControls}
onMiddleClick={() => props.onTabClose(props.tab)}
onMiddleClick={props.onTabClose ? () => props.onTabClose?.(props.tab) : undefined}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
closeButton={
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
<Show when={props.onTabClose}>
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose?.(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
</Show>
}
hideCloseButton
>
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { virtualScrollElement } from "./virtual-scroll"
import { virtualScrollElement } from "@opencode/plugin-review-desktop/files/virtual-scroll"
test("resolves the connected viewport that owns the virtual root", () => {
const stale = document.createElement("div")
@@ -1,4 +0,0 @@
export function virtualScrollElement(root: HTMLElement | undefined) {
if (!root?.isConnected) return null
return root.closest<HTMLDivElement>(".scroll-view__viewport")
}
+13 -13
View File
@@ -2,8 +2,6 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
SESSION_BROWSER_TAB,
sessionBrowserTab,
SESSION_OPEN_FILE_TAB,
createOpenReviewFile,
createOpenSessionFileTab,
@@ -12,6 +10,7 @@ import {
getTabReorderIndex,
shouldShowFileTree,
} from "./helpers"
import { extensionTabKey } from "@/extensions/keys"
describe("shouldShowFileTree", () => {
test("does not reserve space for a disabled file tree", () => {
@@ -236,35 +235,36 @@ describe("createSessionTabs", () => {
})
})
test("exposes one browser tab without treating it as a file tab", () => {
test("exposes one extension panel without treating it as a file tab", () => {
createRoot((dispose) => {
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
const panel = extensionTabKey("example", "panel")
const tabs = createMemo(() => ({ active: () => panel, all: () => [panel] }))
const result = createSessionTabs({
tabs,
pathFromTab: () => undefined,
normalizeTab: (tab) => tab,
browser: () => true,
extensions: () => [panel],
})
expect(result.panelTabs()).toEqual([SESSION_BROWSER_TAB])
expect(result.panelTabs()).toEqual([panel])
expect(result.openedTabs()).toEqual([])
expect(result.activeTab()).toBe(SESSION_BROWSER_TAB)
expect(result.activeTab()).toBe(panel)
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBe(SESSION_BROWSER_TAB)
expect(result.closableTab()).toBe(panel)
dispose()
})
})
test("keeps browser tabs in layout order beside file tabs, and drops them when the browser is detached", () => {
const first = sessionBrowserTab("first")
const second = sessionBrowserTab("second")
test("keeps extension panels in layout order beside files and drops unavailable contributions", () => {
const first = extensionTabKey("example", "first")
const second = extensionTabKey("example", "second")
const input = {
tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }),
pathFromTab: (tab: string) => (tab.startsWith("file://") ? tab.slice(7) : undefined),
normalizeTab: (tab: string) => tab,
}
createRoot((dispose) => {
const result = createSessionTabs({ ...input, browser: () => true })
const result = createSessionTabs({ ...input, extensions: () => [first, second] })
expect(result.panelTabs()).toEqual([first, "file://src/a.ts", second])
expect(result.openedTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe(second)
@@ -273,7 +273,7 @@ describe("createSessionTabs", () => {
dispose()
})
createRoot((dispose) => {
const result = createSessionTabs({ ...input, browser: () => false })
const result = createSessionTabs({ ...input, extensions: () => [] })
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe("file://src/a.ts")
expect(result.closableTab()).toBe("file://src/a.ts")
+16 -17
View File
@@ -2,14 +2,10 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
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 { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
import { isExtensionTab } from "@/extensions/keys"
export {
SESSION_BROWSER_TAB,
SESSION_OPEN_FILE_TAB,
sessionBrowserTab,
isSessionBrowserTab,
} from "@/shell/state/session-tabs"
export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
const emptyTabs: string[] = []
@@ -25,7 +21,9 @@ type TabsInput = {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: Accessor<boolean>
browser?: Accessor<boolean>
extensions?: Accessor<readonly string[]>
defaultPanel?: Accessor<string | undefined>
canClose?: (key: string) => boolean
}
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
@@ -36,7 +34,6 @@ export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const fileBrowser = input.fileBrowser ?? (() => false)
const browser = input.browser ?? (() => false)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openFileOpen = createMemo(
() =>
@@ -50,8 +47,10 @@ export const createSessionTabs = (input: TabsInput) => {
.tabs()
.all()
.flatMap((tab) => {
if (input.extensions?.().includes(tab)) return [tab]
if (tab === "context" || tab === "review") return []
if (isSessionBrowserTab(tab)) return browser() ? [tab] : []
if (isExtensionTab(tab)) return input.extensions?.().includes(tab) ? [tab] : []
if (tab !== SESSION_OPEN_FILE_TAB && !input.pathFromTab(tab)) return []
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
@@ -62,21 +61,21 @@ 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
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
const preferred = input.defaultPanel?.()
if (preferred) return preferred
if (contextOpen()) return "context"
if (review() && hasReview()) return "review"
return "empty"
@@ -88,9 +87,9 @@ export const createSessionTabs = (input: TabsInput) => {
})
const closableTab = createMemo<string | undefined>(() => {
const active = activeTab()
if (active && input.extensions?.().includes(active)) return input.canClose?.(active) === false ? undefined : active
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (!openedTabs().includes(active)) return
return active
})
@@ -0,0 +1,86 @@
import { For, Show, Suspense, lazy, createEffect, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode/ui/tabs"
import { Menu } from "@opencode/ui/menu"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { useLanguage } from "@/runtime/i18n/language"
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
return { default: StatusDrawer }
})
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
export function SessionMobileViewTabs(props: {
current: string
items: readonly { id: string; title: string; menu?: boolean }[]
onSelect(value: string): void
details?: (close: () => void) => JSX.Element
onDetailsOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ status: false, details: false })
createEffect(() => props.onDetailsOpenChange?.(store.details))
onCleanup(() => props.onDetailsOpenChange?.(false))
return (
<div class="relative flex shrink-0 items-center" data-slot="session-mobile-view-navigation">
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0">
<Tabs.Trigger value="session" onClick={() => props.onSelect("session")}>
{language.t("session.tab.session")}
</Tabs.Trigger>
<For each={props.items.filter((item) => !item.menu)}>
{(item) => (
<Tabs.Trigger value={item.id} onClick={() => props.onSelect(item.id)}>
{item.title}
</Tabs.Trigger>
)}
</For>
<Tabs.Trigger value="terminal" onClick={() => props.onSelect("terminal")}>
{language.t("terminal.title")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
icon={<Icon name="menu" />}
variant="ghost-muted"
size="normal"
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content>
<For each={props.items.filter((item) => item.menu)}>
{(item) => <Menu.Item onSelect={() => props.onSelect(item.id)}>{item.title}</Menu.Item>}
</For>
<Show when={props.details}>
<Menu.Item onSelect={() => setStore("details", true)}>{language.t("session.summary.title")}</Menu.Item>
</Show>
<Menu.Item onSelect={() => setStore("status", true)}>{language.t("status.popover.trigger")}</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.status}>
<Suspense>
<StatusDrawer open onOpenChange={(open) => setStore("status", open)} />
</Suspense>
</Show>
<Show when={store.details}>
<Suspense>
<MobilePanelDrawer
title={language.t("session.summary.title")}
open
onOpenChange={(open) => setStore("details", open)}
>
{props.details?.(() => setStore("details", false))}
</MobilePanelDrawer>
</Suspense>
</Show>
</div>
)
}
+16 -7
View File
@@ -7,7 +7,6 @@ import { useData } from "@/runtime/server/current"
import { same } from "@/runtime/persistence/equality"
import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { projectForSession } from "@/shell/layout/helpers"
import { useBrowserAttachments } from "./browser/attachments"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
@@ -19,6 +18,7 @@ import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useTabs } from "@/shell/tabs/tabs"
import { useServer } from "@/runtime/server/current"
import { useOptionalDesktopExtensions } from "@/extensions/provider"
const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
@@ -29,7 +29,7 @@ export function useSessionModel() {
const data = useData()
const server = useServer()
const shellTabs = useTabs()
const attachments = useBrowserAttachments()
const extensions = useOptionalDesktopExtensions()
const layout = useSessionLayout()
const location = useWorkspaceLocation()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -83,12 +83,21 @@ export function useSessionModel() {
normalizeTab,
review: isDesktop,
hasReview: canReview,
extensions: () =>
extensions?.state.panels
.filter((panel) => panel.session.sessionID === sessionID() && panel.session.server.id === server.key)
.map((panel) => panel.key) ?? [],
defaultPanel: () =>
extensions?.state.panels.find(
(panel) =>
panel.session.sessionID === sessionID() && panel.session.server.id === server.key && panel.props.default,
)?.key,
canClose: (key) =>
extensions?.state.panels.find(
(panel) =>
panel.session.sessionID === sessionID() && panel.session.server.id === server.key && panel.key === key,
)?.props.closable !== false,
fileBrowser: () => isDesktop() && !!sessionID(),
// Same flag the side panel uses, so keyboard tab commands see the browser tab the panel shows.
browser: () => {
const id = sessionID()
return !!id && attachments.state(server, id)?.registration !== undefined
},
})
return {
-430
View File
@@ -1,430 +0,0 @@
import type { FileDiffInfo } from "@opencode/client/promise"
import type { SessionReviewLineComment } from "@opencode/session-ui/session-review"
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
import { checksum } from "@opencode/util/encode"
import { createQuery, skipToken, useQueryClient } from "@tanstack/solid-query"
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, on, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useComments } from "@/composer/comments"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useComposerState } from "@/composer/persistence"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { createOpenReviewFile } from "../helpers"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { createReviewPanelState } from "./panel-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "./review-diff-kinds"
import type { DiffStyle } from "./review-tab"
export type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
export function createSessionReview(input: {
session: SessionModel
screen: SessionScreenLayout
deferRender: Accessor<boolean>
}) {
const data = input.session.shared.data
const queryClient = useQueryClient()
const comments = useComments()
const file = useFile()
const language = useLanguage()
const layout = useLayout()
const prompt = useComposerState()
const location = useWorkspaceLocation()
const server = useServerSDK()
const [state, setState] = createStore({
mobileTab: "session" as "session" | "changes" | "files" | "usage",
detailsOpen: false,
scroll: undefined as HTMLDivElement | undefined,
pendingFile: undefined as string | undefined,
})
const mode = () => input.session.layout.view().review.mode() ?? "git"
const selectedFile = () => input.session.layout.view().review.file()
createEffect(() => {
const tab = input.session.tabs.activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (path) void file.load(path)
})
const vcs = createMemo(() => data.location.vcs.info({ directory: location().directory }))
const options = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
const project = input.session.project()
if (project?.vcs) list.push("git")
if (
project?.vcs &&
vcs()?.branch.current &&
vcs()?.branch.default &&
vcs()?.branch.current !== vcs()?.branch.default
) {
list.push("branch")
}
return list
})
const mobileChanges = createMemo(
() => !input.session.isDesktop() && !input.screen.terminal.open() && state.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
const value = mode()
return value === "git" || value === "branch" ? value : undefined
})
const vcsKey = createMemo(
() =>
[
server.scope,
"session-vcs",
location().directory,
vcs()?.branch.current ?? "",
vcs()?.branch.default ?? "",
] as const,
)
const wantsReview = createMemo(() =>
input.session.isDesktop()
? input.screen.files.open() ||
(input.screen.review.open() &&
(input.session.tabs.activeTab() === "review" || !!input.session.tabs.activeFileTab()))
: mobileChanges(),
)
const vcsQuery = createQuery(() => {
const value = vcsMode()
return {
queryKey: [...vcsKey(), value] as const,
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
? () =>
server.api.vcs
.diff({
location: { directory: location().directory },
mode: value === "git" ? "working" : value,
})
.then((result) => result.data)
: skipToken,
}
})
const detailsQuery = createQuery(() => ({
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs,
queryFn: () =>
server.api.vcs
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
.then((result) => result.data)
.catch((error) => {
console.debug("[session-review] failed to load session details diff", { error })
return []
}),
}))
const refresh = debounce(() => {
void queryClient.invalidateQueries({ queryKey: vcsKey() })
void queryClient.invalidateQueries({
queryKey: [server.scope, "session-details", input.session.workspace.directory()],
})
}, 100)
createEffect(() => {
const stop = location().event.listen((event) => {
if (event.type === "filesystem.changed") refresh()
})
onCleanup(stop)
})
createEffect(
on(
() => input.screen.review.open() || mobileChanges(),
(open, previous) => {
if (!open || previous || !input.screen.files.open() || vcsQuery.isFetching) return
refresh()
},
{ defer: true },
),
)
const diffs = () => {
if (mode() === "git" || mode() === "branch") return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return []
}
const activeFile = () => {
const list = diffs()
const selected = selectedFile()
if (selected && list.some((diff) => diff.file === selected)) return selected
return list[0]?.file
}
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
}
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
const value = vcsMode()
if (!value) return undefined
const root = reviewRootDirectory(input.session.project()?.worktree ?? location().directory)
const directory = reviewDiffDirectory(root, path)
const source = diffs().find((diff) => diff.file === path)
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
if (!diff || !source) return undefined
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return undefined
if (reviewDiffNeedsLoad(diff)) return undefined
return diff
}
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
queryKey: [server.scope, ...vcsKey(), value, "directory", scope, context, version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
server.api.vcs
.diff({
location: { directory: scope },
mode: value === "git" ? "working" : value,
context,
})
.then((result) => result.data),
})
.then((result) => result.find((diff) => diff.file === path))
if (directory !== root) {
try {
const scoped = valid(await request(directory))
if (scoped) return scoped
} catch (error) {
console.debug("[session-review] failed to load scoped vcs diff", { mode: value, path, directory, error })
}
}
try {
const bounded = valid(await request(root, 3))
if (bounded) return bounded
} catch (error) {
console.debug("[session-review] failed to load bounded vcs diff", { mode: value, path, root, error })
}
return undefined
}
const selectionPreview = (path: string, selection: FileSelection): string | undefined => {
const content = file.get(path)?.content?.content
if (!content) return undefined
return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
}
const addComment = (comment: SessionReviewLineComment) => {
const selection = selectionFromLines(comment.selection)
const saved = comments.add({ file: comment.file, selection: comment.selection, comment: comment.comment })
prompt.context.add({
type: "file",
path: comment.file,
selection,
comment: comment.comment,
commentID: saved.id,
commentOrigin: "review",
preview: comment.preview ?? selectionPreview(comment.file, selection),
})
}
const updateComment = (comment: {
id: string
file: string
selection: SelectedLineRange
comment: string
preview?: string
}) => {
comments.update(comment.file, comment.id, comment.comment)
prompt.context.updateComment(comment.file, comment.id, {
comment: comment.comment,
...(comment.preview ? { preview: comment.preview } : {}),
})
}
const removeComment = (comment: { id: string; file: string }) => {
comments.remove(comment.file, comment.id)
prompt.context.removeComment(comment.file, comment.id)
}
const commentActions = createMemo(() => ({
moreLabel: language.t("common.moreOptions"),
editLabel: language.t("common.edit"),
deleteLabel: language.t("common.delete"),
saveLabel: language.t("common.save"),
}))
const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return
layout.fileTree.setTab("all")
}
const open = () => {
if (!input.session.layout.view().reviewPanel.opened()) input.session.layout.view().reviewPanel.open()
}
const openFile = createOpenReviewFile({
showAllFiles,
tabForPath: file.tab,
openTab: (tab) => input.session.layout.tabs().open(tab),
setActive: (tab) => input.session.layout.tabs().setActive(tab),
loadFile: file.load,
})
const reviewDiffId = (path: string): string | undefined => {
const sum = checksum(path)
if (!sum) return undefined
return `session-review-diff-${sum}`
}
const reviewDiffTop = (path: string): number | undefined => {
if (!state.scroll) return undefined
const id = reviewDiffId(path)
if (!id) return undefined
const element = document.getElementById(id)
if (!(element instanceof HTMLElement) || !state.scroll.contains(element)) return undefined
const target = element.getBoundingClientRect()
const root = state.scroll.getBoundingClientRect()
return target.top - root.top + state.scroll.scrollTop
}
const scrollToFile = (path: string) => {
if (!state.scroll) return false
const top = reviewDiffTop(path)
if (top === undefined) return false
input.session.layout.view().setScroll("review", { x: state.scroll.scrollLeft, y: top })
state.scroll.scrollTo({ top, behavior: "auto" })
return true
}
const focusFile = (path: string) => {
open()
input.session.layout.view().review.openPath(path)
input.session.layout.view().review.setFile(path)
setState("pendingFile", path)
}
createEffect(() => {
const pending = state.pendingFile
if (!pending || !state.scroll || !ready()) return
const attempt = (count: number) => {
if (state.pendingFile !== pending) return
if (count > 60) {
setState("pendingFile", undefined)
return
}
if (!state.scroll || !scrollToFile(pending)) {
requestAnimationFrame(() => attempt(count + 1))
return
}
const top = reviewDiffTop(pending)
if (top === undefined || Math.abs(state.scroll.scrollTop - top) > 1) {
requestAnimationFrame(() => attempt(count + 1))
return
}
setState("pendingFile", undefined)
}
requestAnimationFrame(() => attempt(0))
})
createEffect(
on(
input.session.tabs.activeFileTab,
(active) => {
if (!active || layout.fileTree.tab() !== "changes") return
showAllFiles()
},
{ defer: true },
),
)
let treeDirectory: string | undefined
createEffect(() => {
const directory = location().directory
if (!input.session.isDesktop() || !layout.fileTree.opened() || server.connection.status() !== "connected") return
layout.fileTree.tab()
const refreshTree = treeDirectory !== directory
treeDirectory = directory
void (refreshTree ? file.tree.refresh("") : file.tree.list(""))
})
createEffect(
on(
() => location().directory,
() => {
const tab = input.session.tabs.activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (path) void file.load(path, { force: true })
},
{ defer: true },
),
)
createEffect(() => {
if (!layout.ready() || server.connection.status() !== "connected" || !input.session.project()) return
const list = options()
const value = mode()
if (list.includes(value)) return
const next = list[0]
if (next) input.session.layout.view().review.setMode(next)
})
createEffect(
on(
() => data.session.status(input.session.identity.params.id ?? ""),
(next, previous) => {
if (next !== "idle" || previous === undefined || previous === "idle") return
refresh()
},
{ defer: true },
),
)
createEffect(
on(
input.session.identity.sessionKey,
() => {
setState("mobileTab", "session")
setState("scroll", undefined)
setState("pendingFile", undefined)
},
{ defer: true },
),
)
const panelState = createReviewPanelState()
const panelRendered = createMemo<boolean>((previous) => previous || !input.deferRender(), false)
return {
activeFile,
canReview: input.session.canReview,
comments: {
actions: commentActions,
add: addComment,
all: comments.all,
focus: comments.focus,
mentions: file.searchFilesAndDirectories,
remove: removeComment,
changeFocus: (focus: { file: string; id: string } | null) => {
if (!focus) {
const current = comments.focus()
if (current && diffs().some((diff) => diff.file === current.file)) focusFile(current.file)
}
comments.setFocus(focus)
},
setFocus: comments.setFocus,
update: updateComment,
},
count,
deferRender: input.deferRender,
details: {
diffs: () => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined),
setOpen: (open: boolean) => setState("detailsOpen", open),
},
diffVersion: () => vcsQuery.dataUpdatedAt,
diffStyle: {
current: layout.review.diffStyle,
set: (style: DiffStyle) => layout.review.setDiffStyle(style),
},
diffs,
focusFile,
hasChanges,
loadDiff,
mobile: {
changes: mobileChanges,
setTab: (tab: "session" | "changes" | "files" | "usage") => setState("mobileTab", tab),
tab: () => state.mobileTab,
},
mode,
noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs),
open,
openFile,
options,
panelState,
panelRendered,
ready,
screen: input.screen,
setMode: (value: ChangeMode) => input.session.layout.view().review.setMode(value),
setScroll: (element: HTMLDivElement | undefined) => setState("scroll", element),
view: input.session.layout.view,
}
}
export type SessionReviewModel = ReturnType<typeof createSessionReview>
@@ -1,49 +0,0 @@
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
type SessionReviewExpandMode,
} from "@opencode/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const ReviewPanel = Persistence.struct({
sidebarOpened: Schema.Boolean,
sidebarWidth: Schema.Finite.check(
Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }),
),
expandMode: Schema.Literals(["expand", "collapse"]),
})
export function createReviewPanelState(platform?: Platform) {
const [store, setStore, , ready] = persisted(
Persist.global("review-panel-v2"),
ReviewPanel,
{ sidebarOpened: true, sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT, expandMode: "collapse" },
platform,
)
// The filter is transient by design: a persisted filter would silently hide
// files after a reload.
const [filter, setFilter] = createSignal("")
return {
sidebarOpened: () => store.sidebarOpened,
sidebarWidth: () => store.sidebarWidth,
sidebarTransition: ready,
filter,
setFilter,
expandMode: () => store.expandMode,
setExpandMode: (mode: SessionReviewExpandMode) => setStore("expandMode", mode),
resizeSidebar: (width: number) =>
setStore(
"sidebarWidth",
Math.min(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, Math.max(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, width)),
),
toggleSidebar: () => setStore("sidebarOpened", (opened) => !opened),
}
}
export type ReviewPanelState = ReturnType<typeof createReviewPanelState>
@@ -1,180 +0,0 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileDiffInfo } from "@opencode/client/promise"
import { SessionReview } from "@opencode/session-ui/session-review"
import type {
SessionReviewCommentActions,
SessionReviewCommentDelete,
SessionReviewCommentUpdate,
} from "@opencode/session-ui/session-review"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useLayout } from "@/shell/state/layout"
import type { LineComment } from "@/composer/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = FileDiffInfo
export interface SessionReviewTabProps {
title?: JSX.Element
empty?: JSX.Element
diffs: ReviewDiff[]
view: ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle
changeSummary?: boolean
overflow?: "wrap" | "scroll"
disableLineNumbers?: boolean
onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
lineCommentActions?: SessionReviewCommentActions
comments?: LineComment[]
focusedComment?: { file: string; id: string } | null
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
focusedFile?: string
onScrollRef?: (el: HTMLDivElement | undefined) => void
commentMentions?: {
items: (query: string) => string[] | Promise<string[]>
}
classes?: {
root?: string
header?: string
container?: string
}
}
export function SessionReviewTab(props: SessionReviewTabProps) {
let scroll: HTMLDivElement | undefined
let restoreFrame: number | undefined
let userInteracted = false
let restored: { x: number; y: number } | undefined
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const layout = useLayout()
const readFile = async (path: string) => {
return serverSDK.api.file
.read({ path, location: { directory: sdk().directory } })
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
.catch((error) => {
console.debug("[session-review] failed to read file", { path, error })
return undefined
})
}
const handleInteraction = () => {
userInteracted = true
if (restoreFrame !== undefined) {
cancelAnimationFrame(restoreFrame)
restoreFrame = undefined
}
}
const doRestore = () => {
restoreFrame = undefined
const el = scroll
if (!el || !layout.ready() || userInteracted) return
if (el.clientHeight === 0 || el.clientWidth === 0) return
const s = props.view.scroll("review")
if (!s || (s.x === 0 && s.y === 0)) return
const maxY = Math.max(0, el.scrollHeight - el.clientHeight)
const maxX = Math.max(0, el.scrollWidth - el.clientWidth)
const targetY = Math.min(s.y, maxY)
const targetX = Math.min(s.x, maxX)
if (el.scrollTop === targetY && el.scrollLeft === targetX) return
if (el.scrollTop !== targetY) el.scrollTop = targetY
if (el.scrollLeft !== targetX) el.scrollLeft = targetX
restored = { x: el.scrollLeft, y: el.scrollTop }
}
const queueRestore = () => {
if (userInteracted || restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(doRestore)
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
const el = event.currentTarget
const prev = restored
if (prev && el.scrollTop === prev.y && el.scrollLeft === prev.x) {
restored = undefined
return
}
restored = undefined
handleInteraction()
if (!layout.ready()) return
if (el.clientHeight === 0 || el.clientWidth === 0) return
props.view.setScroll("review", {
x: el.scrollLeft,
y: el.scrollTop,
})
}
createEffect(() => {
props.diffs.length
props.diffStyle
props.overflow
if (!layout.ready()) return
queueRestore()
})
onCleanup(() => {
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
props.onScrollRef?.(undefined)
})
return (
<SessionReview
title={props.title}
empty={props.empty}
scrollRef={(el) => {
scroll = el
makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "keydown", handleInteraction, { capture: true })
props.onScrollRef?.(el)
queueRestore()
}}
onScroll={handleScroll}
onDiffRendered={queueRestore}
open={props.view.review.open()}
onOpenChange={props.view.review.setOpen}
classes={{
root: props.classes?.root ?? "pr-3",
header: props.classes?.header ?? "px-3",
container: props.classes?.container ?? "pl-3",
}}
diffs={props.diffs}
diffStyle={props.diffStyle}
changeSummary={props.changeSummary}
overflow={props.overflow}
disableLineNumbers={props.disableLineNumbers}
onDiffStyleChange={props.onDiffStyleChange}
onViewFile={props.onViewFile}
focusedFile={props.focusedFile}
readFile={readFile}
onLineComment={props.onLineComment}
onLineCommentUpdate={props.onLineCommentUpdate}
onLineCommentDelete={props.onLineCommentDelete}
lineCommentActions={props.lineCommentActions}
lineCommentMention={props.commentMentions}
comments={props.comments}
focusedComment={props.focusedComment}
onFocusedCommentChange={props.onFocusedCommentChange}
/>
)
}
-324
View File
@@ -1,324 +0,0 @@
import { SessionReviewEmptyChangesV2 } from "@opencode/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
import { Select } from "@opencode/ui/select"
import { Tabs } from "@opencode/ui/tabs"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { For, Match, Show, Suspense, Switch, lazy, createEffect, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SessionSidePanel } from "../files/session-side-panel"
import { ReviewPanel } from "./panel"
import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
import type { createSessionBrowser } from "../browser/model"
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
return { default: StatusDrawer }
})
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
export function SessionMobileViewTabs(props: {
current: "session" | "changes" | "files" | "usage" | "terminal"
onSelect: (view: "session" | "changes" | "files" | "usage" | "terminal") => void
details?: (close: () => void) => JSX.Element
onDetailsOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({
menu: false,
status: false,
statusLoaded: false,
details: false,
detailsLoaded: false,
pending: undefined as "status" | "details" | undefined,
})
createEffect(() => props.onDetailsOpenChange?.(store.details))
onCleanup(() => props.onDetailsOpenChange?.(false))
let trigger: HTMLButtonElement | undefined
return (
<div
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
data-slot="session-mobile-view-navigation"
>
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0 before:!hidden">
<For each={["session", "changes", "files", "terminal"] as const}>
{(view) => (
<Tabs.Trigger
value={view}
class="min-w-0 flex-1"
classes={{ button: "w-full justify-center" }}
onClick={() => props.onSelect(view)}
>
{view === "session"
? language.t("session.tab.session")
: view === "changes"
? language.plural("session.review.change", 0)
: view === "files"
? language.t("session.tab.files")
: language.t("terminal.title")}
</Tabs.Trigger>
)}
</For>
</Tabs.List>
</Tabs>
<Menu
appearance="standard"
modal={false}
placement="bottom-end"
gutter={4}
open={store.menu}
onOpenChange={(open) => setStore("menu", open)}
>
<Menu.Trigger
as={IconButton}
ref={(element: HTMLButtonElement) => {
trigger = element
}}
icon={<Icon name="menu" />}
variant="ghost-muted"
size="normal"
class="mx-1.5 shrink-0"
state={props.current === "usage" || store.menu ? "pressed" : undefined}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content
onCloseAutoFocus={(event) => {
if (!store.pending) return
event.preventDefault()
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
setStore("pending", undefined)
}}
>
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
<Show when={props.details}>
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
{language.t("session.summary.title")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
{language.t("status.popover.trigger")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.statusLoaded}>
<Suspense>
<StatusDrawer
open={store.status}
onOpenChange={(open) => setStore("status", open)}
returnFocus={() => trigger}
/>
</Suspense>
</Show>
<Show when={store.detailsLoaded}>
<Suspense>
<MobilePanelDrawer
title={language.t("session.summary.title")}
open={store.details}
onOpenChange={(open) => setStore("details", open)}
returnFocus={() => trigger}
>
{props.details?.(() => setStore("details", false))}
</MobilePanelDrawer>
</Suspense>
</Show>
</div>
)
}
export function SessionMobileReview(props: { review: SessionReviewModel }) {
return (
<div class="relative h-full overflow-hidden">
<ReviewContent review={props.review} />
</div>
)
}
export function SessionDesktopReview(props: {
review: SessionReviewModel
browser: ReturnType<typeof createSessionBrowser>
present?: boolean
}) {
return (
<Suspense>
<SessionSidePanel
canReview={props.review.canReview()}
diffs={props.review.diffs()}
diffsReady={props.review.ready()}
hasReview={props.review.hasChanges()}
reviewHasFocusableContent={props.review.hasChanges() || props.review.panelState.sidebarOpened()}
reviewCount={props.review.count()}
reviewPanel={() => <ReviewPanelContent review={props.review} />}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={props.review.panelState.sidebarOpened()}
disabled={disabled}
onToggle={props.review.panelState.toggleSidebar}
/>
)}
fileBrowserState={props.review.panelState}
activeDiff={props.review.activeFile()}
focusReviewDiff={props.review.focusFile}
reviewPresent={props.present}
size={props.review.screen.size}
stacked={props.review.screen.side.layout().stacked}
browser={props.browser}
/>
</Suspense>
)
}
function ReviewContent(props: { review: SessionReviewModel }) {
const settings = useSettings()
return (
<Show when={!props.review.deferRender()}>
<SessionReviewTab
title={<ReviewTitle review={props.review} />}
empty={<ReviewEmpty review={props.review} loadingClass="px-2 py-2 text-text-weak" />}
diffs={props.review.diffs()}
view={props.review.view()}
diffStyle="unified"
changeSummary
disableLineNumbers={false}
overflow={settings.general.mobileDiffWrap() ? "wrap" : "scroll"}
onViewFile={(file) => {
props.review.openFile(file)
props.review.mobile.setTab("files")
}}
onScrollRef={props.review.setScroll}
focusedFile={props.review.activeFile()}
onLineComment={props.review.comments.add}
onLineCommentUpdate={props.review.comments.update}
onLineCommentDelete={props.review.comments.remove}
lineCommentActions={props.review.comments.actions()}
commentMentions={{ items: props.review.comments.mentions }}
comments={props.review.comments.all()}
focusedComment={props.review.comments.focus()}
onFocusedCommentChange={props.review.comments.setFocus}
classes={{
root: "[&_[data-slot=session-review-list]]:pb-0 [&_[data-slot=accordion-trigger]]:!rounded-none [&_[data-slot=accordion-trigger]]:!border-x-0 [&_[data-slot=accordion-item]:first-child_[data-slot=accordion-trigger]]:!border-t-0 [&_[data-slot=accordion-item]:last-child:not([data-expanded])_[data-slot=accordion-trigger]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=accordion-content]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=session-review-diff-placeholder]]:!border-b-0 [&_[data-slot=accordion-content]]:!rounded-none [&_[data-slot=accordion-content]]:!border-x-0 [&_[data-slot=session-review-diff-placeholder]]:!rounded-none [&_[data-slot=session-review-diff-placeholder]]:!border-x-0",
header:
"!px-2 !h-10 !pb-0 relative before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']",
container: "!px-0",
}}
/>
</Show>
)
}
function ReviewPanelContent(props: { review: SessionReviewModel }) {
return (
<div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
<Show when={props.review.panelRendered()}>
<ReviewPanel
title={<ReviewTitle review={props.review} />}
empty={<ReviewPanelEmpty review={props.review} />}
diffs={props.review.diffs()}
diffsReady={props.review.ready()}
diffVersion={props.review.diffVersion()}
loadDiff={props.review.loadDiff}
activeFile={props.review.activeFile()}
onSelectFile={props.review.focusFile}
diffStyle={props.review.diffStyle.current()}
onDiffStyleChange={props.review.diffStyle.set}
state={props.review.panelState}
onLineComment={props.review.comments.add}
onLineCommentUpdate={props.review.comments.update}
onLineCommentDelete={props.review.comments.remove}
lineCommentActions={props.review.comments.actions()}
comments={props.review.comments.all()}
focusedComment={props.review.comments.focus()}
onFocusedCommentChange={props.review.comments.changeFocus}
/>
</Show>
</div>
)
}
function ReviewTitle(props: { review: SessionReviewModel }) {
const language = useLanguage()
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
return language.t("ui.sessionReview.title.lastTurn")
}
return (
<Show when={props.review.canReview()}>
<Select
options={props.review.options()}
current={props.review.mode()}
label={label}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && props.review.setMode(option)}
/>
</Show>
)
}
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
return language.t("session.review.noChanges")
}
return (
<Switch>
<Match when={loading()}>
<div class={props.loadingClass}>{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
</div>
</Match>
<Match when={true}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{text()}</div>
</div>
</Match>
</Switch>
)
}
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
return (
<Switch>
<Match when={loading()}>
<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
</Match>
<Match when={true}>
<SessionReviewEmptyChangesV2 />
</Match>
</Switch>
)
}
+60 -30
View File
@@ -23,9 +23,9 @@ import { TerminalPanel } from "@/session/terminal/panel"
import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
import { SessionErrorFallback } from "./route-error"
import { createSessionScreenLayout } from "./screen-layout"
import { createSessionReview } from "./review/model"
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
import { SessionContextTab } from "./files/session-context-tab"
import { createSessionSummary } from "./summary"
import { SessionMobileViewTabs } from "./mobile-view-tabs"
import { SessionSidePanel } from "./files/session-side-panel"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { createTimelineSearchController } from "./timeline/search-controller"
import { TimelineSearchBar } from "./timeline/search-bar"
@@ -33,12 +33,9 @@ import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./compos
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
return { default: SessionMobileFiles }
})
import { useExtensionPanels } from "@/extensions/session"
import { createSessionServices } from "@/extensions/workspace"
import { ExtensionPanelContent } from "@/extensions/content"
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
@@ -48,7 +45,14 @@ export function SessionScreen(props: { session: SessionModel }) {
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const browser = createSessionBrowser(session)
const extensions = useExtensionPanels({
services: createSessionServices(session),
active: session.tabs.activeTab,
serverID: () => server.key,
sessionID: session.identity.sessionID,
tabs: session.layout.tabs,
open: () => session.layout.view().reviewPanel.open(),
})
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
@@ -67,6 +71,7 @@ export function SessionScreen(props: { session: SessionModel }) {
sideTerminalPresent: false,
mobileTerminalCached: false,
mobileMoveDismissed: false,
mobileTab: "session",
})
const [elements, setElements] = createStore<{
side?: HTMLDivElement
@@ -135,8 +140,19 @@ export function SessionScreen(props: { session: SessionModel }) {
}
return key
})
const review = createSessionReview({ session, screen, deferRender: () => store.deferRender })
const mobileView = createMemo(() => (screen.terminal.open() ? "terminal" : review.mobile.tab()))
const summary = createSessionSummary(session)
const mobileView = createMemo(() => {
if (screen.terminal.open()) return "terminal"
if (store.mobileTab === "session") return store.mobileTab
const selected = extensions.panels().find((panel) => panel.key === session.tabs.activeTab())
return selected?.props.group ?? selected?.key ?? store.mobileTab
})
const mobileItems = createMemo(() =>
Array.from(new Set(extensions.panels().map((panel) => panel.props.group ?? panel.key))).map((id) => {
const panel = extensions.panels().find((panel) => (panel.props.group ?? panel.key) === id)!
return { id, title: panel.props.title, menu: panel.props.initial === "closed" && !panel.props.group }
}),
)
const conversationVisible = createMemo(() => isDesktop() || mobileView() === "session")
createEffect(() => {
if (!isDesktop() && screen.terminal.open()) setStore("mobileTerminalCached", true)
@@ -158,8 +174,9 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={session.identity.sessionKey()} keyed>
{(_key) => (
<SessionMobileViewTabs
items={mobileItems()}
current={mobileView()}
onDetailsOpenChange={review.details.setOpen}
onDetailsOpenChange={summary.setOpen}
details={
!session.data.isChild() && detailsProject()
? (close) => (
@@ -177,14 +194,18 @@ export function SessionScreen(props: { session: SessionModel }) {
baseBranch={
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
}
diffs={project().vcs ? review.details.diffs() : []}
diffs={project().vcs ? summary.diffs() : []}
sessionID={session.identity.params.id ?? ""}
moveEligible={composer.workspaceMoveEligible()}
moveDismissed={store.mobileMoveDismissed}
onMoveDismiss={() => setStore("mobileMoveDismissed", true)}
onReview={() => {
close()
review.mobile.setTab("changes")
const panel = extensions.defaultPanel()
if (panel) {
session.layout.tabs().setActive(panel)
setStore("mobileTab", panel)
}
session.layout.view().terminal.close()
}}
backgroundTasks={composer.requests.background.tasks()}
@@ -199,7 +220,13 @@ export function SessionScreen(props: { session: SessionModel }) {
session.layout.view().terminal.open()
return
}
review.mobile.setTab(view)
setStore("mobileTab", view)
const panel = extensions.panels().find((panel) => (panel.props.group ?? panel.key) === view)
if (panel) {
void session.layout.tabs().open(panel.key)
session.layout.tabs().setActive(panel.key)
session.layout.view().reviewPanel.open()
}
session.layout.view().terminal.close()
}}
/>
@@ -231,16 +258,8 @@ export function SessionScreen(props: { session: SessionModel }) {
<Match when={!isDesktop() && mobileView() === "terminal"}>
<></>
</Match>
<Match when={!isDesktop() && mobileView() === "usage"}>
<SessionContextTab />
</Match>
<Match when={!isDesktop() && mobileView() === "files"}>
<Suspense>
<SessionMobileFiles />
</Suspense>
</Match>
<Match when={session.identity.params.id && review.mobile.changes()}>
<SessionMobileReview review={review} />
<Match when={!isDesktop() && mobileItems().some((item) => item.id === mobileView())}>
<ExtensionPanelContent panels={extensions.panels()} active={session.tabs.activeTab()} />
</Match>
<Match when={session.identity.params.id}>
<Show when={isDesktop() && !messagesReady()}>
@@ -249,6 +268,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
headerActions={extensions.header()}
hideHeader={!isDesktop()}
session={session}
background={composer.requests.background}
@@ -265,10 +285,14 @@ export function SessionScreen(props: { session: SessionModel }) {
pinned={timeline.view.pinned()}
centered={screen.centered()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
diffs={summary.diffs}
onReview={() => {
session.layout.view().reviewPanel.open()
const panel = extensions.defaultPanel()
if (panel) session.layout.tabs().setActive(panel)
}}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
onSummaryOpenChange={summary.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
@@ -288,6 +312,7 @@ export function SessionScreen(props: { session: SessionModel }) {
return (
<>
{extensions.declarations()}
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
{/* Keep the control outside panel animations; the terminal's 52px header includes a 1px divider. */}
@@ -379,7 +404,12 @@ export function SessionScreen(props: { session: SessionModel }) {
setStore("sideReviewPresent", false)
}}
>
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
<SessionSidePanel
size={screen.size}
stacked={screen.side.layout().stacked}
extensions={extensions}
present={store.sideReviewPresent}
/>
</div>
</Show>
</div>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { resetSessionModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
sessionID: "session",
@@ -54,7 +54,8 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
variant: { current: () => "high" },
set() {},
variant: { current: () => "high", set() {} },
},
},
{
@@ -76,7 +77,8 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
variant: { current: () => model.variant },
set() {},
variant: { current: () => model.variant, set() {} },
},
},
{
@@ -91,26 +93,54 @@ describe("syncPromptModel", () => {
})
})
describe("stale prompt model", () => {
test("replaces the submission mirror without changing the effective selection", () => {
describe("restorePromptModel", () => {
test("restores the persisted prompt model into session selection", () => {
const calls: unknown[] = []
syncPromptModel(
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
set: (value) => calls.push(value),
set() {},
},
},
)
expect(calls).toEqual([{ providerID: "openai", modelID: "gpt", variant: undefined }])
expect(restored).toBe(true)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
})
test("does nothing without a persisted prompt model", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => undefined,
set() {},
},
},
)
expect(restored).toBe(false)
expect(calls).toEqual([])
})
})
@@ -12,8 +12,10 @@ type Local = {
type ModelSelection = {
model: {
current(): { id: string; provider: { id: string } } | undefined
set(model: { providerID: string; modelID: string }): void
variant: {
current(): string | undefined
set(variant: string | undefined): void
}
}
}
@@ -49,3 +51,18 @@ export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
return
prompt.model.set(next)
}
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = prompt.model.current()
if (!model) return false
const current = local.model.current()
if (
current?.provider.id === model.providerID &&
current.id === model.modelID &&
local.model.variant.current() === (model.variant ?? undefined)
)
return true
local.model.set({ providerID: model.providerID, modelID: model.modelID })
local.model.variant.set(model.variant ?? undefined)
return true
}
+3 -2
View File
@@ -20,8 +20,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { ReviewPanelView } from "@/session/review/panel"
import { createReviewPanelState } from "@/session/review/panel-state"
import { ReviewPanelView } from "@opencode/plugin-review-desktop/review/panel"
import { createReviewPanelState } from "@opencode/plugin-review-desktop/review/panel-state"
import { TerminalSurface } from "@/session/terminal/surface"
import type { WebSearchRequestModel } from "./requests/websearch"
@@ -365,6 +365,7 @@ function SessionReviewPane(props: { diffs: SessionDocument["diffs"] }) {
})
return (
<ReviewPanelView
labels={{ loading: language.t("common.loading"), empty: language.t("palette.empty") }}
title={language.t("ui.sessionReview.title.lastTurn")}
empty={<SessionReviewEmptyChangesV2 />}
diffs={props.diffs}
+30
View File
@@ -0,0 +1,30 @@
import { createQuery, useQueryClient } from "@tanstack/solid-query"
import { createStore } from "solid-js/store"
import { createEffect, onCleanup } from "solid-js"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { SessionModel } from "./model"
export function createSessionSummary(session: SessionModel) {
const server = useServerSDK()
const location = useWorkspaceLocation()
const queryClient = useQueryClient()
const [state, setState] = createStore({ open: false })
const key = () => [server.scope, "session-details", session.workspace.directory()]
const query = createQuery(() => ({
queryKey: key(),
enabled: state.open && server.connection.status() === "connected" && !!session.project()?.vcs,
queryFn: () =>
server.api.vcs
.diff({ location: { directory: session.workspace.directory() }, mode: "working" })
.then((result) => result.data),
}))
createEffect(() => {
onCleanup(
location().event.listen((event) => {
if (event.type === "filesystem.changed") void queryClient.invalidateQueries({ queryKey: key() })
}),
)
})
return { diffs: () => query.data, setOpen: (value: boolean) => setState("open", value) }
}
@@ -14,7 +14,6 @@ import { ProjectAvatar } from "@opencode/ui/project-avatar"
import type { Project } from "@/runtime/server/types"
import { getFilename } from "@opencode/util/path"
import { Popover } from "@kobalte/core/popover"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -335,6 +334,7 @@ export function SessionSummaryPanel(props: {
}
type MessageTimelineProps = {
headerActions?: JSX.Element
hideHeader?: boolean
session: TimelineSessionSource
background: SessionBackground
@@ -794,7 +794,7 @@ function MessageTimelineView(
{(id) => (
<div class="shrink-0 flex items-center gap-2">
{props.search}
<SessionContextUsage placement="bottom" />
{props.headerActions}
<Show when={!parentID() && project()}>
{(project) => (
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
@@ -1,163 +0,0 @@
import { Show, createMemo, type ComponentProps, type JSX } from "solid-js"
import { ProgressCircle } from "@opencode/ui/progress-circle"
import { IconButton } from "@opencode/ui/icon-button"
import { Tooltip } from "@opencode/ui/tooltip"
import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/workspaces/files/model"
import { useLayout } from "@/shell/state/layout"
import { useData } from "@/runtime/server/current"
import { useLanguage } from "@/runtime/i18n/language"
import { useProviders } from "@/providers/catalog/providers"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionTabs } from "@/session/helpers"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
placement?: ComponentProps<typeof Tooltip>["placement"]
}
function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
return (
<div class="flex min-w-0 items-center gap-4">
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
</div>
)
}
function openSessionContext(args: {
view: ReturnType<ReturnType<typeof useLayout>["view"]>
layout: ReturnType<typeof useLayout>
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) {
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
void args.tabs.open("context").then(() => args.tabs.setActive("context"))
}
export function SessionContextUsage(props: SessionContextUsageProps) {
const data = useData()
const file = useFile()
const layout = useLayout()
const language = useLanguage()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const variant = createMemo(() => props.variant ?? "button")
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
fileBrowser: () => isDesktop() && !!params.id,
})
const messages = createMemo(() => (params.id ? data.session.message.list(params.id) : []))
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
const usd = createMemo(
() =>
new Intl.NumberFormat(language.intl(), {
style: "currency",
currency: "USD",
}),
)
const context = createMemo(() => {
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
if (message?.type !== "assistant" || !message.tokens) return
const model = providers.all().get(message.model.providerID)?.models[message.model.id]
const total =
message.tokens.input +
message.tokens.output +
message.tokens.reasoning +
message.tokens.cache.read +
message.tokens.cache.write
return {
total,
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0)
})
const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context")
const hasOtherTabs = createMemo(() =>
tabs()
.all()
.some((tab) => tab !== "context" && tab !== "review"),
)
const openContext = () => {
if (!params.id) return
const sessionView = view()
if (contextVisible()) {
tabs().close("context")
if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close()
return
}
openSessionContext({
view: sessionView,
layout,
tabs: tabs(),
})
}
const circle = () => (
<div class="flex items-center justify-center">
<ProgressCircle
appearance="indicator"
size={16}
strokeWidth={2}
percentage={context()?.usage ?? 0}
style={{
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
}}
/>
</div>
)
const compactCircle = () => (
<div class="flex items-center justify-center">
<ProgressCircle appearance="compact" percentage={context()?.usage ?? 0} />
</div>
)
const tooltipValue = () => (
<div class="flex w-[120px] flex-col gap-2">
<ContextTooltipRow name={language.t("context.usage.cost")} value={cost()} />
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
<ContextTooltipRow
name={language.t("context.usage.tokens")}
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
/>
</div>
)
return (
<Show when={params.id}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
<Show
when={variant() === "indicator"}
fallback={
<IconButton
type="button"
variant="ghost-muted"
size="large"
icon={compactCircle()}
onClick={openContext}
aria-label={language.t("context.usage.view")}
/>
}
>
{circle()}
</Show>
</Tooltip>
</Show>
)
}
@@ -2,18 +2,17 @@ import { Component, Show } from "solid-js"
import { Select } from "@opencode/ui/select"
import { Switch } from "@opencode/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsList } from "@/settings/list"
import { useSettings } from "@/settings/model"
import { SettingsRow } from "@/settings/row"
import "@/settings/settings.css"
import { ExtensionSlot } from "@/extensions/slot"
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
export const SettingsExperimental: Component = () => {
const language = useLanguage()
const settings = useSettings()
const platform = usePlatform()
return (
<>
@@ -31,22 +30,7 @@ export const SettingsExperimental: Component = () => {
<div class="settings-tab-body">
<div class="settings-section">
<SettingsList>
<Show when={platform.browserPane}>
<SettingsRow
title={language.t("settings.general.row.browserPane.title")}
description={language.t("settings.general.row.browserPane.description")}
>
<div data-action="settings-experimental-browser">
<Switch
checked={settings.general.experimentalBrowser()}
onChange={settings.general.setExperimentalBrowser}
hideLabel
>
{language.t("settings.general.row.browserPane.title")}
</Switch>
</div>
</SettingsRow>
</Show>
<ExtensionSlot path="settings.experimental" />
<SettingsRow
title={language.t("settings.appearance.row.tabs.title")}
description={language.t("settings.appearance.row.tabs.description")}
+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 -4
View File
@@ -4,7 +4,8 @@ import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
import { DesktopExtensionsProvider } from "@/extensions/provider"
import { ExtensionSlot } from "@/extensions/slot"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
@@ -79,9 +80,11 @@ function AppLayout(props: ParentProps) {
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
<DesktopExtensionsProvider>
<ExtensionSlot path="app">
<Shell>{props.children}</Shell>
</ExtensionSlot>
</DesktopExtensionsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
</Show>
+1 -1
View File
@@ -52,7 +52,7 @@ export type HomeProjectSelection = typeof layoutSchema.Type.home.selection
export type ReviewDiffStyle = typeof layoutSchema.Type.review.diffStyle
export type ReviewChangeMode = NonNullable<(typeof layoutSchema.Type.sessionView)[string]["reviewMode"]>
export type ReviewPanelSource = "context-button" | "other"
export type ReviewPanelSource = string
export type TabPanes = {
terminalOpened: Accessor<boolean>
setTerminalOpened(opened: boolean): void
@@ -1,8 +1,4 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export const SESSION_BROWSER_TAB = "browser"
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
export const isSessionBrowserTab = (tab: string | undefined) =>
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
export type SessionTabs = {
active?: string
@@ -1,6 +1,7 @@
import { createContext, onCleanup, onMount, Show, useContext, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { ExtensionSlot } from "@/extensions/slot"
type Registration = {
active: () => boolean
@@ -45,7 +46,9 @@ export function TitlebarRightMount(props: { vertical?: boolean }) {
ref={slot.setMount}
id="opencode-titlebar-right"
class={props.vertical ? "flex w-full shrink-0 flex-col" : "flex shrink-0 items-center justify-end gap-0"}
/>
>
<ExtensionSlot path="titlebar.actions" />
</div>
)
}
+1 -1
View File
@@ -257,7 +257,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return state
}
function withPath(input: string, action: (file: string) => unknown) {
function withPath<Value>(input: string, action: (file: string) => Value): Value {
return action(path.normalize(input))
}
const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
@@ -1,459 +0,0 @@
import { afterEach, expect, mock, test } from "bun:test"
import { createRequire } from "node:module"
import { createComponent, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import type { Agent } from "@/runtime/server/types"
import type { ModelKey } from "@/providers/models/selection"
import { ServerScope } from "@/runtime/server/scope"
// Bun does not compile Solid JSX. Compile the real context provider with the
// same presets as Vite instead of replacing LocalProvider's implementation.
const require = createRequire(import.meta.url)
const solid = createRequire(require.resolve("vite-plugin-solid"))
const { transformSync } = solid("@babel/core")
Bun.plugin({
name: "selection-solid-context",
setup(build) {
build.onLoad({ filter: /[\\/]ui[\\/]src[\\/]context[\\/]helper\.tsx$/ }, async (args) => ({
contents: transformSync(await Bun.file(args.path).text(), {
filename: args.path,
presets: [solid.resolve("babel-preset-solid"), solid.resolve("@babel/preset-typescript")],
}).code,
loader: "js",
}))
},
})
type Commit = { agent?: string; model?: { providerID: string; id: string; variant?: string } }
type Event = { data: { sessionID: string } }
type ConfigModel = string | { providerID: string; model: string; variant?: string }
const key = (modelID: string): ModelKey => ({ providerID: "provider", modelID })
const durable = (modelID: string, variant?: string, agent = "build"): Commit => ({
agent,
model: { providerID: "provider", id: modelID, variant },
})
const agent = (name: string, model?: ModelKey, variant?: string): Agent => ({
name,
mode: "primary",
permission: [],
options: {},
model,
variant,
})
let active: ReturnType<typeof fixture>
mock.module("@solidjs/router", () => ({ useParams: () => active.state.route }))
mock.module("@/runtime/server/current", () => ({ useData: () => active.data }))
mock.module("@/runtime/server/client", () => ({ useServerSDK: () => active.sdk }))
mock.module("@/runtime/server/runtime", () => ({ useGlobal: () => ({ models: active.preferences }) }))
mock.module("@/workspaces/location", () => ({ useWorkspaceLocation: () => () => ({ directory: active.directory }) }))
mock.module("@/settings/model", () => ({
useSettings: () => ({ visibility: { customAgents: () => active.state.visible } }),
}))
mock.module("@/composer/persistence", () => ({ useComposerState: () => active.prompt }))
mock.module("@/shell/state/layout", () => ({ useLayout: () => undefined }))
mock.module("@/runtime/platform/platform", () => ({
usePlatform: () => ({
platform: "web",
openExternal() {},
restart: async () => {},
notify: async () => {},
}),
}))
const { LocalProvider, useLocal } = await import("@/providers/models/selection")
const { ModelsProvider } = await import("@/providers/models/models")
const { Persist } = await import("@/runtime/persistence/storage")
const { createMemoryComposerState } = await import("@/composer/state")
const { createComposerModelSelection } = await import("@/composer/selection")
const cleanups: Array<() => void> = []
afterEach(() =>
cleanups
.splice(0)
.reverse()
.forEach((dispose) => dispose()),
)
function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigModel; preferred?: string } = {}) {
const directory = `/selection-test/${crypto.randomUUID()}`
const [state, set] = createStore({
visible: true,
configLoaded: true,
connection: "connected",
route: { id: "ses_a" as string | undefined },
agents: input.agents ?? [agent("build"), agent("plan")],
config: input.config as ConfigModel | undefined,
sessions: { ses_a: input.session } as Record<string, Commit | undefined>,
providers: [{ id: "provider", name: "Provider", package: "@ai-sdk/test", activation: "enabled" }],
models: ["a", "b", "c"].map((id) => ({
id,
modelID: id,
providerID: "provider",
name: `Model ${id}`,
settings: {},
headers: {},
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: ["low", "high"].map((id) => ({ id, settings: {} })),
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8192 },
})),
})
const [preferences, setPreferences] = createStore({
user: [] as Array<ModelKey & { visibility: "show" | "hide" }>,
recent: [] as ModelKey[],
variant: (input.preferred ? { "provider/a": input.preferred } : {}) as Record<string, string>,
})
const events = new Map<string, Set<(event: Event) => void>>()
const configLoads: string[] = []
const result = {
prompt: createMemoryComposerState(),
directory,
state,
set,
setPreferences,
preferences: { store: preferences, set: setPreferences, ready: () => true, recent: () => preferences.recent },
data: {
session: { get: (id: string) => state.sessions[id] },
location: {
agent: { list: () => state.agents },
config: {
list: () => (state.configLoaded ? [{ type: "document", info: { model: state.config } }] : undefined),
sync: async () => {
configLoads.push(state.connection)
},
},
provider: { list: () => state.providers },
model: { list: () => state.models },
integration: { list: () => [] },
},
},
sdk: {
scope: ServerScope.local,
connection: { status: () => state.connection },
event: {
on(type: string, handler: (event: Event) => void) {
const handlers = events.get(type) ?? new Set()
events.set(type, handlers)
handlers.add(handler)
return () => handlers.delete(handler)
},
},
},
emit(type: string, sessionID = "ses_a") {
events.get(type)?.forEach((handler) => handler({ data: { sessionID } }))
},
configLoads,
mount(draft = false) {
active = result
let local!: ReturnType<typeof useLocal>
let composer: ReturnType<typeof createComposerModelSelection> | undefined
const dispose = createRoot((dispose) => {
createComponent(ModelsProvider, {
directory,
get children() {
return createComponent(LocalProvider, {
get children() {
local = useLocal()
if (draft) composer = createComposerModelSelection({ agent: local.agent.current })
return null
},
})
},
})
return dispose
})
cleanups.push(dispose)
return { local, composer, dispose }
},
}
const target = Persist.serverWorkspace(ServerScope.local, directory, "model-selection")
cleanups.push(() => localStorage.removeItem(`${target.storage}:${target.key}`))
return result
}
function selection(local: ReturnType<typeof useLocal>) {
return {
agent: local.agent.current()?.name,
model: local.model.current()?.id,
variant: local.model.variant.current(),
}
}
test("restores durable agents even when the agent selector is hidden", () => {
const f = fixture({ session: durable("b", "high", "plan") })
f.set("visible", false)
const { local } = f.mount()
expect(local.agent.visible()).toBe(false)
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("waits for initial configuration and reloads it after reconnecting", () => {
const f = fixture({ config: "provider/b" })
f.set("configLoaded", false)
const { local } = f.mount()
expect(local.model.ready()).toBe(false)
expect(local.model.current()).toBeUndefined()
f.set("connection", "reconnecting")
f.set("connection", "connected")
expect(f.configLoads).toEqual(["connected", "reconnecting", "connected"])
f.set("configLoaded", true)
expect(local.model.ready()).toBe(true)
expect(local.model.current()?.id).toBe("b")
})
test("new-session promotion does not mask a command's durable overrides", () => {
const f = fixture({ session: durable("a", "low") })
const { local } = f.mount()
local.session.promote(f.directory, "ses_a", { agent: "build", model: key("a"), variant: "low" })
f.set("sessions", "ses_a", durable("b", "high", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("new-session drafts remember each agent's model and hand off inactive choices", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
f.set("route", "id", undefined)
const { local, composer } = f.mount(true)
if (!composer) throw new Error("missing draft composer")
composer.set(key("c"))
composer.variant.set("high")
local.agent.set("plan")
expect(composer.current()?.id).toBe("b")
expect(composer.variant.current()).toBeUndefined()
local.agent.set("build")
expect(composer.current()?.id).toBe("c")
expect(composer.variant.current()).toBe("high")
local.agent.set("plan")
const choices = composer.remembered()
f.set("sessions", "ses_a", durable("b", undefined, "plan"))
local.session.promote(f.directory, "ses_a", { agent: "plan", model: key("b"), choices })
f.set("route", "id", "ses_a")
local.agent.set("build")
expect(local.model.current()?.id).toBe("c")
})
test("session model picks snapshot the variant rather than following another session's preferences", () => {
const f = fixture({ session: durable("a") })
const { local } = f.mount()
f.setPreferences("variant", "provider/b", "low")
local.model.set(key("b"))
f.setPreferences("variant", "provider/b", "high")
expect(local.model.variant.current()).toBe("low")
})
test("remembers distinct variants for agents using the same model, scoped to the session", () => {
const f = fixture({ session: durable("a", "low"), agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.agent.set("plan")
local.model.variant.set("low")
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
f.set("sessions", "ses_b", durable("b", "high"))
f.set("route", "id", "ses_b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("restores each agent's model and variant after provider remount", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
const first = f.mount()
first.local.model.variant.set("high")
first.local.agent.set("plan")
first.local.model.set(key("c"))
first.local.model.variant.set("low")
first.dispose()
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test("changing models drops the old variant even when both models support it", () => {
const f = fixture({ session: durable("a", "high") })
const { local } = f.mount()
local.model.set(key("b"))
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
f.setPreferences("variant", "provider/c", "low")
local.model.set(key("c"))
expect(local.model.variant.current()).toBe("low")
})
test("restores durable selection ahead of agent, global, and saved variant defaults", () => {
const f = fixture({
session: durable("a", "low", "plan"),
agents: [agent("build"), agent("plan", key("b"), "high")],
config: { providerID: "provider", model: "c", variant: "high" },
preferred: "high",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("uses historical message selection only when durable and local selection are absent", () => {
const f = fixture()
const { local } = f.mount()
local.session.restore({ sessionID: "ses_b", agent: "plan", model: key("b") })
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: undefined })
local.session.restore({ sessionID: "ses_a", agent: "plan", model: { ...key("b"), variant: "low" } })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "low" })
local.model.variant.set("high")
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("invalid durable models fall through agent, global, recent, and connected defaults", () => {
const f = fixture({
session: durable("removed", "high"),
agents: [agent("build", key("b"), "low")],
config: "provider/c",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("agents", [agent("build", key("removed"))])
expect(local.model.current()?.id).toBe("c")
f.setPreferences("recent", [key("removed"), key("b")])
f.set("config", "provider/removed")
expect(local.model.current()?.id).toBe("b")
f.setPreferences("recent", [key("removed")])
expect(local.model.current()?.id).toBe("a")
f.set("providers", [])
expect(local.model.current()).toBeUndefined()
})
test("global and agent model/variant defaults react to configuration replacement", () => {
const f = fixture({ config: { providerID: "provider", model: "a", variant: "low" } })
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("config", { providerID: "provider", model: "b", variant: "high" })
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("agents", [agent("build", key("a"), "low")])
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("agents", [agent("build", key("c"), "high")])
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
f.set("agents", [agent("build")])
f.set("config", "provider/b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
})
test("durable and explicitly selected Default override a saved variant preference", () => {
const f = fixture({
session: durable("a"),
preferred: "high",
config: { providerID: "provider", model: "a", variant: "low" },
})
const { local } = f.mount()
expect(local.model.variant.current()).toBeUndefined()
local.model.variant.set(undefined)
expect(f.preferences.store.variant["provider/a"]).toBe("default")
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_a")
f.setPreferences("variant", "provider/a", "high")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBe("high")
f.set("route", "id", "ses_a")
expect(local.model.variant.current()).toBeUndefined()
})
test("waits for both commit acknowledgments, then releases only the matching draft", () => {
const f = fixture({ session: durable("b", "low") })
const { local } = f.mount()
local.agent.set("plan")
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "plan", model: key("a"), variant: "high" })
f.set("sessions", "ses_a", durable("b", "low", "plan"))
f.emit("session.agent.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
f.set("sessions", "ses_a", durable("a", "high", "plan"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test.each(["a", "b"])("a delayed commit preserves the newer %s/low selection", (model) => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.model.set(key(model))
local.model.variant.set("low")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model, variant: "low" })
})
test("a delayed commit preserves a newer agent even when model and variant match", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.agent.set("plan")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
})
test("a commit received while its session is inactive does not discard its local selection", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
f.set("sessions", "ses_b", durable("b", "low"))
f.set("route", "id", "ses_b")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("sessions", "ses_a", durable("c", "low"))
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
})
test("cancelling a failed commit retains the draft and does not cancel a newer commit", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
const cancel = local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
cancel()
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low"))
expect(local.model.current()?.id).toBe("a")
local.model.set(key("b"))
local.model.variant.set("low")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("b"), variant: "low" })
cancel()
f.set("sessions", "ses_a", durable("b", "low"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "high"))
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
})
test.each([1, -1] as const)("cycles %p from outside recents to the correct end and wraps", (direction) => {
const f = fixture({ session: durable("a") })
f.setPreferences("recent", [key("removed"), key("b"), key("c")])
const { local } = f.mount()
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "c" : "b")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
})
@@ -1,23 +0,0 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
test("LocalProvider selection integration", async () => {
// Isolate provider module substitutions from the rest of the browser suite.
const child = Bun.spawn(
[
process.execPath,
"test",
"--conditions=browser",
"--preload",
"./happydom.ts",
"./test-browser/fixtures/model-selection.ts",
],
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
)
const [status, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(status, stdout + stderr).toBe(0)
}, 30_000)
+1 -11
View File
@@ -17,7 +17,6 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Skill } from "@opencode/schema/skill"
import type { Event } from "@opencode/schema/event"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode/schema/event-log"
@@ -37,6 +36,7 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
import type { Reference } from "@opencode/schema/reference"
import type { Worktree } from "@opencode/schema/worktree"
import type { Vcs } from "@opencode/schema/vcs"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
@@ -360,15 +360,6 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
export type SessionDiffInput = {
readonly sessionID: Session.ID
readonly messageID?: SessionMessage.ID | undefined
readonly to?: SessionMessage.ID | undefined
readonly context?: number | undefined
}
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
export type SessionInboxListInput = { readonly sessionID: Session.ID }
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (
@@ -1142,7 +1133,6 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly diff: SessionDiffOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
@@ -68,8 +68,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -596,17 +594,6 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
preserveEffect<SessionDiffOutput>()(
raw["session.diff"]({
params: { sessionID: input["sessionID"] },
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
preserveEffect<SessionInboxListOutput>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
@@ -757,7 +744,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -62,8 +62,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -846,18 +844,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionDiffOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
@@ -147,14 +147,6 @@ export type SessionProviderContextProvenance = {
endpoint: string
}
export type SessionMessageIdle = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "idle"
outcome: "succeeded" | "failed" | "interrupted"
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -2185,7 +2177,6 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -3131,13 +3122,6 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3429,13 +3413,6 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3727,13 +3704,6 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
@@ -4223,27 +4193,6 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionDiffInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["messageID"]
readonly to?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["to"]
readonly context?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["context"]
}
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
-12
View File
@@ -1032,18 +1032,6 @@ export function createData(config: CreateDataInput) {
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// Mirror the projected idle marker so turn boundaries match before the next message read.
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "idle",
outcome:
event.type === "session.execution.succeeded"
? "succeeded"
: event.type === "session.execution.failed"
? "failed"
: "interrupted",
time: { created: event.created },
})
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
+64 -75
View File
@@ -9,7 +9,6 @@ import { AppProcess } from "@opencode/util/process"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -309,7 +308,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
options?: { stdin?: string; env?: Record<string, string> },
) {
const result = yield* proc
.run(
@@ -318,7 +317,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
{ stdin: options?.stdin },
)
.pipe(
Effect.mapError(
@@ -332,8 +331,7 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -387,7 +385,9 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,7 +464,13 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -493,23 +499,19 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file))
})
/**
* Three batched invocations over the tree pair instead of three per file. An
* explicit empty selection diffs nothing; an absent one diffs every changed path.
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
*/
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
repository: Repository
from: TreeID
@@ -517,57 +519,49 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
repositoryOperation(
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
"diff",
input.repository,
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
),
],
{ concurrency: 3 },
)
const statuses = nuls(names.text)
const files = statuses.flatMap((code, index) => {
const file = statuses[index + 1]
if (index % 2 !== 0 || !file) return []
return [
{
file: RelativePath.make(file),
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
} as const,
]
})
const stats = new Map(
nuls(numbers.text).flatMap((line) => {
const [additions, deletions, ...file] = line.split("\t")
if (!additions || !deletions || file.length === 0) return []
return [
[
file.join("\t"),
additions === "-" || deletions === "-"
? { binary: true, additions: 0, deletions: 0 }
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
] as const,
]
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
}),
)
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
return files.map((entry) => {
const stat = stats.get(entry.file)
return {
...entry,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
})
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
@@ -739,11 +733,6 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
-24
View File
@@ -57,11 +57,8 @@ import { SessionModelTransport } from "./session/model-transport.js"
import { llmClient } from "./effect/app-node-platform.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionDiff, TurnRangeError } from "./session/diff.js"
import { LocationServiceMap } from "./location-service-map.js"
import { FSUtil } from "@opencode/util/fs-util"
import type { EventLog } from "@opencode/schema/event-log"
import type { FileDiff } from "@opencode/schema/file-diff"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
@@ -116,7 +113,6 @@ export {
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -146,13 +142,6 @@ export interface Interface {
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
readonly diff: (input: {
readonly sessionID: SessionSchema.ID
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -241,7 +230,6 @@ const layer = Layer.effect(
const moves = yield* SessionMove.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -374,17 +362,6 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
diff: Effect.fn("Session.diff")(function* (input) {
const session = yield* result.get(input.sessionID)
const active = yield* execution.isActive(input.sessionID)
return yield* SessionDiff.turn(db, locations, {
session,
active,
messageID: input.messageID,
to: input.to,
context: input.context,
})
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
@@ -473,7 +450,6 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
-138
View File
@@ -1,138 +0,0 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["messageID", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
+3 -20
View File
@@ -60,21 +60,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
clearCurrentRetry.pipe(
Effect.andThen(
adapter.appendMessage(
SessionMessage.Idle.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "idle",
outcome,
metadata: event.metadata,
time: { created },
}),
),
),
)
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
@@ -138,11 +123,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => idle("succeeded"),
"session.execution.failed": () => idle("failed"),
// Shutdown keeps the execution claim and the resumed drain continues the turn.
"session.execution.interrupted": (event) =>
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
@@ -226,7 +226,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
+16 -33
View File
@@ -131,55 +131,38 @@ const layer = Layer.effect(
)
})
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
source: repo.source,
const comparison = {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
})
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
const ignored = Effect.fnUntraced(function* (
operation: "files" | "diff",
source: Git.Repository,
paths: readonly RelativePath[],
) {
return yield* git.index
.ignored({ repository: source, paths })
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const compared = yield* comparison("files", input)
const changed = yield* git.tree
.files({ repository: compared.repository, from: compared.from, to: compared.to })
.pipe(Effect.mapError((cause) => failure("files", cause)))
const skipped = yield* ignored("files", compared.source, changed)
return changed.filter((file) => !skipped.has(file))
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
if (input.paths?.length === 0) return []
const compared = yield* comparison("diff", input)
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
const diffs = yield* git.tree
const comparison = yield* compare("diff", input)
return yield* git.tree
.diff({
repository: compared.repository,
from: compared.from,
to: compared.to,
...comparison.input,
context: input.context,
paths: input.paths,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
const skipped = yield* ignored(
"diff",
compared.source,
diffs.map((file) => RelativePath.make(file.file)),
)
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
})
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
-37
View File
@@ -6,7 +6,6 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Git } from "@opencode/core/git"
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
import { VcsPatch } from "@opencode/core/vcs/patch"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -197,42 +196,6 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
-198
View File
@@ -1,198 +0,0 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionDiff } from "@opencode/core/session/diff"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionInbox } from "@opencode/core/session/inbox"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionProjector } from "@opencode/core/session/projector"
import { Snapshot } from "@opencode/core/snapshot"
import { Money } from "@opencode/schema/money"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
),
)
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
file.file,
file.status,
file.additions,
file.deletions,
]
describe("Session.diff", () => {
it.live(
"diffs the busy period containing a user message and ranges across later turns",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await write("first.txt", "first\n")()
await write("second.txt", "second\n")()
await write("manual.txt", "manual\n")()
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
sessions
.diff({ sessionID: created.id, context: 0, ...input })
.pipe(Effect.map((files) => files.map(summarize)))
expect(yield* diff()).toEqual([])
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const usage = {
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
const prompt = Effect.fn(function* (text: string) {
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
return admitted.id
})
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
const before = yield* snapshot.capture()
if (!before) throw new Error("Start snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(edit)
if (end === "running") return assistantMessageID
const after = end === "recorded" ? yield* snapshot.capture() : undefined
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
...usage,
snapshot: after,
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
})
return assistantMessageID
})
const idle = (outcome: "succeeded" | "failed") =>
outcome === "succeeded"
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
: bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
// Before any idle marker exists, a prompt's turn ends at the next prompt.
const first = yield* prompt("Edit the first file")
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
yield* Effect.promise(write("manual.txt", "manual edited\n"))
const second = yield* prompt("Edit the second file")
yield* step(write("second.txt", "second edited\n"), "recorded")
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
yield* idle("succeeded")
const third = yield* prompt("Add a third file")
yield* step(write("third.txt", "third\n"), "recorded")
const steer = yield* prompt("Also add a fourth file")
yield* step(write("fourth.txt", "fourth\n"), "recorded")
yield* idle("failed")
const busy = [
["fourth.txt", "added", 1, 0],
["third.txt", "added", 1, 0],
]
expect(yield* diff()).toEqual(busy)
expect(yield* diff({ messageID: steer })).toEqual(busy)
expect(yield* diff({ messageID: second })).toEqual([
["first.txt", "modified", 1, 1],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
])
expect(yield* diff({ messageID: first, to: third })).toEqual([
["first.txt", "modified", 1, 1],
["fourth.txt", "added", 1, 0],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
["third.txt", "added", 1, 0],
])
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
expect(full[0]?.patch).toContain("-first\n+first edited\n")
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "to",
})
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "messageID",
})
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
// A completed step without an end snapshot falls back to the last recorded end.
yield* prompt("Edit both files again")
yield* step(write("first.txt", "first edited twice\n"), "recorded")
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
yield* idle("succeeded")
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
// Only a step still running in the active session compares against the working copy.
yield* prompt("Delete the manual file")
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
expect(yield* diff()).toEqual([])
const session = yield* sessions.get(created.id)
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
// Reverting removes later history, markers included; a fork keeps the copied turns.
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
yield* sessions.revert.commit(created.id)
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 30_000 },
)
})
+2 -3
View File
@@ -561,9 +561,7 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
expect(yield* sessions.messages({ sessionID })).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
@@ -571,6 +569,7 @@ describe("SessionRestart background recovery", () => {
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
+2
View File
@@ -38,8 +38,10 @@
"@lydell/node-pty": "catalog:",
"@opencode/app": "workspace:*",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin-browser-desktop": "workspace:*",
"@opencode/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -0,0 +1,4 @@
import type { MainPlugin } from "@opencode/plugin/desktop/main"
import BrowserExtension from "@opencode/plugin-browser-desktop/main"
export const mainExtensions: readonly MainPlugin.Entry[] = [BrowserExtension]
@@ -0,0 +1,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)
},
}
}
@@ -1,35 +1,42 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { EventRpcs } from "../../shared/ipc-rpc"
import { createBrowserPane } from "../browser-pane"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
import { isRendererUrl } from "../windows/protocol"
import { sender } from "./context"
import { createMainExtensionHost } from "../extensions/host"
import { mainExtensions } from "../extensions/builtins"
import { emitIpcEvent } from "../ipc-events"
import { ExtensionEvent } from "../../shared/ipc-rpc/events"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const shutdown = yield* Shutdown.Service
const browser = createBrowserPane()
const stop = Effect.promise(() => browser.dispose())
const extensions = createMainExtensionHost(mainExtensions, (win, event) =>
emitIpcEvent(win.webContents, new ExtensionEvent({ event })),
)
const stop = Effect.promise(() => extensions.dispose())
const remove = yield* shutdown.add(stop)
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
BrowserPane: ({ request }, context) =>
DesktopExtension: ({ request }, context) =>
Effect.tryPromise(async () => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
throw new Error("browser.pane.owner.invalid")
}
if (request.type === "register") return browser.register(win, request.bindingID, request.target)
if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout)
if (request.type === "command") return browser.command(win, request.bindingID, request.command)
return browser.close(win, request.bindingID)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL()))
throw new Error("Desktop extension owner is unavailable")
if (request.type === "call") return extensions.call(win, request.call)
if (request.type === "cancel") extensions.cancel(win, request.extensionID, request.requestID)
if (request.type === "servers") extensions.configure(win, request.servers)
if (request.type === "surface")
extensions.surface(win, request.extensionID, request.surfaceID, request.layout)
if (request.type === "release") extensions.release(win, request.extensionID)
return null
}).pipe(Effect.orDie),
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
})
}),
)
@@ -1,6 +1,6 @@
import { nativeImage } from "electron"
// Native browser surfaces ignore a parent View's clip path. Cover only the
// Native surfaces ignore a parent View's clip path. Cover only the
// pixels outside the bottom arcs; never resize or style the page itself.
export function createCornerImages(color: readonly [number, number, number, number], radius: number, scale: number) {
const size = Math.max(1, Math.round(radius * scale))
+2 -7
View File
@@ -1,9 +1,8 @@
import type { BrowserPaneEvent } from "@opencode/app/desktop"
import type { DesktopMenuAction } from "@opencode/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode/app/updater"
import type { WslServersPlatform } from "@opencode/app/wsl/types"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import type {
ClipboardImage,
DirectoryPickerOptions,
@@ -23,13 +22,9 @@ export type UpdaterAPI = {
}
export type ElectronAPI = {
extensions: DesktopExtension.Transport
awaitInitialization(): Promise<ServerReadyData>
reconnectService(): Promise<ServerReadyData>
browserPane: {
request(request: BrowserPaneRequest): Promise<void>
send(request: BrowserPaneRequest): void
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
}
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks(): Promise<string[]>
+22 -5
View File
@@ -23,13 +23,30 @@ 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: {
request: (request) => invoke("BrowserPane", { request }),
send: (request) => send("BrowserPane", { request }),
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(value)),
},
wslServers: {
getState: () => invoke("WslGetState").then(mutable),
subscribe: (cb) => {
@@ -1,9 +1,4 @@
import {
ACCEPTED_FILE_EXTENSIONS,
ServerConnection,
type Platform,
type UpdaterPlatform,
} from "@opencode/app/desktop"
import { ACCEPTED_FILE_EXTENSIONS, ServerConnection, type Platform, type UpdaterPlatform } from "@opencode/app/desktop"
import type { ElectronAPI } from "../api-types"
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
import { windowFullscreen } from "../window/fullscreen"
@@ -11,6 +6,7 @@ import { DragCancelEvent } from "../../shared/ipc-transport"
import { createDesktopFiles } from "./files"
import { createDesktopMenuAction } from "./menu"
import { createDesktopNotify } from "./notifications"
import BrowserExtension from "@opencode/plugin-browser-desktop"
import { createDesktopStorage } from "./storage"
export type DesktopWindowState = {
@@ -29,37 +25,10 @@ export function createDesktopPlatform(
os,
version: windowState.version,
windowID: windowState.id,
extensions: api.extensions,
extensionPlugins: [BrowserExtension],
...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS),
...createDesktopStorage(api),
browserPane: {
register(target, onEvent) {
const bindingID = crypto.randomUUID()
let closed = false
const dispose = api.browserPane.onEvent((value) => {
if (!closed && value.bindingID === bindingID) onEvent(value.event)
})
const ready = api.browserPane.request({ type: "register", bindingID, target })
// Failures reach the owner through the closed-state event; keep the bare promise handled.
void ready.catch(() => undefined)
return {
setLayout(layout) {
if (!closed)
void ready
.then(() =>
api.browserPane.send({ type: "layout", bindingID, ...(layout === undefined ? {} : { layout }) }),
)
.catch(() => undefined)
},
command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })),
close() {
if (closed) return
closed = true
dispose()
void ready.then(() => api.browserPane.request({ type: "close", bindingID })).catch(() => undefined)
},
}
},
},
updater,
exportDebugLogs: () => api.exportDebugLogs(),
setForceFocus: (enabled) => api.setForceFocus(enabled),
@@ -1,38 +0,0 @@
import { Browser } from "@opencode/plugin-browser/rpc"
import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum))
const bindingID = text(128)
const endpoint = Schema.Struct({
url: text(16_384),
username: Schema.optionalKey(text(1_024)),
password: Schema.optionalKey(text(4_096)),
})
const target = Schema.Struct({ sessionID: text(256).check(Schema.isStartsWith("ses")), endpoint })
const bounds = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite })
const channel = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 255 }))
const layout = Schema.Struct({
tabID: Browser.TabID,
visible: Schema.Boolean,
bounds: Schema.optionalKey(bounds),
background: Schema.optionalKey(Schema.Tuple([channel, channel, channel, channel])),
radius: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
})
export const BrowserPaneRequestSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("register"), bindingID, target }),
Schema.Struct({ type: Schema.Literal("layout"), bindingID, layout: Schema.optionalKey(layout) }),
Schema.Struct({ type: Schema.Literal("command"), bindingID, command: Browser.Action }),
Schema.Struct({ type: Schema.Literal("close"), bindingID }),
])
export type BrowserPaneRequest = Schema.Schema.Type<typeof BrowserPaneRequestSchema>
export const BrowserPaneEventSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("focus"), tabID: Browser.TabID }),
Schema.Struct({
type: Schema.Literal("state"),
state: Schema.NullOr(Browser.State),
error: Schema.optionalKey(Schema.String),
}),
])
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
@@ -1,12 +1,12 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import { ExtensionRpc } from "./extensions"
export class BrowserPaneEvent extends Schema.TaggedClass<BrowserPaneEvent>()("BrowserPaneEvent", {
bindingID: Schema.String,
event: BrowserPaneEventSchema,
export class ExtensionEvent extends Schema.TaggedClass<ExtensionEvent>()("ExtensionEvent", {
event: DesktopExtension.Event,
}) {}
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
@@ -46,7 +46,7 @@ export class StorageChanged extends Schema.TaggedClass<StorageChanged>()("Storag
}) {}
export const DesktopEvent = Schema.Union([
BrowserPaneEvent,
ExtensionEvent,
DeepLinksOpened,
MenuCommandTriggered,
UpdaterStateChanged,
@@ -59,4 +59,4 @@ export const DesktopEvent = Schema.Union([
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
export const EventRpcs = RpcGroup.make(DesktopEvents, ExtensionRpc)
@@ -0,0 +1,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,
})
+41 -37
View File
@@ -6,9 +6,10 @@ import path from "node:path"
import { app, BrowserWindow, nativeImage } from "electron"
import { Browser } from "@opencode/plugin-browser/rpc"
import { OpenCode } from "@opencode/client"
import { Effect, Fiber, Schema, Stream } from "effect"
import { createBrowserPane } from "../../src/main/browser-pane"
import { bindIpcEvents, ipcEventStream } from "../../src/main/ipc-events"
import { Schema } from "effect"
import BrowserExtension from "@opencode/plugin-browser-desktop/main"
import { BrowserDesktop } from "@opencode/plugin-browser-desktop/rpc"
import { createMainExtensionHost } from "../../src/main/extensions/host"
import { Smoke } from "./contract"
import { verifyTargets } from "./targets"
@@ -126,7 +127,6 @@ async function main() {
const location = { directory: process.env.SMOKE_SERVER_FILES! }
const rpc = client.rpc(Smoke)
const session = await client.session.create({ title: "Browser suite", location })
const pane = createBrowserPane()
const win = new BrowserWindow({ show: false, width: 1100, height: 800, webPreferences: { sandbox: true } })
const readyToShow = once(win, "ready-to-show")
await win.loadURL("about:blank")
@@ -139,28 +139,40 @@ async function main() {
const ipcErrors: string[] = []
const replaced: string[] = []
const inventories = new Map<string, Browser.State | null>()
const unbind = await Effect.runPromise(bindIpcEvents(win.webContents.id))
const events = Effect.runFork(
ipcEventStream(win.webContents.id).pipe(
Stream.runForEach((event) =>
Effect.sync(() => {
if (event._tag !== "BrowserPaneEvent") return
if (event.event.type === "state") {
if (event.event.error === "browser.pane.replaced") replaced.push(event.bindingID)
inventories.set(event.bindingID, event.event.state)
return
}
if (!inventories.get(event.bindingID)?.tabs.some((tab) => tab.id === event.event.tabID))
ipcErrors.push("Focus arrived before its tab inventory")
pane.layout(win, event.bindingID, {
tabID: event.event.tabID,
visible: true,
bounds: { x: 0, y: 0, width: 1000, height: 700 },
})
}),
const surfaces = new Map<string, Readonly<Record<string, string>>>()
const pane = createMainExtensionHost([BrowserExtension], (_win, payload) => {
const event = Schema.decodeUnknownSync(BrowserDesktop.Definition.events.changed.schema)(payload.data)
if (event.event.type === "state") {
if (event.event.error === "browser.pane.replaced") replaced.push(event.bindingID)
inventories.set(event.bindingID, event.event.state)
surfaces.set(event.bindingID, event.event.surfaces)
return
}
if (!inventories.get(event.bindingID)?.tabs.some((tab) => tab.id === event.event.tabID))
ipcErrors.push("Focus arrived before its tab inventory")
layout(event.bindingID, event.event.tabID)
})
pane.configure(win, [{ id: "fixture", url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD }])
function layout(bindingID: string, tabID: Browser.TabID) {
Object.entries(surfaces.get(bindingID) ?? {}).forEach(([id, surfaceID]) =>
pane.surface(
win,
BrowserExtension.id,
surfaceID,
id === tabID ? { visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } } : undefined,
),
),
)
)
}
async function register(bindingID: string) {
const result = await pane.call(win, {
extensionID: BrowserExtension.id,
rpcID: BrowserDesktop.Definition.id,
method: "register",
requestID: crypto.randomUUID(),
input: { bindingID, sessionID: session.id, serverID: "fixture" },
})
assert.deepEqual(result, { ok: true, output: null })
}
const visited = new Set<Browser.Method>()
async function call<Name extends Browser.Method>(
name: Name,
@@ -187,10 +199,7 @@ async function main() {
}
try {
await verifyTargets(win, fixture)
await pane.register(win, "suite", {
sessionID: session.id,
endpoint: { url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD },
})
await register("suite")
const first = await call("tabs.open", { url: fixture })
const second = await call("tabs.open", { url: `${fixture}/other`, focus: false })
assert.equal((await call("tabs.list", {})).tabs.length, 2)
@@ -290,9 +299,9 @@ async function main() {
await new Promise(resolve=>{const frame=document.querySelector('iframe'); frame.onload=()=>resolve(null); frame.src=${JSON.stringify(`http://localhost:${address.port}/frame`)};});
})()`,
})
pane.layout(win, "suite", { tabID, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } })
layout("suite", tabID)
await call("tabs.focus", { tabID: second.id })
pane.layout(win, "suite", { tabID: second.id, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } })
layout("suite", second.id)
const snap = await call("snapshot", { tabID, boxes: true })
const ref = (text: string) => {
const match = snap.content
@@ -546,18 +555,13 @@ async function main() {
[],
)
assert.deepEqual(ipcErrors, [])
await pane.register(win, "replacement", {
sessionID: session.id,
endpoint: { url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD },
})
await register("replacement")
await until(async () => replaced.includes("suite"))
console.log(
`PASS ${visited.size} browser operations over physical authenticated HTTP, including file bytes in both directions`,
)
} finally {
await pane.dispose()
await Effect.runPromise(Fiber.interrupt(events))
await Effect.runPromise(unbind)
win.destroy()
web.closeAllConnections()
await new Promise<void>((resolve) => web.close(() => resolve()))

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