Compare commits

...
Author SHA1 Message Date
kitlangton 9cc8b259aa feat(tui): copy session ID from tab menu 2026-09-20 15:11:22 +00:00
opencode-agent[bot] 66f10ab7bf chore(core): refresh bundled models.dev snapshot 2026-09-20 14:37:06 +00:00
opencode-agent[bot] 1ca8f63a79 chore(core): refresh bundled models.dev snapshot 2026-09-20 13:36:51 +00:00
opencode-agent[bot] 03bcdd580d chore(core): refresh bundled models.dev snapshot 2026-09-20 12:43:39 +00:00
opencode-agent[bot] cc502f7e5f chore(core): refresh bundled models.dev snapshot 2026-09-20 11:37:37 +00:00
opencode-agent[bot] 64ce8771c0 chore(core): refresh bundled models.dev snapshot 2026-09-20 10:37:01 +00:00
opencode-agent[bot] 702a73d91a chore(core): refresh bundled models.dev snapshot 2026-09-20 09:37:50 +00:00
opencode 991b727eb8 sync release versions for v2.0.11 2026-09-20 08:57:41 +00:00
opencode-agent[bot] ba342ce227 chore(core): refresh bundled models.dev snapshot 2026-09-20 08:42:13 +00:00
David Hill 1464545665 fix(app): center timeline beside summary (#49620) 2026-09-20 18:06:32 +10:00
David Hill fecacc9e68 fix(app): match vertical nav tab states (#49619) 2026-09-20 18:03:49 +10:00
David HillandLukeParkerDev 1f73b4806b fix(desktop): polish browser panel states and navigation (#49432)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-20 07:54:25 +00:00
opencode-agent[bot] da2ce02596 chore(core): refresh bundled models.dev snapshot 2026-09-20 07:38:48 +00:00
opencode-agent[bot] 25f35dcfb8 chore(core): refresh bundled models.dev snapshot 2026-09-20 06:49:40 +00:00
Luke Parker 717f81ce08 chore(desktop): attribute renderer CPU profiles to source files (#50114) 2026-09-20 16:30:35 +10:00
Luke Parker 5fdfcc7a80 perf(desktop): hand the window its persisted stores before the first render (#50113) 2026-09-20 16:29:02 +10:00
Luke Parker 0530c8e512 perf(desktop): talk to the main process without the Effect runtime in the renderer (#50112) 2026-09-20 16:26:08 +10:00
Luke Parker 1d8cf4564b perf(desktop): move startup work that the first window does not need off its path (#50111) 2026-09-20 16:23:37 +10:00
Luke Parker 7e88f6bb18 perf(desktop): bundle the renderer's initial module graph as one chunk (#50110) 2026-09-20 16:19:28 +10:00
Luke Parker af592fb779 perf(desktop): serve the renderer's first assets before evaluating the main bundle (#50109) 2026-09-20 16:18:50 +10:00
Luke Parker 7fc3f68007 perf(desktop): boot the renderer alongside the main bundle (#50108) 2026-09-20 16:15:58 +10:00
opencode-agent[bot] cdcbb0047e chore(core): refresh bundled models.dev snapshot 2026-09-20 05:38:29 +00:00
opencode-agent[bot] a1956a7522 chore(core): refresh bundled models.dev snapshot 2026-09-20 04:42:05 +00:00
opencode-agent[bot] 55bc7fd403 chore(core): refresh bundled models.dev snapshot 2026-09-20 03:43:32 +00:00
opencode-agent[bot] 3049b1e684 chore(core): refresh bundled models.dev snapshot 2026-09-20 02:45:23 +00:00
opencode-agent[bot] 1f36a7aff8 chore(core): refresh bundled models.dev snapshot 2026-09-20 01:31:11 +00:00
91 changed files with 1199 additions and 315 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -111,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.10",
"version": "2.0.11",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +201,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -214,7 +214,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -250,7 +250,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -277,7 +277,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -294,7 +294,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -318,7 +318,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -406,7 +406,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -455,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -527,7 +527,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +540,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +554,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +569,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -608,7 +608,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +638,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +653,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +677,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +698,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -720,7 +720,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +755,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -775,7 +775,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -809,7 +809,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -828,7 +828,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -874,7 +874,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +923,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +958,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +991,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1032,7 +1032,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.11",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -19,6 +19,13 @@ export function mountBrowserPane() {
session: "Alpha",
mounted: true,
visible: true,
url: undefined as string | undefined,
loading: false,
generation: 0,
delayNavigation: false,
pendingURL: undefined as string | undefined,
loadErrors: {} as Record<string, string | undefined>,
error: undefined as string | undefined,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
})
const tabs = ["Alpha", "Beta"].map((name) => ({
@@ -47,13 +54,33 @@ export function mountBrowserPane() {
opened: () => !!registrations.get(store.session),
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
tabs: () => tabs.filter((tab) => tab.title === store.session),
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
active: () => {
const tab = tabs.find((tab) => tab.title === store.session) ?? tabs[0]
return {
...tab,
url: store.url ?? tab.url,
loading: store.loading,
generation: store.generation,
loadError: store.loadErrors[store.session],
}
},
registration: () => registrations.get(store.session),
error: () => undefined,
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
suspended: () => false,
close: () => undefined,
open: () => undefined,
command: () => undefined,
command: (command) => {
setStore("error", undefined)
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
if (command.type === "navigate") {
if (store.delayNavigation) {
setStore("pendingURL", command.url)
return
}
setStore({ url: command.url, loading: false, generation: store.generation + 1 })
}
if (command.type === "stop") setStore("loading", false)
},
}
return (
<>
@@ -64,6 +91,32 @@ export function mountBrowserPane() {
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
</For>
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
<button
onClick={() =>
setStore({ loading: true, generation: store.generation + 1, loadErrors: { [store.session]: undefined } })
}
>
Load current page
</button>
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
Block navigation
</button>
<button
onClick={() =>
setStore({
url: store.pendingURL,
pendingURL: undefined,
loading: false,
generation: store.generation + 1,
})
}
>
Complete navigation
</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
@@ -57,3 +57,153 @@ story("hides the native view immediately while the pane stays mounted", async ({
await toggle.click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Loading page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Stop", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("keeps the submitted URL visible until the browser reports navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
await address.fill("https://unsubmitted.example/")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
await expect(address).toHaveValue("https://example.com/")
})
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://blocked.example/")
await address.press("Enter")
await expect(address).toHaveValue("https://blocked.example/")
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
"https://alpha.example/",
)
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Reload", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("returns a failed tab to the empty state when an empty URL is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("selects the full URL when the address field gains focus", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
await address.pressSequentially("https://example.com/")
await expect(address).toHaveValue("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await address.focus()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
await address.press("ArrowRight")
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
})
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
})
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.11",
"description": "",
"type": "module",
"exports": {
+4 -4
View File
@@ -65,14 +65,14 @@
}
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
Move only as far as needed; wider panels already have enough space in the margin. */
@container session-chat (min-width: 1320px) {
When they overlap, center the conversation in the space that remains beside the summary. */
@container session-chat (min-width: 1320px) and (width < 1640px) {
[data-slot="session-chat-panel"][data-summary-open="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
--session-summary-translate: min(0px, calc(50cqi - 820px));
--session-summary-translate: -160px;
&:dir(rtl) {
--session-summary-translate: max(0px, calc(820px - 50cqi));
--session-summary-translate: 160px;
}
}
}
+4
View File
@@ -906,6 +906,10 @@ export const dict = {
"session.browser.replaced": "Browser control moved to another desktop window.",
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
"session.browser.address.placeholder": "Enter URL",
"session.browser.empty.title": "Enter URL",
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
"session.browser.failed.title": "URL can't be reached",
"session.browser.failed.description": "Check the URL and your connection, then try again.",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
+63 -10
View File
@@ -20,13 +20,18 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const command = useCommand()
const state = props.browser.active
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
const failed = () => !!state()?.loadError
const registration = props.browser.registration
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
submitted: false,
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
navigating: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
const empty = () => !address() && !state()?.loading && !store.navigating
let surface: HTMLDivElement | undefined
let addressDisplay: HTMLDivElement | undefined
let frame: number | undefined
@@ -43,7 +48,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
title: language.t("command.browser.reload"),
category: language.t("command.category.view"),
keybind: "f5",
disabled: !props.visible || !state(),
disabled: !props.visible || !address(),
onSelect: () => {
const tab = state()
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
@@ -71,7 +76,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
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 desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -107,7 +114,25 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(() => !store.editing && setStore("address", address()))
createEffect(on([() => state()?.id, address], () => !store.editing && setStore("address", address())))
// Any reported movement, including a rejected or blocked request, ends the submitted navigation.
createEffect(
on(
[() => state()?.id, () => state()?.generation, () => state()?.loading, () => props.browser.error()],
() => setStore("navigating", false),
{ defer: true },
),
)
// A blocked or rejected submission leaves the page where it was; show that page's URL again.
createEffect(
on(
() => props.browser.error(),
(error) => {
if (error && !store.editing) setStore("address", address())
},
{ defer: true },
),
)
createEffect(
on(registration, (current) => {
// Session routes can change before this pane unmounts. Hide the registration
@@ -123,6 +148,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
() => store.visible,
() => props.visible,
() => state()?.id,
empty,
failed,
registration,
],
() => {
@@ -157,7 +184,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
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">
<div class="h-10 shrink-0 flex items-center gap-1 px-3 border-b border-v2-border-border-muted">
<For each={["back", "forward"] as const}>
{(direction) => (
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
@@ -193,7 +220,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
>
<IconButton
{...button}
disabled={!state()}
disabled={!state()?.loading && !address()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
@@ -212,8 +239,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
onSubmit={(event) => {
event.preventDefault()
const tab = state()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
const url = store.address.trim()
if (!tab) return
if (url || failed()) {
setStore({ submitted: true, address: url, navigating: true })
props.browser.command({ type: "navigate", tabID: tab.id, url: url || "about:blank" })
}
event.currentTarget.querySelector("input")?.blur()
}}
>
<input
@@ -224,8 +256,14 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
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: address() })}
onFocus={(event) => {
setStore("editing", true)
event.currentTarget.select()
}}
onClick={(event) => event.currentTarget.select()}
onBlur={() =>
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
}
onInput={(event) => setStore("address", event.currentTarget.value)}
onScroll={(event) => {
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
@@ -243,12 +281,27 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
</div>
</form>
</div>
<Show when={props.browser.error()}>
<Show when={props.browser.error() && !failed()}>
<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 flex items-center justify-center">
<Show when={(empty() || failed()) && !props.browser.suspended()}>
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
<div
dir="auto"
class="flex size-full flex-col items-center justify-center gap-2 p-6 pb-[200px] text-center text-text-weak"
>
<Icon name="globe" size="large" class="mb-2 shrink-0" />
<div class="text-[13px] font-medium leading-[var(--line-height-compact)] text-text-strong">
{language.t(failed() ? "session.browser.failed.title" : "session.browser.empty.title")}
</div>
<div class="text-13-regular leading-[var(--line-height-base)]">
{language.t(failed() ? "session.browser.failed.description" : "session.browser.empty.description")}
</div>
</div>
</Show>
<Show when={props.browser.suspended()}>
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
{language.t("session.browser.suspended")}
+15 -3
View File
@@ -1,6 +1,8 @@
import { children, createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Tabs } from "@opencode/ui/tabs"
@@ -69,9 +71,19 @@ export function SortableTab(props: {
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
<IconButton
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.onTabClose(props.tab)
}}
icon={<Icon name="xmark-small" />}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
+10 -1
View File
@@ -9,7 +9,8 @@
justify-content: center;
}
[data-titlebar-tab] {
[data-titlebar-tab],
[data-titlebar-tab-action] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
@@ -68,6 +69,14 @@
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab-action]:is(:hover, :focus-visible) {
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab-action]:is(:active, [data-state="pressed"]) {
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
+4 -2
View File
@@ -351,9 +351,10 @@ export function Titlebar(props: {
>
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=pressed]:text-v2-text-text-base"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
@@ -650,8 +651,9 @@ export function Titlebar(props: {
{homeButton(true)}
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-new-session"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.10",
"version": "2.0.11",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.10",
"version": "2.0.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.11",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
File diff suppressed because one or more lines are too long
+28
View File
@@ -28,6 +28,29 @@ const sentry =
})
: false
// Every module the entry reaches through static imports lands in one chunk. Automatic splitting
// otherwise fragments the initial graph into ~50 files shared with lazy routes, and each file costs
// the renderer a main-thread request round trip through the main process before first paint.
type ChunkingContext = { getModuleInfo(id: string): { isEntry: boolean; importers: readonly string[] } | null }
const initialGraph = new WeakMap<ChunkingContext, Map<string, boolean>>()
function inInitialGraph(id: string, ctx: ChunkingContext) {
const memo = initialGraph.get(ctx) ?? new Map<string, boolean>()
initialGraph.set(ctx, memo)
const visit = (id: string, path: Set<string>): boolean => {
const known = memo.get(id)
if (known !== undefined) return known
if (path.has(id)) return false
const info = ctx.getModuleInfo(id)
if (!info) return false
path.add(id)
const result = info.isEntry || info.importers.some((importer) => visit(importer, path))
path.delete(id)
memo.set(id, result)
return result
}
return visit(id, new Set())
}
export default defineConfig(({ command }) => ({
main: {
resolve: {
@@ -110,6 +133,11 @@ const require = __cjs_mod__.createRequire(import.meta.url);
input: {
main: "src/renderer/index.html",
},
output: {
codeSplitting: {
groups: [{ name: (id, ctx) => (inInitialGraph(id, ctx) ? "app" : null), priority: 10 }],
},
},
},
},
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+17 -4
View File
@@ -191,8 +191,14 @@ const phaseOrder = [
["electron js init → entry", "nodeBootstrapped", "entryStart"],
["entry → chromium ready", "entryStart", "electronReady"],
["ready → window shown", "electronReady", "windowVisible"],
["main bundle load + evaluate", "windowVisible", "bundleEvaluated"],
["layers → first log line", "bundleEvaluated", "appStarting"],
["window → renderer assets served", "windowVisible", "rendererAssetsServed"],
["main bundle load + evaluate", "rendererAssetsServed", "bundleEvaluated"],
["bundle → onboarding decided", "bundleEvaluated", "onboardingDecided"],
["onboarding → logging ready", "onboardingDecided", "loggingReady"],
["logging → first log line", "loggingReady", "appStarting"],
["first log line → storage open", "appStarting", "storageOpen"],
["storage → initialization done", "storageOpen", "initializationDone"],
["initialization → layers ready", "initializationDone", "layersReady"],
["layers → renderer process", "appStarting", "rendererProcess"],
["renderer boot → first paint", "rendererProcess", "firstPaint"],
["first paint → shell", "firstPaint", "shellVisible"],
@@ -397,8 +403,15 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt),
entryStart: main.marks.entry && main.marks.entry - spawnAt,
electronReady: main.marks.ready && main.marks.ready - spawnAt,
rendererAssetsServed: main.marks.served && main.marks.served - spawnAt,
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
onboardingDecided: main.marks.onboarding && main.marks.onboarding - spawnAt,
loggingReady: main.marks.logging && main.marks.logging - spawnAt,
crashReporterStarted: main.marks.crash && main.marks.crash - spawnAt,
appStarting: main.appStarting && main.appStarting - spawnAt,
storageOpen: main.marks.storage && main.marks.storage - spawnAt,
initializationDone: main.marks.init && main.marks.init - spawnAt,
layersReady: main.marks.layers && main.marks.layers - spawnAt,
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
serviceStarting: main.serviceStarting && main.serviceStarting - spawnAt,
@@ -593,8 +606,8 @@ function mainLog() {
// A window shown before the logger existed reports when it was shown; the line itself is later.
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
if (shown) windowShownAt = Number(shown)
if (/app starting/.test(message))
for (const [, key, value] of entry.matchAll(/\b(entry|ready|window|bundle): (\d{10,})/g)) marks[key] = Number(value)
if (/app starting|layers ready/.test(message))
for (const [, key, value] of entry.matchAll(/\b(\w+): (\d{10,})/g)) marks[key] = Number(value)
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
}
}
@@ -0,0 +1,10 @@
param([Parameter(Mandatory)][string]$Name, [int]$Runs = 5)
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot\..
# electron-vite directly: the package's prebuild hook re-downloads the CLI, which the bench keeps fixed.
bunx electron-vite build 2>&1 | Select-String -Pattern "built in|error" | Select-Object -Last 3
if (-not (Test-Path out\main\index.js)) { throw "build failed" }
bunx electron-builder --win --dir --config electron-builder.config.ts 2>&1 | Select-String -Pattern "error|signing with signtool.*OpenCode Dev" | Select-Object -Last 2
if (Test-Path "dist\$Name-unpacked") { Remove-Item "dist\$Name-unpacked" -Recurse -Force }
Rename-Item -Path dist\win-unpacked -NewName "$Name-unpacked"
bun ./scripts/bench-startup.ts --exe "dist\base-unpacked\OpenCode Dev.exe" --compare "dist\$Name-unpacked\OpenCode Dev.exe" --runs $Runs --warmup 1 --window-at=-1700,20 --out "dist\bench-startup\$Name" 2>&1 | Select-String -Pattern "^warm service|^\s{2,}|^phases|^\S+\s+\d+\s+\(|^report|^warm-up|Error|error" | Select-Object -Last 45
@@ -0,0 +1,94 @@
// Attribute a renderer .cpuprofile's self time to original source files through the build's
// source maps. Run with: bun scripts/profile-by-source.ts <profile.cpuprofile> [out/renderer/assets]
import { readFileSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { TraceMap, originalPositionFor } from "C:/Users/Lukem/.local/share/opencode/worktree/6c1049/quiet-wolf-2/node_modules/.bun/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs"
const profilePath = process.argv[2]!
const assets = process.argv[3] ?? "out/renderer/assets"
const profile = JSON.parse(readFileSync(profilePath, "utf8"))
const maps = new Map<string, TraceMap>()
for (const name of readdirSync(assets).filter((f) => f.endsWith(".js.map"))) {
maps.set(name.slice(0, -4), new TraceMap(JSON.parse(readFileSync(join(assets, name), "utf8"))))
}
const nodes = new Map<number, any>()
for (const n of profile.nodes) nodes.set(n.id, n)
const self = new Map<string, number>()
const byPkg = new Map<string, number>()
const group = (source: string) => {
const n = source.replace(/\\/g, "/")
const nm = n.match(/node_modules\/(?:\.bun\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)(?:\/dist\/([^/]+))?/)
if (nm) return nm[1] === "effect" ? `effect/${(nm[2] ?? "").replace(/\.js$/, "")}` : nm[1]
const pk = n.match(/packages\/([^/]+)\/src\/(.+)$/)
return pk ? `${pk[1]}/${pk[2]}` : n.slice(-50)
}
const parent = new Map<number, number>()
for (const n of profile.nodes) for (const c of n.children ?? []) parent.set(c, n.id)
const resolve = (frame: any) => {
const name = frame.functionName
if (name === "(program)" || name === "(garbage collector)") return { label: name, fn: name }
const file = frame.url.split("/").pop()
const map = maps.get(file)
if (!map) return { label: `(no map) ${file}`, fn: name }
const pos = originalPositionFor(map, { line: frame.lineNumber + 1, column: frame.columnNumber })
return { label: pos.source ? group(pos.source) : `(unmapped) ${file}`, fn: pos.name ?? name }
}
const labelOf = (frame: any) => resolve(frame).label
// A sample belongs to the render phase once Solid's root is on the stack; everything before that is
// module evaluation, everything after the first render is later work (hydration, effects, timers).
const stackHas = (id: number, test: (label: string, fn: string) => boolean) => {
for (let cur: number | undefined = id; cur !== undefined; cur = parent.get(cur)) {
const resolved = resolve(nodes.get(cur).callFrame)
if (test(resolved.label, resolved.fn)) return true
}
return false
}
const phases = { evaluate: new Map<string, number>(), render: new Map<string, number>(), later: new Map<string, number>() }
let phase: keyof typeof phases = "evaluate"
let t = 0
let total = 0
for (let i = 0; i < profile.samples.length; i++) {
const dt = (profile.timeDeltas[i] ?? 0) / 1000
t += dt
const node = nodes.get(profile.samples[i])
if (node.callFrame.functionName === "(idle)") {
if (phase === "render" && dt > 5) phase = "later"
continue
}
total += dt
if (phase === "evaluate" && stackHas(node.id, (label, fn) => label === "solid-js" && (fn === "render" || fn === "createRoot")))
phase = "render"
const label = labelOf(node.callFrame)
const bucket = phases[phase]
bucket.set(label, (bucket.get(label) ?? 0) + dt)
self.set(label, (self.get(label) ?? 0) + dt)
const pkg = label.split("/").slice(0, label.startsWith("effect/") || label.startsWith("@") ? 2 : 1).join("/")
byPkg.set(pkg, (byPkg.get(pkg) ?? 0) + dt)
}
console.log(`busy ${total.toFixed(0)} ms over ${t.toFixed(0)} ms`)
void phases
// Timeline: 25 ms buckets with the top sources, so module evaluation, render and hydration show as bands.
const buckets = new Map<number, Map<string, number>>()
t = 0
for (let i = 0; i < profile.samples.length; i++) {
const dt = (profile.timeDeltas[i] ?? 0) / 1000
t += dt
const node = nodes.get(profile.samples[i])
if (node.callFrame.functionName === "(idle)") continue
const b = Math.floor(t / 25) * 25
const m = buckets.get(b) ?? new Map()
const label = labelOf(node.callFrame).replace(/^(\.\.\/)+/, "")
m.set(label, (m.get(label) ?? 0) + dt)
buckets.set(b, m)
}
console.log("\n== timeline (25 ms buckets) ==")
for (const [b, m] of [...buckets].sort((a, c) => a[0] - c[0])) {
const busy = [...m.values()].reduce((a, c) => a + c, 0)
if (busy < 1) continue
const top = [...m].sort((a, c) => c[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(" | ")
console.log(String(b).padStart(5), busy.toFixed(0).padStart(3), top)
}
console.log("\n== by package (all) ==")
for (const [k, v] of [...byPkg].sort((a, b) => b[1] - a[1]).slice(0, 20)) console.log(v.toFixed(1).padStart(7), k)
+44 -6
View File
@@ -103,11 +103,17 @@ export function createBrowserPage(
revision++
})
let closed = false
// Whether the native surface holds a real document worth showing. Chromium keeps the
// previous document painted until the next one renders, so a shown page stays shown
// through later navigations; blank and failed documents hide until a real one is ready.
let content = false
let failure: { url: string; message: string } | undefined
const state = (): Browser.Tab => ({
id: options.id,
url: contents.getURL().slice(0, 16_384),
url: (failure?.url ?? contents.getURL()).slice(0, 16_384),
title: contents.getTitle().slice(0, 2_048),
loading: contents.isLoading(),
...(failure ? { loadError: failure.message } : {}),
canGoBack: contents.navigationHistory.canGoBack(),
canGoForward: contents.navigationHistory.canGoForward(),
generation,
@@ -115,16 +121,40 @@ export function createBrowserPage(
const publish = () => {
if (!closed) options.publish()
}
const reset = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => {
const reset = (event: Electron.Event<{ url: string; isMainFrame: boolean; isSameDocument: boolean }>) => {
if (!event.isMainFrame || event.isSameDocument) return
failure = undefined
generation++
documents.clear()
refs.clear()
diagnostics.clear()
publish()
}
const settle = () => {
content = contents.getURL() !== "about:blank" && !failure
updateVisibility()
}
contents.on("did-start-navigation", reset)
contents.on("did-stop-loading", publish)
contents.on("did-navigate", (_event, url, status, statusText) => {
// The server-network proxy answers an unreachable HTTP target with an empty 502. Other
// error statuses are real documents from the user's server and stay visible.
if (status === 502) failure = { url, message: `${status} ${statusText}`.trim().slice(0, 2_048) }
// A blank or failed document paints at commit; a real one waits for dom-ready.
if (url === "about:blank" || failure) settle()
publish()
})
contents.on("did-fail-load", (_event, code, description, url, isMainFrame) => {
// Cancelled navigation and failed subframes do not replace the current page.
if (!isMainFrame || code === -3) return
failure = { url, message: description.slice(0, 2_048) }
settle()
publish()
})
contents.on("dom-ready", settle)
contents.on("did-stop-loading", () => {
settle()
publish()
})
contents.on("did-navigate-in-page", publish)
contents.on("page-title-updated", publish)
contents.on("render-process-gone", () => {
@@ -252,6 +282,14 @@ export function createBrowserPage(
corner.setVisible(false)
win.contentView.addChildView(corner)
})
let visible = false
const updateVisibility = () => {
// The renderer's layout requests may lag behind navigation; the page decides
// whether there is a document worth exposing over the themed background.
const show = visible && content
view.setVisible(show)
corners.forEach((corner) => corner.setVisible(show && !!cornerKey))
}
const ready = Promise.all([
files.ready,
...(options.initialize === false
@@ -302,9 +340,9 @@ export function createBrowserPage(
)
})
},
setVisible(visible: boolean) {
view.setVisible(visible)
corners.forEach((corner) => corner.setVisible(visible && !!cornerKey))
setVisible(value: boolean) {
visible = value
updateVisibility()
},
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
await ready
+8
View File
@@ -5,6 +5,7 @@ import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { installContextMenu } from "./lifecycle/environment"
import { ApplicationLifecycle } from "./lifecycle"
import { DesktopLogging } from "./native/logging"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
@@ -15,8 +16,15 @@ marks.bundle = Date.now()
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
marks.layers = Date.now()
yield* Effect.logInfo("layers ready", { marks })
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
// The first window's renderer now has its IPC port and is hydrating its stores over it. The crash
// reporter (spawns a process) and the context menu (a dependency tree) are not worth answering late.
yield* Effect.sleep("500 millis")
const logging = yield* DesktopLogging.Service
yield* logging.startCrashReporter
yield* installContextMenu
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
+10 -1
View File
@@ -2,7 +2,10 @@
import { marks } from "./lifecycle/marks"
import { app } from "electron"
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
import { startSidecarProbe } from "./service/sidecar-probe"
import { registerStorageSnapshotHandler } from "./storage/snapshot"
import { createEarlyWindow } from "./windows/early"
import { rendererAssetsServed } from "./windows/protocol"
import { registerRendererScheme } from "./windows/scheme"
// This module stays small on purpose. Electron holds the ready event until the entry module has
@@ -14,10 +17,16 @@ if (acquireApplicationLock()) {
registerRendererScheme()
// Window first, then the bundle: starting the import before ready delays ready itself, because the
// module graph evaluates on the same thread Chromium needs to finish initialising.
void app.whenReady().then(() => {
void app.whenReady().then(async () => {
marks.ready = Date.now()
registerStorageSnapshotHandler()
createEarlyWindow()
marks.window = Date.now()
startSidecarProbe()
// The window's renderer is already loading. Its HTML and preloaded chunks are served from this
// thread, so the bundle waits for that burst to be answered (or a cap) before it evaluates.
if (!process.env.ELECTRON_RENDERER_URL) await rendererAssetsServed({ quietMs: 40, capMs: 400 })
marks.served = Date.now()
return import("./desktop")
})
}
@@ -4,7 +4,7 @@ import { EventRpcs } from "../../shared/ipc-rpc"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
import { isRendererUrl } from "../windows/protocol"
import { isRendererUrl } from "../windows/scheme"
import { DesktopStorage } from "../storage"
import { sender } from "./context"
+5 -2
View File
@@ -65,12 +65,15 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
win.webContents.on("did-finish-load", () => {
const post = () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
})
}
win.webContents.on("did-finish-load", post)
// The first window starts loading before the layers exist and may already be done.
if (!win.webContents.isLoading() && win.webContents.getURL()) post()
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import { enableCompileCache } from "node:module"
import { homedir, tmpdir } from "node:os"
import path from "node:path"
import { app } from "electron"
@@ -31,6 +32,8 @@ export function configureApplication() {
app.setPath("sessionData", path.join(testRoot, "session"))
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
}
// V8 bytecode for the main bundle survives between launches, like the renderer's code cache.
enableCompileCache(path.join(app.getPath("userData"), "compile-cache"))
}
export function acquireApplicationLock() {
@@ -4,6 +4,7 @@ import { app } from "electron"
import { Context, Effect, Layer } from "effect"
import { DesktopLogging } from "../native/logging"
import { getStore } from "../storage/store"
import { marks } from "./marks"
import {
loadProxyEnvironment,
preferApplicationEnvironment,
@@ -22,12 +23,15 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const logging = yield* DesktopLogging.Service
yield* prepareApplicationEnvironment
yield* preferApplicationEnvironment
yield* loadProxyEnvironment
// System certificates, the proxy and the net log serve later network work; the first window and
// its IPC port do not wait for them.
yield* Effect.forkScoped(
prepareApplicationEnvironment.pipe(Effect.andThen(loadProxyEnvironment), Effect.andThen(logging.startNetwork)),
)
yield* Effect.promise(() => app.whenReady())
yield* logging.startNetwork
yield* prepareDesktop
marks.init = Date.now()
return Service.of({
version: app.getVersion(),
updaterStore: getStore("opencode.updater"),
@@ -4,7 +4,8 @@ import { app } from "electron"
import { Effect, Path } from "effect"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { registerRendererProtocol, setDockIcon } from "../windows"
import { registerRendererProtocol, setDockIcon, setProtocolReporter } from "../windows"
import { scoped } from "../native/logging"
// electron-context-menu attaches to every existing and future window, so it can load once the first
// window is up instead of holding up startup with its dependency tree.
@@ -37,7 +38,11 @@ export const prepareDesktop = Effect.gen(function* () {
const paths = yield* DesktopPaths.resolve
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
yield* registerRendererProtocol()
const runFork = Effect.runForkWith(yield* Effect.context())
setProtocolReporter((level, message, data) =>
runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))),
)
registerRendererProtocol(paths.rendererRoot)
setDockIcon(path, paths)
})
@@ -9,6 +9,7 @@ import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { marks } from "./marks"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
@@ -157,6 +158,7 @@ export const layer = Layer.unwrap(
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
// otherwise read as evidence of an earlier launch on a fresh install.
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
marks.onboarding = Date.now()
return runtime.pipe(Layer.provideMerge(platform))
}),
)
+6 -3
View File
@@ -1,4 +1,7 @@
// Startup marks, epoch ms. The entry module records them before any logger exists; the logging
// layer reports them with "app starting" so the startup benchmark can split the time before the
// first log line into Electron's own initialisation, our entry, and the main bundle.
export const marks: { entry: number; ready?: number; window?: number; bundle?: number } = { entry: Date.now() }
// layer reports the early ones with "app starting" and the rest with "layers ready", so the startup
// benchmark can split the time before the renderer gets its IPC port into Electron's own
// initialisation, our entry, the main bundle and each layer.
export const marks: { entry: number } & Partial<
Record<"ready" | "window" | "served" | "bundle" | "onboarding" | "logging" | "crash" | "storage" | "init" | "layers", number>
> = { entry: Date.now() }
@@ -8,6 +8,10 @@ import { getStore } from "../storage/store"
const DEFAULT_PROJECT_DIR = "Default Project"
export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize")(function* (userDataPath: string) {
const store = getStore()
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
if (typeof current === "boolean") return current
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const names = (yield* fs.exists(userDataPath)) ? yield* fs.readDirectory(userDataPath) : []
@@ -17,11 +21,8 @@ export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize"
const info = yield* fs.stat(path.join(userDataPath, name)).pipe(Effect.option)
return { name, directory: Option.isSome(info) && info.value.type === "Directory" }
}),
{ concurrency: "unbounded" },
)
const store = getStore()
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
if (typeof current === "boolean") return current
const complete = hasExistingAppState(entries)
store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete)
return complete
+10 -2
View File
@@ -19,6 +19,7 @@ let netLogPath: string | undefined
export interface Interface {
readonly startNetwork: Effect.Effect<void>
readonly startCrashReporter: Effect.Effect<void>
readonly exportDebug: Effect.Effect<string>
}
@@ -30,7 +31,9 @@ const serviceLayer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* initLogging(fs, path).pipe(Effect.orDie)
yield* initCrashReporter(fs, path).pipe(Effect.orDie)
// Old run directories go away in the background; listing them is not worth a wait at startup.
yield* Effect.forkScoped(cleanup(fs, path).pipe(Effect.catch(() => Effect.void)))
marks.logging = Date.now()
yield* Effect.logInfo("app starting", {
version: VERSION,
packaged: app.isPackaged,
@@ -42,6 +45,12 @@ const serviceLayer = Layer.effect(
startNetwork: startNetLog(path).pipe(
Effect.catch((error) => Effect.logWarning("failed to start net log", { error })),
),
// Starting crashpad spawns its handler process, ~60 ms on the main thread, so the first window
// and its IPC port come first.
startCrashReporter: initCrashReporter(fs, path).pipe(
Effect.tap(() => Effect.sync(() => (marks.crash = Date.now()))),
Effect.catch((error) => Effect.logWarning("failed to start crash reporter", { error })),
),
exportDebug,
})
}),
@@ -99,7 +108,6 @@ function initLogging(fs: FileSystem.FileSystem, path: Path.Path) {
log.initialize({ preload: false, spyRendererConsole: true })
initConsoleTransport()
})
yield* cleanup(fs, path)
})
}
@@ -3,6 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { BackgroundServiceState } from "./background-service-state"
import { cleanStages, DesktopCli } from "./desktop-cli"
import { SidecarCredentials } from "./sidecar-credentials"
import { sidecarProbe } from "./sidecar-probe"
export * as BackgroundService from "./background-service"
@@ -36,7 +37,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
const version = mode === "initial" ? cli.version : undefined
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const client = yield* Effect.promise(() => import("@opencode/client/service"))
const service = yield* Effect.tryPromise(() =>
const ensure = () =>
client.Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
@@ -46,13 +47,18 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
}),
)
})
// A compatible service the entry module already found is adopted at once; ensure() still runs
// afterwards for its side effects (terminal handoff completion), off the renderer's path.
const early = mode === "initial" && !isolated ? yield* Effect.promise(sidecarProbe) : undefined
if (early) yield* Effect.sync(() => void ensure().catch(() => undefined))
const service = early ?? (yield* Effect.tryPromise(ensure))
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
const url = new URL(service.url)
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
yield* Effect.logInfo("v2 CLI background service ready", {
version,
probed: !!early,
...endpoint(url.origin),
})
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
@@ -1,6 +1,7 @@
export * as DesktopCli from "./desktop-cli"
import { execFile, spawn } from "node:child_process"
import { existsSync, readFileSync } from "node:fs"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
@@ -93,9 +94,15 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const shipped = yield* fs
.readFileString(path.join(path.dirname(bundled), "opencode-cli.version"))
.pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => ""))
// Synchronous on purpose: this sits on the path to the first window's IPC port, and a queued
// async read waits behind everything else the main thread is doing at that moment.
const shipped = yield* Effect.sync(() => {
try {
return readFileSync(path.join(path.dirname(bundled), "opencode-cli.version"), "utf8").trim()
} catch {
return ""
}
})
if (shipped) {
yield* Effect.logInfo("v2 CLI version bundled", { version: shipped })
return shipped
@@ -147,7 +154,7 @@ const installCli = Effect.fn("DesktopCli.install")(function* (source: string, ve
const path = yield* Path.Path
const directory = path.join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
const destination = path.join(directory, executableName())
if (yield* fs.exists(destination)) {
if (existsSync(destination)) {
yield* Effect.logInfo("v2 CLI staged executable reused", { path: destination, version })
return destination
}
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs"
import path from "node:path"
import { app } from "electron"
import type { Endpoint } from "@opencode/client/service"
// The main thread idles between showing the first window and evaluating the main bundle, waiting
// for the renderer's asset requests. That slot is long enough to find out whether a compatible
// background service is already running, so the renderer's first data request is not the first
// moment anyone asks. The probe only looks; a service that has to be started waits for the layers,
// which set the environment the CLI expects.
let probe: Promise<Endpoint | undefined> | undefined
export function startSidecarProbe() {
if (!app.isPackaged) return
const version = bundledVersion()
if (!version) return
probe = import("@opencode/client/service")
.then(({ Service }) => Service.discover({ version }))
.catch(() => undefined)
}
export function sidecarProbe() {
return probe ?? Promise.resolve(undefined)
}
function bundledVersion() {
try {
return readFileSync(path.join(process.resourcesPath, "opencode-cli.version"), "utf8").trim()
} catch {
return ""
}
}
@@ -2,7 +2,9 @@ export * as DesktopStorage from "./index"
import { app, BrowserWindow } from "electron"
import { Context, Effect, Layer, Path } from "effect"
import { marks } from "../lifecycle/marks"
import { openDatabase } from "./database"
import { setStorageSnapshotProvider } from "./snapshot"
import { createDraftStore } from "./drafts"
import { importLegacyStores } from "./legacy"
import { createStateStore } from "./state"
@@ -40,6 +42,8 @@ export const layer = Layer.effect(
storage.close()
}),
)
setStorageSnapshotProvider((names) => Object.fromEntries(names.map((name) => [name, storage.state.items(name)])))
marks.storage = Date.now()
return Service.of(storage)
}),
)
@@ -0,0 +1,56 @@
import { existsSync, readdirSync } from "node:fs"
import path from "node:path"
import { DatabaseSync } from "node:sqlite"
import { app, ipcMain } from "electron"
import { StorageSnapshotChannel, type StorageSnapshot } from "../../shared/ipc-transport"
import { isRendererUrl } from "../windows/scheme"
// A window's preload asks for the namespaces its shell reads before the page runs, so the first
// render is the hydrated one. Until the storage layer is up (the first window asks before it exists)
// the answer comes from the database file; the layer takes over so later windows see queued writes.
type Provider = (names: ReadonlyArray<string>) => StorageSnapshot
let provider: Provider = readFromDisk
export function setStorageSnapshotProvider(next: Provider) {
provider = next
}
export function registerStorageSnapshotHandler() {
ipcMain.handle(StorageSnapshotChannel, (event, names: unknown): StorageSnapshot => {
if (!isRendererUrl(event.senderFrame?.url)) return {}
if (!Array.isArray(names)) return {}
return provider(names.filter((name): name is string => typeof name === "string"))
})
}
// Nothing has been written in this process yet, so every namespace is at revision 0, as the
// storage layer would report before its first update. Legacy electron-store files still waiting
// to be imported would make the database stale for this launch, so the renderer asks the layer then.
function readFromDisk(names: ReadonlyArray<string>): StorageSnapshot {
const userData = app.getPath("userData")
const file = path.join(userData, "drafts.sqlite")
if (!existsSync(file)) return {}
if (readdirSync(userData).some((name) => name === "default.dat" || /^opencode\..+\.dat$/.test(name))) return {}
try {
const db = new DatabaseSync(file)
try {
const rows = db.prepare("SELECT key, value FROM state WHERE name = ?")
return Object.fromEntries(
names.map((name) => [
name,
{
items: Object.fromEntries(
(rows.all(name) as { key: string; value: string }[]).map((row) => [row.key, row.value]),
),
revision: 0,
},
]),
)
} finally {
db.close()
}
} catch {
return {}
}
}
@@ -0,0 +1,20 @@
import { windowBootstrapArgument, type WindowBootstrap } from "../../shared/window-bootstrap"
import { getDefaultServerUrl } from "../service/server-settings"
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
// The settings store is already in memory when a window is created, so the renderer gets the
// answers its shell gate would otherwise ask for over IPC. A fresh install has no onboarding
// decision yet; the renderer asks once the layers have made one.
export function windowBootstrap(id: string): WindowBootstrap {
const complete = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
return {
id,
firstLaunchPending: typeof complete === "boolean" ? !complete : undefined,
defaultServerUrl: getDefaultServerUrl(),
}
}
export function windowArguments(id: string) {
return [windowBootstrapArgument(windowBootstrap(id))]
}
@@ -9,10 +9,6 @@ import { getStore } from "../storage/store"
// full window setup in appearance.ts, so both draw the same frame.
const oc2Theme = oc2ThemeJson as DesktopTheme
const oc2Background = {
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
}
// Match the renderer's 36px titlebar plus its former 8px content inset.
export const titlebarHeight = 44
@@ -21,10 +17,13 @@ export function tone() {
}
// The colour the renderer reported on its last run, or the default theme's for the system tone, so
// a window shown before the renderer paints already has the right background.
// a window shown before the renderer paints already has the right background. Resolving a palette
// costs tens of milliseconds before the first window, so it only happens when nothing is stored.
export function storedBackgroundColor() {
const stored = getStore().get(BACKGROUND_COLOR_KEY)
return typeof stored === "string" ? stored : oc2Background[tone()]
if (typeof stored === "string") return stored
const dark = tone() === "dark"
return resolveThemeVariant(dark ? oc2Theme.dark : oc2Theme.light, dark)["background-base"]
}
export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {
+33 -5
View File
@@ -1,14 +1,25 @@
import { randomUUID } from "node:crypto"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { app, BrowserWindow, screen } from "electron"
import { windowIDArgument } from "../../shared/window-bootstrap"
import { app, BrowserWindow, screen, shell } from "electron"
import { resolveExternalURL } from "../files/external-url"
import { windowArguments } from "./bootstrap"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
import { registerRendererProtocol } from "./protocol"
import { loadWindow } from "./scheme"
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
export type EarlyWindow = {
id: string
win: BrowserWindow
state: WindowState
shownAt: number
// Navigation policy is wired before the layers exist; the adopter swaps in the logged version.
openExternal: (url: string) => void
}
let pending: EarlyWindow | undefined
@@ -46,7 +57,7 @@ export function createEarlyWindow() {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
additionalArguments: [windowIDArgument(id)],
additionalArguments: windowArguments(id),
},
})
manageWindowState(win, file, state, displays)
@@ -56,7 +67,24 @@ export function createEarlyWindow() {
pending = undefined
app.quit()
})
pending = { id, win, state, shownAt: Date.now() }
const record: EarlyWindow = {
id,
win,
state,
shownAt: Date.now(),
openExternal: (url) => {
const target = resolveExternalURL(url)
if (target) void shell.openExternal(target)
},
}
pending = record
// The renderer boots while the main bundle and layers load, instead of after them. Everything the
// page needs before its first request is wired here; the IPC port arrives once the layers are up.
registerRendererProtocol(path.join(root, "../renderer"))
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => record.openExternal(url))
wireRendererHeaders(win)
loadWindow(win, "index.html")
}
export function takeEarlyWindow() {
+16 -16
View File
@@ -7,7 +7,8 @@ import { DesktopPaths } from "../paths"
import { DesktopStorage } from "../storage"
import { getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { windowIDArgument } from "../../shared/window-bootstrap"
import { windowDataFile } from "../../shared/ipc-transport"
import { windowArguments } from "./bootstrap"
import {
getBackgroundColor,
getPinchZoomEnabled,
@@ -21,7 +22,8 @@ import {
wireFullscreen,
wireZoom,
} from "./appearance"
import { loadWindow, registerRendererProtocol } from "./protocol"
import { registerRendererProtocol, setProtocolReporter } from "./protocol"
import { loadWindow } from "./scheme"
import { createWindowRegistry } from "./registry"
import { makeWindowRecovery } from "./recovery"
import { takeEarlyWindow, type EarlyWindow } from "./early"
@@ -48,6 +50,7 @@ export {
getBackgroundColor,
getPinchZoomEnabled,
registerRendererProtocol,
setProtocolReporter,
setBackgroundColor,
setDockIcon,
setPinchZoomEnabled,
@@ -115,18 +118,23 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
...appearance,
webPreferences: {
...appearance.webPreferences,
additionalArguments: [windowIDArgument(id)],
additionalArguments: windowArguments(id),
},
})
allowRendererPermissions(win)
// The early window was secured and loaded when it was created; only its external-URL policy is
// upgraded to the logged one.
if (early) early.openExternal = (url) => runFork(openExternalURL(url))
if (!early) {
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
manageWindowState(win, stateFile, state, displays)
}
wireWindowRecovery(win, id, () => relaunchHandler())
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
if (!early) manageWindowState(win, stateFile, state, displays)
register(win, id)
wireFullscreen(win)
loadWindow(win, "index.html")
if (!early) loadWindow(win, "index.html")
wireZoom(win)
let contentReady = false
let appliedTheme = false
@@ -182,12 +190,4 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
return { create, restore }
})
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
// namespace the renderer persists this window's tabs under.
function windowDataFile(id: string) {
return `opencode.window.${safeWindowID(id)}.dat`
}
function safeWindowID(id: string) {
return id.replace(/[^a-zA-Z0-9._-]/g, "-")
}
+69 -57
View File
@@ -1,73 +1,85 @@
import { net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { Effect, Path } from "effect"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
import { rendererHost, rendererProtocol } from "./scheme"
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const runFork = Effect.runForkWith(yield* Effect.context<never>())
export type ProtocolReport = (level: "warning" | "error", message: string, data: Record<string, unknown>) => void
// The entry module registers the handler the moment the first window exists, before logging is up,
// so problems go to the console until the logging layer installs a reporter.
let report: ProtocolReport = (level, message, data) => console[level === "error" ? "error" : "warn"](message, data)
export function setProtocolReporter(reporter: ProtocolReport) {
report = reporter
}
// Requests in flight and when the last one arrived. The entry module holds the main bundle back
// until the renderer's initial burst of asset requests has been answered, because this handler
// runs on the main thread and a 100 ms module evaluation would otherwise sit between the renderer
// and its HTML.
let inflight = 0
let served = 0
let lastRequest = 0
export function rendererAssetsServed(options: { quietMs: number; capMs: number }) {
const start = Date.now()
return new Promise<void>((resolve) => {
const check = () => {
const now = Date.now()
if (now - start >= options.capMs) return resolve()
if (served > 0 && inflight === 0 && now - lastRequest >= options.quietMs) return resolve()
setTimeout(check, 5)
}
check()
})
}
export function registerRendererProtocol(rendererRoot: string) {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
return new Response("Not found", { status: 404 })
}
const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = path.relative(paths.rendererRoot, file)
if (rel.startsWith("..") || path.isAbsolute(rel)) {
runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file })))
return new Response("Not found", { status: 404 })
}
inflight++
lastRequest = Date.now()
try {
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
runFork(
scoped(
"protocol",
Effect.logError("fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
}),
),
)
}
return addDocumentPolicy(response, file)
} catch (error) {
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
return new Response("Not found", { status: 404 })
return await serve(request, rendererRoot)
} finally {
inflight--
served++
}
})
})
export function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
void win.loadURL(new URL(html, devUrl).toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
export function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
async function serve(request: Request, rendererRoot: string) {
const url = new URL(request.url)
if (url.host !== rendererHost) {
report("warning", "rejected host", { url: request.url })
return new Response("Not found", { status: 404 })
}
const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = path.relative(rendererRoot, file)
if (rel.startsWith("..") || path.isAbsolute(rel)) {
report("warning", "rejected path", { url: request.url, file })
return new Response("Not found", { status: 404 })
}
try {
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
report("error", "fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
})
}
return addDocumentPolicy(response, file)
} catch (error) {
report("error", "fetch error", { url: request.url, file, error })
return new Response("Not found", { status: 404 })
}
}
function addDocumentPolicy(response: Response, file: string) {
@@ -1,8 +1,28 @@
import { protocol } from "electron"
import type { BrowserWindow } from "electron"
export const rendererProtocol = "oc"
export const rendererHost = "renderer"
export function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
void win.loadURL(new URL(html, devUrl).toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
export function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
}
// Scheme privileges can only be granted before the app is ready, so the entry module calls this
// before it loads anything else.
export function registerRendererScheme() {
@@ -1,7 +1,7 @@
import type { BrowserWindow } from "electron"
import { SidecarCredentials } from "../service/sidecar-credentials"
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
import { isRendererUrl } from "./protocol"
import { isRendererUrl } from "./scheme"
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
+17 -3
View File
@@ -1,6 +1,12 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { windowIDFromArguments } from "../shared/window-bootstrap"
import {
DragCancelEvent,
IpcTransportPort,
StorageSnapshotChannel,
storageSnapshotNames,
type StorageSnapshot,
} from "../shared/ipc-transport"
import { windowBootstrapFromArguments } from "../shared/window-bootstrap"
ipcRenderer.on(IpcTransportPort, (event) => {
const port = event.ports[0]
@@ -9,7 +15,15 @@ ipcRenderer.on(IpcTransportPort, (event) => {
ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelEvent)))
const bootstrap = windowBootstrapFromArguments(process.argv)
// Asked before the page runs, so the stores the shell reads are hydrated on the first render.
const storageSnapshot: Promise<StorageSnapshot> = ipcRenderer
.invoke(StorageSnapshotChannel, storageSnapshotNames(bootstrap.id))
.catch(() => ({}))
contextBridge.exposeInMainWorld("electron", {
windowID: windowIDFromArguments(process.argv),
windowID: bootstrap.id,
bootstrap,
storageSnapshot,
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
+5
View File
@@ -1,4 +1,9 @@
import type { StorageSnapshot } from "../shared/ipc-transport"
import type { WindowBootstrap } from "../shared/window-bootstrap"
export type ElectronNative = {
windowID: string
bootstrap: WindowBootstrap
storageSnapshot: Promise<StorageSnapshot>
getPathForFile(file: File): string
}
@@ -5,6 +5,7 @@ import type { UpdaterState } from "@opencode/app/updater"
import type { WslServersPlatform } from "@opencode/app/wsl/types"
import type { SshPlatform } from "@opencode/app/ssh"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type { WindowBootstrap } from "../shared/window-bootstrap"
import type {
ClipboardImage,
DirectoryPickerOptions,
@@ -53,6 +54,7 @@ export type ElectronAPI = {
draftBlobPut(data: ArrayBuffer): Promise<string>
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): string
getWindowBootstrap(): WindowBootstrap
themeReady(): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
+13 -1
View File
@@ -22,6 +22,9 @@ const updaterHandler = (state: UpdaterState) => {
updaterCallbacks.forEach((callback) => callback(state))
}
// One renderer-side copy: the bridge clones on every crossing, so consumption is tracked here.
const seeded = window.electron.storageSnapshot.then((snapshot) => new Map(Object.entries(snapshot)))
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
@@ -99,7 +102,15 @@ export const api: ElectronAPI = {
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
storeItems: (name) => invoke("StorageItems", { name }).then(mutable),
// The first read of a namespace the preload already fetched is served from that snapshot; later
// reads (a window re-opening a namespace) go to the main process as usual.
storeItems: (name) =>
seeded.then((snapshot) => {
const item = snapshot.get(name)
if (!item) return invoke("StorageItems", { name }).then(mutable)
snapshot.delete(name)
return item
}),
storeUpdate: (name, insert, remove) => invoke("StorageUpdate", { name, insert, remove }),
storeClear: (name) => invoke("StorageClear", { name }),
onStoreChanged: (cb) =>
@@ -111,6 +122,7 @@ export const api: ElectronAPI = {
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
getWindowID: () => window.electron.windowID,
getWindowBootstrap: () => window.electron.bootstrap,
themeReady: () => invoke("WindowThemeReady"),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
+13 -5
View File
@@ -48,15 +48,23 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
drawingReady: false,
route,
})
// The window was created with the answers the shell gate needs; only a fresh install, which has no
// onboarding decision yet, asks over IPC and waits for the port.
const bootstrap = props.api.getWindowBootstrap()
const [firstLaunch] = createResource(() =>
props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
bootstrap.firstLaunchPending !== undefined
? Promise.resolve(bootstrap.firstLaunchPending)
: props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
)
const platform = createDesktopPlatform(props.api, windowState, props.updater)
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [defaultServer] = createResource(async () => {
if (bootstrap.defaultServerUrl === undefined) return platform.getDefaultServer?.()
return bootstrap.defaultServerUrl ? ServerConnection.Key.make(bootstrap.defaultServerUrl) : null
})
const [locale] = createResource(() => preloadStoredLocale(platform))
const [initialRoute] = createResource(
() => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl),
+73 -69
View File
@@ -1,10 +1,14 @@
import { Context, Effect, Layer, ManagedRuntime, Queue, Schema, Stream } from "effect"
import { RpcClient, RpcMessage } from "effect/unstable/rpc"
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
import type { Effect } from "effect"
import type { RpcMessage } from "effect/unstable/rpc"
import type { DesktopRpcClient } from "../shared/ipc-rpc"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
import { IpcTransportPort } from "../shared/ipc-transport"
class DesktopClient extends Context.Service<DesktopClient, DesktopRpcClient>()("opencode/desktop/DesktopClient") {}
// The main process serves Effect's RpcServer over a MessagePort; this side speaks its wire format
// directly. Messages cross by structured clone (no serialization layer, binary stays binary), every
// payload in the contract is JSON-native or a Uint8Array, and the main process is trusted, so the
// renderer needs neither the Effect runtime nor the contract's schemas to talk to it. Keeping them
// out of the renderer's initial module graph is worth about a third of its startup script.
type EventTag = DesktopEvent["_tag"]
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
@@ -13,55 +17,48 @@ type InvokeResult<Tag extends InvokeTag> =
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
type Pending = {
readonly resolve: (value: unknown) => void
readonly reject: (error: unknown) => void
readonly chunk?: (values: ReadonlyArray<unknown>) => void
}
const pending = new Map<number, Pending>()
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
const beforeDispose = new Set<() => Promise<unknown> | void>()
let nextId = 0
const port = new Promise<MessagePort>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data !== IpcTransportPort) return
const value = event.ports[0]
if (!value) return
window.removeEventListener("message", onMessage)
value.addEventListener("message", (message) => receive(value, message.data as RpcMessage.FromServerEncoded))
value.start()
resolve(value)
}
window.addEventListener("message", onMessage)
})
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive))
const runtime = ManagedRuntime.make(ClientLive)
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
const beforeDispose = new Set<() => Promise<unknown> | void>()
// Let queued work (storage flushes) hand its messages to the port before the runtime goes away.
// Let queued work (storage flushes) hand its messages to the port before it closes.
window.addEventListener(
"pagehide",
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => runtime.dispose()),
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => port.then((p) => p.close())),
{ once: true },
)
void request("DesktopEvents", null, (values) => {
for (const value of values as ReadonlyArray<DesktopEvent>) listeners.get(value._tag)?.forEach((fn) => fn(value))
})
export function onBeforeDispose(callback: () => Promise<unknown> | void) {
beforeDispose.add(callback)
return () => beforeDispose.delete(callback)
}
runtime.runFork(
Effect.gen(function* () {
const client = yield* DesktopClient
yield* client
.DesktopEvents()
.pipe(
Stream.runForEach((event) =>
Effect.sync(() => listeners.get(event._tag)?.forEach((listener) => listener(event))),
),
)
}),
)
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* DesktopClient
const method = client[tag] as unknown as (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown>
return yield* method(...payload)
}),
) as Promise<InvokeResult<Tag>>
return request(tag, payload[0] ?? null) as Promise<InvokeResult<Tag>>
}
export function send<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>) {
@@ -79,42 +76,49 @@ export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventVa
}
}
// Structured clone over the port, like Effect's worker protocol: no serialization layer, so binary
// payloads stay binary. Buffers are cloned rather than transferred: Electron's MessagePortMain
// drops transferred ArrayBuffers, so a request carrying one would never arrive.
function clientProtocol(value: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) => {
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
}
value.addEventListener("message", onMessage)
value.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
value.removeEventListener("message", onMessage)
value.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
codecFor: Schema.toCodecJson,
send: (_clientId, request) =>
Effect.sync(() => {
value.postMessage(request)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
)
function request(tag: string, payload: unknown, chunk?: Pending["chunk"]) {
const id = nextId++
return new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject, chunk })
const message: RpcMessage.RequestEncoded = { _tag: "Request", id, tag, payload, headers: [] }
void port.then((p) => p.postMessage(message))
})
}
function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) {
switch (message._tag) {
case "Chunk": {
pending.get(Number(message.requestId))?.chunk?.(message.values)
p.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded)
return
}
case "Exit": {
const id = Number(message.requestId)
const entry = pending.get(id)
pending.delete(id)
if (!entry) return
if (message.exit._tag === "Success") return entry.resolve(message.exit.value)
return entry.reject(failure(message.exit.cause))
}
case "Defect": {
const error = new Error("Desktop IPC defect", { cause: message.defect })
pending.forEach((entry) => entry.reject(error))
pending.clear()
return
}
case "ClientProtocolError": {
console.error("[desktop-ipc] protocol error", message.error)
return
}
}
}
// The RPC failure a caller sees is the encoded error the handler failed with, as before; defects
// and interrupts surface as errors.
function failure(cause: ReadonlyArray<{ readonly _tag: string; readonly error?: unknown; readonly defect?: unknown }>) {
const failed = cause.find((item) => item._tag === "Fail")
if (failed) return failed.error
const died = cause.find((item) => item._tag === "Die")
if (died) return new Error("Desktop IPC handler failed", { cause: died.defect })
return new Error("Desktop IPC request interrupted")
}
@@ -1,9 +1,22 @@
import { loadLocaleDict, normalizeLocale, type Locale, type Platform } from "@opencode/app/desktop"
import { storedLocaleValue } from "./locale-value"
// The stored language lives in the main process's SQLite store, behind the IPC port. A copy of the
// last answer in localStorage lets the shell mount without waiting for the port; the store is still
// asked every launch and the copy refreshed, and the language provider hydrates from the store
// itself, so a stale copy costs one visible switch, not a wrong language.
const cacheKey = "opencode.desktop.language"
export async function preloadStoredLocale(platform: Platform) {
const raw = await platform.storage?.("opencode.global.dat").getItem("language")
const locale = storedLocale(raw)
const fresh = Promise.resolve(platform.storage?.("opencode.global.dat").getItem("language")).then(
(raw) => {
localStorage.setItem(cacheKey, raw ?? "")
return raw
},
() => undefined,
)
const cached = localStorage.getItem(cacheKey)
const locale = storedLocale(cached ?? (await fresh))
if (!locale) return
if (locale !== "en") await loadLocaleDict(locale)
return locale
@@ -1,2 +1,16 @@
export const IpcTransportPort = "desktop-rpc-port"
export const DragCancelEvent = "opencode:drag-cancel"
export const StorageSnapshotChannel = "desktop-storage-snapshot"
export type StorageSnapshot = Record<string, { items: Record<string, string>; revision: number }>
// The namespaces a window reads while its shell mounts. The preload asks for them before the page
// runs so the first render already has them; mirrors windowStorage() in
// packages/app/src/runtime/persistence/storage.ts.
export function storageSnapshotNames(windowID: string) {
return ["opencode.global.dat", "default.dat", windowDataFile(windowID)]
}
export function windowDataFile(id: string) {
return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat`
}
@@ -1,13 +1,17 @@
import { describe, expect, test } from "bun:test"
import { windowIDArgument, windowIDFromArguments } from "./window-bootstrap"
import { windowBootstrapArgument, windowBootstrapFromArguments } from "./window-bootstrap"
describe("window bootstrap", () => {
test("round-trips the window ID through renderer arguments", () => {
const id = "window/id with spaces"
expect(windowIDFromArguments(["electron", windowIDArgument(id)])).toBe(id)
test("round-trips through argv", () => {
const bootstrap = { id: "win a/b ü", firstLaunchPending: false, defaultServerUrl: "http://127.0.0.1:1234" }
expect(windowBootstrapFromArguments(["electron", windowBootstrapArgument(bootstrap)])).toEqual(bootstrap)
})
test("requires a window ID argument", () => {
expect(() => windowIDFromArguments(["electron"])).toThrow("Window ID argument not found")
test("keeps unknown values absent", () => {
expect(windowBootstrapFromArguments([windowBootstrapArgument({ id: "x" })])).toEqual({ id: "x" })
})
test("throws when the argument is missing", () => {
expect(() => windowBootstrapFromArguments(["electron"])).toThrow("Window bootstrap argument not found")
})
})
@@ -1,11 +1,19 @@
const windowIDPrefix = "--opencode-window-id="
export function windowIDArgument(id: string) {
return windowIDPrefix + encodeURIComponent(id)
// What the main process already knows when it creates a window, handed to the renderer through the
// preload's argv so the shell can mount before the IPC port exists. Undefined means "ask over IPC".
export type WindowBootstrap = {
id: string
firstLaunchPending?: boolean
defaultServerUrl?: string | null
}
export function windowIDFromArguments(args: readonly string[]) {
const value = args.find((arg) => arg.startsWith(windowIDPrefix))?.slice(windowIDPrefix.length)
if (!value) throw new Error("Window ID argument not found")
return decodeURIComponent(value)
const prefix = "--opencode-window="
export function windowBootstrapArgument(bootstrap: WindowBootstrap) {
return prefix + encodeURIComponent(JSON.stringify(bootstrap))
}
export function windowBootstrapFromArguments(args: readonly string[]): WindowBootstrap {
const value = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
if (!value) throw new Error("Window bootstrap argument not found")
return JSON.parse(decodeURIComponent(value))
}
+31
View File
@@ -31,7 +31,25 @@ async function main() {
app.setPath("userData", path.join(root, "electron-data"))
app.on("window-all-closed", () => {})
await app.whenReady()
let unavailable = true
let finishLoading: (() => void) | undefined
const web = createServer((request, response) => {
if (request.url === "/loading") {
response.writeHead(200, { "content-type": "text/html" })
response.write("<!doctype html><html><body>Loading")
finishLoading = () => response.end("</body></html>")
return
}
if (request.url === "/finish-loading") {
finishLoading?.()
finishLoading = undefined
response.end()
return
}
if (request.url === "/unreachable" && unavailable) {
request.socket.destroy()
return
}
if (request.url === "/cors-allowed" || request.url === "/cors-denied") {
if (request.url === "/cors-allowed") response.setHeader("access-control-allow-origin", "*")
response.setHeader("content-type", "text/plain")
@@ -204,6 +222,19 @@ async function main() {
assert.equal((await call("tabs.list", {})).tabs.length, 2)
const tabID = first.id
assert.equal(first.url, fixture + "/")
assert.equal((await call("navigate", { tabID, url: `${fixture}/unreachable` })).loadError, "502 Bad Gateway")
const failed = await until(async () => {
const tab = (await call("tabs.list", {})).tabs.find((tab) => tab.id === tabID)
return tab?.loadError && !tab.loading ? tab : undefined
})
assert.equal(failed.url, `${fixture}/unreachable`)
assert.equal(failed.loadError, "502 Bad Gateway")
await until(async () => inventories.get("suite")?.tabs.find((tab) => tab.id === tabID)?.loadError)
assert.equal((await call("tabs.list", {})).tabs.find((tab) => tab.id === second.id)?.loadError, undefined)
unavailable = false
assert.equal((await call("reload", { tabID })).loadError, undefined)
assert.equal((await call("navigate", { tabID, url: fixture })).loadError, undefined)
console.log("PASS browser navigation failure state and retry")
const networkProof = await call("evaluate", {
tabID,
script: `(async () => ({
+53
View File
@@ -29,6 +29,58 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
}
try {
await page.ready
const started = new Promise<void>((resolve) => page.contents.once("did-navigate", () => resolve()))
page.setVisible(true)
const navigation = execute({ type: "navigate", tabID, url: url + "/loading" })
await started
assert(page.state().loading)
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
page.setVisible(true)
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
const stopped = new Promise<void>((resolve) => page.contents.once("did-stop-loading", () => resolve()))
await fetch(url + "/finish-loading")
await navigation
await stopped
assert.equal(page.state().loading, false)
assert(page.view.getVisible(), "The requested visibility must return after loading finishes")
console.log("PASS native loading from a blank page keeps the themed background visible until the document is ready")
const again = new Promise<void>((resolve) => page.contents.once("did-navigate", () => resolve()))
const reloading = execute({ type: "navigate", tabID, url: url + "/loading" })
await again
assert(page.state().loading)
assert(page.view.getVisible(), "A shown page must stay visible while the next document loads")
await fetch(url + "/finish-loading")
await reloading
assert(page.view.getVisible())
console.log("PASS native loading from a shown page keeps the previous document visible")
await execute({ type: "navigate", tabID, url: url + "/missing" })
assert.equal(page.state().loadError, undefined, "An HTTP error page from the server is a real document")
page.setVisible(true)
assert(page.view.getVisible(), "An HTTP error page from the server must stay visible")
console.log("PASS native HTTP error pages stay visible")
await assert.rejects(execute({ type: "navigate", tabID, url: url + "/unreachable" }), /ERR_EMPTY_RESPONSE/)
assert.equal(page.state().loadError, "ERR_EMPTY_RESPONSE")
assert.equal(page.state().url, url + "/unreachable")
page.setVisible(true)
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
const clearing = new Promise<void>((resolve) => {
page.contents.once("did-start-navigation", () => {
page.setVisible(true)
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
resolve()
})
})
await execute({ type: "navigate", tabID, url: "about:blank" })
await clearing
assert.equal(page.state().loadError, undefined)
page.setVisible(true)
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
const restored = new Promise<void>((resolve) => page.contents.once("did-stop-loading", () => resolve()))
await execute({ type: "navigate", tabID, url })
await restored
page.setVisible(true)
assert(page.view.getVisible())
console.log("PASS native blank navigation stays hidden through stale layout updates")
for (const color of [
[255, 255, 255, 255],
[20, 24, 30, 255],
@@ -91,6 +143,7 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
await execute({ type: "evaluate", tabID, script: "document.querySelector('a[href=\"/download\"]').click()" })
const fileID = await waitForFile()
assert.equal(page.state().loadError, undefined, "A download must not mark the page as unreachable")
const fileAction: Browser.Action = { type: "files.get", tabID, fileID }
const original = await inspect(fileAction)
assert(original.resources.includes(url + "/download"))
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.10",
"version": "2.0.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.11",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.10",
"version": "2.0.11",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1
View File
@@ -54,6 +54,7 @@ export const Tab = Schema.Struct({
url: Schema.String.check(Schema.isMaxLength(16_384)),
title: short,
loading: Schema.Boolean,
loadError: optional(short),
canGoBack: Schema.Boolean,
canGoForward: Schema.Boolean,
generation: count,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.11",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"license": "MIT",
+13 -1
View File
@@ -23,6 +23,7 @@ import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
import { useData } from "../context/data"
import { useClipboard } from "../context/clipboard"
import { useTheme } from "../context/theme"
import {
adaptiveSessionTabLayout,
@@ -44,6 +45,7 @@ import { SESSION_SIDEBAR_WIDTH, SESSION_TABS_COMPACT_BREAKPOINT } from "../ui/la
import { projectName } from "../util/project"
import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/marquee"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { registerOpencodeSpinner } from "./register-spinner"
@@ -75,7 +77,7 @@ const FADE_WIDTH = 4
const ADD_TAB_WIDTH = 3
const MARQUEE_DELAY = 600
const MARQUEE_INTERVAL = 80
const CONTEXT_MENU_WIDTH = 16
const CONTEXT_MENU_WIDTH = 20
const MIDDLE_MOUSE_BUTTON = 1
const RIGHT_MOUSE_BUTTON = 2
const MOUSE_CLOSE_HOLD_MS = 5_000
@@ -370,6 +372,8 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
const background = () => theme.background.raised.base
const actionHovered = () => theme.background.raised.high
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
onCleanup(Keymap.use().mode.push("menu"))
Keymap.createLayer(() => ({
mode: "menu",
@@ -387,6 +391,14 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
run: () =>
props.tabs.rename ? props.tabs.rename(sessionID) : DialogSessionRename.show(dialog, sessionID, title),
},
{
title: "Copy session ID",
run: () =>
void clipboard
.write(sessionID)
.then(() => toast.show({ message: "Session ID copied to clipboard", variant: "info" }))
.catch(toast.error),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
]
: []),
@@ -34,6 +34,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
const [active, setActive] = createSignal("second")
const [newTab, setNewTab] = createSignal(false)
const settings: Info = { tabs: { enabled: true } }
const copied: string[] = []
let config!: ReturnType<typeof useConfig>
let theme!: ReturnType<typeof useTheme>
function Colors() {
@@ -61,7 +62,10 @@ for (const orientation of ["horizontal", "vertical"] as const) {
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts paths={{ state: temporary.path }}>
<TestTuiContexts
paths={{ state: temporary.path }}
clipboard={{ read: async () => undefined, write: async (text) => void copied.push(text) }}
>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider
@@ -202,10 +206,12 @@ for (const orientation of ["horizontal", "vertical"] as const) {
await app.mockMouse.click(column, row, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Rename"))
expect(app.captureCharFrame().split("\n")[row + 1]!.indexOf("Rename")).toBe(column + 1)
expect(app.captureCharFrame()).toContain("Copy session ID")
expect(app.captureCharFrame()).toContain("Close")
expect(app.captureCharFrame()).not.toContain("Keep open")
expect(active()).toBe("second")
app.mockInput.pressKey("c", { ctrl: true })
await app.mockMouse.click(column + 1, row + 2)
expect(copied).toEqual(["first"])
await app.waitForFrame((frame) => !frame.includes("Rename"))
setNewTab(true)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"repository": {
+3 -2
View File
@@ -553,8 +553,9 @@
}
&[data-value="open-file"],
&[data-value^="file://"]
{
&[data-value^="file://"],
&[data-value="browser"],
&[data-value^="browser:"] {
gap: 8px;
padding-inline-end: 4px;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.10",
"version": "2.0.11",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.10",
"version": "2.0.11",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"scripts": {