Compare commits

..
Author SHA1 Message Date
LukeParkerDev 6a57105b2c refactor(app): keep the SSH contract schemas out of the renderer
The Effect schemas move to servers/ssh/schema.ts, imported by the desktop main process for its RPC server; the renderer gets types only. The SSH attempt controller tracks its one in-flight request per server with a cancellation flag instead of Effect fibers, with the same settle-on-cancel behaviour.
2026-09-20 16:37:02 +10:00
LukeParkerDev 7930c5f676 refactor(app): port the model selection and terminal stores to plain codecs 2026-09-20 16:37:02 +10:00
LukeParkerDev a440d0bced refactor(app): port language, drafts, pairing, review panel, open-in-app and upsell stores to plain codecs
Also replaces the Effect Iterable pipeline in the provider catalog with array methods.
2026-09-20 16:37:01 +10:00
LukeParkerDev 169ababd60 refactor(app): port command, notification, highlights, home, file-view and workspace-tip stores to plain codecs
Session import keeps validating against the shared Effect schema through a dynamic import, so the shared schema and Effect load only when a file is imported. Persisted notification errors are checked structurally instead of through the shared schema. composer/schema.ts carries Effect twins of the file selection shapes until it is ported.
2026-09-20 16:37:00 +10:00
LukeParkerDev 10638d1d9f refactor(app): port the settings store to plain codecs
Migration shapes use preserving structs so undeclared stored fields survive, as Effect's onExcessProperty preserve did at every level; explicit-but-invalid legacy values still decode to null rather than absent; persisted() encodes through encodeOrThrow so an invalid in-memory value fails instead of being written.
2026-09-20 16:37:00 +10:00
LukeParkerDev faa72aea3a refactor(app): port the server, model, vcs and project stores to plain codecs
Migrations keep their behaviour: URL strings and bare HTTP blocks become server objects, canonical-local project buckets move under local, invalid map entries are dropped individually. Migrated definitions now preserve stored fields the migration does not mention, as Effect's onExcessProperty preserve did. The Effect ServerKey is gone; the codec one in runtime/server/key.ts carries the same brand.
2026-09-20 16:36:59 +10:00
LukeParkerDev 211ce5e9f8 refactor(app): port the layout store to plain codecs
Same shapes, defaults and migration as before (workspaces flag to map, review panel flag inherited from the file tree, legacy file-tree width), expressed with the codec module instead of Effect Schema.
2026-09-20 16:36:59 +10:00
LukeParkerDev 3858b11bf9 refactor(app): plain codecs for persisted state, starting with tabs
Adds an Effect-free codec module for persisted stores with the same recovery semantics as the Persistence helpers, lets persisted() accept either a Schema or a codec while stores migrate, and ports the tab storage schemas as the first store. ServerKey keeps its brand type on both sides so ServerConnection.Key stays one type. Effect leaves the renderer's initial module graph once the remaining stores are ported.
2026-09-20 16:36:58 +10:00
206 changed files with 3405 additions and 6085 deletions
+39 -39
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -111,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.12",
"version": "2.0.10",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +201,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -214,7 +214,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.12",
"version": "2.0.10",
"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.12",
"version": "2.0.10",
"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.12",
"version": "2.0.10",
"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.12",
"version": "2.0.10",
"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.12",
"version": "2.0.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -406,10 +406,10 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-updater": "6.8.9",
"lighthouse": "13.4.1",
@@ -436,7 +436,7 @@
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "44.4.3",
"electron": "42.10.1",
"electron-builder": "26.15.7",
"electron-vite": "6.0.0-beta.1",
"puppeteer-core": "25.9.0",
@@ -455,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -527,7 +527,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +540,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +554,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +569,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -608,7 +608,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +638,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +653,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +677,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +698,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -720,7 +720,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +755,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -775,7 +775,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -809,7 +809,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.12",
"version": "2.0.10",
"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.12",
"version": "2.0.10",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -874,7 +874,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +923,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +958,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +991,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1032,7 +1032,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3880,13 +3880,13 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@44.4.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-LTpSFTB40qVCXIX5xMo+cgHI/Jjkbjw7VpB26PccEbroqOn72LBukeaDwPVo1fBYzSzs0c9iPuAucFCO7Tw81Q=="],
"electron": ["electron@42.10.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-ITc1HPeoDzsxCCaH6MFsN67Nq2nUiJf5N9pBWxzhUpFfKJF0IwiAkKT3emg1lPbvC4OfFE+Cdwe4vhgdOZ1YKg=="],
"electron-builder": ["electron-builder@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.7", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-DBpaNzxsPs1BvEblzFoNriSbzsBqDCy/gseIngeEhYzQG1IxfB7Hvc2tBBVmpWE2BTQGP9J1RrAvDT+Vc/uAxg=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-B4uvn2NzFSuf084udWqugludFull6CRJiWe2dLzMnZLl6G5hdAGk0fsBMGlBSpKjvQCJn8IPc+S7OnJ+GXqwLA=="],
"electron-context-menu": ["electron-context-menu@5.0.0", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-rgFpRtwY0/rhsRCoz9rE6VM4WueEsLbIpca7ucOhERVrbGB2dQrxa9xwBNLplU54jRgPuv6nTEbxoplE5bzy2A=="],
"electron-context-menu": ["electron-context-menu@4.1.2", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-9xYTUV0oRqKL50N9W71IrXNdVRB0LuBp3R1zkUdUc2wfIa2/QZwYYj5RLuO7Tn7ZSLVIaO3X6u+EIBK+cBvzrQ=="],
"electron-dl": ["electron-dl@4.0.0", "", { "dependencies": { "ext-name": "^5.0.0", "pupa": "^3.1.0", "unused-filename": "^4.0.1" } }, "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
}
}
+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.12",
"version": "2.0.10",
"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.12",
"version": "2.0.10",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -19,13 +19,6 @@ 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) => ({
@@ -54,33 +47,13 @@ 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: () => {
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],
}
},
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
registration: () => registrations.get(store.session),
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
error: () => undefined,
suspended: () => false,
close: () => undefined,
open: () => 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)
},
command: () => undefined,
}
return (
<>
@@ -91,32 +64,6 @@ 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,153 +57,3 @@ 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,19 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
story("shows question navigation shortcuts", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const shortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘⏎" : "Ctrl+⏎"))
const next = component.getByRole("button", { name: "Next", exact: true })
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
await next.click()
await expect(component.getByRole("button", { name: "Submit", exact: true })).toContainText(shortcut)
const back = component.getByRole("button", { name: "Back", exact: true })
const backShortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘[" : "Alt+←"))
await expect(back).toHaveText("Back")
await back.hover()
await expect(page.getByRole("tooltip")).toContainText(backShortcut)
await page.keyboard.press(
await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+[" : "Alt+ArrowLeft")),
)
await expect(next).toBeVisible()
})
@@ -158,10 +158,6 @@ for (const direction of ["ltr", "rtl"]) {
story(`fits narrow and wide layouts in ${direction}, ${theme}`, async ({ mount, page }, testInfo) => {
await page.setViewportSize({ width: 900, height: 900 })
const component = await mount("settings-timeline-detail--interactive", { globals: { direction, theme } })
await expect(component.locator('[data-slot="timeline-detail-scale"]')).toHaveCSS("margin-top", "16px")
const advanced = component.locator('[data-slot="timeline-detail-advanced"]')
await expect(advanced).toHaveCSS("margin-top", "0px")
await expect(advanced).toHaveCSS("padding-top", "8px")
await expect(component.locator('[data-slot="timeline-detail-summary"]')).toHaveCSS(
"color",
await component
@@ -181,46 +177,28 @@ for (const direction of ["ltr", "rtl"]) {
),
)
if (theme === "light") {
await expect(track.locator("span").last()).toHaveCSS("background-image", "none")
await expect(track.locator("span").first()).toHaveCSS("background-image", "none")
await expect(track).toHaveCSS(
"--timeline-detail-marker-background",
await track.evaluate((element) => getComputedStyle(element).getPropertyValue("--v2-grey-500").trim()),
)
}
if (theme === "dark") {
await expect(track.locator("span").last()).not.toHaveCSS("background-image", "none")
await expect(track.locator("span").first()).not.toHaveCSS("background-image", "none")
}
await page.screenshot({ path: testInfo.outputPath(`timeline-${theme}-${direction}.png`) })
const list = component.locator('[data-slot="timeline-detail-list"]')
const columns = component.locator('[data-slot="timeline-detail-columns"]')
await expect(component.locator('[data-slot="timeline-detail-categories"]')).toHaveCSS("margin-top", "0px")
const advancedTrigger = component.getByRole("button", { name: "Advanced", exact: true })
expect(
await advancedTrigger.evaluate((element) => {
const trigger = element.getBoundingClientRect()
const heading = document
.querySelector('[data-slot="timeline-detail-columns"] > :nth-child(2)')!
.getBoundingClientRect()
return Math.abs(trigger.y + trigger.height / 2 - (heading.y + heading.height / 2))
}),
).toBeLessThan(1)
expect(
await list.evaluate((element) => {
const list = element.getBoundingClientRect()
const trigger = document
.querySelector('[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"]')!
.getBoundingClientRect()
return list.y - trigger.bottom
}),
).toBe(8)
for (const [column, field] of [
[2, "placement"],
[3, "details"],
] as const) {
const heading = await columns.locator(`> :nth-child(${column})`).evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
const heading = await component
.locator(`[data-slot="timeline-detail-columns"] > :nth-child(${column})`)
.evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
const toggle = await component
.locator(`[data-category="shell"][data-field="${field}"] [data-slot="switch-control"]`)
.evaluate((element) => {
@@ -229,7 +207,7 @@ for (const direction of ["ltr", "rtl"]) {
})
expect(Math.abs(heading - toggle)).toBeLessThan(1)
}
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "8px")
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "12px")
for (const width of [900, 320]) {
await page.setViewportSize({ width, height: 900 })
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeVisible()
@@ -249,7 +227,6 @@ for (const direction of ["ltr", "rtl"]) {
const slider = component.getByRole("slider", { name: "Timeline detail" })
const track = component.locator('[data-slot="timeline-detail-track"]')
await expect(slider).not.toHaveCSS("cursor", "pointer")
expect(await track.evaluate((element) => element.getBoundingClientRect().width)).toBe(
await component
.locator('[data-slot="timeline-detail-scale"]')
@@ -260,11 +237,6 @@ for (const direction of ["ltr", "rtl"]) {
for (const position of [0, 1, 2, 3, 4]) {
if (position > 0) await slider.press("ArrowUp")
await expect(track).toHaveCSS("--timeline-detail-progress", `${position * 25}%`)
await expect(track.locator("span[data-selected]")).toHaveCount(position + 1)
await expect(track.locator("span").nth(position)).toHaveCSS(
"background-color",
await track.evaluate((element) => getComputedStyle(element, "::before").backgroundColor),
)
const fill = await track.evaluate((element) => {
const style = getComputedStyle(element, "::before")
return {
@@ -17,12 +17,14 @@ const milestones = [
"electronSpawnStarted",
"debugEndpointReady",
"electronStarted",
"layersReady",
"windowVisible",
"serviceEnsureStarted",
"serviceSpawnRequested",
"serviceReady",
"backgroundLoadingReady",
"rendererViteConnected",
"rendererInitializationStarted",
"rendererInitializationReady",
"windowVisible",
"homeReady",
] as const
const phases = [
@@ -126,12 +128,14 @@ const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
{ name: "electronSpawnStarted", text: "starting electron app..." },
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
{ name: "electronStarted", text: "app starting" },
{ name: "layersReady", text: "layers ready" },
{ name: "windowVisible", text: "main window visible" },
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
{ name: "serviceReady", text: "v2 CLI background service ready" },
{ name: "backgroundLoadingReady", text: "loading task finished" },
{ name: "rendererViteConnected", text: "[vite] connected." },
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
{ name: "rendererInitializationReady", text: "server ready" },
{ name: "windowVisible", text: "main window visible" },
]
async function createColdProfile() {
@@ -400,12 +404,14 @@ function requireMilestones(observed: Partial<Record<Milestone, number>>) {
electronSpawnStarted: get("electronSpawnStarted"),
debugEndpointReady: get("debugEndpointReady"),
electronStarted: get("electronStarted"),
layersReady: get("layersReady"),
windowVisible: get("windowVisible"),
serviceEnsureStarted: get("serviceEnsureStarted"),
serviceSpawnRequested: get("serviceSpawnRequested"),
serviceReady: get("serviceReady"),
backgroundLoadingReady: get("backgroundLoadingReady"),
rendererViteConnected: get("rendererViteConnected"),
rendererInitializationStarted: get("rendererInitializationStarted"),
rendererInitializationReady: get("rendererInitializationReady"),
windowVisible: get("windowVisible"),
homeReady: get("homeReady"),
}
}
@@ -20,12 +20,14 @@ describe("desktop startup benchmark", () => {
["electronSpawnStarted", "starting electron app..."],
["debugEndpointReady", "DevTools listening on ws://"],
["electronStarted", "app starting"],
["layersReady", "layers ready"],
["windowVisible", "main window visible"],
["serviceEnsureStarted", "starting v2 background service"],
["serviceSpawnRequested", "v2 CLI background service starting"],
["serviceReady", "v2 CLI background service ready"],
["backgroundLoadingReady", "loading task finished"],
["rendererViteConnected", "[vite] connected."],
["rendererInitializationStarted", "awaiting server ready"],
["rendererInitializationReady", "server ready"],
["windowVisible", "main window visible"],
] as const
cases.forEach(([milestone, line]) => {
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
@@ -56,12 +58,14 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
electronSpawnStarted: 7,
debugEndpointReady: 8,
electronStarted: 9,
layersReady: 10,
windowVisible: 11,
serviceEnsureStarted: 12,
serviceSpawnRequested: 13,
serviceReady: 14,
rendererViteConnected: 15,
serviceEnsureStarted: 10,
serviceSpawnRequested: 11,
serviceReady: 12,
backgroundLoadingReady: 13,
rendererViteConnected: 14,
rendererInitializationStarted: 15,
rendererInitializationReady: 16,
windowVisible: 17,
homeReady: commandToHomeReadyMs,
}
return {
@@ -76,8 +80,8 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
electronStartup: 2,
serviceSpawnWait: 1,
serviceProcessStartup: 1,
rendererStartup: commandToHomeReadyMs - 15,
visibleWindowToHome: commandToHomeReadyMs - 11,
rendererStartup: commandToHomeReadyMs - 14,
visibleWindowToHome: commandToHomeReadyMs - 17,
},
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
}
@@ -96,7 +96,7 @@ for (const field of [
expect(patches).toEqual(field.patches)
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await settings.getByRole("tab", { name: "General", exact: true }).click()
await settings.getByRole("tab", { name: project.name, exact: true }).click()
if (field.name === "color") {
await expect(settings.getByRole("button", { name: "Select orange color", exact: true })).toHaveAttribute(
"aria-pressed",
@@ -71,7 +71,7 @@ for (const direction of ["ltr", "rtl"] as const) {
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
for (const width of [1520, 1280, 1800]) {
for (const width of [1520, 1280, 1600]) {
await page.setViewportSize({ width, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
await page.clock.runFor(100)
@@ -84,7 +84,6 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect(content).not.toHaveCSS("translate", shifted)
await page.setViewportSize({ width: 1440, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveCSS("translate", shifted)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
@@ -113,8 +112,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(summary).toBeVisible()
await page.setViewportSize({ width: 1800, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveCSS("translate", "none")
await expect(content).toHaveCSS("translate", "0px")
await expect(summary).toBeVisible()
await page.emulateMedia({ reducedMotion: "reduce" })
@@ -99,21 +99,6 @@ test("settings has its own route and returns through app history", async ({ page
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("settings page survives refresh", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
const appearance = settings.getByRole("tab", { name: "Appearance", exact: true })
await appearance.click()
await expect(page).toHaveURL("/settings?tab=appearance")
await expect(appearance).toHaveAttribute("aria-selected", "true")
await page.reload()
await expect(settings).toBeVisible()
await expect(appearance).toHaveAttribute("aria-selected", "true")
await expect(page).toHaveURL("/settings?tab=appearance")
})
test("single-server settings expose scoped pages without a server picker", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeVisible()
@@ -131,7 +116,7 @@ test("single-server settings expose scoped pages without a server picker", async
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "10px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
@@ -149,52 +134,10 @@ test("project settings open as a nested autosaving view", async ({ page }) => {
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
await expect(settings.getByRole("button", { name: "Back to projects", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "General", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Settings demo", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Worktrees", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Scripts", exact: true })).toHaveCount(0)
await expect(settings.getByTitle(directory)).toHaveCount(0)
const projectIcon = settings.locator('.settings-tab-header [data-component="project-avatar-v2"]')
await expect(projectIcon).toHaveCSS("width", "32px")
const projectOptions = settings
.locator(".settings-tab-header")
.getByRole("button", { name: "More options", exact: true })
await expect(projectOptions).toHaveCSS("width", "28px")
await expect
.poll(async () =>
new Set(
await Promise.all(
[
settings.getByRole("button", { name: "Back to projects", exact: true }),
settings.getByRole("heading", { name: "Settings demo", exact: true }),
projectIcon,
projectOptions,
].map((item) =>
item.evaluate((element) => {
const bounds = element.getBoundingClientRect()
return Math.round(bounds.top + bounds.height / 2)
}),
),
),
).size,
)
.toBe(1)
await expect
.poll(async () => {
const back = await settings
.getByRole("button", { name: "Back to projects", exact: true })
.evaluate((element) => element.getBoundingClientRect().top)
const general = await settings
.getByRole("tab", { name: "General", exact: true })
.evaluate((element) => element.getBoundingClientRect().top)
return Math.round(general - back)
})
.toBe(72)
await projectOptions.click()
const projectMenu = page.getByRole("menu")
await expect(projectMenu.getByRole("menuitem")).toHaveText(["Clear notifications", "Close"])
await page.keyboard.press("Escape")
await expect(projectMenu).toBeHidden()
const name = settings.getByRole("textbox", { name: "Project name", exact: true })
const saved = page.waitForRequest(
@@ -203,8 +146,7 @@ test("project settings open as a nested autosaving view", async ({ page }) => {
await name.fill("Renamed project")
await name.blur()
expect((await saved).postDataJSON()).toEqual({ name: "Renamed project" })
await expect(settings.getByRole("tab", { name: "General", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Renamed project", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Renamed project", exact: true })).toBeVisible()
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
const scriptSaved = page.waitForRequest(
@@ -215,17 +157,6 @@ test("project settings open as a nested autosaving view", async ({ page }) => {
expect((await scriptSaved).postDataJSON()).toEqual({ commands: { start: "bun install" } })
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
await expect(page).toHaveURL(
(url) =>
url.pathname === "/settings" &&
url.searchParams.get("server") === "http://127.0.0.1:4096" &&
url.searchParams.get("project") === directory &&
url.searchParams.get("tab") === "workspaces",
)
await page.reload()
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
@@ -295,7 +226,7 @@ test("recording a new session shortcut stays in settings until recording finishe
await page.keyboard.press("Control+t")
await expect(binding).toHaveText("Ctrl+T")
await expect(page).toHaveURL("/settings?tab=shortcuts")
await expect(page).toHaveURL("/settings")
await expect(page.locator("[data-titlebar-tab]")).toHaveCount(0)
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
@@ -5,7 +5,7 @@ for (const colorScheme of ["light", "dark"] as const) {
test.describe(colorScheme, () => {
test.use({ colorScheme, contextOptions: { reducedMotion: "reduce" } })
test("project list actions and edges stay inside the settings scrollport", async ({ page }, info) => {
test("project card edges stay inside the settings scrollport", async ({ page }, info) => {
const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => ({
id: `project-${index}`,
name,
@@ -20,18 +20,10 @@ for (const colorScheme of ["light", "dark"] as const) {
sessions: [],
pageMessages: () => ({ items: [] }),
provider: { all: [], connected: [], default: {} },
fileList: () => [],
})
await page.route("**/api/project", (route) =>
route.fulfill({ json: projects, headers: { "access-control-allow-origin": "*" } }),
)
await page.route("**/api/project/project-0", async (route) => {
const update = route.request().postDataJSON()
await route.fulfill({
json: { ...projects[0], ...update },
headers: { "access-control-allow-origin": "*" },
})
})
await page.addInitScript((projects) => {
localStorage.setItem(
"opencode.global.dat:server",
@@ -48,76 +40,18 @@ for (const colorScheme of ["light", "dark"] as const) {
const panel = settings.getByRole("tabpanel")
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await expect(panel.getByText("Playground", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Add project", exact: true })).toBeVisible()
const list = panel.getByRole("list")
await expect(list.getByRole("listitem")).toHaveCount(4)
const projectButton = panel.getByRole("button", { name: "rebase", exact: true })
const projectCard = list.getByRole("listitem").filter({ hasText: "rebase" })
const projectName = projectButton.getByText("rebase", { exact: true })
await expect(projectName).toHaveCSS("font-weight", "530")
await expect(projectName).toHaveCSS("line-height", "20px")
await expect(projectButton.getByText("/projects/rebase", { exact: true })).toHaveCount(0)
await expect
.poll(() =>
list.evaluate((group) => {
const icons = Array.from(group.querySelectorAll('[data-component="project-avatar-v2"]'))
const first = icons.at(0)?.getBoundingClientRect()
const last = icons.at(-1)?.getBoundingClientRect()
const bounds = group.getBoundingClientRect()
if (!first || !last) return []
return [first.top - bounds.top, bounds.bottom - last.bottom, first.left - bounds.left]
}),
)
.toEqual([16, 16, 16])
await expect
.poll(() =>
list
.locator(".settings-project-row-shell")
.filter({ hasText: "rebase" })
.evaluate((row) => {
const group = row.parentElement
if (!group) return 0
return (
row.getBoundingClientRect().left -
group.getBoundingClientRect().left +
Number.parseFloat(getComputedStyle(row, "::after").insetInlineStart)
)
}),
)
.toBe(16)
const chevron = projectButton.locator('svg:has(use[href="#opencode-v2-icon-chevron-right"])')
await expect(chevron).toHaveCount(0)
const hoverColor = await projectCard.evaluate((element) => {
const probe = document.createElement("div")
probe.style.backgroundColor = "var(--v2-background-bg-layer-02)"
element.append(probe)
const color = getComputedStyle(probe).backgroundColor
probe.remove()
return color
})
await projectCard.hover()
await expect(projectCard).toHaveCSS("background-color", hoverColor)
const more = projectCard.getByRole("button", { name: "More options", exact: true })
await more.click()
const menu = page.getByRole("menu")
await expect(menu.getByRole("menuitem")).toHaveText(["Edit", "Rename", "Clear notifications", "Close"])
await menu.getByRole("menuitem", { name: "Close", exact: true }).hover()
await expect(projectCard).toHaveCSS("background-color", hoverColor)
await expect(menu.getByRole("separator")).toHaveCount(1)
await menu.getByRole("menuitem", { name: "Edit", exact: true }).click()
await expect(panel.getByRole("heading", { name: "rebase", exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await page.evaluate(() => document.fonts.ready)
for (const width of [1280, 1050, 960, 720, 600]) {
await page.setViewportSize({ width, height: 720 })
await page.mouse.move(0, 0)
await page.screenshot({ path: info.outputPath(`projects-${width}.png`), animations: "disabled" })
// Each project card stays fully inside every horizontal clip ancestor.
// Raised cards paint a half-pixel border outside their box. The scrollport
// must leave room for that border and the soft shadow on both sides.
await expect
.poll(() =>
projectCard.evaluate((row) => {
panel.getByText("rebase", { exact: true }).evaluate((label) => {
const row = label.parentElement!.parentElement!
const bounds = row.getBoundingClientRect()
const clips = []
for (let parent = row.parentElement; parent; parent = parent.parentElement) {
@@ -139,45 +73,11 @@ for (const colorScheme of ["light", "dark"] as const) {
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
const secondProject = panel.getByRole("button", { name: "dinocms", exact: true })
await list
.getByRole("listitem")
.filter({ hasText: "dinocms" })
.getByRole("button", { name: "More options", exact: true })
.click()
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
await expect(secondProject).toHaveCount(0)
await page.setViewportSize({ width: 1280, height: 260 })
await panel.getByText("rebase", { exact: true }).hover()
await page.mouse.wheel(0, 400)
await expect(panel.getByText("Playground", { exact: true })).toBeInViewport({ ratio: 1 })
await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeInViewport({ ratio: 1 })
await page.setViewportSize({ width: 1280, height: 720 })
await more.click()
await menu.getByRole("menuitem", { name: "Rename", exact: true }).click()
const rename = panel.getByRole("textbox", { name: "Rename", exact: true })
await expect(rename).toBeFocused()
await expect(rename).toHaveValue("rebase")
await expect(rename).toHaveCSS("border-radius", "0px")
await rename.fill("Renamed project")
const renamed = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/project-0",
)
await rename.press("Enter")
expect((await renamed).postDataJSON()).toEqual({ name: "Renamed project" })
await expect(panel.getByRole("button", { name: "Renamed project", exact: true })).toBeVisible()
await panel.getByRole("button", { name: "Add project", exact: true }).click()
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
await picker.getByRole("combobox").fill("/projects/added")
await picker.getByRole("combobox").press("Enter")
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await expect(picker).toBeHidden()
await expect(panel.getByRole("heading", { name: "added", exact: true })).toBeVisible()
})
})
}
@@ -30,19 +30,6 @@ function projectList(count: number) {
}))
}
async function persistProjects(page: Page, projects: ReturnType<typeof projectList>) {
await page.evaluate((projects) => {
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
...value,
projects: { ...value.projects, local: projects.map((project) => ({ worktree: project.canonical })) },
}),
)
}, projects)
}
function ui(page: Page) {
const settings = page.getByTestId("settings-screen")
return {
@@ -63,14 +50,6 @@ test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, config)
await page.route("https://api.github.com/**", (route) => route.fulfill({ json: [] }))
await page.addInitScript((directory) => {
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
if (value.projects?.local) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ ...value, projects: { ...value.projects, local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
await page.getByRole("button", { name: "Settings", exact: true }).click()
@@ -177,7 +156,7 @@ test("empty queries keep the closing quote beside the ellipsis while typing and
await expect(status).toHaveText('No results for "zzzzzzzzzzx"')
})
test("Models and Shortcuts leave focus on navigation", async ({ page }) => {
test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => {
const view = ui(page)
for (const entry of [
{ tab: "Models", search: "Search models" },
@@ -185,7 +164,7 @@ test("Models and Shortcuts leave focus on navigation", async ({ page }) => {
{ tab: "Models", search: "Search models" },
]) {
await view.settings.getByRole("tab", { name: entry.tab, exact: true }).click()
await expect(view.settings.getByRole("searchbox", { name: entry.search, exact: true })).not.toBeFocused()
await expect(view.settings.getByRole("searchbox", { name: entry.search, exact: true })).toBeFocused()
}
await view.search.fill("shortcuts")
const result = view.results.getByRole("option", { name: "Keyboard shortcuts", exact: true })
@@ -193,172 +172,6 @@ test("Models and Shortcuts leave focus on navigation", async ({ page }) => {
await expect(result).toBeFocused()
})
test("Shortcuts search keeps focus while filtering", async ({ page }) => {
const view = ui(page)
await view.settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search shortcuts", exact: true })
await search.press("p")
await expect(search).toBeFocused()
await page.keyboard.type("alette")
await expect(search).toHaveValue("palette")
await expect(view.settings.getByText("Command palette", { exact: true })).toBeVisible()
})
test("section searches share controls and hierarchical Escape behavior", async ({ page }) => {
const projects = projectList(8)
await page.route("**/api/project", (route) => route.fulfill({ json: projects }))
await persistProjects(page, projects)
await page.reload()
const view = ui(page)
for (const [index, entry] of [
{ tab: "Projects", label: "Search projects", query: "missing-project" },
{ tab: "Shortcuts", label: "Search shortcuts", query: "missing-shortcut" },
{ tab: "Models", label: "Search models", query: "missing-model" },
].entries()) {
if (index > 0) {
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(view.settings).toBeVisible()
}
await view.settings.getByRole("tab", { name: entry.tab, exact: true }).click()
const field = view.settings.locator('[data-component="settings-filter-search"]')
const search = field.getByRole("searchbox", { name: entry.label, exact: true })
await expect(field).toHaveCount(1)
await expect(field.locator('[data-slot="text-input-v2-leading-icon"]')).toBeVisible()
await expect(search).not.toBeFocused()
await page.keyboard.type(entry.query)
await expect(search).toBeFocused()
await expect(search).toHaveValue(entry.query)
await expect(view.settings.getByText(`No results for "${entry.query}"`, { exact: true })).toBeVisible()
const clear = field.getByRole("button", { name: "Clear", exact: true })
await expect(clear).toHaveAttribute("data-variant", "clear")
await clear.click()
await expect(search).toHaveValue("")
await expect(search).toBeFocused()
await search.fill(`${entry.query} `.repeat(20))
await search.press("End")
await expect(search).toHaveAttribute("data-overflow-start", "true")
await search.press("Home")
await expect(search).toHaveAttribute("data-overflow-start", "false")
await expect(search).toHaveAttribute("data-overflow-end", "true")
await search.press("Escape")
await expect(search).toHaveValue("")
await expect(search).toBeFocused()
await expect(view.settings).toBeVisible()
await search.press("Escape")
await expect(search).not.toBeFocused()
await expect(view.settings).toBeFocused()
await expect(view.settings).toBeVisible()
await page.keyboard.press("Escape")
await expect(view.settings).toBeHidden()
await expect(page).toHaveURL("/")
}
})
for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
const inventory = projectList(count)
await page.route("**/api/project", (route) => route.fulfill({ json: inventory }))
await persistProjects(page, inventory)
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(count)
await view.search.clear()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
await expect(projects).toHaveCount(count)
if (count === 7) {
await expect(search).toHaveCount(0)
return
}
await expect(search).not.toBeFocused()
await search.fill(" CODE 06 ")
await expect(projects).toHaveCount(1)
await expect(projects).toHaveAccessibleName("OpenCode 06")
await expect(search).toBeVisible()
await search.fill("missing-project")
await expect(projects).toHaveCount(0)
await expect(view.settings.getByText('No results for "missing-project"', { exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toBeFocused()
await expect(projects).toHaveCount(count)
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).not.toBeFocused()
await search.fill("OpenCode 06")
await projects.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
})
}
test("Projects shows an add action in its empty state", async ({ page }) => {
await page.route("**/api/project", (route) => route.fulfill({ json: [] }))
await persistProjects(page, [])
await page.reload()
const view = ui(page)
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(view.settings.getByText("No projects yet", { exact: true })).toBeVisible()
await expect(view.settings.getByText("Create your first project to get started", { exact: true })).toBeVisible()
const emptyCard = view.settings.locator('[data-component="settings-project-empty-card"]')
await expect(emptyCard).toHaveCSS("border-radius", "8px")
await expect(emptyCard).toHaveCSS("padding-top", "96px")
await expect(emptyCard).toHaveCSS("padding-bottom", "96px")
const emptyBackground = await emptyCard.evaluate((element) => {
const probe = document.createElement("div")
probe.style.background =
"color-mix(in oklab, var(--v2-background-bg-base) 50%, var(--v2-background-bg-layer-01))"
element.append(probe)
const color = getComputedStyle(probe).backgroundColor
probe.remove()
return color
})
await expect(emptyCard).toHaveCSS("background-color", emptyBackground)
const add = view.settings.getByRole("button", { name: "Add project", exact: true })
await expect(add).toHaveCount(1)
await add.click()
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
await expect(picker).toBeVisible()
await picker.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(picker).toBeHidden()
})
test("a single project uses the full grouped hover surface", async ({ page }) => {
const view = ui(page)
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const list = view.settings.getByRole("list")
const project = list.getByRole("listitem")
await expect(project).toHaveCount(1)
await expect
.poll(() =>
project.evaluate((item) => {
const row = item.getBoundingClientRect()
const group = item.parentElement?.parentElement?.getBoundingClientRect()
if (!group) return []
return [row.top - group.top, row.left - group.left, group.right - row.right, group.bottom - row.bottom]
}),
)
.toEqual([0, 0, 0, 0])
})
test("Projects search renders with a qualifying persisted inventory", async ({ page }) => {
const projects = projectList(8)
await page.route("**/api/project", (route) => route.fulfill({ json: projects }))
await persistProjects(page, projects)
await page.reload()
const view = ui(page)
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).toBeVisible()
await expect(search).not.toBeFocused()
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
})
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
const view = ui(page)
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
@@ -394,13 +207,11 @@ test("qualified project results preserve query and selection on return", async (
})
test("returning from a project restores a scrolled result list", async ({ page }) => {
const projects = projectList(30)
await page.route("**/api/project", (route) =>
route.fulfill({
json: projects,
json: projectList(30),
}),
)
await persistProjects(page, projects)
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
@@ -502,17 +313,14 @@ test("multi-server results navigate to the named server and hide search in neste
body: Buffer.from(await response.arrayBuffer()),
})
})
await page.addInitScript((server) => {
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
...value,
list: [{ type: "http", displayName: "Build server", http: { url: server } }],
projects: { ...value.projects, [server]: [{ worktree: "/remote/opencode", expanded: true }] },
}),
)
}, server)
await page.addInitScript(
(server) =>
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", displayName: "Build server", http: { url: server } }] }),
),
server,
)
await page.reload()
const view = ui(page)
await view.search.fill("MCPs")
@@ -527,13 +335,6 @@ test("multi-server results navigate to the named server and hide search in neste
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await view.search.fill("Build server projects")
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(view.settings.getByRole("button", { name: "Add project", exact: true })).toBeVisible()
await expect(view.settings.getByRole("list").getByRole("listitem")).toHaveCount(1)
await expect(view.settings.getByRole("button", { name: "OpenCode", exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await view.search.fill("Build server OpenCode name")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.results.getByRole("option").click()
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.12",
"version": "2.0.10",
"description": "",
"type": "module",
"exports": {
@@ -10,7 +10,8 @@
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./ssh/schema": "./src/servers/ssh/schema.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
@@ -94,3 +95,4 @@
"tailwindcss": "4.3.3"
}
}
+15 -1
View File
@@ -3,7 +3,19 @@ import { checksum } from "@opencode/util/encode"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import { Persistence } from "@/runtime/persistence/schema"
import { FileSelection, SelectedLineRange } from "@/workspaces/files/types"
// Effect twins of workspaces/files/types until this module moves to plain codecs.
const SelectedLineRange = Persistence.struct({
start: Schema.Number,
end: Schema.Number,
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
})
const FileSelection = Persistence.struct({
startLine: Schema.Number,
startChar: Schema.Number,
endLine: Schema.Number,
endChar: Schema.Number,
})
const PartBase = {
content: Schema.String,
@@ -228,3 +240,5 @@ export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pi
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
+16 -21
View File
@@ -5,8 +5,6 @@ import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
import type { SessionInfo } from "@opencode/client/promise"
import { pathKey } from "@/workspaces/path-key"
import { addProjects } from "./projects/add"
export function createHomeController() {
const layout = useLayout()
@@ -43,24 +41,6 @@ export function createHomeController() {
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
void ctx.sync.worktrees.list(id).then(() => ctx.sync.worktrees.refresh(id))
})
createEffect(() => {
// The project list is empty until the server store hydrates; clearing the restored
// selection against it would persist the loss.
if (!servers.hydrated()) return
const current = selection()
const directory = current.directory
if (!directory) return
const conn = servers.visible.find((conn) => ServerConnection.key(conn) === current.server)
if (!conn) return
if (
global
.ensureServerCtx(conn)
.projects.list()
.some((project) => pathKey(project.worktree) === pathKey(directory))
)
return
setSelection({ server: current.server })
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
@@ -119,8 +99,23 @@ export function createHomeController() {
setSelection(toggleHomeProjectSelection(selection(), key, directory))
},
add: (conn: ServerConnection.Any, directories: string[]) => {
const directory = addProjects(global.ensureServerCtx(conn), directories)
const directory = directories[0]
if (!directory) return
const ctx = global.ensureServerCtx(conn)
directories.forEach((item) => {
if (ctx.projects.list().some((project) => project.worktree === item)) return
const location = { directory: item }
void ctx.sdk.api.file
.list({ path: ".", location })
.then(async (files) => {
// TODO: Initialize empty directories when V2 exposes a native Git init API.
return ctx.sdk.api.location.get({ location }).then((result) => result.project)
})
.then((project) => ctx.sync.child(item, { bootstrap: false })[1]("project", project.id))
.catch(() => undefined)
ctx.projects.open(item)
})
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
},
openNewSession: () => {
-19
View File
@@ -1,19 +0,0 @@
import type { ServerCtx } from "@/runtime/server/runtime"
export function addProjects(context: ServerCtx, directories: string[]) {
const directory = directories[0]
if (!directory) return
directories.forEach((item) => {
if (context.projects.list().some((project) => project.worktree === item)) return
const location = { directory: item }
void context.sdk.api.file
.list({ path: ".", location })
.then(() => context.sdk.api.location.get({ location }))
.then((value) => context.sync.child(item, { bootstrap: false })[1]("project", value.project.id))
.catch(() => undefined)
context.projects.open(item)
})
context.projects.touch(directory)
return directory
}
+23 -32
View File
@@ -6,21 +6,20 @@ import { type LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { closeHomeProject, displayName, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers"
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useDialog } from "@opencode/ui/context/dialog"
import { createResource } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
import { revealProject } from "./reveal"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
export const HomeServersSchema = Codec.struct({
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
})
export function createHomeProjectsController(home: HomeController) {
@@ -44,7 +43,7 @@ export function createHomeProjectsController(home: HomeController) {
}
function canRevealProject(conn: ServerConnection.Any) {
return platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(conn)
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
}
function choose(conn: ServerConnection.Any) {
@@ -115,8 +114,13 @@ export function createHomeProjectsController(home: HomeController) {
extensions: ["json"],
},
async (file) => {
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
// Validating an imported file is the one place the shared Effect schema is needed here.
const [{ Schema }, { SessionTransfer }] = await Promise.all([
import("effect"),
import("@opencode/schema/session-transfer"),
])
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
)
const api = home.server.context(conn).sdk.api.session
const imported = await api.import({
@@ -168,27 +172,13 @@ export function createHomeProjectsController(home: HomeController) {
},
canReveal: canRevealProject,
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.revealPath || !canRevealProject(conn)) return
const context = global.ensureServerCtx(conn)
void revealProject({
directory: project.worktree,
reveal: platform.revealPath,
remove: context.projects.remove,
})
.then((revealed) => {
if (revealed) return
showToast({
variant: "error",
title: language.t("home.project.missing.title"),
description: language.t("home.project.missing.description", { name: displayName(project) }),
})
})
.catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
},
},
utility: {
@@ -199,3 +189,4 @@ export function createHomeProjectsController(home: HomeController) {
}
export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>
@@ -1,28 +0,0 @@
import { describe, expect, test } from "bun:test"
import { revealProject } from "./reveal"
describe("reveal project", () => {
test("keeps a project when its directory is revealed", async () => {
const removed: string[] = []
expect(
await revealProject({
directory: "/projects/current",
reveal: async () => true,
remove: (directory) => removed.push(directory),
}),
).toBe(true)
expect(removed).toEqual([])
})
test("removes a project when its directory no longer exists", async () => {
const removed: string[] = []
expect(
await revealProject({
directory: "/projects/missing",
reveal: async () => false,
remove: (directory) => removed.push(directory),
}),
).toBe(false)
expect(removed).toEqual(["/projects/missing"])
})
})
-9
View File
@@ -1,9 +0,0 @@
export async function revealProject(input: {
directory: string
reveal: (directory: string) => Promise<boolean>
remove: (directory: string) => void
}) {
if (await input.reveal(input.directory)) return true
input.remove(input.directory)
return false
}
+4 -4
View File
@@ -65,14 +65,14 @@
}
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
When they overlap, center the conversation in the space that remains beside the summary. */
@container session-chat (min-width: 1320px) and (width < 1640px) {
Move only as far as needed; wider panels already have enough space in the margin. */
@container session-chat (min-width: 1320px) {
[data-slot="session-chat-panel"][data-summary-open="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
--session-summary-translate: -160px;
--session-summary-translate: min(0px, calc(50cqi - 820px));
&:dir(rtl) {
--session-summary-translate: 160px;
--session-summary-translate: max(0px, calc(820px - 50cqi));
}
}
}
+6 -6
View File
@@ -5,7 +5,7 @@ import { Show, Suspense, createMemo, createSignal, lazy, onMount } from "solid-j
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { debounce } from "@solid-primitives/scheduled"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
@@ -21,7 +21,6 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
import { SummaryPopover } from "@/session/summary/popover"
@@ -34,12 +33,12 @@ const NewSessionSummary = lazy(async () => {
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
export const WorkspaceOnboardingSchema = Persistence.struct({
used: Schema.Boolean,
export const WorkspaceOnboardingSchema = Codec.struct({
used: Codec.boolean,
})
export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
export const ProviderTipSchema = Codec.struct({
dismissedAt: Codec.number,
})
export const WorkspaceTipSchema = ProviderTipSchema
@@ -267,3 +266,4 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
</Show>
)
}
+11 -22
View File
@@ -1,7 +1,6 @@
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js"
import type { ProviderListResponse } from "@/runtime/server/types"
import { useIntegrations } from "./integrations"
@@ -53,34 +52,24 @@ export function useProviders(directory: Accessor<string | undefined>) {
.filter((integration) => popularProviderSet.has(integration.id))
.map((integration) => ({ id: integration.id, name: integration.name }))
const seen = new Set(catalog.map((integration) => integration.id))
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
Iterable.map((p) => ({ id: p.id, name: p.name })),
(v) => [...catalog, ...v],
)
const more = [...providers().all.values()]
.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id))
.map((p) => ({ id: p.id, name: p.name }))
return [...catalog, ...more]
},
connected: () => {
const connected = new Set(providers().connected)
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => connected.has(p.id)),
(v) => Array.from(v),
)
return [...providers().all.values()].filter((p) => connected.has(p.id))
},
paid: () => {
const connected = new Set(providers().connected)
const paid = [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
const paid = [...providers().all].filter(
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
)
return paid
},
}
}
+28 -30
View File
@@ -3,12 +3,11 @@ import { base64Encode } from "@opencode/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { useModels } from "@/providers/models/models"
import { useSettings } from "@/settings/model"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { hasCustomAgent, resolveAgent } from "./agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -18,46 +17,45 @@ import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
modelID: Schema.String,
variant: Schema.optional(Schema.String),
const ModelKeySchema = Codec.struct({
providerID: Codec.string,
modelID: Codec.string,
variant: Codec.optional(Codec.string),
})
export type ModelKey = typeof ModelKeySchema.Type
const ChoiceSchema = Schema.Struct({
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
const ChoiceSchema = Codec.struct({
model: Codec.lenientOptional(ModelKeySchema),
variant: Codec.lenientOptional(Codec.nullOr(Codec.string)),
})
const StateSchema = Schema.Struct({
const StateSchema = Codec.struct({
...ChoiceSchema.fields,
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
agent: Codec.lenientOptional(Codec.string),
choices: Codec.lenientOptional(Codec.record(ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Schema.Record(
Schema.String,
Schema.mutableKey(Persistence.fallback(Schema.UndefinedOr(StateSchema), () => undefined)),
const SessionsSchema = Codec.record(Codec.fallback(Codec.undefinedOr(StateSchema), () => undefined))
const Current = Codec.struct({ session: SessionsSchema })
const StoredSelection = Codec.struct(
{
session: Codec.lenientOptional(Codec.record(Codec.unknown)),
pick: Codec.lenientOptional(Codec.record(Codec.unknown)),
},
{ preserve: true },
)
const Current = Persistence.struct({ session: SessionsSchema })
export const ModelSelectionSchema = Persistence.migrate(
export const ModelSelectionSchema = Codec.migrate(
Current,
Schema.Struct({
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => ({
session:
value.session ??
Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
})),
encode: SchemaGetter.transform((value) => value),
Codec.transform(StoredSelection, {
decode: (value) => ({
...value,
session:
value.session ?? Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
}),
),
encode: (value) => value,
}),
)
const WORKSPACE_KEY = "__workspace__"
+1 -19
View File
@@ -625,16 +625,6 @@ export const dict = {
"toast.file.loadFailed.title": "Failed to load file",
"file.error.notFound": "File not found: {{name}}",
"toast.file.listFailed.title": "Failed to list files",
"file.view.preview": "Preview",
"file.view.source": "Source",
"file.view.openInBrowser": "Open in browser",
"file.view.binary": "Binary file · {{size}}",
"file.view.table.rows.one": "{{count}} row",
"file.view.table.rows.other": "{{count}} rows",
"file.view.table.columns.one": "{{count}} column",
"file.view.table.columns.other": "{{count}} columns",
"file.view.table.truncated": "Showing the first {{shown}} of {{total}} rows.",
"file.view.fontSample": "Sphinx of black quartz, judge my vow.",
"toast.context.noLineSelection.title": "No line selection",
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
@@ -732,8 +722,6 @@ export const dict = {
"home.projects": "Projects",
"home.projects.all": "All projects",
"home.project.add": "Add project",
"home.project.missing.title": "Project folder not found",
"home.project.missing.description": "{{name}} was removed because its folder no longer exists.",
"home.recentlyClosed": "Recently closed",
"home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects",
@@ -918,10 +906,6 @@ 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",
@@ -1084,10 +1068,8 @@ export const dict = {
"settings.server.preferences.websearch.title": "Third-party search",
"settings.server.preferences.websearch.description": "Select the search provider agents use to search the web",
"settings.projects.title": "Projects",
"settings.projects.description": "Manage projects and their settings",
"settings.projects.description": "Manage project settings on this server",
"settings.projects.empty": "No projects found",
"settings.projects.empty.title": "No projects yet",
"settings.projects.empty.description": "Create your first project to get started",
"settings.projects.search.placeholder": "Search projects",
"settings.projects.server.all": "All servers",
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
+9 -16
View File
@@ -1,7 +1,7 @@
import { flatten, resolveTemplate, translator, type Flatten } from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Schema, SchemaGetter } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { createSimpleContext } from "@opencode/ui/context"
import {
I18nProvider,
@@ -12,7 +12,6 @@ import {
type UiPluralCategory,
} from "@opencode/ui/context/i18n"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import en from "@/runtime/i18n/en"
import { dict } from "@opencode/ui/i18n/en"
import {
@@ -56,14 +55,9 @@ function cookie(locale: Locale) {
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
const LocaleSchema = Schema.Literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Schema.Struct({
locale: Schema.String.pipe(
Schema.decodeTo(LocaleSchema, {
decode: SchemaGetter.transform(normalizeLocale),
encode: SchemaGetter.transform((locale) => locale),
}),
),
const LocaleSchema = Codec.literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Codec.struct({
locale: Codec.transform(Codec.string, { decode: normalizeLocale, encode: (locale) => locale }),
})
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
@@ -160,11 +154,11 @@ function detectLocale(): Locale {
}
export function normalizeLocale(value: string): Locale {
return Option.getOrElse(Schema.decodeUnknownOption(LocaleSchema)(value), () => "en")
return Codec.decodeOption(LocaleSchema, value) ?? "en"
}
export const languageSchema = Persistence.struct({
locale: StoredLocaleSchema.fields.locale,
export const languageSchema = Codec.struct({
locale: StoredLocaleSchema.fields.locale,
})
function readStoredLocale() {
@@ -172,9 +166,7 @@ function readStoredLocale() {
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = Schema.decodeUnknownOption(Schema.fromJsonString(StoredLocaleSchema))(raw)
if (Option.isNone(next)) return
return next.value.locale
return Codec.decodeOption(Codec.fromJsonString(StoredLocaleSchema), raw)?.locale
} catch {
return
}
@@ -296,3 +288,4 @@ export function UiI18nBridge(props: { children?: JSX.Element }) {
</I18nProvider>
)
}
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test"
import { Codec } from "./codec"
// Type-level checks: these compile only if inference matches what `typeof schema.Type` gave callers.
test("struct types infer optional and required fields", () => {
const s = Codec.struct({ id: Codec.string, tab: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
const value: typeof s.Type = { id: "x" }
const tab: string | undefined = value.tab
const n: number | undefined = value.n
const t = Codec.transform(s, { decode: (old) => old.tab ?? old.id, encode: (v) => ({ id: v }) })
const out: string | Codec.Invalid = t.decode({ id: "a" })
const onlyOptional = Codec.struct({ tab: Codec.optional(Codec.string) })
const empty: typeof onlyOptional.Type = {}
const maybe: string | undefined = empty.tab
const viaTransform = Codec.transform(onlyOptional, { decode: (old) => old.tab, encode: (tab) => ({ tab }) })
expect([tab, n, out, maybe, viaTransform.decode({})]).toEqual([undefined, undefined, "a", undefined, undefined])
})
@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test"
import { Codec } from "./codec"
describe("Codec", () => {
test("primitives reject the wrong shape and finite numbers only", () => {
expect(Codec.string.decode("a")).toBe("a")
expect(Codec.string.decode(1)).toBe(Codec.INVALID)
expect(Codec.number.decode(1.5)).toBe(1.5)
expect(Codec.number.decode(Number.NaN)).toBe(Codec.INVALID)
expect(Codec.nonNegativeInt.decode(-1)).toBe(Codec.INVALID)
expect(Codec.literals(["a", "b"]).decode("c")).toBe(Codec.INVALID)
expect(Codec.literal("x").decode("x")).toBe("x")
})
test("struct keeps optional fields absent and rejects invalid required ones", () => {
const codec = Codec.struct({ id: Codec.string, title: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
expect(codec.decode({ id: "1" })).toEqual({ id: "1" })
expect(codec.decode({ id: "1", title: "t", n: "bad" })).toEqual({ id: "1", title: "t" })
expect(codec.decode({ id: "1", title: 3 })).toBe(Codec.INVALID)
expect(codec.decode({ title: "t" })).toBe(Codec.INVALID)
expect(codec.decode([])).toBe(Codec.INVALID)
expect(codec.encode({ id: "1" })).toEqual({ id: "1" })
const value: typeof codec.Type = { id: "1", title: undefined }
expect(value.id).toBe("1")
})
test("lenient collections recover what they can", () => {
const items = Codec.lenientArray(Codec.struct({ id: Codec.string }))
expect(items.decode([{ id: "a" }, { id: 1 }, "x", { id: "b" }])).toEqual([{ id: "a" }, { id: "b" }])
expect(items.decode("nope")).toEqual([])
expect(Codec.array(Codec.string).decode(["a", 1])).toBe(Codec.INVALID)
const map = Codec.lenientRecord(Codec.boolean)
expect(map.decode({ a: true, b: "x" })).toEqual({})
expect(map.decode({ a: true })).toEqual({ a: true })
})
test("union, transform and fallback compose", () => {
const session = Codec.struct({ type: Codec.literal("session"), id: Codec.string })
const draft = Codec.struct({ type: Codec.literal("draft"), directory: Codec.string })
const tab = Codec.union([session, draft])
expect(tab.decode({ type: "draft", directory: "/x" })).toEqual({ type: "draft", directory: "/x" })
expect(tab.decode({ type: "other" })).toBe(Codec.INVALID)
const upper = Codec.transform(Codec.string, { decode: (s) => s.toUpperCase(), encode: (s) => s.toLowerCase() })
expect(upper.decode("ab")).toBe("AB")
expect(upper.encode("AB")).toBe("ab")
const safe = Codec.fallback(Codec.number, () => 7)
expect(safe.decode("x")).toBe(7)
expect(safe.decode(undefined)).toBe(7)
expect(safe.decode(2)).toBe(2)
})
test("brand constructs and decodes as its base", () => {
const Key = Codec.brand<"ServerConnection.Key">()
const key = Key.make("http://a")
expect(Key.decode(key)).toBe(key)
expect(Key.decode(3)).toBe(Codec.INVALID)
})
test("withInitial recovers field by field and merges new defaults", () => {
const layout = Codec.struct({
sidebar: Codec.struct({ opened: Codec.boolean, width: Codec.number }),
theme: Codec.lenientOptional(Codec.literals(["light", "dark"])),
})
const initial: typeof layout.Type = { sidebar: { opened: true, width: 240 } }
const codec = Codec.fromJsonString(Codec.withInitial(layout, initial))
expect(codec.decode(JSON.stringify({ sidebar: { opened: false, width: "wide" }, theme: "dark" }))).toEqual({
sidebar: { opened: false, width: 240 },
theme: "dark",
})
expect(codec.decode(JSON.stringify({ sidebar: 5 }))).toEqual(initial)
expect(codec.decode("{not json")).toBe(Codec.INVALID)
expect(JSON.parse(codec.encode({ sidebar: { opened: true, width: 1 } }))).toEqual({ sidebar: { opened: true, width: 1 } })
})
test("migrate reads the old shape first", () => {
const current = Codec.struct({ tabs: Codec.array(Codec.string) })
const previous = Codec.struct({ tab: Codec.optional(Codec.string) })
const read = Codec.transform(previous, {
decode: (old) => ({ tabs: old.tab ? [old.tab] : [] }),
encode: (value) => ({ tab: value.tabs[0] }),
})
const codec = Codec.withInitial(Codec.migrate(current, read), { tabs: [] })
expect(codec.decode({ tab: "a" })).toEqual({ tabs: ["a"] })
})
})
@@ -0,0 +1,366 @@
export * as Codec from "./codec"
// Plain codecs for persisted state. They replace Effect Schema in the renderer's initial module
// graph, where Effect's own module initialisation was the single largest startup cost that was not
// rendering. Semantics mirror the Persistence helpers: decoding never throws, `INVALID` marks a
// value that cannot be recovered, and the lenient combinators recover what they can.
export const INVALID: unique symbol = Symbol.for("opencode/persistence/codec/invalid")
export type Invalid = typeof INVALID
const tag: unique symbol = Symbol.for("opencode/persistence/codec")
export interface Of<T, E = unknown> {
readonly [tag]: true
/** Phantom: `typeof codec.Type` is the decoded type, as with Effect schemas. */
readonly Type: T
readonly Encoded: E
readonly optional?: boolean
decode(input: unknown): T | Invalid
encode(value: T): E
}
export type Any = Of<any, any>
export type Type<C extends Any> = C["Type"]
export function isCodec(value: unknown): value is Any {
return typeof value === "object" && value !== null && tag in value
}
export function make<T, E = unknown>(decode: (input: unknown) => T | Invalid, encode: (value: T) => E): Of<T, E> {
return { [tag]: true, decode, encode } as Of<T, E>
}
export function is<T>(codec: Of<T>, input: unknown): input is T {
return codec.decode(input) !== INVALID
}
export function decodeOption<T>(codec: Of<T>, input: unknown): T | undefined {
const value = codec.decode(input)
return value === INVALID ? undefined : value
}
export function decodeOrThrow<T>(codec: Of<T>, input: unknown): T {
const value = codec.decode(input)
if (value === INVALID) throw new Error("Value does not match its codec")
return value
}
/** Encodes and checks the result decodes, so an invalid in-memory value fails loudly instead of persisting. */
export function encodeOrThrow<T, E>(codec: Of<T, E>, value: T): E {
const encoded = codec.encode(value)
if (codec.decode(encoded) === INVALID) throw new Error("Value does not match its codec")
return encoded
}
const identity = <T>(value: T) => value
export const string: Of<string, string> = make((v) => (typeof v === "string" ? v : INVALID), identity)
export const boolean: Of<boolean, boolean> = make((v) => (typeof v === "boolean" ? v : INVALID), identity)
export const unknown: Of<unknown, unknown> = make((v) => v, identity)
/** Finite numbers only: NaN and infinities are not JSON and never valid state. */
export const number: Of<number, number> = make((v) => (typeof v === "number" && Number.isFinite(v) ? v : INVALID), identity)
export const int: Of<number, number> = make((v) => (typeof v === "number" && Number.isInteger(v) ? v : INVALID), identity)
export const nonNegativeInt: Of<number, number> = make(
(v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : INVALID),
identity,
)
export function literal<const L extends string | number | boolean | null>(value: L): Of<L, L> {
return make((v) => (v === value ? value : INVALID), identity)
}
export function literals<const L extends ReadonlyArray<string | number | boolean | null>>(values: L): Of<L[number], L[number]> {
const set = new Set<unknown>(values)
return make((v) => (set.has(v) ? (v as L[number]) : INVALID), identity)
}
/** A string carrying a nominal brand, with the constructor Effect's `Schema.brand` gave callers. */
export function brand<B extends string>(): Of<string & { readonly [K in B]: B }, string> & {
make(value: string): string & { readonly [K in B]: B }
} {
return Object.assign(make<string & { readonly [K in B]: B }, string>((v) => (typeof v === "string" ? (v as never) : INVALID), identity), {
make: (value: string) => value as never,
})
}
export function nullOr<T, E>(codec: Of<T, E>): Of<T | null, E | null> {
return make((v) => (v === null ? null : codec.decode(v)), (v) => (v === null ? null : codec.encode(v)))
}
export function undefinedOr<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> {
return make((v) => (v === undefined ? undefined : codec.decode(v)), (v) => (v === undefined ? undefined : codec.encode(v)))
}
/** A struct field that may be absent. Present but invalid values make the struct invalid. */
export function optional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
return { ...undefinedOr(codec), optional: true } as never
}
/** A struct field that may be absent, and whose invalid values are dropped rather than rejected. */
export function lenientOptional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
return {
...make<T | undefined, E | undefined>(
(v) => {
if (v === undefined) return undefined
const value = codec.decode(v)
return value === INVALID ? undefined : value
},
(v) => (v === undefined ? undefined : codec.encode(v)),
),
optional: true,
} as never
}
type Fields = Record<string, Any>
type OptionalKeys<F extends Fields> = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F]
type RequiredKeys<F extends Fields> = Exclude<keyof F, OptionalKeys<F>>
type Simplify<T> = { [K in keyof T]: T[K] } & {}
export type StructType<F extends Fields> = Simplify<
{ [K in RequiredKeys<F>]: F[K]["Type"] } & { [K in OptionalKeys<F>]?: F[K]["Type"] }
>
export type StructEncoded<F extends Fields> = Simplify<
{ [K in RequiredKeys<F>]: F[K]["Encoded"] } & { [K in OptionalKeys<F>]?: F[K]["Encoded"] }
>
export interface Struct<F extends Fields> extends Of<StructType<F>, StructEncoded<F>> {
readonly fields: F
}
// `preserve` keeps keys the struct does not declare, for migration shapes that only describe the
// fields they rewrite (Effect's `onExcessProperty: "preserve"`); the current schema then decides.
export function struct<const F extends Fields>(fields: F, options?: { preserve?: boolean }): Struct<F> {
const entries = Object.entries(fields)
return {
...make<StructType<F>, StructEncoded<F>>(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const record = input as Record<string, unknown>
const out: Record<string, unknown> = options?.preserve ? { ...record } : {}
for (const [key, codec] of entries) {
const present = Object.hasOwn(record, key)
if (!present && codec.optional) continue
const value = codec.decode(record[key])
if (value === INVALID) return INVALID
if (value !== undefined || present) out[key] = value
else delete out[key]
}
return out as StructType<F>
},
(value) => {
const out: Record<string, unknown> = {}
for (const [key, codec] of entries) {
const field = (value as Record<string, unknown>)[key]
if (field === undefined && !Object.hasOwn(value as object, key)) continue
out[key] = codec.encode(field)
}
return out as StructEncoded<F>
},
),
fields,
}
}
export function array<T, E>(codec: Of<T, E>): Of<T[], E[]> {
return make(
(input) => {
if (!Array.isArray(input)) return INVALID
const out: T[] = []
for (const item of input) {
const value = codec.decode(item)
if (value === INVALID) return INVALID
out.push(value)
}
return out
},
(value) => value.map((item) => codec.encode(item)),
)
}
/** Keeps the items that decode and drops the rest, like `Persistence.array`. */
export function lenientArray<T, E>(codec: Of<T, E>): Of<T[], E[]> {
return make(
(input) => {
if (!Array.isArray(input)) return []
return input.flatMap((item) => {
const value = codec.decode(item)
return value === INVALID ? [] : [value]
})
},
(value) => value.map((item) => codec.encode(item)),
)
}
export function record<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
return make(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const out: Record<string, T> = {}
for (const [key, item] of Object.entries(input)) {
const value = codec.decode(item)
if (value === INVALID) return INVALID
out[key] = value
}
return out
},
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
)
}
/** A record that drops entries whose values are invalid, the replacement for `catchDecoding` to none. */
export function sparseRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
return make(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const out: Record<string, T> = {}
for (const [key, item] of Object.entries(input)) {
const value = codec.decode(item)
if (value !== INVALID) out[key] = value
}
return out
},
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
)
}
/** An invalid record becomes empty rather than failing the whole store, like `Persistence.record`. */
export function lenientRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
const strict = record(codec)
return make(
(input) => {
const value = strict.decode(input)
return value === INVALID ? {} : value
},
strict.encode,
)
}
export function union<const C extends ReadonlyArray<Any>>(codecs: C): Of<C[number]["Type"], C[number]["Encoded"]> {
return make(
(input) => {
for (const codec of codecs) {
const value = codec.decode(input)
if (value !== INVALID) return value
}
return INVALID
},
(value) => {
// Encode with the first member that accepts the value's shape; members are disjoint in practice.
for (const codec of codecs) if (codec.decode(value) !== INVALID) return codec.encode(value)
return value as C[number]["Encoded"]
},
)
}
/** Maps a decoded value into another shape, the replacement for `decodeTo` + `SchemaGetter.transform`. */
export function transform<T, E, T2>(
codec: Of<T, E>,
options: { decode: (value: T) => T2; encode: (value: T2) => T },
): Of<T2, E> {
return make(
(input) => {
const value = codec.decode(input)
return value === INVALID ? INVALID : options.decode(value)
},
(value) => codec.encode(options.encode(value)),
)
}
/** Decodes with `source`, maps, then validates with `target`: Effect's `decodeTo` with a transform. */
export function decodeTo<T, E, T2, E2>(
source: Of<T, E>,
target: Of<T2, E2>,
options: { decode: (value: T) => E2; encode: (value: T2) => T },
): Of<T2, E> {
return make(
(input) => {
const value = source.decode(input)
return value === INVALID ? INVALID : target.decode(options.decode(value))
},
(value) => source.encode(options.encode(value)),
)
}
/** Invalid and missing values become `value()`, like `Persistence.fallback`. */
export function fallback<T, E>(codec: Of<T, E>, value: () => NoInfer<T>): Of<T, E> {
return make(
(input) => {
if (input === undefined) return value()
const decoded = codec.decode(input)
return decoded === INVALID ? value() : decoded
},
codec.encode,
)
}
export function fromJsonString<T, E>(codec: Of<T, E>): Of<T, string> {
return make(
(input) => {
if (typeof input !== "string") return INVALID
try {
return codec.decode(JSON.parse(input))
} catch {
return INVALID
}
},
(value) => JSON.stringify(codec.encode(value)),
)
}
export type Decoder = Pick<Of<unknown>, "decode">
export type Migrated<C extends Any> = { readonly current: C; readonly read: Decoder }
/** Older stored shapes go through `read` first; `current` describes what the store holds today. */
export function migrate<C extends Any>(current: C, read: Decoder): Migrated<C> {
return { current, read }
}
function isMigrated<C extends Any>(definition: C | Migrated<C>): definition is Migrated<C> {
return !isCodec(definition) && "current" in definition
}
// Stored values recover field by field against the initial value: an object's valid fields are
// kept, invalid or missing ones take their initial counterpart, and the result is merged over the
// initial so new fields appear with their defaults. Mirrors `Persistence.withInitial`.
export function withInitial<C extends Any>(definition: C | Migrated<C>, initial: Type<C>): Of<Type<C>, unknown> {
const codec = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition) ? definition.read : unknown
return make(
(input) => {
const stored = read.decode(input)
if (stored === INVALID) return INVALID
return merge(initial, recover(codec, stored, initial))
},
(value) => codec.encode(value),
)
}
function recover(codec: Any, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if ("fields" in codec && isObject(value)) {
const fields = (codec as Struct<Fields>).fields
return Object.fromEntries(
Object.entries(fields).flatMap(([name, field]) => {
const defaults = isObject(initial) ? initial[name] : undefined
const next = recover(field, value[name], defaults)
if (next === undefined && !Object.hasOwn(value, name) && defaults === undefined) return []
return [[name, next]]
}),
)
}
const decoded = codec.decode(value)
return decoded === INVALID ? initial : decoded
}
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial
if (!isObject(initial) || !isObject(value)) return value
return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -3,6 +3,7 @@ import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
import { languageSchema } from "@/runtime/i18n/language"
import { HomeServersSchema } from "@/home/projects/controller"
@@ -10,9 +11,9 @@ import { ModelProvidersSchema } from "@/settings/models/models"
describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
const onboarding = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(WorkspaceOnboardingSchema, { used: false }), input))
const tip = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ProviderTipSchema, { dismissedAt: 0 }), input))
const workspaceTip = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }), input))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
@@ -25,7 +26,7 @@ describe("persisted consumer schemas", () => {
test("collapse records recover malformed entries without losing valid siblings", () => {
for (const schema of [HomeServersSchema, ModelProvidersSchema]) {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(schema, { collapsed: {} }))
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(schema, { collapsed: {} }), input)
expect(decode({})).toEqual({ collapsed: {} })
expect(decode({ collapsed: [] })).toEqual({ collapsed: {} })
expect(decode({ collapsed: { open: false, closed: true, invalid: "false" } })).toEqual({
@@ -35,21 +36,19 @@ describe("persisted consumer schemas", () => {
})
test("model selection migrates legacy picks and omits workspace state", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), input)
expect(decode({})).toEqual({ session: {} })
const state = decode({ pick: { __workspace__: { agent: "plan" }, session1: { agent: "build" } } })
expect(state.session.session1?.agent).toBe("build")
expect(state.session.__workspace__).toBeUndefined()
const encoded = Schema.encodeSync(
Schema.fromJsonString(Persistence.withInitial(ModelSelectionSchema, { session: {} })),
)(state)
const encoded = Codec.fromJsonString(Codec.withInitial(ModelSelectionSchema, { session: {} })).encode(state)
expect(JSON.parse(encoded)).toEqual({ session: { session1: { agent: "build" } } })
expect(decode(JSON.parse(encoded))).toEqual(state)
})
test("current model selections take precedence over legacy picks", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
session: {},
pick: { session1: { agent: "plan" } },
}),
@@ -57,7 +56,7 @@ describe("persisted consumer schemas", () => {
})
test("model selection validates nested model keys and preserves explicit null variants", () => {
const state = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
const state = Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
session: {
good: { agent: "build", model: { providerID: "provider", modelID: "model", variant: "high" }, variant: null },
partial: { agent: "plan", model: { providerID: "provider", modelID: 42 }, variant: false },
@@ -76,7 +75,7 @@ describe("persisted consumer schemas", () => {
})
test("file views validate scroll positions and line sides independently", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(FileViewsSchema, { file: {} }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(FileViewsSchema, { file: {} }), input))
expect(decode({})).toEqual({ file: {} })
const state = decode({
file: {
@@ -103,7 +102,7 @@ describe("persisted consumer schemas", () => {
})
test("language preserves runtime defaults and normalizes unsupported locales to English", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(languageSchema, { locale: "fr" }))
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(languageSchema, { locale: "fr" }), input)
expect(decode({})).toEqual({ locale: "fr" })
expect(decode({ locale: undefined })).toEqual({ locale: "fr" })
expect(decode({ locale: 42 })).toEqual({ locale: "fr" })
@@ -111,3 +110,6 @@ describe("persisted consumer schemas", () => {
expect(decode({ locale: "ar" })).toEqual({ locale: "ar" })
})
})
@@ -1,5 +1,5 @@
import type { AsyncStorage } from "@solid-primitives/storage"
import { Option, Schema } from "effect"
import { Codec } from "./codec"
export type BlobReference = { id: string; url: string }
@@ -312,12 +312,12 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
getItem: async (key) => {
const value = await driver.get(key)
if (value === null) return null
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed.value), grace)
return JSON.stringify(await decode(parsed.value))
const parsed = Codec.fromJsonString(Codec.unknown).decode(value)
// Let the owning persistence codec apply its invalid-document policy.
if (parsed === Codec.INVALID) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed), grace)
return JSON.stringify(await decode(parsed))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
setDocument,
@@ -455,3 +455,4 @@ export async function blobDataUrl(blob: BlobReference, mime: string) {
export function createLegacyBlobReference(dataUrl: string): BlobReference {
return { id: dataUrl, url: dataUrl }
}
+44 -11
View File
@@ -6,6 +6,7 @@ import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Option, Schema } from "effect"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Codec } from "./codec"
import { persistStore } from "./persist"
import { Persistence } from "./schema"
@@ -472,25 +473,57 @@ export function removePersisted(
}
}
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
type Definition<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any> =
| S
| Persistence.Migrated<Extract<S, Schema.ConstraintCodec<object, unknown>>>
| Codec.Migrated<Extract<S, Codec.Any>>
// Persisted stores are moving from Effect Schema to the plain codecs in ./codec so the renderer
// stops paying for Effect at startup; both are accepted while the migration is underway.
function serializer<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
definition: Definition<S>,
initial: S["Type"],
) {
if (Codec.isCodec(definition) || (!("current" in definition) ? false : Codec.isCodec(definition.current))) {
const codec = Codec.withInitial(definition as Codec.Any | Codec.Migrated<Codec.Any>, initial)
const json = Codec.fromJsonString(codec)
return {
decode: (raw: string) => Codec.decodeOption(json, raw) as S["Type"] | undefined,
deserialize: (raw: unknown) => Codec.decodeOrThrow(json, raw) as S["Type"],
serialize: (value: S["Type"]) => Codec.encodeOrThrow(json, value),
encode: (value: S["Type"]) => Codec.encodeOrThrow(codec, value),
initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"],
}
}
const schema = definition as Schema.ConstraintCodec<object, unknown> | Persistence.Migrated<Schema.ConstraintCodec<object, unknown>>
const initialized = Persistence.withInitial(schema, initial as object)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
return {
decode: (raw: string) => Option.getOrUndefined(decode(raw)) as S["Type"] | undefined,
deserialize: Schema.decodeUnknownSync(json) as (raw: unknown) => S["Type"],
serialize: Schema.encodeSync(json) as (value: S["Type"]) => string,
encode: Schema.encodeSync(initialized) as (value: S["Type"]) => unknown,
initial: Schema.decodeUnknownSync(Schema.toType(initialized))(initial as object) as S["Type"],
}
}
export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
target: string | PersistTarget,
schema: S | Persistence.Migrated<S>,
schema: Definition<S>,
initial: NoInfer<S["Type"]>,
platformOverride?: Platform,
): PersistedWithReady<S["Type"]> {
const platform = platformOverride ?? usePlatform()
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
const initialized = Persistence.withInitial(schema, initial)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
const encode = Schema.encodeSync(initialized)
const serialize = Schema.encodeSync(json)
const codec = serializer<S>(schema, initial)
const { encode, serialize } = codec
const normalize = (raw: string) => {
const value = decode(raw)
if (Option.isSome(value)) return serialize(value.value)
const value = codec.decode(raw)
if (value !== undefined) return serialize(value)
}
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
const store = createStore<S["Type"]>(codec.initial)
const isDesktop = platform.platform === "desktop" && !!platform.storage
const draft = config.draft ? platform.draftStore : undefined
const prefix = `${config.storage ?? "default"}:`
@@ -602,7 +635,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
name: config.key,
storage,
serialize,
deserialize: Schema.decodeUnknownSync(json),
deserialize: codec.deserialize,
sync: channel ? messageSync(channel) : undefined,
// Drafts take the encoded document itself so large text is externalized without the store
// re-parsing the serialized form on every save.
@@ -19,7 +19,6 @@ export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "preview"; path: string }
| { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
@@ -46,9 +46,6 @@ type PlatformBase = {
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean>
/** Return the local paths that still exist as directories; paths that cannot be checked are kept (desktop only) */
checkDirectories?(paths: string[]): Promise<string[]>
/** Restart the app */
restart(): Promise<void>
+11
View File
@@ -0,0 +1,11 @@
import type { Brand } from "effect"
import { Codec } from "@/runtime/persistence/codec"
// The server key's brand is shared with the Effect schema in ./persistence.ts (type only, so this
// module loads nothing of Effect), letting stores port to plain codecs one at a time.
export type ServerKey = string & Brand.Brand<"ServerConnection.Key">
export const ServerKey: Codec.Of<ServerKey, string> & { make(value: string): ServerKey } = Object.assign(
Codec.make<ServerKey, string>((v) => (typeof v === "string" ? (v as ServerKey) : Codec.INVALID), (v) => v),
{ make: (value: string) => value as ServerKey },
)
@@ -1,15 +1,14 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { IconState, ModelState, ProjectState, VcsState, serverState } from "./persistence"
import { createRoot } from "solid-js"
import { isServer } from "solid-js/web"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
const initial = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
function serverSchema(canonical?: () => string | undefined) {
return Persistence.withInitial(serverState(canonical), initial)
return Codec.withInitial(serverState(canonical), initial)
}
describe("server persistence schema", () => {
@@ -29,7 +28,7 @@ describe("server persistence schema", () => {
],
projects: { local: [{ worktree: "/project", expanded: true }] },
}
const state = Schema.decodeUnknownSync(schema)(input)
const state = Codec.decodeOrThrow(schema, input)
expect(state).toEqual({
list: [
{ type: "http", http: { url: "http://localhost:4096" } },
@@ -48,13 +47,13 @@ describe("server persistence schema", () => {
recentlyClosed: {},
})
expect(input.list[1]).toHaveProperty("username", "legacy")
const encoded = Schema.encodeSync(schema)(state)
const encoded = schema.encode(state)
expect(encoded).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(encoded)).toEqual(state)
expect(Codec.decodeOrThrow(schema, encoded)).toEqual(state)
})
test("defaults missing or malformed fields and drops invalid entries independently", () => {
const decode = Schema.decodeUnknownSync(serverSchema())
const decode = ((input: unknown) => Codec.decodeOrThrow(serverSchema(), input))
const empty = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
expect(decode({})).toEqual(empty)
expect(decode({ list: null, hidden: [], projects: false, lastProject: 1, recentlyClosed: "bad" })).toEqual(empty)
@@ -74,7 +73,7 @@ describe("server persistence schema", () => {
test("moves canonical project buckets without changing server keys or unrelated scopes", () => {
const schema = serverSchema(() => "https://opencode.example.com")
const state = Schema.decodeUnknownSync(schema)({
const state = Codec.decodeOrThrow(schema, {
list: ["https://opencode.example.com"],
hidden: { "https://opencode.example.com": true },
projects: {
@@ -100,14 +99,14 @@ describe("server persistence schema", () => {
expect(state.list[0]?.http.url).toBe("https://opencode.example.com")
expect(state.hidden).toEqual({ "https://opencode.example.com": true })
expect(state.recentlyClosed).toEqual({ local: ["/closed"], "https://opencode.example.com": ["/old-closed"] })
expect(Schema.encodeSync(schema)(state)).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(state)).toEqual(state)
expect(schema.encode(state)).toEqual(state)
expect(Codec.decodeOrThrow(schema, state)).toEqual(state)
})
test("reads the latest canonical local prop on each decode", () => {
const props: { canonicalLocalServer?: string } = {}
const schema = serverSchema(() => props.canonicalLocalServer)
const decode = Schema.decodeUnknownSync(schema)
const decode = ((input: unknown) => Codec.decodeOrThrow(schema, input))
const input = {
projects: { remote: [{ worktree: "/project", expanded: true }] },
lastProject: { remote: "/project" },
@@ -122,7 +121,7 @@ describe("server persistence schema", () => {
})
test("migrates a last project without a project list", () => {
expect(Schema.decodeUnknownSync(serverSchema(() => "remote"))({ lastProject: { remote: "/project" } })).toEqual({
expect(Codec.decodeOrThrow(serverSchema(() => "remote"), { lastProject: { remote: "/project" } })).toEqual({
list: [],
hidden: {},
projects: {},
@@ -134,7 +133,7 @@ describe("server persistence schema", () => {
describe("model persistence schema", () => {
test("defaults missing state and keeps valid entries beside malformed entries", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelState, { user: [], recent: [], variant: {} }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelState, { user: [], recent: [], variant: {} }), input))
expect(decode({})).toEqual({ user: [], recent: [], variant: {} })
expect(decode({ user: null, recent: 1, variant: [] })).toEqual({ user: [], recent: [], variant: {} })
const state = decode({
@@ -155,24 +154,24 @@ describe("model persistence schema", () => {
recent: [{ providerID: "provider", modelID: "model" }],
variant: { model: "high" },
})
expect(Schema.encodeSync(ModelState)(state)).toEqual(state)
expect(ModelState.encode(state)).toEqual(state)
})
})
describe("directory cache schemas", () => {
test("defaults missing and malformed VCS caches but retains optional branch metadata", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(VcsState, { value: undefined }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(VcsState, { value: undefined }), input))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: { branch: 1 } })).toEqual({ value: undefined })
expect(decode({ value: { default_branch: "main" } })).toEqual({ value: { default_branch: "main" } })
const state = decode({ value: { branch: "feature", default_branch: "main", obsolete: true } })
expect(state).toEqual({ value: { branch: "feature", default_branch: "main" } })
expect(Schema.encodeSync(VcsState)(state)).toEqual(state)
expect(VcsState.encode(state)).toEqual(state)
})
test("validates project name, icon overrides and startup commands", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ProjectState, { value: undefined }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ProjectState, { value: undefined }), input))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: [] })).toEqual({ value: undefined })
expect(decode({ value: { icon: { override: 1 } } })).toEqual({ value: undefined })
@@ -185,7 +184,7 @@ describe("directory cache schemas", () => {
commands: { start: "bun dev" },
},
})
expect(Schema.encodeSync(ProjectState)(state)).toEqual(state)
expect(ProjectState.encode(state)).toEqual(state)
expect(state.value).toEqual({
name: "Project",
icon: { override: "data:image/png;base64,abc", color: "blue" },
@@ -194,12 +193,12 @@ describe("directory cache schemas", () => {
})
test("validates optional icon strings", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(IconState, { value: undefined }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(IconState, { value: undefined }), input))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: 42 })).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: "" })).toEqual({ value: "" })
expect(Schema.encodeSync(IconState)(decode({ value: "data:image/png;base64,abc" }))).toEqual({
expect(IconState.encode(decode({ value: "data:image/png;base64,abc" }))).toEqual({
value: "data:image/png;base64,abc",
})
})
@@ -255,7 +254,7 @@ test.skipIf(isServer)(
const stored = values.get("opencode.global.dat:server")
expect(stored).toBeDefined()
if (!stored) throw new Error("server state was not written")
const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(serverSchema()))(stored)
const decoded = Codec.decodeOrThrow(Codec.fromJsonString(serverSchema()), stored)
expect(decoded.projects.local).toEqual([{ worktree: "/project", expanded: true }])
expect(stored).not.toContain("username")
expect(decoded.list).toEqual(root.state[0].list)
@@ -264,3 +263,4 @@ test.skipIf(isServer)(
}
},
)
+97 -106
View File
@@ -1,136 +1,127 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { ServerKey } from "./key"
export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key"))
export { ServerKey }
export const ServerHttpBase = Persistence.struct({
url: Schema.String,
password: Schema.optional(Schema.String),
export const ServerHttpBase = Codec.struct({
url: Codec.string,
password: Codec.optional(Codec.string),
})
export const ServerHttp = Persistence.struct({
type: Schema.Literal("http"),
export const ServerHttp = Codec.struct({
type: Codec.literal("http"),
http: ServerHttpBase,
authToken: Schema.optional(Schema.Boolean),
displayName: Schema.optional(Schema.String),
label: Schema.optional(Schema.String),
authToken: Codec.optional(Codec.boolean),
displayName: Codec.optional(Codec.string),
label: Codec.optional(Codec.string),
})
const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe(
Schema.decodeTo(ServerHttp, {
decode: SchemaGetter.transform((value) => {
if (typeof value === "string") return { type: "http", http: { url: value } }
if ("http" in value) return value
return { type: "http", http: value }
}),
encode: SchemaGetter.transform((value) => value),
}),
)
const ProjectList = Persistence.array(
Persistence.struct({
worktree: Schema.String,
expanded: Persistence.fallback(Schema.Boolean, () => true),
}),
)
const Projects = Persistence.record(ProjectList)
const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))))
const State = Persistence.struct({
list: Persistence.array(StoredServer),
hidden: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.Boolean.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
projects: Schema.Record(Schema.String, Schema.mutableKey(ProjectList)),
lastProject: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
recentlyClosed: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(Schema.String))),
// Servers were stored as a URL string, then as the HTTP block alone, before the current shape.
const StoredServer = Codec.decodeTo(Codec.union([ServerHttp, ServerHttpBase, Codec.string]), ServerHttp, {
decode: (value) => {
if (typeof value === "string") return { type: "http" as const, http: { url: value } }
if ("http" in value) return value
return { type: "http" as const, http: value }
},
encode: (value) => value,
})
const ProjectList = Codec.lenientArray(
Codec.struct({
worktree: Codec.string,
expanded: Codec.fallback(Codec.boolean, () => true),
}),
)
const Projects = Codec.lenientRecord(ProjectList)
const LastProject = Codec.fallback(Codec.sparseRecord(Codec.string), () => ({}))
const State = Codec.struct({
list: Codec.lenientArray(StoredServer),
hidden: Codec.sparseRecord(Codec.boolean),
projects: Codec.record(ProjectList),
lastProject: Codec.sparseRecord(Codec.string),
recentlyClosed: Codec.record(Codec.lenientArray(Codec.string)),
})
const StoredState = Codec.struct({ projects: Projects, lastProject: LastProject }, { preserve: true })
// Projects and last-opened entries recorded under the canonical local server's URL move under
// "local" when that URL is known, so they survive the server changing address.
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
return Persistence.migrate(
return Codec.migrate(
State,
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
Codec.transform(StoredState, {
decode: (value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
projects.local = [
...local,
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
}),
encode: SchemaGetter.transform((value) => value),
}),
),
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
projects.local = [
...local,
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
},
encode: (value) => value,
}),
)
}
export const ModelState = Persistence.struct({
user: Persistence.array(
Persistence.struct({
providerID: Schema.String,
modelID: Schema.String,
visibility: Schema.Literals(["show", "hide"]),
favorite: Schema.optional(Schema.Boolean),
export const ModelState = Codec.struct({
user: Codec.lenientArray(
Codec.struct({
providerID: Codec.string,
modelID: Codec.string,
visibility: Codec.literals(["show", "hide"]),
favorite: Codec.optional(Codec.boolean),
}),
),
recent: Persistence.array(Persistence.struct({ providerID: Schema.String, modelID: Schema.String })),
variant: Schema.Record(
Schema.String,
Schema.mutableKey(
Schema.UndefinedOr(Schema.String).pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))),
),
),
recent: Codec.lenientArray(Codec.struct({ providerID: Codec.string, modelID: Codec.string })),
variant: Codec.sparseRecord(Codec.undefinedOr(Codec.string)),
})
export const VcsState = Persistence.struct({
value: Schema.optional(
Persistence.struct({
branch: Schema.optional(Schema.String),
default_branch: Schema.optional(Schema.String),
export const VcsState = Codec.struct({
value: Codec.optional(
Codec.struct({
branch: Codec.optional(Codec.string),
default_branch: Codec.optional(Codec.string),
}),
),
})
const ProjectMeta = Persistence.struct({
name: Schema.optional(Schema.String),
icon: Schema.optional(
Persistence.struct({
override: Schema.optional(Schema.String),
color: Schema.optional(Schema.String),
const ProjectMeta = Codec.struct({
name: Codec.optional(Codec.string),
icon: Codec.optional(
Codec.struct({
override: Codec.optional(Codec.string),
color: Codec.optional(Codec.string),
}),
),
commands: Schema.optional(Persistence.struct({ start: Schema.optional(Schema.String) })),
commands: Codec.optional(Codec.struct({ start: Codec.optional(Codec.string) })),
})
export const ProjectState = Persistence.struct({
value: Schema.optional(ProjectMeta),
export const ProjectState = Codec.struct({
value: Codec.optional(ProjectMeta),
})
export const IconState = Persistence.struct({
value: Schema.optional(Schema.String),
export const IconState = Codec.struct({
value: Codec.optional(Codec.string),
})
@@ -1,13 +1,12 @@
import { describe, expect, test } from "bun:test"
import { canRemoveServer, createServerProjects, resolveServerList, ServerConnection } from "./registry"
import { Schema } from "effect"
import { serverState } from "./persistence"
import { createStore } from "solid-js/store"
import { ServerScope } from "./scope"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
function serverSchema() {
return Persistence.withInitial(serverState(), {
return Codec.withInitial(serverState(), {
list: [],
hidden: {},
projects: {},
@@ -19,7 +18,7 @@ function serverSchema() {
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
const list = resolveServerList({
stored: Schema.decodeUnknownSync(serverSchema())({ list: [{ url: "https://server.example.test" }] }).list,
stored: Codec.decodeOrThrow(serverSchema(), { list: [{ url: "https://server.example.test" }] }).list,
props: [
{
type: "http",
@@ -44,7 +43,7 @@ describe("resolveServerList", () => {
test("keeps persisted credentials when startup has no auth_token", () => {
const list = resolveServerList({
stored: Schema.decodeUnknownSync(serverSchema())({
stored: Codec.decodeOrThrow(serverSchema(), {
list: [{ url: "https://server.example.test", password: "saved" }],
}).list,
props: [{ type: "http", http: { url: "https://server.example.test" } }],
@@ -77,7 +76,7 @@ test("treats WSL sidecars as remote server connections", () => {
})
test("keeps exact persisted server identities and prevents removing provided servers", () => {
const stored = Schema.decodeUnknownSync(serverSchema())({
const stored = Codec.decodeOrThrow(serverSchema(), {
list: ["http://localhost:4096", "http://localhost:4096/", "http://127.0.0.1:4096"],
}).list
expect(resolveServerList({ stored }).map((server) => String(ServerConnection.key(server)))).toEqual([
@@ -91,7 +90,7 @@ test("keeps exact persisted server identities and prevents removing provided ser
})
test("project actions update schema-derived state and follow dynamic server scopes", () => {
const [store, setStore] = createStore(Schema.decodeUnknownSync(serverSchema())({}))
const [store, setStore] = createStore(Codec.decodeOrThrow(serverSchema(), {}))
const props: { server: ServerConnection.Key; canonicalLocalServer?: ServerConnection.Key } = {
server: ServerConnection.Key.make("https://remote.example"),
}
@@ -115,3 +114,4 @@ test("project actions update schema-derived state and follow dynamic server scop
expect(store.projects.local).toEqual([{ worktree: "/local", expanded: true }])
expect(store.projects[props.server]).toEqual([{ worktree: "/remote", expanded: false }])
})
+1 -4
View File
@@ -200,7 +200,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
const [store, setStore, _, hydrated] = persisted(
const [store, setStore, _] = persisted(
{
...Persist.global("server"),
sync: true,
@@ -258,9 +258,6 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
get visible() {
return visibleServers()
},
// Named to avoid the context `ready` gate: consumers that derive from persisted project
// state wait on this, but the provider must not block first render on storage.
hydrated,
isHidden(key: ServerConnection.Key) {
return store.hidden[key] ?? false
},
+1 -24
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -14,7 +14,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
import { useSettings } from "@/settings/model"
@@ -110,7 +109,6 @@ function createServerController(
) {
const language = useLanguage()
const settings = useSettings()
const platform = usePlatform()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const source = createData({
@@ -172,27 +170,6 @@ function createServerController(
const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
// Only the desktop can stat a local server's project directories without a server round-trip.
// Each directory is checked once per app run; the list is tracked so persisted projects are
// checked after the store hydrates.
const checked = new Set<string>()
createEffect(() => {
if (!isLocal || !platform.checkDirectories) return
const directories = projects
.list()
.map((project) => project.worktree)
.filter((directory) => !checked.has(pathKey(directory)))
if (directories.length === 0) return
directories.forEach((directory) => checked.add(pathKey(directory)))
void platform
.checkDirectories(directories)
.then((available) => {
const keep = new Set(available.map(pathKey))
batch(() => directories.filter((directory) => !keep.has(pathKey(directory))).forEach(projects.remove))
})
.catch(() => undefined)
})
return {
data,
sdk,
-2
View File
@@ -33,8 +33,6 @@ export type FileContent = {
}
encoding?: "base64"
mimeType?: string
/** On-disk size when the bytes themselves are not retained. */
size?: number
}
export type Path = {
+13 -12
View File
@@ -1,12 +1,12 @@
import { Option, Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
const pairing = Codec.fromJsonString(
Codec.struct({
urls: Codec.array(Codec.string),
username: Codec.literal("opencode"),
password: Codec.string,
}),
)
export function serverAddress(value: string) {
@@ -20,9 +20,10 @@ export function serverAddress(value: string) {
}
export function decodePairingCode(value: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.value.password }
const result = Codec.decodeOption(pairing, value)
if (!result) return
const urls = [...new Set(result.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.password }
}
+44 -51
View File
@@ -1,4 +1,3 @@
import { Effect, Fiber } from "effect"
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { SshConfig, SshItem, SshPlatform } from "./types"
@@ -24,7 +23,8 @@ export function createSshController(input: {
| undefined
>
>({})
const tasks = new Map<string, Fiber.Fiber<void>>()
// One in-flight request per server; cancelling settles it immediately and ignores its outcome.
const tasks = new Map<string, { cancelled: boolean }>()
const item = (id: string) => input.items().find((item) => item.config.id === id)
const settle = (id: string) => {
const attempt = attempts[id]
@@ -33,31 +33,32 @@ export function createSshController(input: {
setAttempts(id, { active: false, onConnected: undefined })
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
}
const run = (id: string, effect: Effect.Effect<unknown, unknown>) => {
setAttempts(id, { submitting: true, error: false })
tasks.set(
id,
Effect.runFork(
effect.pipe(
Effect.asVoid,
Effect.catch(() =>
Effect.sync(() => {
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
}),
),
Effect.ensuring(
Effect.sync(() => {
tasks.delete(id)
setAttempts(id, "submitting", false)
}),
),
),
),
)
}
onCleanup(() => {
Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
const finish = (id: string, task: { cancelled: boolean }) => {
if (task.cancelled) return
task.cancelled = true
if (tasks.get(id) === task) tasks.delete(id)
setAttempts(id, "submitting", false)
}
const interrupt = (id: string) => {
const task = tasks.get(id)
if (task) finish(id, task)
}
const run = (id: string, work: () => Promise<unknown>) => {
setAttempts(id, { submitting: true, error: false })
const task = { cancelled: false }
tasks.set(id, task)
work().then(
() => finish(id, task),
() => {
if (task.cancelled) return
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
finish(id, task)
},
)
}
onCleanup(() => {
for (const id of [...tasks.keys()]) interrupt(id)
})
createEffect(() => {
for (const item of input.items()) {
@@ -114,40 +115,32 @@ export function createSshController(input: {
error: false,
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
})
run(
config.id,
Effect.gen(function* () {
yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
// Observe admission before treating an older disconnected snapshot as cancellation.
yield* Effect.tryPromise(input.refresh)
}),
)
run(config.id, async () => {
await api.start({ ...config, replace: options?.replace })
// Observe admission before treating an older disconnected snapshot as cancellation.
await input.refresh()
})
},
respond: (id: string, prompt: string, value: string) => {
const api = input.api
if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
setAttempts(id, "answered", prompt)
run(
id,
Effect.tryPromise(() => api.respond(id, prompt, value)),
)
run(id, () => api.respond(id, prompt, value))
},
cancel: (id: string) => {
const task = tasks.get(id)
const api = input.api
Effect.runFork(
Effect.gen(function* () {
if (task) yield* Fiber.interrupt(task)
setAttempts(id, undefined)
if (!api) return
yield* Effect.tryPromise(() => api.cancel(id))
if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
}).pipe(Effect.ignore),
)
cancel: (id: string) => {
const api = input.api
interrupt(id)
setAttempts(id, undefined)
if (!api) return
void api
.cancel(id)
.then(() => (item(id)?.saved ? undefined : api.forget(id)))
.catch(() => undefined)
},
restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
disconnect: (id: string) => input.api?.disconnect(id),
forget: (id: string) => input.api?.forget(id),
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Schema } from "effect"
// The SSH state contract between the desktop main process and its renderer. Only the main process
// needs the schemas (its RPC server validates with them); the renderer imports the types from ./types.
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
+8 -60
View File
@@ -1,63 +1,9 @@
import { Schema } from "effect"
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
import type { SshConfig, SshHttp, SshItem, SshStart, SshState } from "./schema"
export type { SshConfig, SshHttp, SshItem, SshStart, SshState }
export type SshPlatform = {
getState(): Promise<SshState>
subscribe(callback: (state: SshState) => void): () => void
@@ -70,3 +16,5 @@ export type SshPlatform = {
forget(id: string): Promise<void>
openConfig(): Promise<void>
}
+11 -25
View File
@@ -1,15 +1,14 @@
import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, runWithOwner } from "solid-js"
import { createEffect, createMemo, getOwner, on, onCleanup, runWithOwner } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { useServer } from "@/runtime/server/current"
import type { SessionStateKey } from "@/runtime/server/scope"
import { useSettings } from "@/settings/model"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
import { sessionBrowserTab } from "@/shell/state/session-tabs"
import { useCurrentRoute } from "@/shell/state/layout"
import { createEventListener } from "@solid-primitives/event-listener"
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
@@ -36,14 +35,13 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
const settings = useSettings()
const language = useLanguage()
const shellTabs = useTabs()
const layout = useLayout()
const route = useCurrentRoute()
const owner = getOwner()
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
const live = new Map<string, Live>()
const preview = new Map<string, Set<(path: string) => void>>()
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
const enabled = createMemo(
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
@@ -89,14 +87,11 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
enabled,
supported: (server: Server) => !unsupported[server.key],
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
attach(server: Server, sessionID: string, sessionKey: SessionStateKey) {
attach(server: Server, sessionID: string) {
const id = key(server, sessionID)
if (live.has(id)) return
const pane = platform.browserPane
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
// Focus requests write to the owning session's layout even while another shell tab is routed,
// so the Review pane and browser tab are already selected when the user returns to it.
const tabs = createRoot((dispose) => ({ dispose, layout: layout.tabs(sessionKey) }), owner)
const connection = createBrowserConnection({
pane,
// Resolve the current port at every wake, including after sidecar replacement.
@@ -105,15 +100,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
sessionID,
endpoint: { ...server.conn.http, url: server.ctx.sdk.url },
}),
focus: (tabID) => {
const tab = sessionBrowserTab(tabID)
batch(() => {
shellTabs.setPane(findSessionTab(shellTabs.store, server.key, sessionID), "review", true)
if (!tabs.layout.all().includes(tab)) tabs.layout.setAll([...tabs.layout.all(), tab])
tabs.layout.setActive(tab)
})
},
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
focus: (tabID) => focus.get(id)?.forEach((listener) => listener(tabID)),
change: (state) => {
if (state.error === "browser.pane.unsupported") {
setUnsupported(server.key, true)
@@ -151,18 +138,17 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
entry.dispose = () => {
unsubscribe?.forEach((dispose) => dispose())
connection.dispose()
tabs.dispose()
}
},
/** Agent requests to show a file in this session's Review pane. */
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
const id = key(server, sessionID)
const listeners = preview.get(id) ?? new Set()
const listeners = focus.get(id) ?? new Set()
listeners.add(listener)
preview.set(id, listeners)
focus.set(id, listeners)
return () => {
listeners.delete(listener)
if (!listeners.size) preview.delete(id)
if (!listeners.size) focus.delete(id)
}
},
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
@@ -28,12 +28,10 @@ function fixture() {
commands: Browser.Action[]
}[] = []
const endpoint = { url: "http://localhost:4096" }
const previews: string[] = []
const connection = createBrowserConnection({
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
change: (state) => states.push(state),
focus: () => {},
preview: (path) => previews.push(path),
pane: {
register(target, emit) {
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
@@ -52,21 +50,9 @@ function fixture() {
})
connection.wake()
calls[0].emit({ type: "state", state: browser })
return { connection, calls, states, endpoint, previews }
return { connection, calls, states, endpoint }
}
test("preview requests reach the session without touching connection state", () => {
const app = fixture()
try {
const before = app.states.length
app.calls[0].emit({ type: "preview", path: "docs/report.pdf" })
expect(app.previews).toEqual(["docs/report.pdf"])
expect(app.states).toHaveLength(before)
} finally {
app.connection.dispose()
}
})
test("suspension retains tabs and reconnects once on demand using the current endpoint", async () => {
const app = fixture()
try {
@@ -19,7 +19,6 @@ export function createBrowserConnection(input: {
target: () => BrowserPaneTarget
change: (state: BrowserConnectionState) => void
focus: (tabID: Browser.TabID) => void
preview: (path: string) => void
}) {
const state: BrowserConnectionState = { browser: null, suspended: false }
let disposed = false
@@ -34,7 +33,6 @@ export function createBrowserConnection(input: {
(event) => {
if (disposed || state.registration !== registration) return
if (event.type === "focus") return input.focus(event.tabID)
if (event.type === "preview") return input.preview(event.path)
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
blocked = true
registration.close()
+10 -2
View File
@@ -1,4 +1,4 @@
import { batch, createEffect, createMemo, on } from "solid-js"
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
@@ -33,6 +33,13 @@ export function createSessionBrowser(session: SessionModel) {
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
[],
)
const focus = (tabID: Browser.TabID) => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
const key = sessionBrowserTab(tabID)
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
tabs.setActive(key)
}
const command = (command: BrowserPaneCommand) => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
@@ -59,7 +66,8 @@ export function createSessionBrowser(session: SessionModel) {
createEffect(() => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
if (attachments.enabled()) attachments.attach(server, sessionID, session.layout.sessionKey())
if (attachments.enabled()) attachments.attach(server, sessionID)
onCleanup(attachments.onFocus(server, sessionID, focus))
})
createEffect(
on(
+10 -63
View File
@@ -20,18 +20,13 @@ 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
@@ -48,7 +43,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 || !address(),
disabled: !props.visible || !state(),
onSelect: () => {
const tab = state()
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
@@ -76,9 +71,7 @@ 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)
// 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)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -114,25 +107,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
if (frame === undefined) frame = requestAnimationFrame(tick)
}
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(() => !store.editing && setStore("address", address()))
createEffect(
on(registration, (current) => {
// Session routes can change before this pane unmounts. Hide the registration
@@ -148,8 +123,6 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
() => store.visible,
() => props.visible,
() => state()?.id,
empty,
failed,
registration,
],
() => {
@@ -184,7 +157,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-3 border-b border-v2-border-border-muted">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 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")}>
@@ -220,7 +193,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
>
<IconButton
{...button}
disabled={!state()?.loading && !address()}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
@@ -239,13 +212,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
onSubmit={(event) => {
event.preventDefault()
const tab = state()
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()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
}}
>
<input
@@ -256,14 +224,8 @@ 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={(event) => {
setStore("editing", true)
event.currentTarget.select()
}}
onClick={(event) => event.currentTarget.select()}
onBlur={() =>
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: address() })}
onInput={(event) => setStore("address", event.currentTarget.value)}
onScroll={(event) => {
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
@@ -281,27 +243,12 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
</div>
</form>
</div>
<Show when={props.browser.error() && !failed()}>
<Show when={props.browser.error()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base 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")}
@@ -1,94 +0,0 @@
/* The control defaults to a fixed 232px with equal segments; here it hugs its two labels. */
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2"] {
width: auto;
}
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item"] {
flex: 0 0 auto;
padding: 0 14px;
}
/* Shrink-wrapped segments round the label box down a fraction; the ellipsis overflow then clips glyph edges. */
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item-label"] {
overflow: visible;
max-width: none;
}
[data-slot="artifact-stage"] {
--artifact-check: color-mix(in oklch, var(--v2-text-text-base) 5%, transparent);
background-color: var(--v2-background-bg-deep);
}
[data-slot="artifact-stage"][data-checker] {
background-image:
linear-gradient(45deg, var(--artifact-check) 25%, transparent 25%),
linear-gradient(-45deg, var(--artifact-check) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--artifact-check) 75%),
linear-gradient(-45deg, transparent 75%, var(--artifact-check) 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
}
[data-slot="artifact-media"] {
display: block;
border-radius: 4px;
box-shadow:
0 0 0 0.5px var(--v2-border-border-base),
0 12px 32px -12px color-mix(in oklch, var(--v2-text-text-base) 25%, transparent);
}
[data-slot="artifact-stage"][data-zoom="fit"] [data-slot="artifact-media"] {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
[data-slot="artifact-stage"][data-zoom="fit"][data-overflow] [data-slot="artifact-media"] {
cursor: zoom-in;
}
[data-slot="artifact-stage"][data-zoom="actual"] [data-slot="artifact-media"] {
cursor: zoom-out;
}
[data-slot="artifact-table"] {
border-collapse: separate;
border-spacing: 0;
font-variant-numeric: tabular-nums;
}
[data-slot="artifact-table"] th,
[data-slot="artifact-table"] td {
padding: 5px 12px;
border-bottom: 1px solid var(--v2-border-border-muted);
border-inline-end: 1px solid var(--v2-border-border-muted);
white-space: pre;
max-width: 40ch;
overflow: hidden;
text-overflow: ellipsis;
text-align: start;
line-height: var(--line-height-compact);
}
[data-slot="artifact-table"] th {
position: sticky;
top: 0;
z-index: 1;
background: var(--v2-background-bg-layer-01);
font-weight: var(--font-weight-medium);
color: var(--v2-text-text-muted);
}
[data-slot="artifact-table"] tbody tr:hover td {
background: color-mix(in oklch, var(--v2-text-text-base) 3%, transparent);
}
[data-slot="artifact-table"] td[data-index] {
color: var(--v2-text-text-faint);
text-align: end;
user-select: none;
}
@@ -1,454 +0,0 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Button } from "@opencode/ui/button"
import { FileIcon } from "@opencode/ui/file-icon"
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Markdown } from "@opencode/session-ui/markdown"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { getDirectory, getFilename } from "@opencode/util/path"
import type { FileContent } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import {
artifactKind,
blobUrlFromContent,
contentBytes,
parseDelimited,
resolveArtifactPath,
type ArtifactKind,
} from "@/workspaces/files/artifact"
import { useArtifactOpener } from "@/session/files/open-artifact"
import "./artifact-view.css"
type ArtifactMode = "preview" | "source"
/** Facts a viewer learns from the decoded media, shown in the toolbar. */
type ArtifactInfo = { width?: number; height?: number; duration?: number; rows?: number; columns?: number }
type MediaProps = {
path: string
content: FileContent
onInfo: (info: ArtifactInfo) => void
/** The browser could not decode the bytes; the host falls back to the binary placeholder. */
onError: () => void
}
/** Kinds that render a preview from their text and can toggle back to highlighted source. */
const previewableKinds = new Set<ArtifactKind>(["svg", "html", "markdown", "mermaid", "table"])
/**
* Renders a loaded non-text file: media, documents, and data get a dedicated viewer with a toolbar;
* previewable text kinds can switch to `source`, which the host supplies (its code view).
*/
export function ArtifactView(props: { path: string; content: FileContent; cacheKey?: string; source: JSX.Element }) {
const language = useLanguage()
const [state, setState] = createStore({
mode: "preview" as ArtifactMode,
info: {} as ArtifactInfo,
// Media the browser could not decode falls back to the binary placeholder.
undecodable: false,
})
createEffect(
on(
() => props.content,
() => setState({ mode: "preview", info: {}, undecodable: false }),
{ defer: true },
),
)
const kind = createMemo<ArtifactKind | "binary">(() => {
if (props.content.type === "binary" && !props.content.mimeType) return "binary"
if (state.undecodable) return "binary"
return artifactKind(props.path)
})
const previewable = createMemo(() => {
const value = kind()
return value !== "binary" && previewableKinds.has(value)
})
const meta = createMemo(() => {
const info = state.info
return [
info.width && info.height ? `${info.width} × ${info.height}` : undefined,
info.duration ? formatDuration(info.duration) : undefined,
info.rows !== undefined ? language.plural("file.view.table.rows", Math.max(0, info.rows - 1)) : undefined,
info.columns !== undefined ? language.plural("file.view.table.columns", info.columns) : undefined,
formatBytes(language.intl(), contentBytes(props.content)),
].filter((item): item is string => !!item)
})
const media = { onInfo: (info: ArtifactInfo) => setState("info", info), onError: () => setState("undecodable", true) }
const rendered = () => (
<ScrollView class="min-h-0 flex-1">
<Show
when={kind() === "markdown"}
fallback={<ArtifactMermaid text={props.content.content} cacheKey={props.cacheKey} />}
>
<ArtifactMarkdown path={props.path} text={props.content.content} cacheKey={props.cacheKey} />
</Show>
</ScrollView>
)
return (
<>
<ArtifactToolbar
mode={state.mode}
onModeChange={previewable() ? (mode) => setState("mode", mode) : undefined}
meta={meta()}
actions={
<Show when={kind() === "html"}>
<OpenInBrowserButton path={props.path} />
</Show>
}
/>
<Show when={!previewable() || state.mode === "preview"} fallback={props.source}>
<Switch>
<Match when={kind() === "image" || kind() === "svg"}>
<ArtifactImage path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "video"}>
<ArtifactVideo path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "audio"}>
<ArtifactAudio path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "pdf" || kind() === "html"}>
<ArtifactFrame path={props.path} content={props.content} kind={kind() === "pdf" ? "pdf" : "html"} />
</Match>
<Match when={kind() === "font"}>
<ArtifactFont path={props.path} content={props.content} />
</Match>
<Match when={kind() === "table"}>
<ArtifactTable path={props.path} text={props.content.content} onInfo={media.onInfo} />
</Match>
<Match when={kind() === "markdown" || kind() === "mermaid"}>{rendered()}</Match>
<Match when={kind() === "binary"}>
<ArtifactBinary path={props.path} size={formatBytes(language.intl(), contentBytes(props.content))} />
</Match>
</Switch>
</Show>
</>
)
}
function formatBytes(locale: string, bytes: number) {
const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const
const index = Math.min(units.length - 1, bytes > 0 ? Math.floor(Math.log10(bytes) / 3) : 0)
const value = bytes / 1000 ** index
return new Intl.NumberFormat(locale, {
style: "unit",
unit: units[index],
// "short" bytes render as the singular "byte"; the long form pluralizes correctly.
unitDisplay: index === 0 ? "long" : "short",
maximumFractionDigits: value >= 100 || index === 0 ? 0 : 1,
}).format(value)
}
function formatDuration(seconds: number) {
const total = Math.round(seconds)
const minutes = Math.floor(total / 60)
return `${minutes}:${String(total % 60).padStart(2, "0")}`
}
function ArtifactToolbar(props: {
mode?: ArtifactMode
onModeChange?: (mode: ArtifactMode) => void
meta: string[]
actions?: JSX.Element
}) {
const language = useLanguage()
return (
<div data-slot="artifact-toolbar" class="flex h-10 shrink-0 items-center gap-3 px-4">
<Show when={props.onModeChange}>
<SegmentedControl
value={props.mode ?? "preview"}
onChange={(value) => {
if (value === "preview" || value === "source") props.onModeChange?.(value)
}}
>
<SegmentedControlItem value="preview">{language.t("file.view.preview")}</SegmentedControlItem>
<SegmentedControlItem value="source">{language.t("file.view.source")}</SegmentedControlItem>
</SegmentedControl>
</Show>
<div class="ms-auto flex min-w-0 items-center gap-3">
<div class="flex min-w-0 items-center gap-2 text-12-regular text-text-weak">
<For each={props.meta}>
{(item, index) => (
<>
<Show when={index() > 0}>
<span aria-hidden class="text-text-faint">
·
</span>
</Show>
<span class="truncate tabular-nums">{item}</span>
</>
)}
</For>
</div>
{props.actions}
</div>
</div>
)
}
function OpenInBrowserButton(props: { path: string }) {
const language = useLanguage()
const artifacts = useArtifactOpener()
return (
<Show when={artifacts.canOpenInBrowser(props.path)}>
<Button size="small" variant="ghost" icon="globe" onClick={() => artifacts.openInBrowser(props.path)}>
{language.t("file.view.openInBrowser")}
</Button>
</Show>
)
}
function createBlobUrl(content: () => FileContent) {
return createMemo(() => {
const value = blobUrlFromContent(content())
onCleanup(() => URL.revokeObjectURL(value))
return value
})
}
/** Images and SVG previews: fit the pane, click to inspect at 1:1 when the image is larger. */
function ArtifactImage(props: MediaProps) {
const url = createBlobUrl(() => props.content)
const [state, setState] = createStore({ zoom: "fit" as "fit" | "actual", overflow: false, width: 0, height: 0 })
let stage: HTMLDivElement | undefined
const measure = () => {
if (!stage) return
setState("overflow", state.width > stage.clientWidth - 48 || state.height > stage.clientHeight - 48)
}
createResizeObserver(
() => stage,
() => measure(),
)
createEffect(() => {
url()
setState({ zoom: "fit", overflow: false })
})
return (
<div
ref={stage}
data-slot="artifact-stage"
data-checker
data-zoom={state.zoom}
data-overflow={state.overflow || undefined}
class="relative min-h-0 flex-1 overflow-auto"
>
<div
classList={{
"absolute inset-0 flex items-center justify-center p-6": state.zoom === "fit",
"flex min-h-full min-w-full w-max items-center justify-center p-6": state.zoom === "actual",
}}
>
<img
data-slot="artifact-media"
src={url()}
alt={getFilename(props.path)}
draggable={false}
onError={() => props.onError()}
onLoad={(event) => {
const image = event.currentTarget
setState({ width: image.naturalWidth, height: image.naturalHeight })
props.onInfo({ width: image.naturalWidth, height: image.naturalHeight })
measure()
}}
onClick={() => {
if (!state.overflow && state.zoom === "fit") return
setState("zoom", state.zoom === "fit" ? "actual" : "fit")
}}
/>
</div>
</div>
)
}
function ArtifactVideo(props: MediaProps) {
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" data-zoom="fit" class="relative min-h-0 flex-1 overflow-hidden">
<div class="absolute inset-0 flex items-center justify-center p-6">
<video
data-slot="artifact-media"
class="w-full bg-black"
controls
preload="metadata"
playsinline
onError={() => props.onError()}
src={url()}
onLoadedMetadata={(event) => {
const video = event.currentTarget
props.onInfo({ width: video.videoWidth, height: video.videoHeight, duration: video.duration })
}}
/>
</div>
</div>
)
}
function ArtifactAudio(props: MediaProps) {
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1 overflow-auto">
<div class="absolute inset-0 flex items-center justify-center p-6">
<div class="flex w-full max-w-lg flex-col items-center gap-5 rounded-xl border border-v2-border-border-muted bg-v2-background-bg-base px-8 py-8 shadow-[var(--v2-elevation-raised)]">
<div class="flex size-14 items-center justify-center rounded-full bg-v2-background-bg-layer-02">
<FileIcon node={{ path: props.path, type: "file" }} class="size-7" />
</div>
<div class="max-w-full truncate text-14-medium text-text-strong">{getFilename(props.path)}</div>
<audio
class="w-full"
onError={() => props.onError()}
controls
preload="metadata"
src={url()}
onLoadedMetadata={(event) => props.onInfo({ duration: event.currentTarget.duration })}
/>
</div>
</div>
</div>
)
}
function ArtifactFrame(props: { path: string; content: FileContent; kind: "pdf" | "html" }) {
const url = createBlobUrl(() => props.content)
// PDF Open Parameters: start with the thumbnail pane closed and the page fitted to the pane width.
const src = () => (props.kind === "pdf" ? `${url()}#navpanes=0&view=FitH` : url())
return (
<iframe
class="block h-full w-full flex-1 border-0 bg-white"
title={getFilename(props.path)}
src={src()}
// The PDF viewer is Chromium's own and does not run in a sandboxed frame. HTML runs as an
// opaque origin: no app storage, cookies, or credentialed requests reach it.
sandbox={props.kind === "html" ? "allow-scripts allow-popups allow-forms allow-modals" : undefined}
referrerPolicy="no-referrer"
/>
)
}
function ArtifactMarkdown(props: { path: string; text: string; cacheKey?: string }) {
const parent = useMarkdown()
const artifacts = useArtifactOpener()
// getDirectory yields "/" for a root-level file, which would make relative links absolute.
const dir = createMemo(() => (props.path.includes("/") || props.path.includes("\\") ? getDirectory(props.path) : ""))
// Absolute references bypass the file's directory; relative ones resolve against it.
const resolve = (href: string) => (/^([a-z]:)?\//i.test(href) ? href : (resolveArtifactPath(dir(), href) ?? href))
return (
<MarkdownProvider
readImage={(src, signal) => parent?.readImage?.(resolve(src), signal) ?? Promise.resolve(undefined)}
openLocalFile={(href) => artifacts.open(href, dir())}
>
<div class="mx-auto w-full max-w-3xl px-8 py-6">
<Markdown text={props.text} cacheKey={props.cacheKey} class="select-text" />
</div>
</MarkdownProvider>
)
}
/** Mermaid sources render through the same fenced-block pipeline the timeline uses. */
function ArtifactMermaid(props: { text: string; cacheKey?: string }) {
return (
<div class="mx-auto w-full max-w-4xl px-8 py-6">
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} class="select-text" />
</div>
)
}
function ArtifactTable(props: { path: string; text: string; onInfo: (info: ArtifactInfo) => void }) {
const language = useLanguage()
const parsed = createMemo(() => parseDelimited(props.text, props.path.toLowerCase().endsWith(".tsv") ? "\t" : ","))
createEffect(() => props.onInfo({ rows: parsed().total, columns: parsed().columns }))
// Pad the header to the widest row so no data column is dropped.
const header = () => Array.from({ length: parsed().columns }, (_, index) => parsed().rows[0]?.[index] ?? "")
const body = () => parsed().rows.slice(1)
return (
<div class="min-h-0 flex-1 overflow-auto">
<table data-slot="artifact-table" class="min-w-full text-13-regular text-text-base">
<thead>
<tr>
<th data-index />
<For each={header()}>{(cell) => <th>{cell}</th>}</For>
</tr>
</thead>
<tbody>
<For each={body()}>
{(row, index) => (
<tr>
<td data-index>{index() + 1}</td>
<For each={header()}>{(_, column) => <td>{row[column()] ?? ""}</td>}</For>
</tr>
)}
</For>
</tbody>
</table>
<Show when={parsed().total > parsed().rows.length}>
<div class="px-4 py-3 text-12-regular text-text-weak">
{language.t("file.view.table.truncated", { shown: parsed().rows.length - 1, total: parsed().total - 1 })}
</div>
</Show>
</div>
)
}
const specimenSizes = [12, 16, 24, 40, 64]
function ArtifactFont(props: { path: string; content: FileContent }) {
const language = useLanguage()
const url = createBlobUrl(() => props.content)
const family = createMemo(() => `artifact-${Math.random().toString(36).slice(2)}`)
createEffect(() => {
const face = new FontFace(family(), `url(${url()})`)
document.fonts.add(face)
void face.load().catch(() => undefined)
onCleanup(() => document.fonts.delete(face))
})
return (
<div class="min-h-0 flex-1 overflow-auto">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-6 px-8 py-8" style={{ "font-family": `"${family()}"` }}>
<div class="text-text-strong" style={{ "font-size": "56px", "line-height": "1.1" }}>
{getFilename(props.path).replace(/\.[^.]+$/, "")}
</div>
<div class="break-all text-text-base" style={{ "font-size": "22px", "line-height": "1.4" }}>
ABCDEFGHIJKLMNOPQRSTUVWXYZ
<br />
abcdefghijklmnopqrstuvwxyz
<br />
0123456789 !?&@#%(){}[]
</div>
<div class="flex flex-col gap-3 border-t border-v2-border-border-muted pt-6">
<For each={specimenSizes}>
{(size) => (
<div class="flex items-baseline gap-4">
<span
class="w-8 shrink-0 text-12-regular text-text-faint tabular-nums"
style={{ "font-family": "var(--font-family-mono)" }}
>
{size}
</span>
<span class="text-text-base" style={{ "font-size": `${size}px`, "line-height": "1.25" }}>
{language.t("file.view.fontSample")}
</span>
</div>
)}
</For>
</div>
</div>
</div>
)
}
function ArtifactBinary(props: { path: string; size: string }) {
const language = useLanguage()
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1">
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<FileIcon node={{ path: props.path, type: "file" }} class="size-8 text-text-weak" />
<div class="text-14-medium text-text-strong">{getFilename(props.path)}</div>
<div class="text-13-regular text-text-weak">{language.t("file.view.binary", { size: props.size })}</div>
</div>
</div>
)
}
+30 -39
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -11,9 +11,8 @@ import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
import { Menu } from "@opencode/ui/menu"
import { Tabs } from "@opencode/ui/tabs"
import { ScrollView } from "@opencode/ui/scroll-view"
import { showToast } from "@/shell/notifications/toast"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { artifactKind } from "@/workspaces/files/artifact"
import { ArtifactView } from "@/session/files/artifact-view"
import { useComments } from "@/composer/comments"
import { useLanguage } from "@/runtime/i18n/language"
import { useComposerState } from "@/composer/persistence"
@@ -206,11 +205,6 @@ export function SessionFileView(props: SessionFileViewProps) {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
// Plain text keeps the code view; every other kind is rendered by ArtifactView.
const artifact = createMemo(() => {
const content = state()?.content
return content?.type === "binary" || artifactKind(path() ?? "") !== "text"
})
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
@@ -439,42 +433,39 @@ export function SessionFileView(props: SessionFileViewProps) {
}}
search={search}
class="select-text"
// Media and previews have their own viewers below; the code view only ever shows text.
media={{ mode: "off" }}
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
}}
/>
</div>
)
// The code view scrolls inside ScrollView so line state and scroll position persist per tab.
const codeView = (source: string) => (
<ScrollView class="min-h-0 flex-1" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
{renderFile(source)}
</ScrollView>
)
const content = () => (
<div class="mt-3 relative h-full min-h-0 flex flex-col">
<Switch>
<Match when={state()?.loaded ? state()?.content : undefined}>
{(value) => (
<Show when={artifact()} fallback={codeView(value().content)}>
<ArtifactView
path={path() ?? ""}
content={value()}
cacheKey={cacheKey()}
source={codeView(value().content)}
/>
</Show>
)}
</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}</div>
</Match>
<Match when={state()?.notFound ? state()?.name : undefined}>
{(name) => <div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>}
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}</div>
</Match>
<Match when={state()?.notFound ? state()?.name : undefined}>
{(name) => (
<div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>
)}
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</div>
)
@@ -1,105 +0,0 @@
import { createEffect, onCleanup, type ParentProps } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { useBrowserAttachments } from "@/session/browser/attachments"
import type { SessionModel } from "@/session/model"
import { useFile } from "@/workspaces/files/model"
import { artifactKind, resolveArtifactPath } from "@/workspaces/files/artifact"
import { encodeFilePath } from "@/workspaces/files/path"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { useSessionLayout } from "@/session/session-layout"
import { createOpenSessionFileTab } from "@/session/helpers"
import type { createSessionBrowser } from "@/session/browser/model"
/** Routes local links in timeline markdown to the artifact opener while keeping image loading. */
export function ArtifactMarkdownProvider(props: ParentProps) {
const markdown = useMarkdown()
const artifacts = useArtifactOpener()
return (
<MarkdownProvider readImage={markdown?.readImage} openLocalFile={(path) => artifacts.open(path)}>
{props.children}
</MarkdownProvider>
)
}
/**
* Opens files the agent references as side-panel tabs, inside or outside the workspace, or as
* a browser tab for HTML when the desktop can load the file directly.
*/
export const { use: useArtifactOpener, provider: ArtifactOpenerProvider } = createSimpleContext({
name: "ArtifactOpener",
init: (props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) => {
const file = useFile()
const server = useServer()
const location = useWorkspaceLocation()
const attachments = useBrowserAttachments()
const { tabs, view } = useSessionLayout()
const root = () => location().directory.replaceAll("\\", "/").replace(/\/+$/, "")
/**
* Turn a link into a path `useFile` can load: workspace-relative when it is under the root,
* otherwise absolute. Relative links resolve against `base`; ones that climb past the root
* become absolute too, so a `../../shared/report.pdf` still opens.
*/
const resolve = (href: string, base?: string) => {
// Agents cite locations as path:line or path:line:col; the file is what opens.
const value = href.replaceAll("\\", "/").replace(/:\d+(?::\d+)?$/, "")
if (/^[a-z]:\//i.test(value) || value.startsWith("/")) return file.normalize(value)
const relative = resolveArtifactPath(base ?? "", value)
if (relative !== undefined) return file.normalize(relative)
// Climbing past the workspace root: resolve from the referencing folder's absolute location.
const dir = base ? `${root()}/${base.replace(/\/+$/, "")}` : root()
return file.normalize(resolveArtifactPath(dir, value) ?? value)
}
const showTab = createOpenSessionFileTab({
normalizeTab: (tab) => tab,
openTab: (tab) => tabs().open(tab),
pathFromTab: file.pathFromTab,
loadFile: () => undefined,
openReviewPanel: () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
},
setActive: (tab) => tabs().setActive(tab),
})
// Inline paths are guessed from text, so confirm the file exists before a tab appears for it.
const openTab = (path: string) => {
void file.load(path).then(() => {
if (file.get(path)?.loaded) showTab(file.tab(path))
})
}
// The desktop's own sidecar shares this disk, and its browser pane accepts file:// URLs inside the
// session workspace only. Forwarded loopback servers do not qualify, matching the desktop policy.
const canOpenInBrowser = (path?: string) =>
ServerConnection.builtin(server.conn) &&
props.browser.available() &&
props.browser.attached() &&
(path === undefined || !file.absolute(path))
const openInBrowser = (path: string) => {
props.browser.command({ type: "tabs.open", url: `file://${encodeFilePath(`${root()}/${path}`)}` })
}
/** Open `href` as referenced from `base` (a workspace-relative directory, "" for the root). */
const open = (href: string, base?: string) => {
const path = resolve(href, base)
if (!path) return
if (artifactKind(path) === "html" && canOpenInBrowser(path)) return openInBrowser(path)
openTab(path)
}
// The agent's browser.preview tool arrives through the desktop browser pane attachment.
createEffect(() => {
const sessionID = props.session.identity.sessionID()
if (!sessionID) return
onCleanup(attachments.onPreview(server, sessionID, (path) => open(path)))
})
return { canOpenInBrowser, openInBrowser, open }
},
})
@@ -1,9 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(OpenAppPreferences, { app: "finder" }), input))
describe("open app preferences", () => {
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
@@ -18,3 +17,4 @@ describe("open app preferences", () => {
expect(decode({})).toEqual({ app: "finder" })
})
})
@@ -5,8 +5,7 @@ import { usePlatform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { fileManagerApp } from "@/home/projects/file-manager"
import { openInAppParentPath } from "@/session/files/open-in-app-path"
@@ -30,8 +29,8 @@ export const OPEN_APPS = [
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
export const OpenAppPreferences = Codec.struct({
app: Codec.literals(OPEN_APPS),
})
const appExistence = new Map<string, Promise<boolean>>()
@@ -242,3 +241,4 @@ function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
appExistence.set(app, request)
return request
}
@@ -173,9 +173,7 @@ export function SessionFileBrowserTab(props: {
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
<Icon name="file-tree" size="large" class="mb-2" />
<div class="text-[13px] font-medium leading-[13px] text-text-strong">
{language.t("command.file.open")}
</div>
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
+3 -15
View File
@@ -1,8 +1,6 @@
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"
@@ -71,19 +69,9 @@ export function SortableTab(props: {
placement="bottom"
gutter={10}
>
<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" />}
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
-1
View File
@@ -137,7 +137,6 @@ export function useSessionModel() {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
sessionKey: layout.sessionKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode/ui/button"
import { IconButton } from "@opencode/ui/icon-button"
import { Tooltip } from "@opencode/ui/tooltip"
import { DockPrompt } from "@opencode/session-ui/dock-prompt"
import { Icon } from "@opencode/ui/icon"
import { useSpring } from "@opencode/ui/motion-spring"
@@ -14,10 +13,8 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey } from "@/runtime/server/scope"
import { useCommand } from "@/shell/commands/command"
const cache = new Map<string, { tab: number; answers: string[][]; custom: string[]; customOn: boolean[] }>()
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
type QuestionField = FormStringField | FormMultiselectField
@@ -73,7 +70,6 @@ function Option(props: {
export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => void }> = (props) => {
const serverSDK = useServerSDK()
const language = useLanguage()
const command = useCommand()
const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
const questions = createMemo(() =>
@@ -265,8 +261,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}))
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const submitShortcut = () => (IS_MAC ? "⌘⏎" : `${language.t("common.key.ctrl")}+⏎`)
const backShortcut = () => (IS_MAC ? "⌘[" : `${language.t("common.key.alt")}+←`)
const reply = (answer: FormAnswer) => {
if (sending()) return
@@ -357,16 +351,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
return
}
const previous = IS_MAC
? event.metaKey && !event.ctrlKey && !event.altKey && event.key === "["
: event.altKey && !event.ctrlKey && !event.metaKey && event.key === "ArrowLeft"
if (previous) {
if (event.repeat) return
event.preventDefault()
back()
return
}
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
if (mod && event.key === "Enter") {
if (event.repeat) return
@@ -477,19 +461,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (!store.minimized) focus(pickFocus(tab))
}
command.register("session.question.back", () => [
{
id: "session.question.back",
title: language.t("ui.common.back"),
keybind: IS_MAC ? "mod+[" : "alt+arrowleft",
hidden: true,
// Stay registered while sending so the shortcut does not fall through to history navigation.
disabled: store.tab <= 0,
when: () => store.tab > 0,
onSelect: back,
},
])
const minimize = () => {
if (sending()) return
setStore("editing", false)
@@ -547,25 +518,9 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Tooltip
placement="top"
value={
<>
{language.t("ui.common.back")}
<span class="opacity-60">{backShortcut()}</span>
</>
}
>
<Button
variant="neutral"
size="large"
disabled={sending()}
onClick={back}
aria-keyshortcuts={IS_MAC ? "Meta+[" : "Alt+ArrowLeft"}
>
{language.t("ui.common.back")}
</Button>
</Tooltip>
<Button variant="neutral" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button
variant={last() ? "submit" : "neutral"}
@@ -575,9 +530,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
<span data-slot="question-submit-shortcut" aria-hidden="true" class="text-11-medium opacity-60">
{submitShortcut()}
</span>
</Button>
</div>
</>
-3
View File
@@ -155,9 +155,6 @@ export function createSessionReview(input: {
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
const project = input.session.project()
if (project && !project.vcs) return true
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
}
+12 -8
View File
@@ -5,17 +5,20 @@ import {
type SessionReviewExpandMode,
} from "@opencode/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import type { Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const ReviewPanel = Persistence.struct({
sidebarOpened: Schema.Boolean,
sidebarWidth: Schema.Finite.check(
Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }),
),
expandMode: Schema.Literals(["expand", "collapse"]),
const ReviewPanel = Codec.struct({
sidebarOpened: Codec.boolean,
sidebarWidth: Codec.make<number, number>(
(v) =>
typeof v === "number" && v >= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN && v <= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX
? v
: Codec.INVALID,
(v) => v,
),
expandMode: Codec.literals(["expand", "collapse"]),
})
export function createReviewPanelState(platform?: Platform) {
@@ -47,3 +50,4 @@ export function createReviewPanelState(platform?: Platform) {
}
export type ReviewPanelState = ReturnType<typeof createReviewPanelState>
+2 -2
View File
@@ -249,7 +249,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.noGit()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
@@ -282,7 +282,7 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.noGit()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
return (
<Switch>
<Match when={loading()}>
+1 -14
View File
@@ -38,7 +38,6 @@ import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -51,26 +50,14 @@ const SessionSummaryPanel = lazy(async () => {
})
export function SessionScreen(props: { session: SessionModel }) {
// The timeline cache captures its owner when created, so link handling must be provided above it.
const browser = createSessionBrowser(props.session)
return (
<ArtifactOpenerProvider session={props.session} browser={browser}>
<ArtifactMarkdownProvider>
<SessionScreenContent session={props.session} browser={browser} />
</ArtifactMarkdownProvider>
</ArtifactOpenerProvider>
)
}
function SessionScreenContent(props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) {
const session = props.session
const browser = props.browser
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const browser = createSessionBrowser(session)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
@@ -2,9 +2,8 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "@/runtime/server/scope"
import { base64Encode } from "@opencode/util/encode"
import { Persist } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import type { Platform } from "@/runtime/platform/platform"
import { Schema } from "effect"
let getWorkspaceTerminalCacheKey: typeof import("./context").getWorkspaceTerminalCacheKey
let clearWorkspaceTerminals: typeof import("./context").clearWorkspaceTerminals
@@ -21,10 +20,10 @@ beforeAll(async () => {
const mod = await import("./context")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
clearWorkspaceTerminals = mod.clearWorkspaceTerminals
const schema = Persistence.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = Schema.decodeUnknownSync(schema)
const schema = Codec.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = ((input: unknown) => Codec.decodeOrThrow(schema, input))
roundTripTerminalState = (value) =>
Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(Schema.decodeUnknownSync(schema)(value)))
Codec.decodeOrThrow(schema, schema.encode(Codec.decodeOrThrow(schema, value)))
})
describe("getWorkspaceTerminalCacheKey", () => {
@@ -141,3 +140,4 @@ describe("TerminalState", () => {
expect(roundTripTerminalState(value)).toEqual(value)
})
})
+33 -31
View File
@@ -8,18 +8,20 @@ import { base64Encode } from "@opencode/util/encode"
import { defaultTitle, titleNumber } from "./title"
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Persistence } from "@/runtime/persistence/schema"
import { Schema, SchemaGetter } from "effect"
import { Codec } from "@/runtime/persistence/codec"
const PTY = Persistence.struct({
id: Schema.NonEmptyString,
title: Persistence.fallback(Schema.String, () => ""),
titleNumber: Persistence.fallback(Schema.Finite, () => 0),
rows: Persistence.optional(Schema.Finite),
cols: Persistence.optional(Schema.Finite),
buffer: Persistence.optional(Schema.String),
scrollY: Persistence.optional(Schema.Finite),
cursor: Persistence.optional(Schema.Finite),
const PTY = Codec.struct({
id: Codec.make<string, string>(
(v) => (typeof v === "string" && v.length > 0 ? v : Codec.INVALID),
(v) => v,
),
title: Codec.fallback(Codec.string, () => ""),
titleNumber: Codec.fallback(Codec.number, () => 0),
rows: Codec.lenientOptional(Codec.number),
cols: Codec.lenientOptional(Codec.number),
buffer: Codec.lenientOptional(Codec.string),
scrollY: Codec.lenientOptional(Codec.number),
cursor: Codec.lenientOptional(Codec.number),
})
export type LocalPTY = typeof PTY.Type
@@ -31,28 +33,26 @@ function numberFromTitle(title: string) {
return titleNumber(title, MAX_TERMINAL_SESSIONS)
}
const State = Persistence.struct({
active: Persistence.optional(Schema.String),
all: Persistence.array(PTY),
const State = Codec.struct({
active: Codec.lenientOptional(Codec.string),
all: Codec.lenientArray(PTY),
})
export const TerminalState = State.pipe(
Schema.decodeTo(Schema.toType(State), {
decode: SchemaGetter.transform((value) => {
const seen = new Set<string>()
const all = value.all.flatMap((pty) => {
if (seen.has(pty.id)) return []
seen.add(pty.id)
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
})
return {
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
all,
}
}),
encode: SchemaGetter.transform((value) => value),
}),
)
export const TerminalState = Codec.transform(State, {
decode: (value): typeof State.Type => {
const seen = new Set<string>()
const all = value.all.flatMap((pty) => {
if (seen.has(pty.id)) return []
seen.add(pty.id)
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
})
return {
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
all,
}
},
encode: (value) => value,
})
export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScope = ServerScope.local) {
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
@@ -458,3 +458,5 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
},
})
@@ -1,40 +1,40 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(
Persistence.withInitial(GoUpsellState, {
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
}),
)
describe("usage exceeded preferences", () => {
test("defaults unseen prompts", () => {
expect(decode({})).toEqual({
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
})
test("preserves timestamps while recovering malformed siblings", () => {
expect(
decode({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: "true",
go_upsell_account_rate_limit_last_seen_at: Infinity,
go_upsell_account_rate_limit_dont_show: 456,
}),
).toEqual({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: 456,
})
})
})
import { describe, expect, test } from "bun:test"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { Codec } from "@/runtime/persistence/codec"
const schema = Codec.withInitial(GoUpsellState, {
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
describe("usage exceeded preferences", () => {
test("defaults unseen prompts", () => {
expect(decode({})).toEqual({
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
})
test("preserves timestamps while recovering malformed siblings", () => {
expect(
decode({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: "true",
go_upsell_account_rate_limit_last_seen_at: Infinity,
go_upsell_account_rate_limit_dont_show: 456,
}),
).toEqual({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: 456,
})
})
})
@@ -2,8 +2,7 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus } from "@opencode/client/promise"
import { onCleanup } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { useSessionLayout } from "./session-layout"
import { useDialog, useI18n } from "@opencode/ui/context"
import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded"
@@ -15,11 +14,11 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
export const GoUpsellState = Persistence.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Schema.NullOr(Schema.Finite),
export const GoUpsellState = Codec.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Codec.nullOr(Codec.number),
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Codec.nullOr(Codec.number),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Codec.nullOr(Codec.number),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Codec.nullOr(Codec.number),
})
function goUpsellKeys(status: SessionStatus) {
@@ -106,3 +105,4 @@ export function useUsageExceededDialogs() {
}),
)
}
+69 -34
View File
@@ -1,7 +1,10 @@
import { For, Show, createMemo, onCleanup } from "solid-js"
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import fuzzysort from "fuzzysort"
import {
@@ -14,8 +17,6 @@ import {
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SettingsList } from "@/settings/list"
import { SettingsSearchEmpty } from "@/settings/search-empty"
import { SettingsSearchField } from "@/settings/search-field"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -339,7 +340,7 @@ export function createKeybindSettingsController(
}
}
export function SettingsKeybinds(props: { active?: boolean }) {
export function SettingsKeybinds(props: { active?: boolean; autofocus?: boolean }) {
const command = useCommand()
const settings = useSettings()
const controller = createKeybindSettingsController({
@@ -350,6 +351,7 @@ export function SettingsKeybinds(props: { active?: boolean }) {
return (
<SettingsKeybindsView
visible={props.active}
autofocus={props.autofocus}
groups={controller.catalog.groups}
filtered={controller.catalog.filtered}
title={controller.catalog.title}
@@ -364,6 +366,7 @@ export function SettingsKeybinds(props: { active?: boolean }) {
function SettingsKeybindsView(props: {
visible?: boolean
autofocus?: boolean
groups: KeybindGroup[]
filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string
@@ -374,6 +377,20 @@ function SettingsKeybindsView(props: {
onReset: () => void
}) {
const language = useLanguage()
let search: HTMLInputElement | undefined
createEffect(
on(
() => props.visible ?? true,
(visible) => {
if (!visible) return
const frame = requestAnimationFrame(() => {
if (props.visible !== false && props.autofocus !== false && search?.isConnected)
search.focus({ preventScroll: true })
})
onCleanup(() => cancelAnimationFrame(frame))
},
),
)
const [store, setStore] = createStore({ filter: "" })
const filtered = createMemo(() => props.filtered(store.filter))
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
@@ -390,12 +407,31 @@ function SettingsKeybindsView(props: {
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<SettingsSearchField
value={store.filter}
active={props.visible ?? true}
onInput={(value) => setStore("filter", value)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
/>
<div class="settings-tab-search">
<TextInput
ref={search}
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")}
/>
<Show when={store.filter}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</div>
<div class="settings-tab-body">
<div class="settings-shortcuts settings-section-stack">
@@ -406,29 +442,27 @@ function SettingsKeybindsView(props: {
<h3 class="settings-section-title">{language.t(groupKey[group])}</h3>
<SettingsList>
<For each={filtered().get(group) ?? []}>
{(id) => {
const binding = props.keybind(id)
return (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<Button
type="button"
size="small"
variant={binding ? "ghost" : "ghost-muted"}
data-keybind-id={id}
data-expanded={props.active === id ? "" : undefined}
onClick={() => props.onCapture(id)}
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-keybind-button": true,
"settings-keybind-button--active": props.active === id,
}}
onClick={() => props.onCapture(id)}
>
<Show
when={props.active === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
<Show
when={props.active === id}
fallback={binding || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</Button>
</div>
)
}}
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</SettingsList>
</div>
@@ -436,8 +470,9 @@ function SettingsKeybindsView(props: {
)}
</For>
<Show when={store.filter && !hasResults()}>
<div class="settings-tab-search-empty">
<SettingsSearchEmpty query={store.filter} />
<div class="settings-shortcuts-status">
<span>{language.t("settings.shortcuts.search.empty")}</span>
<span class="settings-shortcuts-status-filter">&quot;{store.filter}&quot;</span>
</div>
</Show>
</div>
+10 -8
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import {
settingsSchema,
settingsPersistence,
@@ -13,9 +12,9 @@ import {
terminalFontFamily,
} from "./model"
const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
const schema = Codec.withInitial(settingsPersistence, defaultSettings)
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
const encode = (value: typeof settingsSchema.Type) => schema.encode(value)
describe("settings timeline detail migration", () => {
test("migrates saved switches and round trips the current settings", () => {
@@ -51,14 +50,14 @@ describe("settings schema", () => {
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
appearance: { ...defaultSettings.appearance, fontSize: 20 },
}
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(settingsPersistence, initial), input)
expect(restore({})).toEqual(initial)
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
placement: "separate",
details: "expanded",
})
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
expect(() => Codec.decodeOrThrow(settingsSchema, {})).toThrow()
})
test("supplies the existing defaults for an empty document", () => {
@@ -171,7 +170,7 @@ describe("settings schema", () => {
test("does not silently repair invalid values during encoding", () => {
expect(() =>
Schema.encodeUnknownSync(settingsSchema)({ ...decode({}), appearance: { fontSize: "large" } }),
Codec.encodeOrThrow(settingsSchema, { ...decode({}), appearance: { fontSize: "large" } } as never),
).toThrow()
})
})
@@ -203,3 +202,6 @@ describe("settings font families", () => {
expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ')
})
})
File diff suppressed because it is too large Load Diff
+54 -22
View File
@@ -1,9 +1,11 @@
import { useFilteredList } from "@opencode/ui/hooks"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Switch } from "@opencode/ui/switch"
import { type Component, For, Show } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
import { Codec } from "@/runtime/persistence/codec"
import { useLanguage } from "@/runtime/i18n/language"
import { useModels } from "@/providers/models/models"
import { useServerSDK } from "@/runtime/server/client"
@@ -11,22 +13,34 @@ import { popularProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { SettingsSearchEmpty } from "@/settings/search-empty"
import { SettingsSearchField } from "@/settings/search-field"
import "@/settings/settings.css"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const PROVIDER_ICON_SIZE = 16
export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
export const ModelProvidersSchema = Codec.struct({
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
})
export const SettingsModels: Component<{ active?: boolean }> = (props) => {
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
let search: HTMLInputElement | undefined
createEffect(
on(
() => props.active ?? true,
(active) => {
if (!active) return
const frame = requestAnimationFrame(() => {
if (props.active !== false && props.autofocus !== false && search?.isConnected)
search.focus({ preventScroll: true })
})
onCleanup(() => cancelAnimationFrame(frame))
},
),
)
const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
ModelProvidersSchema,
@@ -64,12 +78,31 @@ export const SettingsModels: Component<{ active?: boolean }> = (props) => {
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
</div>
</div>
<SettingsSearchField
value={list.filter()}
active={props.active ?? true}
onInput={list.onInput}
placeholder={language.t("dialog.model.search.placeholder")}
/>
<div class="settings-tab-search">
<TextInput
ref={search}
type="search"
appearance="base"
value={list.filter()}
onInput={(event) => list.onInput(event.currentTarget.value)}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={list.filter()}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => list.clear()}
/>
</Show>
</div>
</div>
<div class="settings-tab-body settings-models">
@@ -85,14 +118,12 @@ export const SettingsModels: Component<{ active?: boolean }> = (props) => {
<Show
when={list.flat().length > 0}
fallback={
<Show
when={list.filter()}
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
>
<div class="settings-tab-search-empty">
<SettingsSearchEmpty query={list.filter()} />
</div>
</Show>
<div class="settings-models-status">
<span>{language.t("dialog.model.empty")}</span>
<Show when={list.filter()}>
<span class="settings-models-status-filter">&quot;{list.filter()}&quot;</span>
</Show>
</div>
}
>
<For each={list.grouped.latest}>
@@ -179,3 +210,4 @@ export const SettingsModels: Component<{ active?: boolean }> = (props) => {
</>
)
}
-67
View File
@@ -1,67 +0,0 @@
import { describe, expect, test } from "bun:test"
import { parseSettingsView, settingsViewUrl, type SettingsView } from "./route"
describe("settings route", () => {
test("keeps root preferences at the canonical route", () => {
expect(settingsViewUrl({ type: "root", tab: "general" })).toBe("/settings")
expect(parseSettingsView("", false)).toEqual({ type: "root", tab: "general", subtab: undefined })
})
test("round trips root, server, and project pages", () => {
const root: SettingsView = { type: "root", tab: "appearance" }
const server: SettingsView = { type: "server", server: "wsl:Debian", tab: "models" }
const project: SettingsView = {
type: "project",
server: "local",
project: "C:\\work folder",
parent: "server",
tab: "extensions",
subtab: "skills",
}
expect(parseSettingsView(new URL(settingsViewUrl(root), "http://localhost").search, false)).toEqual({
...root,
subtab: undefined,
})
expect(parseSettingsView(new URL(settingsViewUrl(server), "http://localhost").search, true)).toEqual({
...server,
subtab: undefined,
})
expect(parseSettingsView(new URL(settingsViewUrl(project), "http://localhost").search, true)).toEqual(project)
})
test("derives project ancestry and keeps search reveal state transient", () => {
expect(
parseSettingsView("?server=local&project=%2Fwork&tab=extensions&subtab=plugins", false, {
target: "settings-plugin",
searchActivation: 2,
}),
).toEqual({
type: "project",
server: "local",
project: "/work",
parent: "root",
tab: "extensions",
subtab: "plugins",
target: "settings-plugin",
searchActivation: 2,
})
})
test("falls back for invalid scope and tab combinations", () => {
expect(parseSettingsView("?tab=unknown", false)).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?project=%2Fwork&tab=extensions", false)).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?server=local&tab=about", true)).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?tab=toString", false)).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?tab=extensions&subtab=toString", false)).toEqual({
type: "root",
tab: "extensions",
subtab: undefined,
})
expect(parseSettingsView("?tab=extensions&subtab=lsps", false)).toEqual({
type: "root",
tab: "extensions",
subtab: undefined,
})
})
})
-124
View File
@@ -1,124 +0,0 @@
export type SettingsRootTab =
| "general"
| "appearance"
| "notifications"
| "shortcuts"
| "projects"
| "workspaces"
| "providers"
| "models"
| "extensions"
| "servers"
| "experimental"
| "about"
export type SettingsServerTab = "general" | "projects" | "workspaces" | "providers" | "models" | "extensions"
export type SettingsProjectTab = "general" | "workspaces" | "extensions"
export type SettingsView = (
| { type: "root"; tab: SettingsRootTab }
| { type: "server"; server: string; tab: SettingsServerTab }
| {
type: "project"
server: string
project: string
tab: SettingsProjectTab
parent: "root" | "server"
}
) & {
target?: string
subtab?: "mcps" | "plugins" | "skills" | "lsps"
searchActivation?: number
}
export type SettingsTransientView = Pick<SettingsView, "target" | "searchActivation">
const rootTabs: Record<SettingsRootTab, true> = {
general: true,
appearance: true,
notifications: true,
shortcuts: true,
projects: true,
workspaces: true,
providers: true,
models: true,
extensions: true,
servers: true,
experimental: true,
about: true,
}
const serverTabs: Record<SettingsServerTab, true> = {
general: true,
projects: true,
workspaces: true,
providers: true,
models: true,
extensions: true,
}
const projectTabs: Record<SettingsProjectTab, true> = {
general: true,
workspaces: true,
extensions: true,
}
const subtabs: Record<NonNullable<SettingsView["subtab"]>, true> = {
mcps: true,
plugins: true,
skills: true,
lsps: true,
}
export function parseSettingsView(
search: string,
multipleServers: boolean,
transient?: SettingsTransientView,
): SettingsView {
const params = new URLSearchParams(search)
const tab = params.get("tab") ?? "general"
const server = params.get("server")
const project = params.get("project")
const subtab = params.get("subtab")
const nested = subtab && isSubtab(subtab) && tab === "extensions" ? subtab : undefined
if (project && server && isProjectTab(tab)) {
return {
type: "project",
server,
project,
parent: multipleServers ? "server" : "root",
tab,
subtab: nested,
...transient,
}
}
if (!project && server && isServerTab(tab))
return { type: "server", server, tab, subtab: nested === "lsps" ? undefined : nested, ...transient }
if (!project && !server && isRootTab(tab))
return { type: "root", tab, subtab: nested === "lsps" ? undefined : nested, ...transient }
return { type: "root", tab: "general", ...transient }
}
export function settingsViewUrl(view: SettingsView) {
const params = new URLSearchParams()
if (view.type !== "root") params.set("server", view.server)
if (view.type === "project") params.set("project", view.project)
if (view.tab !== "general") params.set("tab", view.tab)
if (view.tab === "extensions" && view.subtab) params.set("subtab", view.subtab)
const search = params.toString()
return search ? `/settings?${search}` : "/settings"
}
export function isRootTab(value: string): value is SettingsRootTab {
return Object.hasOwn(rootTabs, value)
}
export function isServerTab(value: string): value is SettingsServerTab {
return Object.hasOwn(serverTabs, value)
}
export function isProjectTab(value: string): value is SettingsProjectTab {
return Object.hasOwn(projectTabs, value)
}
function isSubtab(value: string): value is NonNullable<SettingsView["subtab"]> {
return Object.hasOwn(subtabs, value)
}
@@ -1,95 +0,0 @@
import { createEffect, on, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import "@/settings/search.css"
export function SettingsSearchField(props: {
value: string
placeholder: string
active: boolean
onInput: (value: string) => void
}) {
const [state, setState] = createStore({ overflow: { start: false, end: false } })
let root: HTMLDivElement | undefined
let input: HTMLInputElement | undefined
const updateOverflow = () => {
if (!input) return
const offset = Math.abs(input.scrollLeft)
setState("overflow", {
start: offset > 1,
end: input.scrollWidth - input.clientWidth - offset > 1,
})
}
const clear = () => {
props.onInput("")
input?.focus({ preventScroll: true })
}
createEffect(on(() => props.value, updateOverflow))
onMount(() => {
const screen = root?.closest<HTMLElement>(".settings-screen")
if (!screen) return
makeEventListener(screen, "keydown", (event) => {
if (
!props.active ||
event.defaultPrevented ||
event.isComposing ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.key.length !== 1 ||
!event.key.trim() ||
(event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable=true]"))
)
return
event.preventDefault()
event.stopPropagation()
const value = `${props.value}${event.key}`
props.onInput(value)
input?.focus({ preventScroll: true })
queueMicrotask(() => input?.setSelectionRange(value.length, value.length))
})
})
return (
<div ref={root} class="settings-tab-search settings-filter-search" data-component="settings-filter-search">
<TextInput
ref={(element) => {
input = element
createResizeObserver(element, updateOverflow)
}}
type="search"
appearance="base"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
value={props.value}
data-overflow-start={state.overflow.start}
data-overflow-end={state.overflow.end}
onScroll={updateOverflow}
onInput={(event) => props.onInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return
event.preventDefault()
event.stopPropagation()
if (props.value) {
clear()
return
}
event.currentTarget.blur()
event.currentTarget.closest<HTMLElement>(".settings-screen")?.focus({ preventScroll: true })
}}
placeholder={props.placeholder}
aria-label={props.placeholder}
showClearButton={!!props.value}
clearIcon="circle-xmark"
onClearClick={clear}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
</div>
)
}
+6 -6
View File
@@ -15,12 +15,12 @@
visibility: hidden;
}
:is(.settings-search, .settings-filter-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
color: var(--v2-icon-icon-faint);
cursor: default;
}
:is(.settings-search, .settings-filter-search)
:is(.settings-search, .settings-projects-search)
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
background: transparent;
color: var(--v2-icon-icon-base);
@@ -38,19 +38,19 @@
background: var(--v2-overlay-simple-overlay-hover);
}
:is(.settings-search, .settings-filter-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
--mask-start: 16px;
}
:is(.settings-search, .settings-filter-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
--mask-end: 16px;
}
:is(.settings-search, .settings-filter-search) [data-slot="text-input-v2-input"]:dir(rtl) {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"]:dir(rtl) {
--mask-direction: to left;
}
:is(.settings-search, .settings-filter-search)
:is(.settings-search, .settings-projects-search)
[data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
mask-image: linear-gradient(
var(--mask-direction, to right),
+95 -66
View File
@@ -77,8 +77,6 @@
}
.settings-nav > .settings-back {
position: relative;
inset-block-start: -20px;
margin-block-end: 8px;
}
@@ -113,8 +111,8 @@
}
@media (hover: hover) {
.settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"]:not(:has([data-selected])):hover {
background: var(--v2-overlay-simple-overlay-hover);
.settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"]:hover {
background: var(--v2-background-bg-layer-03);
}
}
@@ -805,106 +803,78 @@
padding-inline-end: 28px;
}
.settings-filter-search [data-component="text-input-v2"] {
.settings-projects-search [data-component="text-input-v2"] {
height: 36px;
background: color-mix(in oklab, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
}
.settings-filter-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
.settings-projects-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
background: var(--v2-background-bg-layer-02);
}
.settings-filter-search [data-slot="text-input-v2-input"] {
.settings-projects-search [data-slot="text-input-v2-input"] {
padding-inline-end: 0;
}
.settings-filter-search [data-slot="text-input-v2-leading-icon"] {
.settings-projects-search [data-slot="text-input-v2-leading-icon"] {
padding-inline-start: 16px;
}
.settings-filter-search [data-slot="text-input-v2-value"] {
gap: 8px;
.settings-projects-search [data-slot="text-input-v2-value"] {
gap: 12px;
}
.settings-filter-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
.settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
margin-inline-end: -4px;
}
.settings-tab-search-empty {
.settings-projects-empty {
width: 60%;
margin-inline: auto;
}
.settings-tab-search-empty .settings-search-empty {
.settings-projects-empty .settings-search-empty {
justify-content: center;
padding-inline: 0;
}
.settings-tab-search-empty .settings-search-empty-quoted {
.settings-projects-empty .settings-search-empty-quoted {
flex: 0 1 auto;
}
.settings-project-empty-card {
width: 100%;
border-radius: 8px;
background: color-mix(
in oklab,
var(--v2-background-bg-base) 50%,
var(--v2-background-bg-layer-01)
);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
.settings-project-list {
width: 100%;
overflow: hidden;
.settings-project-row {
position: relative;
margin-inline: 1px;
border-radius: 8px;
background: var(--v2-background-bg-layer-01);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
.settings-project-row-shell {
position: relative;
margin-inline: 4px;
padding-block: 4px;
}
.settings-project-row-shell:not(:last-child)::after {
content: "";
position: absolute;
inset-inline: 12px;
bottom: 0;
height: 0.5px;
background: var(--v2-border-border-base);
}
.settings-project-row-shell:only-child {
margin-inline: 0;
padding-block: 0;
}
.settings-project-row-shell:only-child .settings-project-card {
height: 48px;
padding-inline: 16px;
border-radius: 8px;
}
.settings-project-card {
display: flex;
height: 40px;
align-items: center;
gap: 8px;
padding-inline: 12px;
border-radius: 4px;
transition: background-color 120ms;
}
.settings-project-card:hover,
.settings-project-card:focus-within,
.settings-project-card[data-menu="true"] {
.settings-project-row:hover {
background: var(--v2-background-bg-layer-02);
}
.settings-project-row-content {
display: flex;
width: 100%;
min-height: 73px;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 16px;
border-radius: inherit;
text-align: start;
}
.settings-project-row-name {
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
color: var(--v2-text-text-base);
}
.settings-tab-search-clear {
position: absolute;
top: 50%;
@@ -1062,6 +1032,61 @@
font-variation-settings: "slnt" 0;
}
.settings-keybind-button {
box-sizing: border-box;
flex-shrink: 0;
padding: 6px 8px;
margin: -6px -8px;
border: 0;
border-radius: 2px;
background: transparent;
cursor: pointer;
font-style: normal;
font-weight: 530;
font-size: 11px;
line-height: 1;
letter-spacing: 0.05px;
font-variant-numeric: tabular-nums;
font-feature-settings:
"tnum" on,
"lnum" on;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-faint);
}
.settings-keybind-button:hover {
background-color: var(--v2-background-bg-layer-02);
}
.settings-keybind-button:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
.settings-keybind-button--active {
color: var(--v2-text-text-faint);
border-radius: 2px;
background-color: var(--v2-background-bg-layer-02);
}
.settings-shortcuts-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
text-align: center;
}
.settings-shortcuts-status-filter {
color: var(--v2-text-text-base);
}
.settings-servers-row {
display: flex;
align-items: center;
@@ -1077,6 +1102,10 @@
padding-block: 20px;
}
.settings-server-connection .settings-servers-lead {
gap: 4px;
}
.settings-servers-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
+6 -6
View File
@@ -5,6 +5,7 @@ import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName } from "@/shell/layout/helpers"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import type { LocalProject } from "@/shell/state/layout"
@@ -279,7 +280,7 @@ function RootSettings() {
<SettingsNotifications />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds active={surface.view().tab === "shortcuts"} />
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
@@ -293,7 +294,6 @@ function RootSettings() {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
onOpenProject={(project) =>
surface.openProject({
server: ServerConnection.key(server),
@@ -312,7 +312,7 @@ function RootSettings() {
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels active={surface.view().tab === "models"} />
<SettingsModels active={surface.view().tab === "models"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
@@ -376,7 +376,6 @@ function ServerSettings(props: { entry: SettingsServer }) {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
onOpenProject={(project) =>
surface.openProject({
server: props.entry.key,
@@ -419,8 +418,10 @@ function ProjectSettings(props: { server: ServerConnection.Any; project: LocalPr
{
items: nestedProjectTabs.map((item) => ({
...item,
label: language.t(item.label),
onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined,
get label() {
return item.value === "general" ? displayName(props.project) : language.t(item.label)
},
})),
},
]
@@ -439,7 +440,6 @@ function ProjectSettings(props: { server: ServerConnection.Any; project: LocalPr
server={props.server}
project={props.project}
onOpenServer={() => surface.replaceServer(ServerConnection.key(props.server))}
onClose={() => surface.back()}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
+95 -38
View File
@@ -5,20 +5,79 @@ import { createSimpleContext } from "@opencode/ui/context"
import { useLayout, type LayoutRoute } from "@/shell/state/layout"
import { useCommand } from "@/shell/commands/command"
import { useSettingsServers } from "./servers/inventory"
import {
isProjectTab,
isRootTab,
isServerTab,
parseSettingsView,
settingsViewUrl,
type SettingsProjectTab,
type SettingsRootTab,
type SettingsServerTab,
type SettingsTransientView,
type SettingsView,
} from "./route"
export type { SettingsProjectTab, SettingsRootTab, SettingsServerTab, SettingsView } from "./route"
export type SettingsRootTab =
| "general"
| "appearance"
| "notifications"
| "shortcuts"
| "projects"
| "workspaces"
| "providers"
| "models"
| "extensions"
| "servers"
| "experimental"
| "about"
export type SettingsServerTab = "general" | "projects" | "workspaces" | "providers" | "models" | "extensions"
export type SettingsProjectTab = "general" | "workspaces" | "extensions"
export type SettingsView = (
| { type: "root"; tab: SettingsRootTab }
| { type: "server"; server: string; tab: SettingsServerTab }
| {
type: "project"
server: string
project: string
tab: SettingsProjectTab
parent: "root" | "server"
}
) & {
target?: string
subtab?: "mcps" | "plugins" | "skills" | "lsps"
searchActivation?: number
}
const rootTabs: Record<SettingsRootTab, true> = {
general: true,
appearance: true,
notifications: true,
shortcuts: true,
projects: true,
workspaces: true,
providers: true,
models: true,
extensions: true,
servers: true,
experimental: true,
about: true,
}
const serverTabs: Record<SettingsServerTab, true> = {
general: true,
projects: true,
workspaces: true,
providers: true,
models: true,
extensions: true,
}
const projectTabs: Record<SettingsProjectTab, true> = {
general: true,
workspaces: true,
extensions: true,
}
function isRootTab(value: string): value is SettingsRootTab {
return value in rootTabs
}
function isServerTab(value: string): value is SettingsServerTab {
return value in serverTabs
}
function isProjectTab(value: string): value is SettingsProjectTab {
return value in projectTabs
}
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
name: "SettingsSurface",
@@ -29,11 +88,11 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
const command = useCommand()
const servers = useSettingsServers()
const location = useLocation<{
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; view?: SettingsTransientView }
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; view: SettingsView }
}>()
const open = () => layout.route().type === "settings"
const source = () => location.state?.settings?.route ?? { type: "home" as const }
const view = () => parseSettingsView(location.search, servers().length > 1, location.state?.settings?.view)
const view = (): SettingsView => location.state?.settings?.view ?? { type: "root", tab: "general" }
const [search, setSearch] = createStore({
query: "",
origin: undefined as SettingsView | undefined,
@@ -45,17 +104,12 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
})
let focus: HTMLElement | undefined
const show = (view: SettingsView) => {
const show = (view: SettingsView, replace: boolean) => {
const route = layout.route()
if (route.type !== "settings" && document.activeElement instanceof HTMLElement) focus = document.activeElement
navigate(settingsViewUrl(view), {
replace: route.type === "settings",
state: {
settings: {
route: route.type === "settings" ? source() : route,
view: { target: view.target, searchActivation: view.searchActivation },
},
},
navigate("/settings", {
replace,
state: { settings: { route: route.type === "settings" ? source() : route, view } },
})
}
@@ -94,7 +148,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
},
open(destination: SettingsView, id: string) {
batch(() => {
show({ ...destination, searchActivation: search.activation + 1 })
show({ ...destination, searchActivation: search.activation + 1 }, true)
setSearch({ selected: id, highlighted: id, expanded: false, activation: search.activation + 1 })
})
},
@@ -103,27 +157,30 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
},
back() {
if (!search.query.trim() || !search.selected || !search.origin) return false
show(search.origin)
show(search.origin, true)
setSearch({ selected: "", expanded: true })
return true
},
},
open(tab: SettingsRootTab = "general") {
show({ type: "root", tab })
show({ type: "root", tab }, open())
},
openServer(server: string, tab: SettingsServerTab = "general") {
show({ type: "server", server, tab })
show({ type: "server", server, tab }, false)
},
replaceServer(server: string, tab: SettingsServerTab = "general") {
show({ type: "server", server, tab })
show({ type: "server", server, tab }, true)
},
openProject(input: { server: string; project: string; tab?: SettingsProjectTab }) {
show({
type: "project",
...input,
parent: servers().length > 1 ? "server" : "root",
tab: input.tab ?? "general",
})
show(
{
type: "project",
...input,
parent: servers().length > 1 ? "server" : "root",
tab: input.tab ?? "general",
},
false,
)
},
select(tab: string) {
const current = view()
@@ -135,10 +192,10 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
: current.type === "project" && isProjectTab(tab)
? { ...current, tab }
: current
show({ ...next, target: undefined, subtab: undefined })
show({ ...next, target: undefined, subtab: undefined }, true)
},
subtab(subtab: SettingsView["subtab"]) {
show({ ...view(), subtab, target: undefined })
show({ ...view(), subtab, target: undefined }, true)
},
back() {
const current = view()
@@ -150,7 +207,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
current.type === "server" || current.parent === "root"
? { type: "root", tab: current.type === "server" ? "general" : "projects" }
: { type: "server", server: current.server, tab: "projects" }
show(parent)
show(parent, true)
},
close() {
if (open()) command.trigger("common.goBack")
+4 -25
View File
@@ -24,7 +24,7 @@
[data-slot="timeline-detail-scale"] {
position: relative;
height: 28px;
margin-top: 16px;
margin-top: 24px;
margin-inline: 8px;
}
@@ -58,10 +58,6 @@
border-radius: 50%;
background: var(--timeline-detail-marker-background);
&[data-selected] {
background: var(--v2-background-bg-accent);
}
&:dir(rtl) {
transform: translate(50%, -50%);
}
@@ -78,6 +74,7 @@
margin: 0;
margin-inline: -8px;
background: transparent;
cursor: pointer;
border-radius: 4px;
&:focus-visible {
@@ -130,7 +127,7 @@
}
[data-slot="timeline-detail-advanced"] {
margin-top: 0;
margin-top: 20px;
padding-top: 8px;
}
@@ -236,7 +233,7 @@
[data-slot="timeline-detail-activity"] {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
min-width: 0;
> label {
@@ -260,24 +257,6 @@
}
}
@container timeline-detail (min-width: 311px) {
[data-component="timeline-detail-control"]
[data-slot="timeline-detail-advanced"]:has(> [data-slot="collapsible-trigger"][aria-expanded="true"]) {
> [data-slot="collapsible-trigger"] {
position: relative;
z-index: 1;
}
> [data-slot="collapsible-content"] {
margin-top: -24px;
}
[data-slot="timeline-detail-columns"] {
min-height: 24px;
}
}
}
[data-color-scheme="dark"] [data-component="timeline-detail-control"] {
--timeline-detail-track-background: var(--v2-background-bg-layer-03);
--timeline-detail-marker-background:
@@ -51,12 +51,7 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
style={{ "--timeline-detail-progress": `${(position() / (presets.length - 1)) * 100}%` }}
>
<For each={presets}>
{(_, index) => (
<span
data-selected={index() <= position() ? "" : undefined}
style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }}
/>
)}
{(_, index) => <span style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }} />}
</For>
</div>
<input
@@ -1,107 +0,0 @@
import { Show, type Component } from "solid-js"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import type { LocalProject } from "@/shell/state/layout"
import { displayName, errorMessage } from "@/shell/layout/helpers"
import { fileManagerApp } from "@/home/projects/file-manager"
import { revealProject } from "@/home/projects/reveal"
import { showToast } from "@/shell/notifications/toast"
export const ProjectOptions: Component<{
server: ServerConnection.Any
project: LocalProject
open?: boolean
onOpenChange?: (open: boolean) => void
onEdit?: () => void
onRename?: () => void
onClose?: () => void
class?: string
size?: "small" | "normal" | "large"
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
const platform = usePlatform()
const context = () => global.ensureServerCtx(props.server)
const canReveal = () =>
platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(props.server)
const unseen = () =>
[props.project.worktree, ...(props.project.sandboxes ?? [])].reduce(
(total, directory) => total + context().notification.project.unseenCount(directory),
0,
)
const reveal = () => {
if (!platform.revealPath || !canReveal()) return
void revealProject({
directory: props.project.worktree,
reveal: platform.revealPath,
remove: context().projects.remove,
})
.then((revealed) => {
if (revealed) return
showToast({
variant: "error",
title: language.t("home.project.missing.title"),
description: language.t("home.project.missing.description", { name: displayName(props.project) }),
})
})
.catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}
const clearNotifications = () => {
const notification = context().notification
const directories = [props.project.worktree, ...(props.project.sandboxes ?? [])]
directories
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
}
const close = () => {
context().projects.close(props.project.worktree)
props.onClose?.()
}
return (
<Menu
gutter={4}
modal={false}
placement="bottom-end"
open={props.open}
onOpenChange={props.onOpenChange}
>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size={props.size ?? "small"}
class={props.class}
icon={<Icon name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content>
<Show when={props.onEdit} keyed>
{(edit) => <Menu.Item onSelect={edit}>{language.t("common.edit")}</Menu.Item>}
</Show>
<Show when={props.onRename} keyed>
{(rename) => <Menu.Item onSelect={rename}>{language.t("common.rename")}</Menu.Item>}
</Show>
<Show when={canReveal()}>
<Menu.Item onSelect={reveal}>{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}</Menu.Item>
</Show>
<Menu.Item disabled={unseen() === 0} onSelect={clearNotifications}>
{language.t("sidebar.project.clearNotifications")}
</Menu.Item>
<Menu.Separator />
<Menu.Item onSelect={close}>{language.t("common.close")}</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
@@ -1,15 +1,18 @@
import { Show } from "solid-js"
import { Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { InlineInput } from "@opencode/ui/inline-input"
import { Menu } from "@opencode/ui/menu"
import { getFilename } from "@opencode/util/path"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import type { LocalProject } from "@/shell/state/layout"
import { useGlobal } from "@/runtime/server/runtime"
import { displayName, errorMessage } from "@/shell/layout/helpers"
import { fileManagerApp } from "@/home/projects/file-manager"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { showToast } from "@/shell/notifications/toast"
import { ProjectOptions } from "./project-options"
import type { LocalProject } from "@/shell/state/layout"
export function SettingsProjectRow(props: {
project: LocalProject
@@ -17,13 +20,21 @@ export function SettingsProjectRow(props: {
onOpen: (project: LocalProject) => void
}) {
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
const [store, setStore] = createStore({
menu: false,
menu: undefined as { x: number; y: number } | undefined,
editor: undefined as { draft: string; saving: boolean } | undefined,
})
let row: HTMLDivElement | undefined
let button: HTMLButtonElement | undefined
let input: HTMLInputElement | undefined
let outside = false
const openMenu = (x: number, y: number) => {
if (!row) return
const bounds = row.getBoundingClientRect()
setStore("menu", { x: x - bounds.left, y: y - bounds.top })
}
const openEditor = () => {
setStore("editor", { draft: displayName(props.project), saving: false })
requestAnimationFrame(() => {
@@ -53,11 +64,11 @@ export function SettingsProjectRow(props: {
: Promise.resolve(context.sync.project.meta(props.project.worktree, { name: value }))
)
.then(() => true)
.catch((cause: unknown) => {
.catch((error: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
@@ -69,26 +80,29 @@ export function SettingsProjectRow(props: {
}
return (
<div class="settings-project-row-shell">
<div
role="listitem"
data-component="settings-project-card"
data-menu={store.menu ? "true" : undefined}
class="settings-project-card"
>
<Show
when={!store.editor}
fallback={
<div class="flex h-full min-w-0 flex-1 items-center gap-2">
<ProjectIcon project={props.project} class="shrink-0" />
<div
ref={row}
data-component="settings-project-row"
class="settings-project-row group"
onContextMenu={(event) => {
if (store.editor) return
event.preventDefault()
openMenu(event.clientX, event.clientY)
}}
>
<Show
when={!store.editor}
fallback={
<div class="settings-project-row-content">
<ProjectRowContent project={props.project}>
<InlineInput
ref={input}
aria-label={language.t("common.rename")}
dir="auto"
value={store.editor?.draft ?? ""}
disabled={store.editor?.saving}
class="w-full text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none"
style={{ "--inline-input-shadow": "none", "border-radius": "0", "text-align": "start" }}
class="settings-project-row-name w-full outline-none"
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => setStore("editor", "draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
@@ -105,31 +119,112 @@ export function SettingsProjectRow(props: {
}}
onBlur={closeEditor}
/>
</div>
}
</ProjectRowContent>
</div>
}
>
<button
ref={button}
type="button"
aria-label={displayName(props.project)}
aria-haspopup="menu"
aria-expanded={!!store.menu}
class="settings-project-row-content"
onClick={() => props.onOpen(props.project)}
onKeyDown={(event) => {
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
openMenu(bounds.left + 12, bounds.bottom)
}}
>
<button
ref={button}
type="button"
aria-label={displayName(props.project)}
class="flex h-full min-w-0 flex-1 items-center gap-2 rounded-[4px] bg-transparent text-start focus-visible:outline-none focus-visible:[box-shadow:inset_0_0_0_1px_var(--v2-border-border-focus)]"
onClick={() => props.onOpen(props.project)}
>
<ProjectIcon project={props.project} class="shrink-0" />
<bdi class="truncate text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base">
{displayName(props.project)}
</bdi>
</button>
<ProjectOptions
server={props.server}
project={props.project}
open={store.menu}
onOpenChange={(open) => setStore("menu", open)}
onEdit={() => props.onOpen(props.project)}
onRename={openEditor}
<ProjectRowContent project={props.project}>
<bdi class="settings-project-row-name truncate">{displayName(props.project)}</bdi>
</ProjectRowContent>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
/>
</Show>
</div>
</button>
</Show>
<Menu
modal={false}
placement="bottom-start"
gutter={2}
open={!!store.menu}
onOpenChange={(open) => {
if (!open) setStore("menu", undefined)
}}
>
<Menu.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${store.menu?.x ?? 0}px`, top: `${store.menu?.y ?? 0}px` }}
/>
<Menu.Portal>
<Menu.Content
onInteractOutside={() => {
outside = true
}}
onCloseAutoFocus={(event) => {
event.preventDefault()
const restore = !outside && !store.editor
outside = false
if (restore) requestAnimationFrame(() => button?.focus())
}}
>
<Menu.Item onSelect={openEditor}>{language.t("common.rename")}</Menu.Item>
<Show
when={platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)}
>
<Menu.Item
onSelect={() => {
if (!platform.openPath) return
void platform.openPath(props.project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}}
>
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item
onSelect={() => {
const next = row?.nextElementSibling ?? row?.previousElementSibling
global.ensureServerCtx(props.server).projects.close(props.project.worktree)
requestAnimationFrame(() => next?.querySelector("button")?.focus())
}}
>
{language.t("common.close")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
)
}
function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) {
return (
<span class="flex items-start gap-2.5 min-w-0 flex-1">
<ProjectIcon project={props.project} class="shrink-0" />
<span class="flex min-w-0 flex-1 flex-col gap-1.5">
{props.children}
<bdi
dir="ltr"
class="text-11-regular leading-[var(--line-height-compact)] text-v2-text-text-muted truncate"
title={props.project.worktree}
>
{props.project.worktree}
</bdi>
</span>
</span>
)
}
@@ -9,7 +9,6 @@ import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useSettingsServers } from "@/settings/servers/inventory"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { ProjectOptions } from "./project-options"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { createEditProjectModel } from "./project-model"
@@ -19,7 +18,6 @@ export const SettingsProjectGeneral: Component<{
project: LocalProject
server: ServerConnection.Any
onOpenServer: () => void
onClose: () => void
}> = (props) => {
const language = useLanguage()
const model = createEditProjectModel(props)
@@ -29,29 +27,16 @@ export const SettingsProjectGeneral: Component<{
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex min-w-0 items-center gap-3">
<ProjectIcon
project={props.project}
fallback={model.store.name || model.defaultName()}
icon={{
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
}}
class="!size-8 shrink-0 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[16px]"
/>
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title truncate">
<bdi dir="auto">{model.store.name || displayName(props.project)}</bdi>
</h2>
<Show when={servers().length > 1}>
<button type="button" class="project-settings-server-link" onClick={() => props.onOpenServer()}>
<bdi dir="auto">{serverName(props.server) || ServerConnection.key(props.server)}</bdi>
</button>
</Show>
</div>
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title truncate">
<bdi dir="auto">{model.store.name || displayName(props.project)}</bdi>
</h2>
<Show when={servers().length > 1}>
<button type="button" class="project-settings-server-link" onClick={() => props.onOpenServer()}>
<bdi dir="auto">{serverName(props.server) || ServerConnection.key(props.server)}</bdi>
</button>
</Show>
</div>
<ProjectOptions server={props.server} project={props.project} size="large" onClose={props.onClose} />
</div>
</div>
@@ -1,33 +1,38 @@
import { Show, createEffect, createMemo, type Component } from "solid-js"
import { Show, createEffect, createMemo, on, type Component } from "solid-js"
import { Key } from "@solid-primitives/keyed"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { displayName, homeProjectDirectories } from "@/shell/layout/helpers"
import { displayName } from "@/shell/layout/helpers"
import type { LocalProject } from "@/shell/state/layout"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { addProjects } from "@/home/projects/add"
import { settingsProjects } from "../servers/inventory"
import { SettingsSearchEmpty } from "../search-empty"
import { SettingsSearchField } from "../search-field"
import { settingsProjects } from "../servers/inventory"
import { SettingsProjectRow } from "./project-row"
import "@/settings/search.css"
import "@/settings/settings.css"
export const SettingsProjects: Component<{
server: ServerConnection.Any
active: boolean
onOpenProject: (project: LocalProject) => void
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [store, setStore] = createStore({ filter: "" })
const context = createMemo(() => global.ensureServerCtx(props.server))
const projects = createMemo(() => settingsProjects(context()))
const [store, setStore] = createStore({ filter: "", overflow: { start: false, end: false } })
let search: HTMLInputElement | undefined
const updateOverflow = () => {
if (!search) return
const offset = Math.abs(search.scrollLeft)
setStore("overflow", {
start: offset > 1,
end: search.scrollWidth - search.clientWidth - offset > 1,
})
}
createEffect(on(() => store.filter, updateOverflow))
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
const searchable = createMemo(() => projects().length > 7)
const filtered = createMemo(() => {
const query = searchable() ? store.filter.trim().toLowerCase() : ""
@@ -36,23 +41,6 @@ export const SettingsProjects: Component<{
createEffect(() => {
if (!searchable()) setStore("filter", "")
})
const addProject = () =>
pickDirectory({
server: props.server,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => {
const directories = homeProjectDirectories(result)
const directory = addProjects(context(), directories)
if (!directory) return
if (directories.length > 1) return
const project = context()
.projects.list()
.find((item) => item.worktree === directory)
if (!project) return
props.onOpenProject(project)
},
})
return (
<>
@@ -62,19 +50,36 @@ export const SettingsProjects: Component<{
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<Show when={projects().length > 0}>
<Button variant="ghost-muted" icon="plus" onClick={addProject}>
{language.t("home.project.add")}
</Button>
</Show>
</div>
<Show when={searchable()}>
<SettingsSearchField
value={store.filter}
active={props.active}
onInput={(value) => setStore("filter", value)}
placeholder={language.t("settings.projects.search.placeholder")}
/>
<div class="settings-tab-search settings-projects-search">
<TextInput
ref={(element) => {
search = element
createResizeObserver(element, updateOverflow)
}}
type="search"
appearance="base"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
value={store.filter}
data-overflow-start={store.overflow.start}
data-overflow-end={store.overflow.end}
onScroll={updateOverflow}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.projects.search.placeholder")}
aria-label={language.t("settings.projects.search.placeholder")}
showClearButton={!!store.filter}
clearIcon="circle-xmark"
onClearClick={() => {
setStore("filter", "")
search?.focus({ preventScroll: true })
}}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
</div>
</Show>
</div>
@@ -83,41 +88,20 @@ export const SettingsProjects: Component<{
when={filtered().length > 0}
fallback={
<Show
when={!store.filter.trim() && projects().length === 0}
when={store.filter.trim()}
fallback={
<Show
when={store.filter.trim()}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div class="settings-tab-search-empty">
<SettingsSearchEmpty query={store.filter} />
</div>
</Show>
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div
data-component="settings-project-empty-card"
class="settings-project-empty-card flex flex-col items-center gap-2 py-24 text-center"
>
<Icon name="folder" size="large" class="mb-2 text-v2-icon-icon-muted" />
<div class="text-13-medium text-v2-text-text-base">
{language.t("settings.projects.empty.title")}
</div>
<div class="text-13-regular text-v2-text-text-muted">
{language.t("settings.projects.empty.description")}
</div>
<Button variant="neutral" icon="plus" class="mt-6" onClick={addProject}>
{language.t("home.project.add")}
</Button>
<div class="settings-projects-empty">
<SettingsSearchEmpty query={store.filter} />
</div>
</Show>
}
>
<div role="list" class="settings-project-list">
<div class="flex w-full flex-col gap-2">
<Key each={filtered()} by="worktree">
{(project) => (
<SettingsProjectRow project={project()} server={props.server} onOpen={props.onOpenProject} />
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import {
activeCommandRegistrations,
addCommandRegistration,
@@ -10,12 +10,12 @@ import {
} from "./command"
test("command catalog persistence validates metadata and omits executable fields", () => {
const decode = Schema.decodeUnknownSync(CommandCatalog)
const decode = ((input: unknown) => Codec.decodeOrThrow(CommandCatalog, input))
const catalog = decode({ open: { title: "Open", keybind: "mod+o", hidden: false, onSelect: "invalid" } })
expect(catalog).toEqual({ open: { title: "Open", keybind: "mod+o", hidden: false } })
expect(decode({})).toEqual({})
expect(() => decode({ open: { title: 1 } })).toThrow()
expect(decode(Schema.encodeSync(CommandCatalog)(catalog))).toEqual(catalog)
expect(decode(CommandCatalog.encode(catalog))).toEqual(catalog)
})
const paletteOptions: CommandOption[] = [
@@ -79,3 +79,4 @@ describe("resolveKeybindOption", () => {
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
})
})
+11 -10
View File
@@ -2,8 +2,8 @@ import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
@@ -109,16 +109,16 @@ export function resolveKeybindOption(candidates: CommandOption[] | undefined, ev
type CommandSource = "palette" | "keybind" | "slash"
export const CommandCatalogItem = Persistence.struct({
title: Schema.String,
description: Schema.optional(Schema.String),
category: Schema.optional(Schema.String),
keybind: Schema.optional(Schema.String),
slash: Schema.optional(Schema.String),
hidden: Schema.optional(Schema.Boolean),
export const CommandCatalogItem = Codec.struct({
title: Codec.string,
description: Codec.optional(Codec.string),
category: Codec.optional(Codec.string),
keybind: Codec.optional(Codec.string),
slash: Codec.optional(Codec.string),
hidden: Codec.optional(Codec.boolean),
})
export type CommandCatalogItem = typeof CommandCatalogItem.Type
export const CommandCatalog = Schema.Record(Schema.String, Schema.mutableKey(CommandCatalogItem))
export const CommandCatalog = Codec.record(CommandCatalogItem)
export type CommandCatalog = typeof CommandCatalog.Type
export type CommandRegistration = {
@@ -480,3 +480,4 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
},
})
@@ -1,23 +1,22 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import type { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "@/shell/tabs/tabs"
import { NotificationStore, openNotificationSession, type Notification } from "./notification"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
test("notification persistence validates and salvages individual notifications", () => {
const valid: Notification[] = [
{ type: "turn-complete", time: 123, viewed: false, session: "session-1" },
{ type: "error", time: 124, viewed: true, error: { type: "api", message: "failed", status: 500 } },
]
const decode = Schema.decodeUnknownSync(Persistence.withInitial(NotificationStore, { list: [] }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(NotificationStore, { list: [] }), input))
const store = decode({
list: [valid[0], null, { type: "unknown", time: 123, viewed: false }, { ...valid[1], error: "invalid" }, valid[1]],
})
expect(store.list).toEqual(valid)
expect(decode({})).toEqual({ list: [] })
expect(decode({ list: {} })).toEqual({ list: [] })
expect(decode(Schema.encodeSync(NotificationStore)(store))).toEqual(store)
expect(decode(NotificationStore.encode(store))).toEqual(store)
})
test("opens notification sessions through the tab router", () => {
@@ -41,3 +40,4 @@ test("opens notification sessions through the tab router", () => {
expect(calls).toEqual(["add:session-1", "route:session-1", "select:session-1"])
})
@@ -1,6 +1,6 @@
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { SessionError } from "@opencode/schema/session-error"
import { Codec } from "@/runtime/persistence/codec"
import type { SessionError } from "@opencode/schema/session-error"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import type { ServerSDK } from "@/runtime/server/client"
@@ -10,7 +10,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { useGlobal } from "@/runtime/server/runtime"
@@ -20,19 +20,28 @@ import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
const NotificationBase = {
directory: Schema.optional(Schema.String),
session: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Unknown),
time: Schema.Finite,
viewed: Schema.Boolean,
}
export const Notification = Schema.Union([
Persistence.struct({ ...NotificationBase, type: Schema.Literal("turn-complete") }),
Persistence.struct({ ...NotificationBase, type: Schema.Literal("error"), error: SessionError.Error }),
const NotificationBase = {
directory: Codec.optional(Codec.string),
session: Codec.optional(Codec.string),
metadata: Codec.optional(Codec.unknown),
time: Codec.number,
viewed: Codec.boolean,
}
// The error payload is whatever the server reported; shape-checking it here would load the shared
// Effect schema into the renderer's startup path for a value the server already validated.
const StoredSessionError = Codec.make<SessionError.Error, unknown>(
(value) =>
typeof value === "object" && value !== null && "name" in value && "message" in value
? (value as unknown as SessionError.Error)
: Codec.INVALID,
(value) => value,
)
export const Notification = Codec.union([
Codec.struct({ ...NotificationBase, type: Codec.literal("turn-complete") }),
Codec.struct({ ...NotificationBase, type: Codec.literal("error"), error: StoredSessionError }),
])
export type Notification = typeof Notification.Type
export const NotificationStore = Persistence.struct({ list: Persistence.array(Notification) })
export const NotificationStore = Codec.struct({ list: Codec.lenientArray(Notification) })
type NotificationIndex = {
session: {
@@ -368,3 +377,5 @@ export const useNotification = () => {
const server = useServer()
return server.ctx.notification
}
+8 -8
View File
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { Schema } from "effect"
import { ServerConnection } from "@/runtime/server/registry"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
@@ -11,21 +10,21 @@ test("settings has its own layout route", () => {
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
const schema = Codec.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
initial.fileTree.width = 300
initial.review.panelOpened = true
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initial))
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(layoutPersistence, initial), input)
expect(restore({})).toEqual(initial)
expect(restore({ sidebar: { width: "bad" } }).sidebar.width).toBe(420)
expect(restore({ fileTree: { width: 260 } }).fileTree.width).toBe(200)
expect(restore({ fileTree: {} }).fileTree.width).toBe(300)
expect(restore({ review: {}, fileTree: { opened: false } }).review.panelOpened).toBe(false)
expect(() => Schema.decodeUnknownSync(layoutSchema)({})).toThrow()
expect(() => Codec.decodeOrThrow(layoutSchema, {})).toThrow()
})
test("restores shipped defaults for missing and invalid fields", () => {
@@ -56,8 +55,8 @@ describe("layout persistence", () => {
expect(value.sidebar).toEqual({ opened: false, width: 344, workspaces: {}, workspacesDefault: true })
expect(value.review).toEqual({ diffStyle: "split", panelOpened: true })
expect(value.fileTree).toEqual({ opened: true, width: 200, tab: "changes" })
expect(Schema.encodeSync(schema)(value)).toEqual(value)
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
expect(schema.encode(value)).toEqual(value)
expect(decode(schema.encode(value))).toEqual(value)
expect(decode({ fileTree: { opened: true } }).review.panelOpened).toBe(false)
})
@@ -169,3 +168,4 @@ describe("pruneSessionKeys", () => {
expect(drop).toEqual([])
})
})
File diff suppressed because it is too large Load Diff
+45 -50
View File
@@ -1,63 +1,58 @@
export * as TabStorage from "./schema"
import { Schema, SchemaGetter } from "effect"
import { ServerKey } from "@/runtime/server/persistence"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { ServerKey } from "@/runtime/server/key"
export { ServerKey }
export const Session = Persistence.struct({
type: Schema.Literal("session"),
export const Session = Codec.struct({
type: Codec.literal("session"),
server: ServerKey,
sessionId: Schema.String,
routeSessionId: Persistence.optional(Schema.String),
routeParentId: Persistence.optional(Schema.String),
sessionId: Codec.string,
routeSessionId: Codec.lenientOptional(Codec.string),
routeParentId: Codec.lenientOptional(Codec.string),
})
export const Draft = Persistence.struct({
type: Schema.Literal("draft"),
draftID: Schema.String,
export const Draft = Codec.struct({
type: Codec.literal("draft"),
draftID: Codec.string,
server: ServerKey,
directory: Schema.String,
worktree: Persistence.optional(Schema.String),
branch: Persistence.optional(Schema.String),
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
directory: Codec.string,
worktree: Codec.lenientOptional(Codec.string),
branch: Codec.lenientOptional(Codec.string),
mcp: Codec.lenientOptional(Codec.struct({ target: Codec.string, states: Codec.lenientRecord(Codec.boolean) })),
})
const SessionCodec = Session.pipe(
Schema.decodeTo(Schema.toType(Session), {
decode: SchemaGetter.transform((tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
})),
encode: SchemaGetter.transform((tab) => tab),
// A stored route that only repeats the session id carries nothing; drop it and its parent.
const SessionCodec = Codec.transform(Session, {
decode: (tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
}),
encode: (tab) => tab,
})
export const Tab = Codec.union([Session, Draft])
export const Tabs = Codec.lenientArray(Codec.union([SessionCodec, Draft]))
export const Recent = Codec.struct({
key: Codec.optional(Codec.string),
})
export const Info = Codec.struct({
title: Codec.optional(Codec.string),
directory: Codec.optional(Codec.string),
})
export const Infos = Codec.record(Info)
export const Panes = Codec.record(
Codec.struct({
terminal: Codec.optional(Codec.boolean),
review: Codec.optional(Codec.boolean),
terminalHeight: Codec.optional(Codec.number),
sessionWidth: Codec.optional(Codec.number),
}),
)
export const Tab = Schema.Union([Session, Draft])
export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft]))
export const Recent = Persistence.struct({
key: Schema.optional(Schema.String),
})
export const Info = Persistence.struct({
title: Schema.optional(Schema.String),
directory: Schema.optional(Schema.String),
})
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
export const Panes = Schema.Record(
Schema.String,
Schema.mutableKey(
Persistence.struct({
terminal: Schema.optional(Schema.Boolean),
review: Schema.optional(Schema.Boolean),
terminalHeight: Schema.optional(Schema.Finite),
sessionWidth: Schema.optional(Schema.Finite),
}),
),
)
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
export const Closed = Persistence.array(ClosedTab)
export const ClosedTab = Codec.struct({ tab: SessionCodec, index: Codec.nonNegativeInt })
export const Closed = Codec.lenientArray(ClosedTab)
+15 -14
View File
@@ -3,13 +3,12 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { Schema } from "effect"
import { TabStorage } from "./schema"
import type { ServerConnection } from "@/runtime/server/registry"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
const decodeTabs = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Tabs, []), input))
function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
@@ -26,7 +25,7 @@ describe("tab migration", () => {
}
const restored = decodeTabs([legacy, draft])
expect(restored).toEqual([legacy, draft])
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
expect(decodeTabs(TabStorage.Tabs.encode(restored))).toEqual([legacy, draft])
})
test("drops null and malformed persisted tabs", () => {
@@ -64,13 +63,13 @@ describe("tab migration", () => {
draft,
])
expect(tabs).toEqual([sessionTab("root"), draft])
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
expect(TabStorage.Tabs.encode(tabs)).toEqual(tabs)
expect(decodeTabs(TabStorage.Tabs.encode(tabs))).toEqual(tabs)
})
test("salvages valid closed session tabs", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Closed, []), input))([
{ tab: sessionTab("a"), index: 1 },
{ tab: sessionTab("b"), index: -1 },
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
@@ -81,16 +80,16 @@ describe("tab migration", () => {
test("validates auxiliary tab state", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Recent, { key: undefined }), input))({ key: 1 }),
).toEqual({ key: undefined })
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
expect(Codec.decodeOrThrow(TabStorage.Infos, {})).toEqual({})
expect(Codec.decodeOrThrow(TabStorage.Panes, {})).toEqual({})
expect(Codec.decodeOrThrow(TabStorage.Infos, { tab: { title: "Title", directory: "/project" } })).toEqual({
tab: { title: "Title", directory: "/project" },
})
const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } })
expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow()
const panes = Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: true, terminalHeight: 300 } })
expect(TabStorage.Panes.encode(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: "yes" } })).toThrow()
})
})
@@ -209,3 +208,5 @@ describe("closed tab stack", () => {
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
})
})
@@ -79,16 +79,4 @@ describe("titlebar history", () => {
expect(back.to.url).toBe("/")
expect(forwardPath(applyPath(back.state, back.to))?.to.state).toEqual({ tab: "models" })
})
test("settings pages replace one history entry", () => {
const initial = applyPath(history(), { url: "/settings" })
const models = applyPath(initial, { url: "/settings?tab=models" })
const project = applyPath(models, { url: "/settings?server=local&project=%2Fwork&tab=extensions" })
expect(project.stack.map((entry) => entry.url)).toEqual([
"/",
"/settings?server=local&project=%2Fwork&tab=extensions",
])
expect(backPath(project)?.to.url).toBe("/")
})
})
+2 -7
View File
@@ -21,9 +21,8 @@ export function applyPath(
}
const active = state.stack[state.index]
const sameUrl = current.url === active.url
if (sameUrl || (isSettingsPath(current.url) && isSettingsPath(active.url))) {
if (sameUrl && !state.action && current.state === active.state) return state
if (current.url === active.url) {
if (!state.action && current.state === active.state) return state
return {
...state,
stack: state.stack.map((entry, index) => (index === state.index ? current : entry)),
@@ -36,10 +35,6 @@ export function applyPath(
return pushPath(state, current, max)
}
function isSettingsPath(url: string) {
return url.split(/[?#]/, 1)[0] === "/settings"
}
export function pushPath(state: TitlebarHistory, path: HistoryLocation, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
const stack = state.stack.slice(0, state.index + 1).concat(path)
const next = trimHistory(stack, stack.length - 1, max)
+1 -10
View File
@@ -9,8 +9,7 @@
justify-content: center;
}
[data-titlebar-tab],
[data-titlebar-tab-action] {
[data-titlebar-tab] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
@@ -69,14 +68,6 @@
--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);
}
+2 -4
View File
@@ -351,10 +351,9 @@ 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:text-v2-text-text-base 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: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"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
@@ -651,9 +650,8 @@ 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: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:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
@@ -1,13 +1,13 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { HighlightsStore } from "./highlights"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
test("highlight persistence defaults missing or invalid versions and round-trips valid versions", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(HighlightsStore, { version: undefined }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(HighlightsStore, { version: undefined }), input))
expect(decode({})).toEqual({ version: undefined })
expect(decode({ version: null })).toEqual({ version: undefined })
const value = decode({ version: "1.2.3", legacy: true })
expect(value).toEqual({ version: "1.2.3" })
expect(Schema.encodeSync(HighlightsStore)(value)).toEqual(value)
expect(HighlightsStore.encode(value)).toEqual(value)
})

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