mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-20 15:47:38 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10638d1d9f | ||
|
|
faa72aea3a | ||
|
|
211ce5e9f8 | ||
|
|
3858b11bf9 |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"version": "2.0.10",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,7 +201,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -406,7 +406,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -455,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -540,7 +540,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -554,7 +554,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -569,7 +569,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.11",
|
||||
"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.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -638,7 +638,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -653,7 +653,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -677,7 +677,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -698,7 +698,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.11",
|
||||
"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.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -755,7 +755,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"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.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -888,7 +888,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -923,7 +923,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -958,7 +958,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -991,7 +991,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.11",
|
||||
"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.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.11",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }])
|
||||
})
|
||||
|
||||
|
||||
@@ -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,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,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", ')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
+507
-511
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -180,8 +180,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
|
||||
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
|
||||
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present. `Error.isError`
|
||||
is true for every Error value.
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
@@ -271,8 +270,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `Object.is` for supported data values.
|
||||
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
|
||||
and plain-object results.
|
||||
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString` (calls the value's
|
||||
`toString`, as in JS), `valueOf`, `hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
|
||||
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString`, `valueOf`,
|
||||
`hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
|
||||
|
||||
## Arrays
|
||||
|
||||
@@ -286,8 +285,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`lastIndexOf`.
|
||||
- [x] Aggregation: `reduce` and `reduceRight`.
|
||||
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, `join`, and `toLocaleString` (each element's
|
||||
`toLocaleString`, holes and nullish elements as empty strings).
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
|
||||
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
|
||||
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
|
||||
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
|
||||
@@ -310,8 +308,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
|
||||
## Strings
|
||||
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`, and the `toLocaleLowerCase`/`toLocaleUpperCase`
|
||||
aliases, which ignore their locale argument and apply the default Unicode casing.
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
|
||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
@@ -335,8 +332,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Coercion functions: `Number`, `parseInt`, and `parseFloat`.
|
||||
- [x] Number predicates/parsers: `Number.isInteger`, `Number.isFinite`, `Number.isNaN`, `Number.isSafeInteger`,
|
||||
`Number.parseInt`, and `Number.parseFloat`.
|
||||
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, `valueOf`, and `toLocaleString`, which
|
||||
always formats as `en-US` (`"1,234.5"`) so output does not depend on the host.
|
||||
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, and `valueOf`.
|
||||
- [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`,
|
||||
`POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`.
|
||||
- [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`.
|
||||
@@ -383,8 +379,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`TimeClip` behavior.
|
||||
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
|
||||
- [x] `toDateString` and `toTimeString` in the host's local timezone.
|
||||
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
|
||||
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
|
||||
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
|
||||
- [ ] Date setters and multi-argument construction coerce object arguments through `valueOf`/`toString` and surface
|
||||
their throws.
|
||||
@@ -410,7 +404,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
|
||||
- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity.
|
||||
- [x] `new Map()` from synchronous iterables of entries.
|
||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, `forEach`, `getOrInsert`, and `getOrInsertComputed`.
|
||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] `new Set()` from synchronous iterables.
|
||||
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect, Exit } from "effect"
|
||||
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { primitivePrototype } from "./intrinsics.js"
|
||||
import { typeError } from "./model.js"
|
||||
import { Callable, get, Native, DateObj, Obj } from "./objects.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
@@ -44,13 +43,6 @@ export const toPrimitive = <R>(
|
||||
})
|
||||
}
|
||||
|
||||
/** Invoke(value, name): calls the method the value would find through its prototype. */
|
||||
export const invoke = <R>(ctx: Interpreter<R>, value: unknown, name: string, label: string) => {
|
||||
const target = value instanceof Obj ? value : primitivePrototype(ctx.builtins, value)
|
||||
if (target === undefined) throw typeError(`${label} called on null or undefined.`)
|
||||
return ctx.call(get(target, name), value, [])
|
||||
}
|
||||
|
||||
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: unknown) =>
|
||||
Effect.map(toPrimitive(ctx, value, "string"), coerceToString)
|
||||
|
||||
|
||||
@@ -168,7 +168,6 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
methods(builtins, prototype, [
|
||||
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
|
||||
])
|
||||
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
|
||||
}
|
||||
return ctor
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
} from "./model.js"
|
||||
import { checkStringLength } from "./limits.js"
|
||||
import { locate, materialize } from "./errors.js"
|
||||
import { type Builtins, primitivePrototype } from "./intrinsics.js"
|
||||
import type { Builtins } from "./intrinsics.js"
|
||||
import { globals } from "./globals.js"
|
||||
import {
|
||||
assign,
|
||||
@@ -2071,14 +2071,16 @@ class Frame<R> {
|
||||
|
||||
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
|
||||
|
||||
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
|
||||
// Primitives read through their wrapper prototype without being boxed; strings own length and indexes.
|
||||
const builtins = self.ctx.builtins
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return { value: objectValue.length }
|
||||
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
|
||||
if (index !== undefined) return { value: objectValue[index] }
|
||||
return { target: builtins.String, key, receiver: objectValue }
|
||||
}
|
||||
const proto = primitivePrototype(self.ctx.builtins, objectValue)
|
||||
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
|
||||
if (typeof objectValue === "number") return { target: builtins.Number, key, receiver: objectValue }
|
||||
if (typeof objectValue === "boolean") return { target: builtins.Boolean, key, receiver: objectValue }
|
||||
|
||||
if (objectValue === null || objectValue === undefined) {
|
||||
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
|
||||
|
||||
@@ -52,14 +52,6 @@ export const createErrorValue = (prototype: Obj, message: string | undefined): E
|
||||
return value
|
||||
}
|
||||
|
||||
/** The prototype a primitive reads its methods from without being boxed; none for null, undefined, and symbols. */
|
||||
export const primitivePrototype = (builtins: Builtins, value: unknown): Obj | undefined => {
|
||||
if (typeof value === "string") return builtins.String
|
||||
if (typeof value === "number") return builtins.Number
|
||||
if (typeof value === "boolean") return builtins.Boolean
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const createBuiltins = (): Builtins => {
|
||||
const object = new Obj(null)
|
||||
// Function.prototype is itself callable and returns undefined.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interp
|
||||
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
|
||||
import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js"
|
||||
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { compareText } from "../tool-runtime.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
@@ -340,19 +340,6 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return target
|
||||
},
|
||||
],
|
||||
[
|
||||
"toLocaleString",
|
||||
0,
|
||||
(thisValue) =>
|
||||
Effect.map(
|
||||
Effect.forEach(self(thisValue, "toLocaleString").items, (item) =>
|
||||
item === null || item === undefined
|
||||
? Effect.succeed("")
|
||||
: Effect.map(invoke(ctx, item, "toLocaleString", "Array.prototype.toLocaleString"), coerceToString),
|
||||
),
|
||||
(parts) => parts.join(","),
|
||||
),
|
||||
],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").items.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").items.values())],
|
||||
[
|
||||
|
||||
@@ -145,29 +145,6 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return target
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsert",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsert").map
|
||||
if (!target.has(args[0])) target.set(args[0], args[1])
|
||||
return target.get(args[0])
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsertComputed",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsertComputed").map
|
||||
const apply = applyCollectionCallback(ctx, args[1], "Map.getOrInsertComputed")
|
||||
if (target.has(args[0])) return target.get(args[0])
|
||||
// The callback sees the stored key (-0 is +0) and its result wins over anything it inserted itself.
|
||||
return Effect.map(apply([args[0] === 0 ? 0 : args[0]]), (value) => {
|
||||
target.set(args[0], value)
|
||||
return value
|
||||
})
|
||||
},
|
||||
],
|
||||
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
|
||||
[
|
||||
"clear",
|
||||
|
||||
@@ -105,13 +105,6 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["toTimeString", 0, (thisValue) => new Date(self(thisValue, "toTimeString").time).toTimeString()],
|
||||
["toUTCString", 0, (thisValue) => new Date(self(thisValue, "toUTCString").time).toUTCString()],
|
||||
["toGMTString", 0, (thisValue) => new Date(self(thisValue, "toGMTString").time).toUTCString()],
|
||||
...(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"] as const).map(
|
||||
(name): Method => [
|
||||
name,
|
||||
0,
|
||||
(thisValue) => new Date(self(thisValue, name).time)[name]("en-US", { timeZone: "UTC" }),
|
||||
],
|
||||
),
|
||||
...getters.map((name): Method => [name, 0, (thisValue) => new Date(self(thisValue, name).time)[name]()]),
|
||||
...setters.map(
|
||||
([name, length]): Method => [
|
||||
|
||||
@@ -50,7 +50,6 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
}
|
||||
methods(builtins, builtins.Number, [
|
||||
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum("toFixed", args[0]))],
|
||||
["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")],
|
||||
[
|
||||
"toExponential",
|
||||
1,
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
set,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { groupBy } from "./collections.js"
|
||||
@@ -196,7 +196,7 @@ export const objectGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
true,
|
||||
],
|
||||
["toString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
|
||||
["toLocaleString", 0, (thisValue) => invoke(ctx, thisValue, "toString", "Object.prototype.toLocaleString")],
|
||||
["toLocaleString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
|
||||
[
|
||||
"valueOf",
|
||||
0,
|
||||
|
||||
@@ -150,8 +150,6 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
simple("valueOf", 0, (value) => value),
|
||||
simple("toLowerCase", 0, (value) => value.toLowerCase()),
|
||||
simple("toUpperCase", 0, (value) => value.toUpperCase()),
|
||||
simple("toLocaleLowerCase", 0, (value) => value.toLowerCase()),
|
||||
simple("toLocaleUpperCase", 0, (value) => value.toUpperCase()),
|
||||
simple("trim", 0, (value) => value.trim()),
|
||||
simple("trimStart", 0, (value) => value.trimStart()),
|
||||
simple("trimLeft", 0, (value) => value.trimStart()),
|
||||
|
||||
@@ -22,16 +22,6 @@ describe("source syntax", () => {
|
||||
})
|
||||
|
||||
describe("error identity", () => {
|
||||
test("Error.isError is true for every Error value and nothing else", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const caught = (() => { try { null.foo } catch (error) { return error } })()
|
||||
return [Error.isError(new Error("x")), Error.isError(new RangeError("x")), Error.isError(caught),
|
||||
Error.isError({ name: "Error", message: "x" }), Error.isError("Error"), Error.isError(null)]
|
||||
`),
|
||||
).toEqual([true, true, true, false, false, false])
|
||||
})
|
||||
|
||||
test("awaiting the same rejected promise twice yields the same error object", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
|
||||
@@ -898,42 +898,6 @@ describe("Map", () => {
|
||||
).toEqual({ a: 3, b: 1, c: 1 })
|
||||
})
|
||||
|
||||
test("getOrInsert and getOrInsertComputed insert only when the key is missing", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const groups = new Map()
|
||||
groups.getOrInsert("a", []).push(1)
|
||||
groups.getOrInsert("a", []).push(2)
|
||||
let calls = 0
|
||||
const computed = (key) => { calls++; return key + "!" }
|
||||
const first = groups.getOrInsertComputed("b", computed)
|
||||
const second = groups.getOrInsertComputed("b", computed)
|
||||
const zero = groups.getOrInsertComputed(-0, (key) => 1 / key === Infinity)
|
||||
return [[...groups], first, second, calls, zero]
|
||||
`),
|
||||
).toEqual([
|
||||
[
|
||||
["a", [1, 2]],
|
||||
["b", "b!"],
|
||||
[0, true],
|
||||
],
|
||||
"b!",
|
||||
"b!",
|
||||
1,
|
||||
true,
|
||||
])
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map()
|
||||
const outer = m.getOrInsertComputed("k", () => { m.set("k", "inner"); return "outer" })
|
||||
let thrown
|
||||
try { m.getOrInsertComputed("j", () => { throw new Error("boom") }) } catch (error) { thrown = error.message }
|
||||
return [outer, m.get("k"), thrown, m.has("j")]
|
||||
`),
|
||||
).toEqual(["outer", "outer", "boom", false])
|
||||
expect((await error(`new Map().getOrInsertComputed("k", 5)`)).message).toContain("expects a function callback")
|
||||
})
|
||||
|
||||
test("maps serialize to {} at the boundary, like JSON", async () => {
|
||||
expect(await value(`return new Map([["a", 1]])`)).toEqual({})
|
||||
expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}")
|
||||
@@ -1097,7 +1061,7 @@ describe("Uint8Array", () => {
|
||||
console.log(b, new Uint8Array())
|
||||
return [String(b), b + "", +new Uint8Array([5]), Number.isNaN(Number(b)), b == "1,2", JSON.stringify(b), b.toLocaleString(), typeof b, b instanceof Uint8Array]
|
||||
`),
|
||||
).toEqual(["1,2", "1,2", 5, true, true, '{"0":1,"1":2}', "1,2", "object", true])
|
||||
).toEqual(["1,2", "1,2", 5, true, true, '{"0":1,"1":2}', "[object Uint8Array]", "object", true])
|
||||
expect((await run(`console.log(new Uint8Array([1, 2]), new Uint8Array())`)).logs).toEqual([
|
||||
"Uint8Array(2) [1,2] Uint8Array(0) []",
|
||||
])
|
||||
@@ -1267,49 +1231,6 @@ describe("built-in iterators", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("toLocaleString", () => {
|
||||
test("numbers and dates format as en-US in UTC; everything else falls back to toString", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
(1234567.891).toLocaleString(), new Date(0).toLocaleString(), new Date(0).toLocaleDateString(),
|
||||
new Date(0).toLocaleTimeString(), "a".toLocaleString(), true.toLocaleString(), ({}).toLocaleString(),
|
||||
({ toString: () => "custom" }).toLocaleString(), new Uint8Array([1, 2]).toLocaleString(),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
"1,234,567.891",
|
||||
"1/1/1970, 12:00:00 AM",
|
||||
"1/1/1970",
|
||||
"12:00:00 AM",
|
||||
"a",
|
||||
"true",
|
||||
"[object Object]",
|
||||
"custom",
|
||||
"1,2",
|
||||
])
|
||||
})
|
||||
|
||||
test("arrays join each element's toLocaleString, skipping holes and nullish elements", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let calls = 0
|
||||
const item = { toLocaleString() { calls++; return "o" } }
|
||||
return [[1234.5, "x", null, undefined, item, new Date(0)].toLocaleString(), [, item, , item].toLocaleString(), calls]
|
||||
`),
|
||||
).toEqual(["1,234.5,x,,,o,1/1/1970, 12:00:00 AM", ",o,,o", 3])
|
||||
expect((await error(`const f = ({}).toLocaleString; f()`)).message).toContain(
|
||||
"Object.prototype.toLocaleString called on null or undefined",
|
||||
)
|
||||
})
|
||||
|
||||
test("toLocaleLowerCase and toLocaleUpperCase ignore the locale argument", async () => {
|
||||
expect(
|
||||
await value(`return ["ABC".toLocaleLowerCase("tr"), "abc".toLocaleUpperCase(), "İ".toLocaleLowerCase()]`),
|
||||
).toEqual(["abc", "ABC", "i̇"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdlib integration", () => {
|
||||
test("constructor follows own keys, shadowing, writes, and new", async () => {
|
||||
expect(
|
||||
|
||||
@@ -225,6 +225,7 @@ built-ins/Array/prototype/splice/S15.4.4.12_A4_T1.js # Array.prototype.splice c
|
||||
built-ins/Array/prototype/splice/S15.4.4.12_A4_T2.js # Array.prototype.splice called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/splice/S15.4.4.12_A4_T3.js # Array.prototype.splice called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/splice/create-ctor-non-object.js # null value Expected a TypeError to be thrown but no exception was thrown at all
|
||||
built-ins/Array/prototype/toLocaleString/S15.4.4.3_A1_T1.js # #1: var n = 0; var obj = {toLocaleString: function() {n++}}; var arr = [undefined, obj, null, obj, o
|
||||
built-ins/Array/prototype/toLocaleString/S15.4.4.3_A3_T1.js # #1: var n = 0; var obj = {toLocaleString: function() {n++}}; Array.prototype[1] = obj; x = [obj]; x.
|
||||
built-ins/Array/prototype/toReversed/holes-not-preserved.js # Actual [4, undefined, 2, undefined, 0] and expected [4, 3, 2, undefined, 0] should have the same con
|
||||
built-ins/Array/prototype/toSorted/holes-not-preserved.js # Actual [1, 3, 4, undefined, undefined] and expected [1, 2, 3, 4, undefined] should have the same con
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -27,7 +27,6 @@ export function resolve(model: Model.Info, supports: readonly Support[] = [{ typ
|
||||
const EFFORTS = ["low", "medium", "high"]
|
||||
const ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
|
||||
const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" }
|
||||
const ANTHROPIC_OUTPUT_TOKEN_MAX = 32_000
|
||||
|
||||
const variant = (id: string, overlay: Overlay): Variants[number] => ({ id: Model.VariantID.make(id), ...overlay })
|
||||
|
||||
@@ -40,9 +39,8 @@ function budgets(
|
||||
model: Model.Info,
|
||||
support: Extract<Support, { type: "budget_tokens" }>,
|
||||
spell: (tokens: number) => Overlay,
|
||||
ceiling = model.limit.output,
|
||||
): Variants {
|
||||
const maximum = Math.min(support.max ?? ceiling - 1, model.limit.output - 1, ceiling - 1)
|
||||
const maximum = Math.min(support.max ?? model.limit.output - 1, model.limit.output - 1)
|
||||
if (maximum <= 0) return []
|
||||
const high = Math.min(Math.max(support.min ?? 0, Math.floor((maximum + 1) / 2)), maximum)
|
||||
return [variant("high", spell(high)), variant("max", spell(maximum))]
|
||||
@@ -275,12 +273,9 @@ const anthropicMessages: Protocol = (model, support) => {
|
||||
return toggle({ settings: { thinking: { type: "disabled" } } }, thinking)
|
||||
}
|
||||
case "budget_tokens":
|
||||
return budgets(
|
||||
model,
|
||||
support,
|
||||
(tokens) => ({ settings: { thinking: { type: "enabled", budgetTokens: tokens } } }),
|
||||
ANTHROPIC_OUTPUT_TOKEN_MAX,
|
||||
)
|
||||
return budgets(model, support, (tokens) => ({
|
||||
settings: { thinking: { type: "enabled", budgetTokens: tokens } },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,14 +381,10 @@ const bedrockConverse: Protocol = (model, support) => {
|
||||
? toggle(fields({ thinking: { type: "disabled" } }), fields({ thinking: ADAPTIVE_THINKING }))
|
||||
: toggle(fields({ reasoningConfig: { type: "disabled" } }), fields({ reasoningConfig: { type: "enabled" } }))
|
||||
case "budget_tokens":
|
||||
return budgets(
|
||||
model,
|
||||
support,
|
||||
(tokens) =>
|
||||
claude
|
||||
? fields({ thinking: { type: "enabled", budget_tokens: tokens } })
|
||||
: fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }),
|
||||
claude ? ANTHROPIC_OUTPUT_TOKEN_MAX : model.limit.output,
|
||||
return budgets(model, support, (tokens) =>
|
||||
claude
|
||||
? fields({ thinking: { type: "enabled", budget_tokens: tokens } })
|
||||
: fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -445,12 +436,9 @@ const bedrockAISDK: Protocol = (model, support) => {
|
||||
{ settings: { additionalModelRequestFields: { reasoningConfig: { type: "enabled" } } } },
|
||||
)
|
||||
case "budget_tokens":
|
||||
return budgets(
|
||||
model,
|
||||
support,
|
||||
(tokens) => ({ settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } } }),
|
||||
claude ? ANTHROPIC_OUTPUT_TOKEN_MAX : model.limit.output,
|
||||
)
|
||||
return budgets(model, support, (tokens) => ({
|
||||
settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,11 +489,8 @@ const sapAICore: Protocol = (model, support) => {
|
||||
return []
|
||||
case "budget_tokens":
|
||||
if (id.includes("anthropic"))
|
||||
return budgets(
|
||||
model,
|
||||
support,
|
||||
(tokens) => sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }),
|
||||
ANTHROPIC_OUTPUT_TOKEN_MAX,
|
||||
return budgets(model, support, (tokens) =>
|
||||
sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }),
|
||||
)
|
||||
if (id.includes("gemini"))
|
||||
return budgets(model, support, (tokens) =>
|
||||
|
||||
@@ -1018,11 +1018,11 @@ describe("ModelsDevPlugin", () => {
|
||||
const budgetModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-budget"))
|
||||
expect(budgetModel?.variants).toContainEqual({
|
||||
id: Model.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 32000 } },
|
||||
})
|
||||
expect(budgetModel?.variants).toContainEqual({
|
||||
id: Model.VariantID.make("max"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 63999 } },
|
||||
})
|
||||
|
||||
const anthropicEffortModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4.7"))
|
||||
|
||||
@@ -93,15 +93,6 @@ test("recognizes Claude version spellings and future models", () => {
|
||||
settings: { effort, thinking: { type: "adaptive", display: "summarized" } },
|
||||
})),
|
||||
)
|
||||
|
||||
expect(
|
||||
resolve(model("@opencode/ai/providers/anthropic", "claude-haiku-4-5", 64_000), [
|
||||
{ type: "budget_tokens", min: 1_024, max: 64_000 },
|
||||
]),
|
||||
).toEqual([
|
||||
{ id: "high", settings: { thinking: { type: "enabled", budgetTokens: 16_000 } } },
|
||||
{ id: "max", settings: { thinking: { type: "enabled", budgetTokens: 31_999 } } },
|
||||
])
|
||||
})
|
||||
|
||||
test("spells Cloudflare AI Gateway variants for their upstream routes", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -103,17 +103,11 @@ export function createBrowserPage(
|
||||
revision++
|
||||
})
|
||||
let closed = false
|
||||
// Whether the native surface holds a real document worth showing. Chromium keeps the
|
||||
// previous document painted until the next one renders, so a shown page stays shown
|
||||
// through later navigations; blank and failed documents hide until a real one is ready.
|
||||
let content = false
|
||||
let failure: { url: string; message: string } | undefined
|
||||
const state = (): Browser.Tab => ({
|
||||
id: options.id,
|
||||
url: (failure?.url ?? contents.getURL()).slice(0, 16_384),
|
||||
url: contents.getURL().slice(0, 16_384),
|
||||
title: contents.getTitle().slice(0, 2_048),
|
||||
loading: contents.isLoading(),
|
||||
...(failure ? { loadError: failure.message } : {}),
|
||||
canGoBack: contents.navigationHistory.canGoBack(),
|
||||
canGoForward: contents.navigationHistory.canGoForward(),
|
||||
generation,
|
||||
@@ -121,40 +115,16 @@ export function createBrowserPage(
|
||||
const publish = () => {
|
||||
if (!closed) options.publish()
|
||||
}
|
||||
const reset = (event: Electron.Event<{ url: string; isMainFrame: boolean; isSameDocument: boolean }>) => {
|
||||
const reset = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => {
|
||||
if (!event.isMainFrame || event.isSameDocument) return
|
||||
failure = undefined
|
||||
generation++
|
||||
documents.clear()
|
||||
refs.clear()
|
||||
diagnostics.clear()
|
||||
publish()
|
||||
}
|
||||
const settle = () => {
|
||||
content = contents.getURL() !== "about:blank" && !failure
|
||||
updateVisibility()
|
||||
}
|
||||
contents.on("did-start-navigation", reset)
|
||||
contents.on("did-navigate", (_event, url, status, statusText) => {
|
||||
// The server-network proxy answers an unreachable HTTP target with an empty 502. Other
|
||||
// error statuses are real documents from the user's server and stay visible.
|
||||
if (status === 502) failure = { url, message: `${status} ${statusText}`.trim().slice(0, 2_048) }
|
||||
// A blank or failed document paints at commit; a real one waits for dom-ready.
|
||||
if (url === "about:blank" || failure) settle()
|
||||
publish()
|
||||
})
|
||||
contents.on("did-fail-load", (_event, code, description, url, isMainFrame) => {
|
||||
// Cancelled navigation and failed subframes do not replace the current page.
|
||||
if (!isMainFrame || code === -3) return
|
||||
failure = { url, message: description.slice(0, 2_048) }
|
||||
settle()
|
||||
publish()
|
||||
})
|
||||
contents.on("dom-ready", settle)
|
||||
contents.on("did-stop-loading", () => {
|
||||
settle()
|
||||
publish()
|
||||
})
|
||||
contents.on("did-stop-loading", publish)
|
||||
contents.on("did-navigate-in-page", publish)
|
||||
contents.on("page-title-updated", publish)
|
||||
contents.on("render-process-gone", () => {
|
||||
@@ -282,14 +252,6 @@ export function createBrowserPage(
|
||||
corner.setVisible(false)
|
||||
win.contentView.addChildView(corner)
|
||||
})
|
||||
let visible = false
|
||||
const updateVisibility = () => {
|
||||
// The renderer's layout requests may lag behind navigation; the page decides
|
||||
// whether there is a document worth exposing over the themed background.
|
||||
const show = visible && content
|
||||
view.setVisible(show)
|
||||
corners.forEach((corner) => corner.setVisible(show && !!cornerKey))
|
||||
}
|
||||
const ready = Promise.all([
|
||||
files.ready,
|
||||
...(options.initialize === false
|
||||
@@ -340,9 +302,9 @@ export function createBrowserPage(
|
||||
)
|
||||
})
|
||||
},
|
||||
setVisible(value: boolean) {
|
||||
visible = value
|
||||
updateVisibility()
|
||||
setVisible(visible: boolean) {
|
||||
view.setVisible(visible)
|
||||
corners.forEach((corner) => corner.setVisible(visible && !!cornerKey))
|
||||
},
|
||||
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
|
||||
await ready
|
||||
|
||||
@@ -31,25 +31,7 @@ async function main() {
|
||||
app.setPath("userData", path.join(root, "electron-data"))
|
||||
app.on("window-all-closed", () => {})
|
||||
await app.whenReady()
|
||||
let unavailable = true
|
||||
let finishLoading: (() => void) | undefined
|
||||
const web = createServer((request, response) => {
|
||||
if (request.url === "/loading") {
|
||||
response.writeHead(200, { "content-type": "text/html" })
|
||||
response.write("<!doctype html><html><body>Loading")
|
||||
finishLoading = () => response.end("</body></html>")
|
||||
return
|
||||
}
|
||||
if (request.url === "/finish-loading") {
|
||||
finishLoading?.()
|
||||
finishLoading = undefined
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
if (request.url === "/unreachable" && unavailable) {
|
||||
request.socket.destroy()
|
||||
return
|
||||
}
|
||||
if (request.url === "/cors-allowed" || request.url === "/cors-denied") {
|
||||
if (request.url === "/cors-allowed") response.setHeader("access-control-allow-origin", "*")
|
||||
response.setHeader("content-type", "text/plain")
|
||||
@@ -222,19 +204,6 @@ async function main() {
|
||||
assert.equal((await call("tabs.list", {})).tabs.length, 2)
|
||||
const tabID = first.id
|
||||
assert.equal(first.url, fixture + "/")
|
||||
assert.equal((await call("navigate", { tabID, url: `${fixture}/unreachable` })).loadError, "502 Bad Gateway")
|
||||
const failed = await until(async () => {
|
||||
const tab = (await call("tabs.list", {})).tabs.find((tab) => tab.id === tabID)
|
||||
return tab?.loadError && !tab.loading ? tab : undefined
|
||||
})
|
||||
assert.equal(failed.url, `${fixture}/unreachable`)
|
||||
assert.equal(failed.loadError, "502 Bad Gateway")
|
||||
await until(async () => inventories.get("suite")?.tabs.find((tab) => tab.id === tabID)?.loadError)
|
||||
assert.equal((await call("tabs.list", {})).tabs.find((tab) => tab.id === second.id)?.loadError, undefined)
|
||||
unavailable = false
|
||||
assert.equal((await call("reload", { tabID })).loadError, undefined)
|
||||
assert.equal((await call("navigate", { tabID, url: fixture })).loadError, undefined)
|
||||
console.log("PASS browser navigation failure state and retry")
|
||||
const networkProof = await call("evaluate", {
|
||||
tabID,
|
||||
script: `(async () => ({
|
||||
|
||||
@@ -29,58 +29,6 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
}
|
||||
try {
|
||||
await page.ready
|
||||
const started = new Promise<void>((resolve) => page.contents.once("did-navigate", () => resolve()))
|
||||
page.setVisible(true)
|
||||
const navigation = execute({ type: "navigate", tabID, url: url + "/loading" })
|
||||
await started
|
||||
assert(page.state().loading)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
page.setVisible(true)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
const stopped = new Promise<void>((resolve) => page.contents.once("did-stop-loading", () => resolve()))
|
||||
await fetch(url + "/finish-loading")
|
||||
await navigation
|
||||
await stopped
|
||||
assert.equal(page.state().loading, false)
|
||||
assert(page.view.getVisible(), "The requested visibility must return after loading finishes")
|
||||
console.log("PASS native loading from a blank page keeps the themed background visible until the document is ready")
|
||||
const again = new Promise<void>((resolve) => page.contents.once("did-navigate", () => resolve()))
|
||||
const reloading = execute({ type: "navigate", tabID, url: url + "/loading" })
|
||||
await again
|
||||
assert(page.state().loading)
|
||||
assert(page.view.getVisible(), "A shown page must stay visible while the next document loads")
|
||||
await fetch(url + "/finish-loading")
|
||||
await reloading
|
||||
assert(page.view.getVisible())
|
||||
console.log("PASS native loading from a shown page keeps the previous document visible")
|
||||
await execute({ type: "navigate", tabID, url: url + "/missing" })
|
||||
assert.equal(page.state().loadError, undefined, "An HTTP error page from the server is a real document")
|
||||
page.setVisible(true)
|
||||
assert(page.view.getVisible(), "An HTTP error page from the server must stay visible")
|
||||
console.log("PASS native HTTP error pages stay visible")
|
||||
await assert.rejects(execute({ type: "navigate", tabID, url: url + "/unreachable" }), /ERR_EMPTY_RESPONSE/)
|
||||
assert.equal(page.state().loadError, "ERR_EMPTY_RESPONSE")
|
||||
assert.equal(page.state().url, url + "/unreachable")
|
||||
page.setVisible(true)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
const clearing = new Promise<void>((resolve) => {
|
||||
page.contents.once("did-start-navigation", () => {
|
||||
page.setVisible(true)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
await execute({ type: "navigate", tabID, url: "about:blank" })
|
||||
await clearing
|
||||
assert.equal(page.state().loadError, undefined)
|
||||
page.setVisible(true)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
const restored = new Promise<void>((resolve) => page.contents.once("did-stop-loading", () => resolve()))
|
||||
await execute({ type: "navigate", tabID, url })
|
||||
await restored
|
||||
page.setVisible(true)
|
||||
assert(page.view.getVisible())
|
||||
console.log("PASS native blank navigation stays hidden through stale layout updates")
|
||||
for (const color of [
|
||||
[255, 255, 255, 255],
|
||||
[20, 24, 30, 255],
|
||||
@@ -143,7 +91,6 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
|
||||
await execute({ type: "evaluate", tabID, script: "document.querySelector('a[href=\"/download\"]').click()" })
|
||||
const fileID = await waitForFile()
|
||||
assert.equal(page.state().loadError, undefined, "A download must not mark the page as unreachable")
|
||||
const fileAction: Browser.Action = { type: "files.get", tabID, fileID }
|
||||
const original = await inspect(fileAction)
|
||||
assert(original.resources.includes(url + "/download"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -54,7 +54,6 @@ export const Tab = Schema.Struct({
|
||||
url: Schema.String.check(Schema.isMaxLength(16_384)),
|
||||
title: short,
|
||||
loading: Schema.Boolean,
|
||||
loadError: optional(short),
|
||||
canGoBack: Schema.Boolean,
|
||||
canGoForward: Schema.Boolean,
|
||||
generation: count,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -553,9 +553,8 @@
|
||||
}
|
||||
|
||||
&[data-value="open-file"],
|
||||
&[data-value^="file://"],
|
||||
&[data-value="browser"],
|
||||
&[data-value^="browser:"] {
|
||||
&[data-value^="file://"]
|
||||
{
|
||||
gap: 8px;
|
||||
padding-inline-end: 4px;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.11",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user