Compare commits

..
Author SHA1 Message Date
LukeParkerDev 6a57105b2c refactor(app): keep the SSH contract schemas out of the renderer
The Effect schemas move to servers/ssh/schema.ts, imported by the desktop main process for its RPC server; the renderer gets types only. The SSH attempt controller tracks its one in-flight request per server with a cancellation flag instead of Effect fibers, with the same settle-on-cancel behaviour.
2026-09-20 16:37:02 +10:00
LukeParkerDev 7930c5f676 refactor(app): port the model selection and terminal stores to plain codecs 2026-09-20 16:37:02 +10:00
LukeParkerDev a440d0bced refactor(app): port language, drafts, pairing, review panel, open-in-app and upsell stores to plain codecs
Also replaces the Effect Iterable pipeline in the provider catalog with array methods.
2026-09-20 16:37:01 +10:00
LukeParkerDev 169ababd60 refactor(app): port command, notification, highlights, home, file-view and workspace-tip stores to plain codecs
Session import keeps validating against the shared Effect schema through a dynamic import, so the shared schema and Effect load only when a file is imported. Persisted notification errors are checked structurally instead of through the shared schema. composer/schema.ts carries Effect twins of the file selection shapes until it is ported.
2026-09-20 16:37:00 +10:00
LukeParkerDev 10638d1d9f refactor(app): port the settings store to plain codecs
Migration shapes use preserving structs so undeclared stored fields survive, as Effect's onExcessProperty preserve did at every level; explicit-but-invalid legacy values still decode to null rather than absent; persisted() encodes through encodeOrThrow so an invalid in-memory value fails instead of being written.
2026-09-20 16:37:00 +10:00
LukeParkerDev faa72aea3a refactor(app): port the server, model, vcs and project stores to plain codecs
Migrations keep their behaviour: URL strings and bare HTTP blocks become server objects, canonical-local project buckets move under local, invalid map entries are dropped individually. Migrated definitions now preserve stored fields the migration does not mention, as Effect's onExcessProperty preserve did. The Effect ServerKey is gone; the codec one in runtime/server/key.ts carries the same brand.
2026-09-20 16:36:59 +10:00
LukeParkerDev 211ce5e9f8 refactor(app): port the layout store to plain codecs
Same shapes, defaults and migration as before (workspaces flag to map, review panel flag inherited from the file tree, legacy file-tree width), expressed with the codec module instead of Effect Schema.
2026-09-20 16:36:59 +10:00
LukeParkerDev 3858b11bf9 refactor(app): plain codecs for persisted state, starting with tabs
Adds an Effect-free codec module for persisted stores with the same recovery semantics as the Persistence helpers, lets persisted() accept either a Schema or a codec while stores migrate, and ports the tab storage schemas as the first store. ServerKey keeps its brand type on both sides so ServerConnection.Key stays one type. Effect leaves the renderer's initial module graph once the remaining stores are ported.
2026-09-20 16:36:58 +10:00
123 changed files with 2586 additions and 2958 deletions
+35 -35
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+10 -50
View File
@@ -265,8 +265,6 @@ export interface ParserState {
readonly finishReason?: FinishReasonDetails
readonly lifecycle: Lifecycle.State
readonly reasoningField?: string
/** A scalar reasoning field (`reasoning_content`, ...) has carried text in this stream. */
readonly reasoningTextObserved: boolean
readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean
@@ -346,18 +344,10 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown,
return Array.isArray(details) ? details : []
})
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
return observed.map(lowerReasoningDetail)
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details.map(lowerReasoningDetail)
return observed
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
// OpenRouter requires its `reasoning.*` entries back unmodified, `index`
// included. Kimi rejects its stream-only `index` on requests
// ("the reasoning_details ... must not contain streaming index") and Kimi Code
// never sends it, so drop it from Kimi entries while leaving every other
// dialect untouched.
const lowerReasoningDetail = (detail: unknown) =>
isKimiDetail(detail) ? Object.fromEntries(Object.entries(detail).filter((entry) => entry[0] !== "index")) : detail
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
@@ -423,9 +413,6 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured || requireReasoning) return "reasoning_content"
// Kimi always expects `reasoning_content` on replayed assistant messages,
// even when thinking arrived only through structured details.
if (details?.some(isKimiDetail)) return "reasoning_content"
})()
const reasoningText = (() => {
if (configuredField !== undefined)
@@ -900,32 +887,12 @@ const reasoningDelta = (
return undefined
}
// Kimi's OpenAI-compatible surface streams preserved thinking through a
// `reasoning_details` shape that is similar to, but not the same as, the
// OpenRouter `reasoning.*` dialect: `{ type: "summary", summary }` and
// `{ type: "encrypted", encrypted }` entries keyed by a stream-only `index`.
// Kimi does not document this publicly; the expected handling is taken from
// how Kimi Code (Kimi's own client) behaves: merge summary deltas by `index`,
// and replay the entries without `index` alongside `reasoning_content`.
const isKimiDetail = (detail: unknown): detail is Record<string, unknown> =>
isRecord(detail) && (detail.type === "summary" || detail.type === "encrypted")
// The field that accumulates across streamed deltas for mergeable detail kinds.
const mergeableDetailField = (detail: Record<string, unknown>) => {
if (detail.type === "reasoning.text") return "text"
if (detail.type === "reasoning.summary" || detail.type === "summary") return "summary"
}
const detailText = (details: ReadonlyArray<unknown>, hideKimiSummary: boolean) => {
const detailText = (details: ReadonlyArray<unknown>) => {
const text = details.flatMap((detail) => {
if (!isRecord(detail)) return []
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
return [detail.summary]
// Kimi streams the full thinking through `reasoning_content` and a separate
// summary through details; show the summary only when nothing else does.
if (detail.type === "summary" && !hideKimiSummary && typeof detail.summary === "string" && detail.summary)
return [detail.summary]
return []
})
if (text.length > 0) return text.join("")
@@ -934,32 +901,30 @@ const detailText = (details: ReadonlyArray<unknown>, hideKimiSummary: boolean) =
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
for (const detail of details) {
const previous = result.at(-1)
const field = isRecord(detail) ? mergeableDetailField(detail) : undefined
if (
field === undefined ||
!isRecord(previous) ||
previous.type !== "reasoning.text" ||
!isRecord(detail) ||
previous.type !== detail.type ||
conflictingReasoningDetails(previous, detail)
detail.type !== "reasoning.text" ||
conflictingReasoningTextDetails(previous, detail)
) {
result.push(detail)
continue
}
const merged = {
result[result.length - 1] = {
...previous,
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
[field]: `${typeof previous[field] === "string" ? previous[field] : ""}${typeof detail[field] === "string" ? detail[field] : ""}`,
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
signature: mergeDetailValue(previous.signature, detail.signature),
format: mergeDetailValue(previous.format, detail.format),
}
result[result.length - 1] = Object.fromEntries(Object.entries(merged).filter((entry) => entry[1] !== undefined))
}
}
const mergeDetailValue = (previous: unknown, current: unknown) =>
previous || current || (previous !== undefined ? previous : current)
const conflictingReasoningDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
conflictingDetailValue(previous.id, current.id) ||
conflictingDetailValue(previous.index, current.index) ||
conflictingDetailValue(previous.format, current.format) ||
@@ -1034,14 +999,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
}
const reasoningField = state.reasoningField ?? reasoning?.field
const reasoningTextObserved = state.reasoningTextObserved || reasoning !== undefined
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
const text = detailDelta?.length
? (detailText(detailDelta, reasoningTextObserved) ?? reasoning?.text)
: reasoning?.text
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
reasoningDetailsObserved &&
@@ -1137,7 +1099,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
finishReason,
lifecycle,
reasoningField,
reasoningTextObserved,
reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved,
reasoningEmitted,
@@ -1218,7 +1179,6 @@ export const protocol = Protocol.make({
toolCallEvents: [],
lifecycle: Lifecycle.initial(),
reasoningField: request.model.compatibility?.reasoningField,
reasoningTextObserved: false,
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
@@ -1208,185 +1208,6 @@ describe("OpenAI Chat route", () => {
}),
)
// Kimi's coding endpoint streams the full thinking through `reasoning_content`
// and a separate summary + encrypted blob through its own `reasoning_details`
// dialect. The stream-only `index` must not be echoed back.
it.effect("merges Kimi summary deltas by index and replays details without the streaming index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_content: "Let me" } }] },
{ choices: [{ delta: { reasoning_content: " think" } }] },
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: "Plan" }] } }] },
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: " tools" }] } }] },
{ choices: [{ delta: { reasoning_details: [{ index: 1, type: "encrypted", encrypted: "opaque" }] } }] },
{
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } },
],
},
},
],
},
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
),
),
),
)
const stored = [
{ index: 0, type: "summary", summary: "Plan tools" },
{ index: 1, type: "encrypted", encrypted: "opaque" },
]
expect(response.reasoning).toBe("Let me think")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning_content", reasoningDetails: stored },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } }],
reasoning_content: "Let me think",
reasoning_details: [
{ type: "summary", summary: "Plan tools" },
{ type: "encrypted", encrypted: "opaque" },
],
},
])
}),
)
it.effect("displays Kimi summaries and replays reasoning_content when no scalar reasoning streams", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: "Plan" }] } }] },
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: " tools" }] } }] },
{ choices: [{ delta: { reasoning_details: [{ index: 1, type: "encrypted", encrypted: "opaque" }] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("Plan tools")
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: "Hello",
reasoning_content: "Plan tools",
reasoning_details: [
{ type: "summary", summary: "Plan tools" },
{ type: "encrypted", encrypted: "opaque" },
],
},
])
}),
)
// Sessions persisted before the fix already hold Kimi details with `index`.
it.effect("strips the streaming index from previously stored Kimi details", () =>
Effect.gen(function* () {
const replay = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "thinking",
providerMetadata: {
openai: {
reasoningField: "reasoning_content",
reasoningDetails: [
{ index: 0, type: "summary", summary: "thinking" },
{ index: 1, type: "encrypted", encrypted: "opaque" },
],
},
},
},
]),
],
}),
)
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: "",
reasoning_content: "thinking",
reasoning_details: [
{ type: "summary", summary: "thinking" },
{ type: "encrypted", encrypted: "opaque" },
],
},
])
}),
)
it.effect("merges consecutive OpenRouter summary deltas and replays them unmodified", () =>
Effect.gen(function* () {
const merged = [
{ type: "reasoning.summary", summary: "Plan tools", format: "openai-responses-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 },
]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
choices: [
{
delta: {
reasoning_details: [
{ type: "reasoning.summary", summary: "Plan", format: "openai-responses-v1", index: 0 },
],
},
},
],
},
{
choices: [
{
delta: {
reasoning_details: [
{ type: "reasoning.summary", summary: " tools", format: "openai-responses-v1", index: 0 },
],
},
},
],
},
{ choices: [{ delta: { reasoning_details: [merged[1]] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("Plan tools")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: merged },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: merged }])
}),
)
it.effect("uses scalar display text for signature-only reasoning details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
@@ -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()
})
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.11",
"version": "2.0.10",
"description": "",
"type": "module",
"exports": {
@@ -10,7 +10,8 @@
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./ssh/schema": "./src/servers/ssh/schema.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
@@ -94,3 +95,4 @@
"tailwindcss": "4.3.3"
}
}
+15 -1
View File
@@ -3,7 +3,19 @@ import { checksum } from "@opencode/util/encode"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import { Persistence } from "@/runtime/persistence/schema"
import { FileSelection, SelectedLineRange } from "@/workspaces/files/types"
// Effect twins of workspaces/files/types until this module moves to plain codecs.
const SelectedLineRange = Persistence.struct({
start: Schema.Number,
end: Schema.Number,
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
})
const FileSelection = Persistence.struct({
startLine: Schema.Number,
startChar: Schema.Number,
endLine: Schema.Number,
endChar: Schema.Number,
})
const PartBase = {
content: Schema.String,
@@ -228,3 +240,5 @@ export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pi
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
+13 -7
View File
@@ -11,15 +11,15 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useDialog } from "@opencode/ui/context/dialog"
import { createResource } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
export const HomeServersSchema = Codec.struct({
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
})
export function createHomeProjectsController(home: HomeController) {
@@ -114,8 +114,13 @@ export function createHomeProjectsController(home: HomeController) {
extensions: ["json"],
},
async (file) => {
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
// Validating an imported file is the one place the shared Effect schema is needed here.
const [{ Schema }, { SessionTransfer }] = await Promise.all([
import("effect"),
import("@opencode/schema/session-transfer"),
])
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
)
const api = home.server.context(conn).sdk.api.session
const imported = await api.import({
@@ -184,3 +189,4 @@ export function createHomeProjectsController(home: HomeController) {
}
export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>
+4 -4
View File
@@ -65,14 +65,14 @@
}
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
When they overlap, center the conversation in the space that remains beside the summary. */
@container session-chat (min-width: 1320px) and (width < 1640px) {
Move only as far as needed; wider panels already have enough space in the margin. */
@container session-chat (min-width: 1320px) {
[data-slot="session-chat-panel"][data-summary-open="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
--session-summary-translate: -160px;
--session-summary-translate: min(0px, calc(50cqi - 820px));
&:dir(rtl) {
--session-summary-translate: 160px;
--session-summary-translate: max(0px, calc(820px - 50cqi));
}
}
}
+6 -6
View File
@@ -5,7 +5,7 @@ import { Show, Suspense, createMemo, createSignal, lazy, onMount } from "solid-j
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { debounce } from "@solid-primitives/scheduled"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
@@ -21,7 +21,6 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
import { SummaryPopover } from "@/session/summary/popover"
@@ -34,12 +33,12 @@ const NewSessionSummary = lazy(async () => {
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
export const WorkspaceOnboardingSchema = Persistence.struct({
used: Schema.Boolean,
export const WorkspaceOnboardingSchema = Codec.struct({
used: Codec.boolean,
})
export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
export const ProviderTipSchema = Codec.struct({
dismissedAt: Codec.number,
})
export const WorkspaceTipSchema = ProviderTipSchema
@@ -267,3 +266,4 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
</Show>
)
}
+11 -22
View File
@@ -1,7 +1,6 @@
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js"
import type { ProviderListResponse } from "@/runtime/server/types"
import { useIntegrations } from "./integrations"
@@ -53,34 +52,24 @@ export function useProviders(directory: Accessor<string | undefined>) {
.filter((integration) => popularProviderSet.has(integration.id))
.map((integration) => ({ id: integration.id, name: integration.name }))
const seen = new Set(catalog.map((integration) => integration.id))
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
Iterable.map((p) => ({ id: p.id, name: p.name })),
(v) => [...catalog, ...v],
)
const more = [...providers().all.values()]
.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id))
.map((p) => ({ id: p.id, name: p.name }))
return [...catalog, ...more]
},
connected: () => {
const connected = new Set(providers().connected)
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => connected.has(p.id)),
(v) => Array.from(v),
)
return [...providers().all.values()].filter((p) => connected.has(p.id))
},
paid: () => {
const connected = new Set(providers().connected)
const paid = [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
const paid = [...providers().all].filter(
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
)
return paid
},
}
}
+28 -30
View File
@@ -3,12 +3,11 @@ import { base64Encode } from "@opencode/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { useModels } from "@/providers/models/models"
import { useSettings } from "@/settings/model"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { hasCustomAgent, resolveAgent } from "./agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -18,46 +17,45 @@ import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
modelID: Schema.String,
variant: Schema.optional(Schema.String),
const ModelKeySchema = Codec.struct({
providerID: Codec.string,
modelID: Codec.string,
variant: Codec.optional(Codec.string),
})
export type ModelKey = typeof ModelKeySchema.Type
const ChoiceSchema = Schema.Struct({
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
const ChoiceSchema = Codec.struct({
model: Codec.lenientOptional(ModelKeySchema),
variant: Codec.lenientOptional(Codec.nullOr(Codec.string)),
})
const StateSchema = Schema.Struct({
const StateSchema = Codec.struct({
...ChoiceSchema.fields,
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
agent: Codec.lenientOptional(Codec.string),
choices: Codec.lenientOptional(Codec.record(ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Schema.Record(
Schema.String,
Schema.mutableKey(Persistence.fallback(Schema.UndefinedOr(StateSchema), () => undefined)),
const SessionsSchema = Codec.record(Codec.fallback(Codec.undefinedOr(StateSchema), () => undefined))
const Current = Codec.struct({ session: SessionsSchema })
const StoredSelection = Codec.struct(
{
session: Codec.lenientOptional(Codec.record(Codec.unknown)),
pick: Codec.lenientOptional(Codec.record(Codec.unknown)),
},
{ preserve: true },
)
const Current = Persistence.struct({ session: SessionsSchema })
export const ModelSelectionSchema = Persistence.migrate(
export const ModelSelectionSchema = Codec.migrate(
Current,
Schema.Struct({
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => ({
session:
value.session ??
Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
})),
encode: SchemaGetter.transform((value) => value),
Codec.transform(StoredSelection, {
decode: (value) => ({
...value,
session:
value.session ?? Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
}),
),
encode: (value) => value,
}),
)
const WORKSPACE_KEY = "__workspace__"
-4
View File
@@ -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",
+9 -16
View File
@@ -1,7 +1,7 @@
import { flatten, resolveTemplate, translator, type Flatten } from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Schema, SchemaGetter } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { createSimpleContext } from "@opencode/ui/context"
import {
I18nProvider,
@@ -12,7 +12,6 @@ import {
type UiPluralCategory,
} from "@opencode/ui/context/i18n"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import en from "@/runtime/i18n/en"
import { dict } from "@opencode/ui/i18n/en"
import {
@@ -56,14 +55,9 @@ function cookie(locale: Locale) {
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
const LocaleSchema = Schema.Literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Schema.Struct({
locale: Schema.String.pipe(
Schema.decodeTo(LocaleSchema, {
decode: SchemaGetter.transform(normalizeLocale),
encode: SchemaGetter.transform((locale) => locale),
}),
),
const LocaleSchema = Codec.literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Codec.struct({
locale: Codec.transform(Codec.string, { decode: normalizeLocale, encode: (locale) => locale }),
})
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
@@ -160,11 +154,11 @@ function detectLocale(): Locale {
}
export function normalizeLocale(value: string): Locale {
return Option.getOrElse(Schema.decodeUnknownOption(LocaleSchema)(value), () => "en")
return Codec.decodeOption(LocaleSchema, value) ?? "en"
}
export const languageSchema = Persistence.struct({
locale: StoredLocaleSchema.fields.locale,
export const languageSchema = Codec.struct({
locale: StoredLocaleSchema.fields.locale,
})
function readStoredLocale() {
@@ -172,9 +166,7 @@ function readStoredLocale() {
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = Schema.decodeUnknownOption(Schema.fromJsonString(StoredLocaleSchema))(raw)
if (Option.isNone(next)) return
return next.value.locale
return Codec.decodeOption(Codec.fromJsonString(StoredLocaleSchema), raw)?.locale
} catch {
return
}
@@ -296,3 +288,4 @@ export function UiI18nBridge(props: { children?: JSX.Element }) {
</I18nProvider>
)
}
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test"
import { Codec } from "./codec"
// Type-level checks: these compile only if inference matches what `typeof schema.Type` gave callers.
test("struct types infer optional and required fields", () => {
const s = Codec.struct({ id: Codec.string, tab: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
const value: typeof s.Type = { id: "x" }
const tab: string | undefined = value.tab
const n: number | undefined = value.n
const t = Codec.transform(s, { decode: (old) => old.tab ?? old.id, encode: (v) => ({ id: v }) })
const out: string | Codec.Invalid = t.decode({ id: "a" })
const onlyOptional = Codec.struct({ tab: Codec.optional(Codec.string) })
const empty: typeof onlyOptional.Type = {}
const maybe: string | undefined = empty.tab
const viaTransform = Codec.transform(onlyOptional, { decode: (old) => old.tab, encode: (tab) => ({ tab }) })
expect([tab, n, out, maybe, viaTransform.decode({})]).toEqual([undefined, undefined, "a", undefined, undefined])
})
@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test"
import { Codec } from "./codec"
describe("Codec", () => {
test("primitives reject the wrong shape and finite numbers only", () => {
expect(Codec.string.decode("a")).toBe("a")
expect(Codec.string.decode(1)).toBe(Codec.INVALID)
expect(Codec.number.decode(1.5)).toBe(1.5)
expect(Codec.number.decode(Number.NaN)).toBe(Codec.INVALID)
expect(Codec.nonNegativeInt.decode(-1)).toBe(Codec.INVALID)
expect(Codec.literals(["a", "b"]).decode("c")).toBe(Codec.INVALID)
expect(Codec.literal("x").decode("x")).toBe("x")
})
test("struct keeps optional fields absent and rejects invalid required ones", () => {
const codec = Codec.struct({ id: Codec.string, title: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
expect(codec.decode({ id: "1" })).toEqual({ id: "1" })
expect(codec.decode({ id: "1", title: "t", n: "bad" })).toEqual({ id: "1", title: "t" })
expect(codec.decode({ id: "1", title: 3 })).toBe(Codec.INVALID)
expect(codec.decode({ title: "t" })).toBe(Codec.INVALID)
expect(codec.decode([])).toBe(Codec.INVALID)
expect(codec.encode({ id: "1" })).toEqual({ id: "1" })
const value: typeof codec.Type = { id: "1", title: undefined }
expect(value.id).toBe("1")
})
test("lenient collections recover what they can", () => {
const items = Codec.lenientArray(Codec.struct({ id: Codec.string }))
expect(items.decode([{ id: "a" }, { id: 1 }, "x", { id: "b" }])).toEqual([{ id: "a" }, { id: "b" }])
expect(items.decode("nope")).toEqual([])
expect(Codec.array(Codec.string).decode(["a", 1])).toBe(Codec.INVALID)
const map = Codec.lenientRecord(Codec.boolean)
expect(map.decode({ a: true, b: "x" })).toEqual({})
expect(map.decode({ a: true })).toEqual({ a: true })
})
test("union, transform and fallback compose", () => {
const session = Codec.struct({ type: Codec.literal("session"), id: Codec.string })
const draft = Codec.struct({ type: Codec.literal("draft"), directory: Codec.string })
const tab = Codec.union([session, draft])
expect(tab.decode({ type: "draft", directory: "/x" })).toEqual({ type: "draft", directory: "/x" })
expect(tab.decode({ type: "other" })).toBe(Codec.INVALID)
const upper = Codec.transform(Codec.string, { decode: (s) => s.toUpperCase(), encode: (s) => s.toLowerCase() })
expect(upper.decode("ab")).toBe("AB")
expect(upper.encode("AB")).toBe("ab")
const safe = Codec.fallback(Codec.number, () => 7)
expect(safe.decode("x")).toBe(7)
expect(safe.decode(undefined)).toBe(7)
expect(safe.decode(2)).toBe(2)
})
test("brand constructs and decodes as its base", () => {
const Key = Codec.brand<"ServerConnection.Key">()
const key = Key.make("http://a")
expect(Key.decode(key)).toBe(key)
expect(Key.decode(3)).toBe(Codec.INVALID)
})
test("withInitial recovers field by field and merges new defaults", () => {
const layout = Codec.struct({
sidebar: Codec.struct({ opened: Codec.boolean, width: Codec.number }),
theme: Codec.lenientOptional(Codec.literals(["light", "dark"])),
})
const initial: typeof layout.Type = { sidebar: { opened: true, width: 240 } }
const codec = Codec.fromJsonString(Codec.withInitial(layout, initial))
expect(codec.decode(JSON.stringify({ sidebar: { opened: false, width: "wide" }, theme: "dark" }))).toEqual({
sidebar: { opened: false, width: 240 },
theme: "dark",
})
expect(codec.decode(JSON.stringify({ sidebar: 5 }))).toEqual(initial)
expect(codec.decode("{not json")).toBe(Codec.INVALID)
expect(JSON.parse(codec.encode({ sidebar: { opened: true, width: 1 } }))).toEqual({ sidebar: { opened: true, width: 1 } })
})
test("migrate reads the old shape first", () => {
const current = Codec.struct({ tabs: Codec.array(Codec.string) })
const previous = Codec.struct({ tab: Codec.optional(Codec.string) })
const read = Codec.transform(previous, {
decode: (old) => ({ tabs: old.tab ? [old.tab] : [] }),
encode: (value) => ({ tab: value.tabs[0] }),
})
const codec = Codec.withInitial(Codec.migrate(current, read), { tabs: [] })
expect(codec.decode({ tab: "a" })).toEqual({ tabs: ["a"] })
})
})
@@ -0,0 +1,366 @@
export * as Codec from "./codec"
// Plain codecs for persisted state. They replace Effect Schema in the renderer's initial module
// graph, where Effect's own module initialisation was the single largest startup cost that was not
// rendering. Semantics mirror the Persistence helpers: decoding never throws, `INVALID` marks a
// value that cannot be recovered, and the lenient combinators recover what they can.
export const INVALID: unique symbol = Symbol.for("opencode/persistence/codec/invalid")
export type Invalid = typeof INVALID
const tag: unique symbol = Symbol.for("opencode/persistence/codec")
export interface Of<T, E = unknown> {
readonly [tag]: true
/** Phantom: `typeof codec.Type` is the decoded type, as with Effect schemas. */
readonly Type: T
readonly Encoded: E
readonly optional?: boolean
decode(input: unknown): T | Invalid
encode(value: T): E
}
export type Any = Of<any, any>
export type Type<C extends Any> = C["Type"]
export function isCodec(value: unknown): value is Any {
return typeof value === "object" && value !== null && tag in value
}
export function make<T, E = unknown>(decode: (input: unknown) => T | Invalid, encode: (value: T) => E): Of<T, E> {
return { [tag]: true, decode, encode } as Of<T, E>
}
export function is<T>(codec: Of<T>, input: unknown): input is T {
return codec.decode(input) !== INVALID
}
export function decodeOption<T>(codec: Of<T>, input: unknown): T | undefined {
const value = codec.decode(input)
return value === INVALID ? undefined : value
}
export function decodeOrThrow<T>(codec: Of<T>, input: unknown): T {
const value = codec.decode(input)
if (value === INVALID) throw new Error("Value does not match its codec")
return value
}
/** Encodes and checks the result decodes, so an invalid in-memory value fails loudly instead of persisting. */
export function encodeOrThrow<T, E>(codec: Of<T, E>, value: T): E {
const encoded = codec.encode(value)
if (codec.decode(encoded) === INVALID) throw new Error("Value does not match its codec")
return encoded
}
const identity = <T>(value: T) => value
export const string: Of<string, string> = make((v) => (typeof v === "string" ? v : INVALID), identity)
export const boolean: Of<boolean, boolean> = make((v) => (typeof v === "boolean" ? v : INVALID), identity)
export const unknown: Of<unknown, unknown> = make((v) => v, identity)
/** Finite numbers only: NaN and infinities are not JSON and never valid state. */
export const number: Of<number, number> = make((v) => (typeof v === "number" && Number.isFinite(v) ? v : INVALID), identity)
export const int: Of<number, number> = make((v) => (typeof v === "number" && Number.isInteger(v) ? v : INVALID), identity)
export const nonNegativeInt: Of<number, number> = make(
(v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : INVALID),
identity,
)
export function literal<const L extends string | number | boolean | null>(value: L): Of<L, L> {
return make((v) => (v === value ? value : INVALID), identity)
}
export function literals<const L extends ReadonlyArray<string | number | boolean | null>>(values: L): Of<L[number], L[number]> {
const set = new Set<unknown>(values)
return make((v) => (set.has(v) ? (v as L[number]) : INVALID), identity)
}
/** A string carrying a nominal brand, with the constructor Effect's `Schema.brand` gave callers. */
export function brand<B extends string>(): Of<string & { readonly [K in B]: B }, string> & {
make(value: string): string & { readonly [K in B]: B }
} {
return Object.assign(make<string & { readonly [K in B]: B }, string>((v) => (typeof v === "string" ? (v as never) : INVALID), identity), {
make: (value: string) => value as never,
})
}
export function nullOr<T, E>(codec: Of<T, E>): Of<T | null, E | null> {
return make((v) => (v === null ? null : codec.decode(v)), (v) => (v === null ? null : codec.encode(v)))
}
export function undefinedOr<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> {
return make((v) => (v === undefined ? undefined : codec.decode(v)), (v) => (v === undefined ? undefined : codec.encode(v)))
}
/** A struct field that may be absent. Present but invalid values make the struct invalid. */
export function optional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
return { ...undefinedOr(codec), optional: true } as never
}
/** A struct field that may be absent, and whose invalid values are dropped rather than rejected. */
export function lenientOptional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
return {
...make<T | undefined, E | undefined>(
(v) => {
if (v === undefined) return undefined
const value = codec.decode(v)
return value === INVALID ? undefined : value
},
(v) => (v === undefined ? undefined : codec.encode(v)),
),
optional: true,
} as never
}
type Fields = Record<string, Any>
type OptionalKeys<F extends Fields> = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F]
type RequiredKeys<F extends Fields> = Exclude<keyof F, OptionalKeys<F>>
type Simplify<T> = { [K in keyof T]: T[K] } & {}
export type StructType<F extends Fields> = Simplify<
{ [K in RequiredKeys<F>]: F[K]["Type"] } & { [K in OptionalKeys<F>]?: F[K]["Type"] }
>
export type StructEncoded<F extends Fields> = Simplify<
{ [K in RequiredKeys<F>]: F[K]["Encoded"] } & { [K in OptionalKeys<F>]?: F[K]["Encoded"] }
>
export interface Struct<F extends Fields> extends Of<StructType<F>, StructEncoded<F>> {
readonly fields: F
}
// `preserve` keeps keys the struct does not declare, for migration shapes that only describe the
// fields they rewrite (Effect's `onExcessProperty: "preserve"`); the current schema then decides.
export function struct<const F extends Fields>(fields: F, options?: { preserve?: boolean }): Struct<F> {
const entries = Object.entries(fields)
return {
...make<StructType<F>, StructEncoded<F>>(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const record = input as Record<string, unknown>
const out: Record<string, unknown> = options?.preserve ? { ...record } : {}
for (const [key, codec] of entries) {
const present = Object.hasOwn(record, key)
if (!present && codec.optional) continue
const value = codec.decode(record[key])
if (value === INVALID) return INVALID
if (value !== undefined || present) out[key] = value
else delete out[key]
}
return out as StructType<F>
},
(value) => {
const out: Record<string, unknown> = {}
for (const [key, codec] of entries) {
const field = (value as Record<string, unknown>)[key]
if (field === undefined && !Object.hasOwn(value as object, key)) continue
out[key] = codec.encode(field)
}
return out as StructEncoded<F>
},
),
fields,
}
}
export function array<T, E>(codec: Of<T, E>): Of<T[], E[]> {
return make(
(input) => {
if (!Array.isArray(input)) return INVALID
const out: T[] = []
for (const item of input) {
const value = codec.decode(item)
if (value === INVALID) return INVALID
out.push(value)
}
return out
},
(value) => value.map((item) => codec.encode(item)),
)
}
/** Keeps the items that decode and drops the rest, like `Persistence.array`. */
export function lenientArray<T, E>(codec: Of<T, E>): Of<T[], E[]> {
return make(
(input) => {
if (!Array.isArray(input)) return []
return input.flatMap((item) => {
const value = codec.decode(item)
return value === INVALID ? [] : [value]
})
},
(value) => value.map((item) => codec.encode(item)),
)
}
export function record<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
return make(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const out: Record<string, T> = {}
for (const [key, item] of Object.entries(input)) {
const value = codec.decode(item)
if (value === INVALID) return INVALID
out[key] = value
}
return out
},
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
)
}
/** A record that drops entries whose values are invalid, the replacement for `catchDecoding` to none. */
export function sparseRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
return make(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const out: Record<string, T> = {}
for (const [key, item] of Object.entries(input)) {
const value = codec.decode(item)
if (value !== INVALID) out[key] = value
}
return out
},
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
)
}
/** An invalid record becomes empty rather than failing the whole store, like `Persistence.record`. */
export function lenientRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
const strict = record(codec)
return make(
(input) => {
const value = strict.decode(input)
return value === INVALID ? {} : value
},
strict.encode,
)
}
export function union<const C extends ReadonlyArray<Any>>(codecs: C): Of<C[number]["Type"], C[number]["Encoded"]> {
return make(
(input) => {
for (const codec of codecs) {
const value = codec.decode(input)
if (value !== INVALID) return value
}
return INVALID
},
(value) => {
// Encode with the first member that accepts the value's shape; members are disjoint in practice.
for (const codec of codecs) if (codec.decode(value) !== INVALID) return codec.encode(value)
return value as C[number]["Encoded"]
},
)
}
/** Maps a decoded value into another shape, the replacement for `decodeTo` + `SchemaGetter.transform`. */
export function transform<T, E, T2>(
codec: Of<T, E>,
options: { decode: (value: T) => T2; encode: (value: T2) => T },
): Of<T2, E> {
return make(
(input) => {
const value = codec.decode(input)
return value === INVALID ? INVALID : options.decode(value)
},
(value) => codec.encode(options.encode(value)),
)
}
/** Decodes with `source`, maps, then validates with `target`: Effect's `decodeTo` with a transform. */
export function decodeTo<T, E, T2, E2>(
source: Of<T, E>,
target: Of<T2, E2>,
options: { decode: (value: T) => E2; encode: (value: T2) => T },
): Of<T2, E> {
return make(
(input) => {
const value = source.decode(input)
return value === INVALID ? INVALID : target.decode(options.decode(value))
},
(value) => source.encode(options.encode(value)),
)
}
/** Invalid and missing values become `value()`, like `Persistence.fallback`. */
export function fallback<T, E>(codec: Of<T, E>, value: () => NoInfer<T>): Of<T, E> {
return make(
(input) => {
if (input === undefined) return value()
const decoded = codec.decode(input)
return decoded === INVALID ? value() : decoded
},
codec.encode,
)
}
export function fromJsonString<T, E>(codec: Of<T, E>): Of<T, string> {
return make(
(input) => {
if (typeof input !== "string") return INVALID
try {
return codec.decode(JSON.parse(input))
} catch {
return INVALID
}
},
(value) => JSON.stringify(codec.encode(value)),
)
}
export type Decoder = Pick<Of<unknown>, "decode">
export type Migrated<C extends Any> = { readonly current: C; readonly read: Decoder }
/** Older stored shapes go through `read` first; `current` describes what the store holds today. */
export function migrate<C extends Any>(current: C, read: Decoder): Migrated<C> {
return { current, read }
}
function isMigrated<C extends Any>(definition: C | Migrated<C>): definition is Migrated<C> {
return !isCodec(definition) && "current" in definition
}
// Stored values recover field by field against the initial value: an object's valid fields are
// kept, invalid or missing ones take their initial counterpart, and the result is merged over the
// initial so new fields appear with their defaults. Mirrors `Persistence.withInitial`.
export function withInitial<C extends Any>(definition: C | Migrated<C>, initial: Type<C>): Of<Type<C>, unknown> {
const codec = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition) ? definition.read : unknown
return make(
(input) => {
const stored = read.decode(input)
if (stored === INVALID) return INVALID
return merge(initial, recover(codec, stored, initial))
},
(value) => codec.encode(value),
)
}
function recover(codec: Any, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if ("fields" in codec && isObject(value)) {
const fields = (codec as Struct<Fields>).fields
return Object.fromEntries(
Object.entries(fields).flatMap(([name, field]) => {
const defaults = isObject(initial) ? initial[name] : undefined
const next = recover(field, value[name], defaults)
if (next === undefined && !Object.hasOwn(value, name) && defaults === undefined) return []
return [[name, next]]
}),
)
}
const decoded = codec.decode(value)
return decoded === INVALID ? initial : decoded
}
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial
if (!isObject(initial) || !isObject(value)) return value
return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -3,6 +3,7 @@ import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
import { languageSchema } from "@/runtime/i18n/language"
import { HomeServersSchema } from "@/home/projects/controller"
@@ -10,9 +11,9 @@ import { ModelProvidersSchema } from "@/settings/models/models"
describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
const onboarding = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(WorkspaceOnboardingSchema, { used: false }), input))
const tip = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ProviderTipSchema, { dismissedAt: 0 }), input))
const workspaceTip = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }), input))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
@@ -25,7 +26,7 @@ describe("persisted consumer schemas", () => {
test("collapse records recover malformed entries without losing valid siblings", () => {
for (const schema of [HomeServersSchema, ModelProvidersSchema]) {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(schema, { collapsed: {} }))
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(schema, { collapsed: {} }), input)
expect(decode({})).toEqual({ collapsed: {} })
expect(decode({ collapsed: [] })).toEqual({ collapsed: {} })
expect(decode({ collapsed: { open: false, closed: true, invalid: "false" } })).toEqual({
@@ -35,21 +36,19 @@ describe("persisted consumer schemas", () => {
})
test("model selection migrates legacy picks and omits workspace state", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), input)
expect(decode({})).toEqual({ session: {} })
const state = decode({ pick: { __workspace__: { agent: "plan" }, session1: { agent: "build" } } })
expect(state.session.session1?.agent).toBe("build")
expect(state.session.__workspace__).toBeUndefined()
const encoded = Schema.encodeSync(
Schema.fromJsonString(Persistence.withInitial(ModelSelectionSchema, { session: {} })),
)(state)
const encoded = Codec.fromJsonString(Codec.withInitial(ModelSelectionSchema, { session: {} })).encode(state)
expect(JSON.parse(encoded)).toEqual({ session: { session1: { agent: "build" } } })
expect(decode(JSON.parse(encoded))).toEqual(state)
})
test("current model selections take precedence over legacy picks", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
session: {},
pick: { session1: { agent: "plan" } },
}),
@@ -57,7 +56,7 @@ describe("persisted consumer schemas", () => {
})
test("model selection validates nested model keys and preserves explicit null variants", () => {
const state = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
const state = Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
session: {
good: { agent: "build", model: { providerID: "provider", modelID: "model", variant: "high" }, variant: null },
partial: { agent: "plan", model: { providerID: "provider", modelID: 42 }, variant: false },
@@ -76,7 +75,7 @@ describe("persisted consumer schemas", () => {
})
test("file views validate scroll positions and line sides independently", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(FileViewsSchema, { file: {} }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(FileViewsSchema, { file: {} }), input))
expect(decode({})).toEqual({ file: {} })
const state = decode({
file: {
@@ -103,7 +102,7 @@ describe("persisted consumer schemas", () => {
})
test("language preserves runtime defaults and normalizes unsupported locales to English", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(languageSchema, { locale: "fr" }))
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(languageSchema, { locale: "fr" }), input)
expect(decode({})).toEqual({ locale: "fr" })
expect(decode({ locale: undefined })).toEqual({ locale: "fr" })
expect(decode({ locale: 42 })).toEqual({ locale: "fr" })
@@ -111,3 +110,6 @@ describe("persisted consumer schemas", () => {
expect(decode({ locale: "ar" })).toEqual({ locale: "ar" })
})
})
@@ -1,5 +1,5 @@
import type { AsyncStorage } from "@solid-primitives/storage"
import { Option, Schema } from "effect"
import { Codec } from "./codec"
export type BlobReference = { id: string; url: string }
@@ -312,12 +312,12 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
getItem: async (key) => {
const value = await driver.get(key)
if (value === null) return null
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed.value), grace)
return JSON.stringify(await decode(parsed.value))
const parsed = Codec.fromJsonString(Codec.unknown).decode(value)
// Let the owning persistence codec apply its invalid-document policy.
if (parsed === Codec.INVALID) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed), grace)
return JSON.stringify(await decode(parsed))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
setDocument,
@@ -455,3 +455,4 @@ export async function blobDataUrl(blob: BlobReference, mime: string) {
export function createLegacyBlobReference(dataUrl: string): BlobReference {
return { id: dataUrl, url: dataUrl }
}
+44 -11
View File
@@ -6,6 +6,7 @@ import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Option, Schema } from "effect"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Codec } from "./codec"
import { persistStore } from "./persist"
import { Persistence } from "./schema"
@@ -472,25 +473,57 @@ export function removePersisted(
}
}
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
type Definition<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any> =
| S
| Persistence.Migrated<Extract<S, Schema.ConstraintCodec<object, unknown>>>
| Codec.Migrated<Extract<S, Codec.Any>>
// Persisted stores are moving from Effect Schema to the plain codecs in ./codec so the renderer
// stops paying for Effect at startup; both are accepted while the migration is underway.
function serializer<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
definition: Definition<S>,
initial: S["Type"],
) {
if (Codec.isCodec(definition) || (!("current" in definition) ? false : Codec.isCodec(definition.current))) {
const codec = Codec.withInitial(definition as Codec.Any | Codec.Migrated<Codec.Any>, initial)
const json = Codec.fromJsonString(codec)
return {
decode: (raw: string) => Codec.decodeOption(json, raw) as S["Type"] | undefined,
deserialize: (raw: unknown) => Codec.decodeOrThrow(json, raw) as S["Type"],
serialize: (value: S["Type"]) => Codec.encodeOrThrow(json, value),
encode: (value: S["Type"]) => Codec.encodeOrThrow(codec, value),
initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"],
}
}
const schema = definition as Schema.ConstraintCodec<object, unknown> | Persistence.Migrated<Schema.ConstraintCodec<object, unknown>>
const initialized = Persistence.withInitial(schema, initial as object)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
return {
decode: (raw: string) => Option.getOrUndefined(decode(raw)) as S["Type"] | undefined,
deserialize: Schema.decodeUnknownSync(json) as (raw: unknown) => S["Type"],
serialize: Schema.encodeSync(json) as (value: S["Type"]) => string,
encode: Schema.encodeSync(initialized) as (value: S["Type"]) => unknown,
initial: Schema.decodeUnknownSync(Schema.toType(initialized))(initial as object) as S["Type"],
}
}
export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
target: string | PersistTarget,
schema: S | Persistence.Migrated<S>,
schema: Definition<S>,
initial: NoInfer<S["Type"]>,
platformOverride?: Platform,
): PersistedWithReady<S["Type"]> {
const platform = platformOverride ?? usePlatform()
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
const initialized = Persistence.withInitial(schema, initial)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
const encode = Schema.encodeSync(initialized)
const serialize = Schema.encodeSync(json)
const codec = serializer<S>(schema, initial)
const { encode, serialize } = codec
const normalize = (raw: string) => {
const value = decode(raw)
if (Option.isSome(value)) return serialize(value.value)
const value = codec.decode(raw)
if (value !== undefined) return serialize(value)
}
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
const store = createStore<S["Type"]>(codec.initial)
const isDesktop = platform.platform === "desktop" && !!platform.storage
const draft = config.draft ? platform.draftStore : undefined
const prefix = `${config.storage ?? "default"}:`
@@ -602,7 +635,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
name: config.key,
storage,
serialize,
deserialize: Schema.decodeUnknownSync(json),
deserialize: codec.deserialize,
sync: channel ? messageSync(channel) : undefined,
// Drafts take the encoded document itself so large text is externalized without the store
// re-parsing the serialized form on every save.
+11
View File
@@ -0,0 +1,11 @@
import type { Brand } from "effect"
import { Codec } from "@/runtime/persistence/codec"
// The server key's brand is shared with the Effect schema in ./persistence.ts (type only, so this
// module loads nothing of Effect), letting stores port to plain codecs one at a time.
export type ServerKey = string & Brand.Brand<"ServerConnection.Key">
export const ServerKey: Codec.Of<ServerKey, string> & { make(value: string): ServerKey } = Object.assign(
Codec.make<ServerKey, string>((v) => (typeof v === "string" ? (v as ServerKey) : Codec.INVALID), (v) => v),
{ make: (value: string) => value as ServerKey },
)
@@ -1,15 +1,14 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { IconState, ModelState, ProjectState, VcsState, serverState } from "./persistence"
import { createRoot } from "solid-js"
import { isServer } from "solid-js/web"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
const initial = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
function serverSchema(canonical?: () => string | undefined) {
return Persistence.withInitial(serverState(canonical), initial)
return Codec.withInitial(serverState(canonical), initial)
}
describe("server persistence schema", () => {
@@ -29,7 +28,7 @@ describe("server persistence schema", () => {
],
projects: { local: [{ worktree: "/project", expanded: true }] },
}
const state = Schema.decodeUnknownSync(schema)(input)
const state = Codec.decodeOrThrow(schema, input)
expect(state).toEqual({
list: [
{ type: "http", http: { url: "http://localhost:4096" } },
@@ -48,13 +47,13 @@ describe("server persistence schema", () => {
recentlyClosed: {},
})
expect(input.list[1]).toHaveProperty("username", "legacy")
const encoded = Schema.encodeSync(schema)(state)
const encoded = schema.encode(state)
expect(encoded).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(encoded)).toEqual(state)
expect(Codec.decodeOrThrow(schema, encoded)).toEqual(state)
})
test("defaults missing or malformed fields and drops invalid entries independently", () => {
const decode = Schema.decodeUnknownSync(serverSchema())
const decode = ((input: unknown) => Codec.decodeOrThrow(serverSchema(), input))
const empty = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
expect(decode({})).toEqual(empty)
expect(decode({ list: null, hidden: [], projects: false, lastProject: 1, recentlyClosed: "bad" })).toEqual(empty)
@@ -74,7 +73,7 @@ describe("server persistence schema", () => {
test("moves canonical project buckets without changing server keys or unrelated scopes", () => {
const schema = serverSchema(() => "https://opencode.example.com")
const state = Schema.decodeUnknownSync(schema)({
const state = Codec.decodeOrThrow(schema, {
list: ["https://opencode.example.com"],
hidden: { "https://opencode.example.com": true },
projects: {
@@ -100,14 +99,14 @@ describe("server persistence schema", () => {
expect(state.list[0]?.http.url).toBe("https://opencode.example.com")
expect(state.hidden).toEqual({ "https://opencode.example.com": true })
expect(state.recentlyClosed).toEqual({ local: ["/closed"], "https://opencode.example.com": ["/old-closed"] })
expect(Schema.encodeSync(schema)(state)).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(state)).toEqual(state)
expect(schema.encode(state)).toEqual(state)
expect(Codec.decodeOrThrow(schema, state)).toEqual(state)
})
test("reads the latest canonical local prop on each decode", () => {
const props: { canonicalLocalServer?: string } = {}
const schema = serverSchema(() => props.canonicalLocalServer)
const decode = Schema.decodeUnknownSync(schema)
const decode = ((input: unknown) => Codec.decodeOrThrow(schema, input))
const input = {
projects: { remote: [{ worktree: "/project", expanded: true }] },
lastProject: { remote: "/project" },
@@ -122,7 +121,7 @@ describe("server persistence schema", () => {
})
test("migrates a last project without a project list", () => {
expect(Schema.decodeUnknownSync(serverSchema(() => "remote"))({ lastProject: { remote: "/project" } })).toEqual({
expect(Codec.decodeOrThrow(serverSchema(() => "remote"), { lastProject: { remote: "/project" } })).toEqual({
list: [],
hidden: {},
projects: {},
@@ -134,7 +133,7 @@ describe("server persistence schema", () => {
describe("model persistence schema", () => {
test("defaults missing state and keeps valid entries beside malformed entries", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelState, { user: [], recent: [], variant: {} }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelState, { user: [], recent: [], variant: {} }), input))
expect(decode({})).toEqual({ user: [], recent: [], variant: {} })
expect(decode({ user: null, recent: 1, variant: [] })).toEqual({ user: [], recent: [], variant: {} })
const state = decode({
@@ -155,24 +154,24 @@ describe("model persistence schema", () => {
recent: [{ providerID: "provider", modelID: "model" }],
variant: { model: "high" },
})
expect(Schema.encodeSync(ModelState)(state)).toEqual(state)
expect(ModelState.encode(state)).toEqual(state)
})
})
describe("directory cache schemas", () => {
test("defaults missing and malformed VCS caches but retains optional branch metadata", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(VcsState, { value: undefined }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(VcsState, { value: undefined }), input))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: { branch: 1 } })).toEqual({ value: undefined })
expect(decode({ value: { default_branch: "main" } })).toEqual({ value: { default_branch: "main" } })
const state = decode({ value: { branch: "feature", default_branch: "main", obsolete: true } })
expect(state).toEqual({ value: { branch: "feature", default_branch: "main" } })
expect(Schema.encodeSync(VcsState)(state)).toEqual(state)
expect(VcsState.encode(state)).toEqual(state)
})
test("validates project name, icon overrides and startup commands", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ProjectState, { value: undefined }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ProjectState, { value: undefined }), input))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: [] })).toEqual({ value: undefined })
expect(decode({ value: { icon: { override: 1 } } })).toEqual({ value: undefined })
@@ -185,7 +184,7 @@ describe("directory cache schemas", () => {
commands: { start: "bun dev" },
},
})
expect(Schema.encodeSync(ProjectState)(state)).toEqual(state)
expect(ProjectState.encode(state)).toEqual(state)
expect(state.value).toEqual({
name: "Project",
icon: { override: "data:image/png;base64,abc", color: "blue" },
@@ -194,12 +193,12 @@ describe("directory cache schemas", () => {
})
test("validates optional icon strings", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(IconState, { value: undefined }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(IconState, { value: undefined }), input))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: 42 })).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: "" })).toEqual({ value: "" })
expect(Schema.encodeSync(IconState)(decode({ value: "data:image/png;base64,abc" }))).toEqual({
expect(IconState.encode(decode({ value: "data:image/png;base64,abc" }))).toEqual({
value: "data:image/png;base64,abc",
})
})
@@ -255,7 +254,7 @@ test.skipIf(isServer)(
const stored = values.get("opencode.global.dat:server")
expect(stored).toBeDefined()
if (!stored) throw new Error("server state was not written")
const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(serverSchema()))(stored)
const decoded = Codec.decodeOrThrow(Codec.fromJsonString(serverSchema()), stored)
expect(decoded.projects.local).toEqual([{ worktree: "/project", expanded: true }])
expect(stored).not.toContain("username")
expect(decoded.list).toEqual(root.state[0].list)
@@ -264,3 +263,4 @@ test.skipIf(isServer)(
}
},
)
+97 -106
View File
@@ -1,136 +1,127 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { ServerKey } from "./key"
export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key"))
export { ServerKey }
export const ServerHttpBase = Persistence.struct({
url: Schema.String,
password: Schema.optional(Schema.String),
export const ServerHttpBase = Codec.struct({
url: Codec.string,
password: Codec.optional(Codec.string),
})
export const ServerHttp = Persistence.struct({
type: Schema.Literal("http"),
export const ServerHttp = Codec.struct({
type: Codec.literal("http"),
http: ServerHttpBase,
authToken: Schema.optional(Schema.Boolean),
displayName: Schema.optional(Schema.String),
label: Schema.optional(Schema.String),
authToken: Codec.optional(Codec.boolean),
displayName: Codec.optional(Codec.string),
label: Codec.optional(Codec.string),
})
const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe(
Schema.decodeTo(ServerHttp, {
decode: SchemaGetter.transform((value) => {
if (typeof value === "string") return { type: "http", http: { url: value } }
if ("http" in value) return value
return { type: "http", http: value }
}),
encode: SchemaGetter.transform((value) => value),
}),
)
const ProjectList = Persistence.array(
Persistence.struct({
worktree: Schema.String,
expanded: Persistence.fallback(Schema.Boolean, () => true),
}),
)
const Projects = Persistence.record(ProjectList)
const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))))
const State = Persistence.struct({
list: Persistence.array(StoredServer),
hidden: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.Boolean.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
projects: Schema.Record(Schema.String, Schema.mutableKey(ProjectList)),
lastProject: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
recentlyClosed: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(Schema.String))),
// Servers were stored as a URL string, then as the HTTP block alone, before the current shape.
const StoredServer = Codec.decodeTo(Codec.union([ServerHttp, ServerHttpBase, Codec.string]), ServerHttp, {
decode: (value) => {
if (typeof value === "string") return { type: "http" as const, http: { url: value } }
if ("http" in value) return value
return { type: "http" as const, http: value }
},
encode: (value) => value,
})
const ProjectList = Codec.lenientArray(
Codec.struct({
worktree: Codec.string,
expanded: Codec.fallback(Codec.boolean, () => true),
}),
)
const Projects = Codec.lenientRecord(ProjectList)
const LastProject = Codec.fallback(Codec.sparseRecord(Codec.string), () => ({}))
const State = Codec.struct({
list: Codec.lenientArray(StoredServer),
hidden: Codec.sparseRecord(Codec.boolean),
projects: Codec.record(ProjectList),
lastProject: Codec.sparseRecord(Codec.string),
recentlyClosed: Codec.record(Codec.lenientArray(Codec.string)),
})
const StoredState = Codec.struct({ projects: Projects, lastProject: LastProject }, { preserve: true })
// Projects and last-opened entries recorded under the canonical local server's URL move under
// "local" when that URL is known, so they survive the server changing address.
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
return Persistence.migrate(
return Codec.migrate(
State,
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
Codec.transform(StoredState, {
decode: (value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
projects.local = [
...local,
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
}),
encode: SchemaGetter.transform((value) => value),
}),
),
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
projects.local = [
...local,
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
},
encode: (value) => value,
}),
)
}
export const ModelState = Persistence.struct({
user: Persistence.array(
Persistence.struct({
providerID: Schema.String,
modelID: Schema.String,
visibility: Schema.Literals(["show", "hide"]),
favorite: Schema.optional(Schema.Boolean),
export const ModelState = Codec.struct({
user: Codec.lenientArray(
Codec.struct({
providerID: Codec.string,
modelID: Codec.string,
visibility: Codec.literals(["show", "hide"]),
favorite: Codec.optional(Codec.boolean),
}),
),
recent: Persistence.array(Persistence.struct({ providerID: Schema.String, modelID: Schema.String })),
variant: Schema.Record(
Schema.String,
Schema.mutableKey(
Schema.UndefinedOr(Schema.String).pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))),
),
),
recent: Codec.lenientArray(Codec.struct({ providerID: Codec.string, modelID: Codec.string })),
variant: Codec.sparseRecord(Codec.undefinedOr(Codec.string)),
})
export const VcsState = Persistence.struct({
value: Schema.optional(
Persistence.struct({
branch: Schema.optional(Schema.String),
default_branch: Schema.optional(Schema.String),
export const VcsState = Codec.struct({
value: Codec.optional(
Codec.struct({
branch: Codec.optional(Codec.string),
default_branch: Codec.optional(Codec.string),
}),
),
})
const ProjectMeta = Persistence.struct({
name: Schema.optional(Schema.String),
icon: Schema.optional(
Persistence.struct({
override: Schema.optional(Schema.String),
color: Schema.optional(Schema.String),
const ProjectMeta = Codec.struct({
name: Codec.optional(Codec.string),
icon: Codec.optional(
Codec.struct({
override: Codec.optional(Codec.string),
color: Codec.optional(Codec.string),
}),
),
commands: Schema.optional(Persistence.struct({ start: Schema.optional(Schema.String) })),
commands: Codec.optional(Codec.struct({ start: Codec.optional(Codec.string) })),
})
export const ProjectState = Persistence.struct({
value: Schema.optional(ProjectMeta),
export const ProjectState = Codec.struct({
value: Codec.optional(ProjectMeta),
})
export const IconState = Persistence.struct({
value: Schema.optional(Schema.String),
export const IconState = Codec.struct({
value: Codec.optional(Codec.string),
})
@@ -1,13 +1,12 @@
import { describe, expect, test } from "bun:test"
import { canRemoveServer, createServerProjects, resolveServerList, ServerConnection } from "./registry"
import { Schema } from "effect"
import { serverState } from "./persistence"
import { createStore } from "solid-js/store"
import { ServerScope } from "./scope"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
function serverSchema() {
return Persistence.withInitial(serverState(), {
return Codec.withInitial(serverState(), {
list: [],
hidden: {},
projects: {},
@@ -19,7 +18,7 @@ function serverSchema() {
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
const list = resolveServerList({
stored: Schema.decodeUnknownSync(serverSchema())({ list: [{ url: "https://server.example.test" }] }).list,
stored: Codec.decodeOrThrow(serverSchema(), { list: [{ url: "https://server.example.test" }] }).list,
props: [
{
type: "http",
@@ -44,7 +43,7 @@ describe("resolveServerList", () => {
test("keeps persisted credentials when startup has no auth_token", () => {
const list = resolveServerList({
stored: Schema.decodeUnknownSync(serverSchema())({
stored: Codec.decodeOrThrow(serverSchema(), {
list: [{ url: "https://server.example.test", password: "saved" }],
}).list,
props: [{ type: "http", http: { url: "https://server.example.test" } }],
@@ -77,7 +76,7 @@ test("treats WSL sidecars as remote server connections", () => {
})
test("keeps exact persisted server identities and prevents removing provided servers", () => {
const stored = Schema.decodeUnknownSync(serverSchema())({
const stored = Codec.decodeOrThrow(serverSchema(), {
list: ["http://localhost:4096", "http://localhost:4096/", "http://127.0.0.1:4096"],
}).list
expect(resolveServerList({ stored }).map((server) => String(ServerConnection.key(server)))).toEqual([
@@ -91,7 +90,7 @@ test("keeps exact persisted server identities and prevents removing provided ser
})
test("project actions update schema-derived state and follow dynamic server scopes", () => {
const [store, setStore] = createStore(Schema.decodeUnknownSync(serverSchema())({}))
const [store, setStore] = createStore(Codec.decodeOrThrow(serverSchema(), {}))
const props: { server: ServerConnection.Key; canonicalLocalServer?: ServerConnection.Key } = {
server: ServerConnection.Key.make("https://remote.example"),
}
@@ -115,3 +114,4 @@ test("project actions update schema-derived state and follow dynamic server scop
expect(store.projects.local).toEqual([{ worktree: "/local", expanded: true }])
expect(store.projects[props.server]).toEqual([{ worktree: "/remote", expanded: false }])
})
+13 -12
View File
@@ -1,12 +1,12 @@
import { Option, Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
const pairing = Codec.fromJsonString(
Codec.struct({
urls: Codec.array(Codec.string),
username: Codec.literal("opencode"),
password: Codec.string,
}),
)
export function serverAddress(value: string) {
@@ -20,9 +20,10 @@ export function serverAddress(value: string) {
}
export function decodePairingCode(value: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.value.password }
const result = Codec.decodeOption(pairing, value)
if (!result) return
const urls = [...new Set(result.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.password }
}
+44 -51
View File
@@ -1,4 +1,3 @@
import { Effect, Fiber } from "effect"
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { SshConfig, SshItem, SshPlatform } from "./types"
@@ -24,7 +23,8 @@ export function createSshController(input: {
| undefined
>
>({})
const tasks = new Map<string, Fiber.Fiber<void>>()
// One in-flight request per server; cancelling settles it immediately and ignores its outcome.
const tasks = new Map<string, { cancelled: boolean }>()
const item = (id: string) => input.items().find((item) => item.config.id === id)
const settle = (id: string) => {
const attempt = attempts[id]
@@ -33,31 +33,32 @@ export function createSshController(input: {
setAttempts(id, { active: false, onConnected: undefined })
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
}
const run = (id: string, effect: Effect.Effect<unknown, unknown>) => {
setAttempts(id, { submitting: true, error: false })
tasks.set(
id,
Effect.runFork(
effect.pipe(
Effect.asVoid,
Effect.catch(() =>
Effect.sync(() => {
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
}),
),
Effect.ensuring(
Effect.sync(() => {
tasks.delete(id)
setAttempts(id, "submitting", false)
}),
),
),
),
)
}
onCleanup(() => {
Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
const finish = (id: string, task: { cancelled: boolean }) => {
if (task.cancelled) return
task.cancelled = true
if (tasks.get(id) === task) tasks.delete(id)
setAttempts(id, "submitting", false)
}
const interrupt = (id: string) => {
const task = tasks.get(id)
if (task) finish(id, task)
}
const run = (id: string, work: () => Promise<unknown>) => {
setAttempts(id, { submitting: true, error: false })
const task = { cancelled: false }
tasks.set(id, task)
work().then(
() => finish(id, task),
() => {
if (task.cancelled) return
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
finish(id, task)
},
)
}
onCleanup(() => {
for (const id of [...tasks.keys()]) interrupt(id)
})
createEffect(() => {
for (const item of input.items()) {
@@ -114,40 +115,32 @@ export function createSshController(input: {
error: false,
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
})
run(
config.id,
Effect.gen(function* () {
yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
// Observe admission before treating an older disconnected snapshot as cancellation.
yield* Effect.tryPromise(input.refresh)
}),
)
run(config.id, async () => {
await api.start({ ...config, replace: options?.replace })
// Observe admission before treating an older disconnected snapshot as cancellation.
await input.refresh()
})
},
respond: (id: string, prompt: string, value: string) => {
const api = input.api
if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
setAttempts(id, "answered", prompt)
run(
id,
Effect.tryPromise(() => api.respond(id, prompt, value)),
)
run(id, () => api.respond(id, prompt, value))
},
cancel: (id: string) => {
const task = tasks.get(id)
const api = input.api
Effect.runFork(
Effect.gen(function* () {
if (task) yield* Fiber.interrupt(task)
setAttempts(id, undefined)
if (!api) return
yield* Effect.tryPromise(() => api.cancel(id))
if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
}).pipe(Effect.ignore),
)
cancel: (id: string) => {
const api = input.api
interrupt(id)
setAttempts(id, undefined)
if (!api) return
void api
.cancel(id)
.then(() => (item(id)?.saved ? undefined : api.forget(id)))
.catch(() => undefined)
},
restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
disconnect: (id: string) => input.api?.disconnect(id),
forget: (id: string) => input.api?.forget(id),
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Schema } from "effect"
// The SSH state contract between the desktop main process and its renderer. Only the main process
// needs the schemas (its RPC server validates with them); the renderer imports the types from ./types.
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
+8 -60
View File
@@ -1,63 +1,9 @@
import { Schema } from "effect"
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
import type { SshConfig, SshHttp, SshItem, SshStart, SshState } from "./schema"
export type { SshConfig, SshHttp, SshItem, SshStart, SshState }
export type SshPlatform = {
getState(): Promise<SshState>
subscribe(callback: (state: SshState) => void): () => void
@@ -70,3 +16,5 @@ export type SshPlatform = {
forget(id: string): Promise<void>
openConfig(): Promise<void>
}
+10 -63
View File
@@ -20,18 +20,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const command = useCommand()
const state = props.browser.active
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
const failed = () => !!state()?.loadError
const registration = props.browser.registration
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
submitted: false,
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
navigating: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
const empty = () => !address() && !state()?.loading && !store.navigating
let surface: HTMLDivElement | undefined
let addressDisplay: HTMLDivElement | undefined
let frame: number | undefined
@@ -48,7 +43,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
title: language.t("command.browser.reload"),
category: language.t("command.category.view"),
keybind: "f5",
disabled: !props.visible || !address(),
disabled: !props.visible || !state(),
onSelect: () => {
const tab = state()
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
@@ -76,9 +71,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -114,25 +107,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(on([() => state()?.id, address], () => !store.editing && setStore("address", address())))
// Any reported movement, including a rejected or blocked request, ends the submitted navigation.
createEffect(
on(
[() => state()?.id, () => state()?.generation, () => state()?.loading, () => props.browser.error()],
() => setStore("navigating", false),
{ defer: true },
),
)
// A blocked or rejected submission leaves the page where it was; show that page's URL again.
createEffect(
on(
() => props.browser.error(),
(error) => {
if (error && !store.editing) setStore("address", address())
},
{ defer: true },
),
)
createEffect(() => !store.editing && setStore("address", address()))
createEffect(
on(registration, (current) => {
// Session routes can change before this pane unmounts. Hide the registration
@@ -148,8 +123,6 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
() => store.visible,
() => props.visible,
() => state()?.id,
empty,
failed,
registration,
],
() => {
@@ -184,7 +157,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-3 border-b border-v2-border-border-muted">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
<For each={["back", "forward"] as const}>
{(direction) => (
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
@@ -220,7 +193,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
>
<IconButton
{...button}
disabled={!state()?.loading && !address()}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
@@ -239,13 +212,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
onSubmit={(event) => {
event.preventDefault()
const tab = state()
const url = store.address.trim()
if (!tab) return
if (url || failed()) {
setStore({ submitted: true, address: url, navigating: true })
props.browser.command({ type: "navigate", tabID: tab.id, url: url || "about:blank" })
}
event.currentTarget.querySelector("input")?.blur()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
}}
>
<input
@@ -256,14 +224,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={(event) => {
setStore("editing", true)
event.currentTarget.select()
}}
onClick={(event) => event.currentTarget.select()}
onBlur={() =>
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: address() })}
onInput={(event) => setStore("address", event.currentTarget.value)}
onScroll={(event) => {
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
@@ -281,27 +243,12 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
</div>
</form>
</div>
<Show when={props.browser.error() && !failed()}>
<Show when={props.browser.error()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<Show when={(empty() || failed()) && !props.browser.suspended()}>
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
<div
dir="auto"
class="flex size-full flex-col items-center justify-center gap-2 p-6 pb-[200px] text-center text-text-weak"
>
<Icon name="globe" size="large" class="mb-2 shrink-0" />
<div class="text-[13px] font-medium leading-[var(--line-height-compact)] text-text-strong">
{language.t(failed() ? "session.browser.failed.title" : "session.browser.empty.title")}
</div>
<div class="text-13-regular leading-[var(--line-height-base)]">
{language.t(failed() ? "session.browser.failed.description" : "session.browser.empty.description")}
</div>
</div>
</Show>
<Show when={props.browser.suspended()}>
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
{language.t("session.browser.suspended")}
@@ -1,9 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(OpenAppPreferences, { app: "finder" }), input))
describe("open app preferences", () => {
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
@@ -18,3 +17,4 @@ describe("open app preferences", () => {
expect(decode({})).toEqual({ app: "finder" })
})
})
@@ -5,8 +5,7 @@ import { usePlatform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { fileManagerApp } from "@/home/projects/file-manager"
import { openInAppParentPath } from "@/session/files/open-in-app-path"
@@ -30,8 +29,8 @@ export const OPEN_APPS = [
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
export const OpenAppPreferences = Codec.struct({
app: Codec.literals(OPEN_APPS),
})
const appExistence = new Map<string, Promise<boolean>>()
@@ -242,3 +241,4 @@ function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
appExistence.set(app, request)
return request
}
+3 -15
View File
@@ -1,8 +1,6 @@
import { children, createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Tabs } from "@opencode/ui/tabs"
@@ -71,19 +69,9 @@ export function SortableTab(props: {
placement="bottom"
gutter={10}
>
<IconButton
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.onTabClose(props.tab)
}}
icon={<Icon name="xmark-small" />}
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
+12 -8
View File
@@ -5,17 +5,20 @@ import {
type SessionReviewExpandMode,
} from "@opencode/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import type { Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const ReviewPanel = Persistence.struct({
sidebarOpened: Schema.Boolean,
sidebarWidth: Schema.Finite.check(
Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }),
),
expandMode: Schema.Literals(["expand", "collapse"]),
const ReviewPanel = Codec.struct({
sidebarOpened: Codec.boolean,
sidebarWidth: Codec.make<number, number>(
(v) =>
typeof v === "number" && v >= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN && v <= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX
? v
: Codec.INVALID,
(v) => v,
),
expandMode: Codec.literals(["expand", "collapse"]),
})
export function createReviewPanelState(platform?: Platform) {
@@ -47,3 +50,4 @@ export function createReviewPanelState(platform?: Platform) {
}
export type ReviewPanelState = ReturnType<typeof createReviewPanelState>
@@ -2,9 +2,8 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "@/runtime/server/scope"
import { base64Encode } from "@opencode/util/encode"
import { Persist } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import type { Platform } from "@/runtime/platform/platform"
import { Schema } from "effect"
let getWorkspaceTerminalCacheKey: typeof import("./context").getWorkspaceTerminalCacheKey
let clearWorkspaceTerminals: typeof import("./context").clearWorkspaceTerminals
@@ -21,10 +20,10 @@ beforeAll(async () => {
const mod = await import("./context")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
clearWorkspaceTerminals = mod.clearWorkspaceTerminals
const schema = Persistence.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = Schema.decodeUnknownSync(schema)
const schema = Codec.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = ((input: unknown) => Codec.decodeOrThrow(schema, input))
roundTripTerminalState = (value) =>
Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(Schema.decodeUnknownSync(schema)(value)))
Codec.decodeOrThrow(schema, schema.encode(Codec.decodeOrThrow(schema, value)))
})
describe("getWorkspaceTerminalCacheKey", () => {
@@ -141,3 +140,4 @@ describe("TerminalState", () => {
expect(roundTripTerminalState(value)).toEqual(value)
})
})
+33 -31
View File
@@ -8,18 +8,20 @@ import { base64Encode } from "@opencode/util/encode"
import { defaultTitle, titleNumber } from "./title"
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Persistence } from "@/runtime/persistence/schema"
import { Schema, SchemaGetter } from "effect"
import { Codec } from "@/runtime/persistence/codec"
const PTY = Persistence.struct({
id: Schema.NonEmptyString,
title: Persistence.fallback(Schema.String, () => ""),
titleNumber: Persistence.fallback(Schema.Finite, () => 0),
rows: Persistence.optional(Schema.Finite),
cols: Persistence.optional(Schema.Finite),
buffer: Persistence.optional(Schema.String),
scrollY: Persistence.optional(Schema.Finite),
cursor: Persistence.optional(Schema.Finite),
const PTY = Codec.struct({
id: Codec.make<string, string>(
(v) => (typeof v === "string" && v.length > 0 ? v : Codec.INVALID),
(v) => v,
),
title: Codec.fallback(Codec.string, () => ""),
titleNumber: Codec.fallback(Codec.number, () => 0),
rows: Codec.lenientOptional(Codec.number),
cols: Codec.lenientOptional(Codec.number),
buffer: Codec.lenientOptional(Codec.string),
scrollY: Codec.lenientOptional(Codec.number),
cursor: Codec.lenientOptional(Codec.number),
})
export type LocalPTY = typeof PTY.Type
@@ -31,28 +33,26 @@ function numberFromTitle(title: string) {
return titleNumber(title, MAX_TERMINAL_SESSIONS)
}
const State = Persistence.struct({
active: Persistence.optional(Schema.String),
all: Persistence.array(PTY),
const State = Codec.struct({
active: Codec.lenientOptional(Codec.string),
all: Codec.lenientArray(PTY),
})
export const TerminalState = State.pipe(
Schema.decodeTo(Schema.toType(State), {
decode: SchemaGetter.transform((value) => {
const seen = new Set<string>()
const all = value.all.flatMap((pty) => {
if (seen.has(pty.id)) return []
seen.add(pty.id)
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
})
return {
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
all,
}
}),
encode: SchemaGetter.transform((value) => value),
}),
)
export const TerminalState = Codec.transform(State, {
decode: (value): typeof State.Type => {
const seen = new Set<string>()
const all = value.all.flatMap((pty) => {
if (seen.has(pty.id)) return []
seen.add(pty.id)
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
})
return {
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
all,
}
},
encode: (value) => value,
})
export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScope = ServerScope.local) {
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
@@ -458,3 +458,5 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
},
})
@@ -1,40 +1,40 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(
Persistence.withInitial(GoUpsellState, {
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
}),
)
describe("usage exceeded preferences", () => {
test("defaults unseen prompts", () => {
expect(decode({})).toEqual({
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
})
test("preserves timestamps while recovering malformed siblings", () => {
expect(
decode({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: "true",
go_upsell_account_rate_limit_last_seen_at: Infinity,
go_upsell_account_rate_limit_dont_show: 456,
}),
).toEqual({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: 456,
})
})
})
import { describe, expect, test } from "bun:test"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { Codec } from "@/runtime/persistence/codec"
const schema = Codec.withInitial(GoUpsellState, {
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
describe("usage exceeded preferences", () => {
test("defaults unseen prompts", () => {
expect(decode({})).toEqual({
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
})
test("preserves timestamps while recovering malformed siblings", () => {
expect(
decode({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: "true",
go_upsell_account_rate_limit_last_seen_at: Infinity,
go_upsell_account_rate_limit_dont_show: 456,
}),
).toEqual({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: 456,
})
})
})
@@ -2,8 +2,7 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus } from "@opencode/client/promise"
import { onCleanup } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { useSessionLayout } from "./session-layout"
import { useDialog, useI18n } from "@opencode/ui/context"
import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded"
@@ -15,11 +14,11 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
export const GoUpsellState = Persistence.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Schema.NullOr(Schema.Finite),
export const GoUpsellState = Codec.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Codec.nullOr(Codec.number),
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Codec.nullOr(Codec.number),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Codec.nullOr(Codec.number),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Codec.nullOr(Codec.number),
})
function goUpsellKeys(status: SessionStatus) {
@@ -106,3 +105,4 @@ export function useUsageExceededDialogs() {
}),
)
}
+10 -8
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import {
settingsSchema,
settingsPersistence,
@@ -13,9 +12,9 @@ import {
terminalFontFamily,
} from "./model"
const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
const schema = Codec.withInitial(settingsPersistence, defaultSettings)
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
const encode = (value: typeof settingsSchema.Type) => schema.encode(value)
describe("settings timeline detail migration", () => {
test("migrates saved switches and round trips the current settings", () => {
@@ -51,14 +50,14 @@ describe("settings schema", () => {
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
appearance: { ...defaultSettings.appearance, fontSize: 20 },
}
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(settingsPersistence, initial), input)
expect(restore({})).toEqual(initial)
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
placement: "separate",
details: "expanded",
})
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
expect(() => Codec.decodeOrThrow(settingsSchema, {})).toThrow()
})
test("supplies the existing defaults for an empty document", () => {
@@ -171,7 +170,7 @@ describe("settings schema", () => {
test("does not silently repair invalid values during encoding", () => {
expect(() =>
Schema.encodeUnknownSync(settingsSchema)({ ...decode({}), appearance: { fontSize: "large" } }),
Codec.encodeOrThrow(settingsSchema, { ...decode({}), appearance: { fontSize: "large" } } as never),
).toThrow()
})
})
@@ -203,3 +202,6 @@ describe("settings font families", () => {
expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ')
})
})
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -5,8 +5,7 @@ import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { useLanguage } from "@/runtime/i18n/language"
import { useModels } from "@/providers/models/models"
import { useServerSDK } from "@/runtime/server/client"
@@ -20,8 +19,8 @@ type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const PROVIDER_ICON_SIZE = 16
export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
export const ModelProvidersSchema = Codec.struct({
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
})
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
@@ -211,3 +210,4 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
</>
)
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import {
activeCommandRegistrations,
addCommandRegistration,
@@ -10,12 +10,12 @@ import {
} from "./command"
test("command catalog persistence validates metadata and omits executable fields", () => {
const decode = Schema.decodeUnknownSync(CommandCatalog)
const decode = ((input: unknown) => Codec.decodeOrThrow(CommandCatalog, input))
const catalog = decode({ open: { title: "Open", keybind: "mod+o", hidden: false, onSelect: "invalid" } })
expect(catalog).toEqual({ open: { title: "Open", keybind: "mod+o", hidden: false } })
expect(decode({})).toEqual({})
expect(() => decode({ open: { title: 1 } })).toThrow()
expect(decode(Schema.encodeSync(CommandCatalog)(catalog))).toEqual(catalog)
expect(decode(CommandCatalog.encode(catalog))).toEqual(catalog)
})
const paletteOptions: CommandOption[] = [
@@ -79,3 +79,4 @@ describe("resolveKeybindOption", () => {
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
})
})
+11 -10
View File
@@ -2,8 +2,8 @@ import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
@@ -109,16 +109,16 @@ export function resolveKeybindOption(candidates: CommandOption[] | undefined, ev
type CommandSource = "palette" | "keybind" | "slash"
export const CommandCatalogItem = Persistence.struct({
title: Schema.String,
description: Schema.optional(Schema.String),
category: Schema.optional(Schema.String),
keybind: Schema.optional(Schema.String),
slash: Schema.optional(Schema.String),
hidden: Schema.optional(Schema.Boolean),
export const CommandCatalogItem = Codec.struct({
title: Codec.string,
description: Codec.optional(Codec.string),
category: Codec.optional(Codec.string),
keybind: Codec.optional(Codec.string),
slash: Codec.optional(Codec.string),
hidden: Codec.optional(Codec.boolean),
})
export type CommandCatalogItem = typeof CommandCatalogItem.Type
export const CommandCatalog = Schema.Record(Schema.String, Schema.mutableKey(CommandCatalogItem))
export const CommandCatalog = Codec.record(CommandCatalogItem)
export type CommandCatalog = typeof CommandCatalog.Type
export type CommandRegistration = {
@@ -480,3 +480,4 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
},
})
@@ -1,23 +1,22 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import type { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "@/shell/tabs/tabs"
import { NotificationStore, openNotificationSession, type Notification } from "./notification"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
test("notification persistence validates and salvages individual notifications", () => {
const valid: Notification[] = [
{ type: "turn-complete", time: 123, viewed: false, session: "session-1" },
{ type: "error", time: 124, viewed: true, error: { type: "api", message: "failed", status: 500 } },
]
const decode = Schema.decodeUnknownSync(Persistence.withInitial(NotificationStore, { list: [] }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(NotificationStore, { list: [] }), input))
const store = decode({
list: [valid[0], null, { type: "unknown", time: 123, viewed: false }, { ...valid[1], error: "invalid" }, valid[1]],
})
expect(store.list).toEqual(valid)
expect(decode({})).toEqual({ list: [] })
expect(decode({ list: {} })).toEqual({ list: [] })
expect(decode(Schema.encodeSync(NotificationStore)(store))).toEqual(store)
expect(decode(NotificationStore.encode(store))).toEqual(store)
})
test("opens notification sessions through the tab router", () => {
@@ -41,3 +40,4 @@ test("opens notification sessions through the tab router", () => {
expect(calls).toEqual(["add:session-1", "route:session-1", "select:session-1"])
})
@@ -1,6 +1,6 @@
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { SessionError } from "@opencode/schema/session-error"
import { Codec } from "@/runtime/persistence/codec"
import type { SessionError } from "@opencode/schema/session-error"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import type { ServerSDK } from "@/runtime/server/client"
@@ -10,7 +10,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { useGlobal } from "@/runtime/server/runtime"
@@ -20,19 +20,28 @@ import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
const NotificationBase = {
directory: Schema.optional(Schema.String),
session: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Unknown),
time: Schema.Finite,
viewed: Schema.Boolean,
}
export const Notification = Schema.Union([
Persistence.struct({ ...NotificationBase, type: Schema.Literal("turn-complete") }),
Persistence.struct({ ...NotificationBase, type: Schema.Literal("error"), error: SessionError.Error }),
const NotificationBase = {
directory: Codec.optional(Codec.string),
session: Codec.optional(Codec.string),
metadata: Codec.optional(Codec.unknown),
time: Codec.number,
viewed: Codec.boolean,
}
// The error payload is whatever the server reported; shape-checking it here would load the shared
// Effect schema into the renderer's startup path for a value the server already validated.
const StoredSessionError = Codec.make<SessionError.Error, unknown>(
(value) =>
typeof value === "object" && value !== null && "name" in value && "message" in value
? (value as unknown as SessionError.Error)
: Codec.INVALID,
(value) => value,
)
export const Notification = Codec.union([
Codec.struct({ ...NotificationBase, type: Codec.literal("turn-complete") }),
Codec.struct({ ...NotificationBase, type: Codec.literal("error"), error: StoredSessionError }),
])
export type Notification = typeof Notification.Type
export const NotificationStore = Persistence.struct({ list: Persistence.array(Notification) })
export const NotificationStore = Codec.struct({ list: Codec.lenientArray(Notification) })
type NotificationIndex = {
session: {
@@ -368,3 +377,5 @@ export const useNotification = () => {
const server = useServer()
return server.ctx.notification
}
+8 -8
View File
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { Schema } from "effect"
import { ServerConnection } from "@/runtime/server/registry"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
@@ -11,21 +10,21 @@ test("settings has its own layout route", () => {
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
const schema = Codec.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
initial.fileTree.width = 300
initial.review.panelOpened = true
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initial))
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(layoutPersistence, initial), input)
expect(restore({})).toEqual(initial)
expect(restore({ sidebar: { width: "bad" } }).sidebar.width).toBe(420)
expect(restore({ fileTree: { width: 260 } }).fileTree.width).toBe(200)
expect(restore({ fileTree: {} }).fileTree.width).toBe(300)
expect(restore({ review: {}, fileTree: { opened: false } }).review.panelOpened).toBe(false)
expect(() => Schema.decodeUnknownSync(layoutSchema)({})).toThrow()
expect(() => Codec.decodeOrThrow(layoutSchema, {})).toThrow()
})
test("restores shipped defaults for missing and invalid fields", () => {
@@ -56,8 +55,8 @@ describe("layout persistence", () => {
expect(value.sidebar).toEqual({ opened: false, width: 344, workspaces: {}, workspacesDefault: true })
expect(value.review).toEqual({ diffStyle: "split", panelOpened: true })
expect(value.fileTree).toEqual({ opened: true, width: 200, tab: "changes" })
expect(Schema.encodeSync(schema)(value)).toEqual(value)
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
expect(schema.encode(value)).toEqual(value)
expect(decode(schema.encode(value))).toEqual(value)
expect(decode({ fileTree: { opened: true } }).review.panelOpened).toBe(false)
})
@@ -169,3 +168,4 @@ describe("pruneSessionKeys", () => {
expect(drop).toEqual([])
})
})
File diff suppressed because it is too large Load Diff
+45 -50
View File
@@ -1,63 +1,58 @@
export * as TabStorage from "./schema"
import { Schema, SchemaGetter } from "effect"
import { ServerKey } from "@/runtime/server/persistence"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { ServerKey } from "@/runtime/server/key"
export { ServerKey }
export const Session = Persistence.struct({
type: Schema.Literal("session"),
export const Session = Codec.struct({
type: Codec.literal("session"),
server: ServerKey,
sessionId: Schema.String,
routeSessionId: Persistence.optional(Schema.String),
routeParentId: Persistence.optional(Schema.String),
sessionId: Codec.string,
routeSessionId: Codec.lenientOptional(Codec.string),
routeParentId: Codec.lenientOptional(Codec.string),
})
export const Draft = Persistence.struct({
type: Schema.Literal("draft"),
draftID: Schema.String,
export const Draft = Codec.struct({
type: Codec.literal("draft"),
draftID: Codec.string,
server: ServerKey,
directory: Schema.String,
worktree: Persistence.optional(Schema.String),
branch: Persistence.optional(Schema.String),
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
directory: Codec.string,
worktree: Codec.lenientOptional(Codec.string),
branch: Codec.lenientOptional(Codec.string),
mcp: Codec.lenientOptional(Codec.struct({ target: Codec.string, states: Codec.lenientRecord(Codec.boolean) })),
})
const SessionCodec = Session.pipe(
Schema.decodeTo(Schema.toType(Session), {
decode: SchemaGetter.transform((tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
})),
encode: SchemaGetter.transform((tab) => tab),
// A stored route that only repeats the session id carries nothing; drop it and its parent.
const SessionCodec = Codec.transform(Session, {
decode: (tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
}),
encode: (tab) => tab,
})
export const Tab = Codec.union([Session, Draft])
export const Tabs = Codec.lenientArray(Codec.union([SessionCodec, Draft]))
export const Recent = Codec.struct({
key: Codec.optional(Codec.string),
})
export const Info = Codec.struct({
title: Codec.optional(Codec.string),
directory: Codec.optional(Codec.string),
})
export const Infos = Codec.record(Info)
export const Panes = Codec.record(
Codec.struct({
terminal: Codec.optional(Codec.boolean),
review: Codec.optional(Codec.boolean),
terminalHeight: Codec.optional(Codec.number),
sessionWidth: Codec.optional(Codec.number),
}),
)
export const Tab = Schema.Union([Session, Draft])
export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft]))
export const Recent = Persistence.struct({
key: Schema.optional(Schema.String),
})
export const Info = Persistence.struct({
title: Schema.optional(Schema.String),
directory: Schema.optional(Schema.String),
})
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
export const Panes = Schema.Record(
Schema.String,
Schema.mutableKey(
Persistence.struct({
terminal: Schema.optional(Schema.Boolean),
review: Schema.optional(Schema.Boolean),
terminalHeight: Schema.optional(Schema.Finite),
sessionWidth: Schema.optional(Schema.Finite),
}),
),
)
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
export const Closed = Persistence.array(ClosedTab)
export const ClosedTab = Codec.struct({ tab: SessionCodec, index: Codec.nonNegativeInt })
export const Closed = Codec.lenientArray(ClosedTab)
+15 -14
View File
@@ -3,13 +3,12 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { Schema } from "effect"
import { TabStorage } from "./schema"
import type { ServerConnection } from "@/runtime/server/registry"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
const decodeTabs = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Tabs, []), input))
function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
@@ -26,7 +25,7 @@ describe("tab migration", () => {
}
const restored = decodeTabs([legacy, draft])
expect(restored).toEqual([legacy, draft])
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
expect(decodeTabs(TabStorage.Tabs.encode(restored))).toEqual([legacy, draft])
})
test("drops null and malformed persisted tabs", () => {
@@ -64,13 +63,13 @@ describe("tab migration", () => {
draft,
])
expect(tabs).toEqual([sessionTab("root"), draft])
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
expect(TabStorage.Tabs.encode(tabs)).toEqual(tabs)
expect(decodeTabs(TabStorage.Tabs.encode(tabs))).toEqual(tabs)
})
test("salvages valid closed session tabs", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Closed, []), input))([
{ tab: sessionTab("a"), index: 1 },
{ tab: sessionTab("b"), index: -1 },
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
@@ -81,16 +80,16 @@ describe("tab migration", () => {
test("validates auxiliary tab state", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Recent, { key: undefined }), input))({ key: 1 }),
).toEqual({ key: undefined })
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
expect(Codec.decodeOrThrow(TabStorage.Infos, {})).toEqual({})
expect(Codec.decodeOrThrow(TabStorage.Panes, {})).toEqual({})
expect(Codec.decodeOrThrow(TabStorage.Infos, { tab: { title: "Title", directory: "/project" } })).toEqual({
tab: { title: "Title", directory: "/project" },
})
const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } })
expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow()
const panes = Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: true, terminalHeight: 300 } })
expect(TabStorage.Panes.encode(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: "yes" } })).toThrow()
})
})
@@ -209,3 +208,5 @@ describe("closed tab stack", () => {
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
})
})
+1 -10
View File
@@ -9,8 +9,7 @@
justify-content: center;
}
[data-titlebar-tab],
[data-titlebar-tab-action] {
[data-titlebar-tab] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
@@ -69,14 +68,6 @@
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab-action]:is(:hover, :focus-visible) {
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab-action]:is(:active, [data-state="pressed"]) {
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
+2 -4
View File
@@ -351,10 +351,9 @@ export function Titlebar(props: {
>
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=pressed]:text-v2-text-text-base"
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
@@ -651,9 +650,8 @@ export function Titlebar(props: {
{homeButton(true)}
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-new-session"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
@@ -1,13 +1,13 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { HighlightsStore } from "./highlights"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
test("highlight persistence defaults missing or invalid versions and round-trips valid versions", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(HighlightsStore, { version: undefined }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(HighlightsStore, { version: undefined }), input))
expect(decode({})).toEqual({ version: undefined })
expect(decode({ version: null })).toEqual({ version: undefined })
const value = decode({ version: "1.2.3", legacy: true })
expect(value).toEqual({ version: "1.2.3" })
expect(Schema.encodeSync(HighlightsStore)(value)).toEqual(value)
expect(HighlightsStore.encode(value)).toEqual(value)
})
@@ -1,18 +1,18 @@
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { usePlatform } from "@/runtime/platform/platform"
import { useSettings } from "@/settings/model"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { DialogReleaseNotes, type Highlight } from "@/shell/updates/release-notes"
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
export const HighlightsStore = Persistence.struct({
version: Schema.UndefinedOr(Schema.String),
export const HighlightsStore = Codec.struct({
version: Codec.undefinedOr(Codec.string),
})
type ParsedRelease = {
@@ -233,3 +233,4 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
}
},
})
+12 -12
View File
@@ -1,20 +1,19 @@
import type { FileContent } from "@/runtime/server/types"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
export const FileSelection = Persistence.struct({
startLine: Schema.Number,
startChar: Schema.Number,
endLine: Schema.Number,
endChar: Schema.Number,
export const FileSelection = Codec.struct({
startLine: Codec.number,
startChar: Codec.number,
endLine: Codec.number,
endChar: Codec.number,
})
export type FileSelection = typeof FileSelection.Type
export const SelectedLineRange = Persistence.struct({
start: Schema.Number,
end: Schema.Number,
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
export const SelectedLineRange = Codec.struct({
start: Codec.number,
end: Codec.number,
side: Codec.lenientOptional(Codec.literals(["additions", "deletions"])),
endSide: Codec.lenientOptional(Codec.literals(["additions", "deletions"])),
})
export type SelectedLineRange = typeof SelectedLineRange.Type
@@ -44,3 +43,4 @@ export function selectionFromLines(range: SelectedLineRange): FileSelection {
endChar: 0,
}
}
@@ -1,8 +1,7 @@
import { createEffect, createRoot } from "solid-js"
import { produce } from "solid-js/store"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import { SelectedLineRange } from "./types"
import type { ServerScope } from "@/runtime/server/scope"
@@ -11,14 +10,14 @@ const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
const FileViewSchema = Persistence.struct({
scrollTop: Persistence.optional(Schema.Finite),
scrollLeft: Persistence.optional(Schema.Finite),
selectedLines: Persistence.optional(Schema.NullOr(SelectedLineRange)),
const FileViewSchema = Codec.struct({
scrollTop: Codec.lenientOptional(Codec.number),
scrollLeft: Codec.lenientOptional(Codec.number),
selectedLines: Codec.lenientOptional(Codec.nullOr(SelectedLineRange)),
})
export const FileViewsSchema = Schema.Struct({
file: Persistence.record(Persistence.fallback(FileViewSchema, () => ({}))),
export const FileViewsSchema = Codec.struct({
file: Codec.lenientRecord(Codec.fallback(FileViewSchema, () => ({}))),
})
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
@@ -150,3 +149,4 @@ export function createFileViewCache(scope: ServerScope) {
clear: () => cache.clear(),
}
}
+1 -1
View File
@@ -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": {
+2 -13
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env bun
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Cause, Effect } from "effect"
import { getErrorReported } from "effect/Runtime"
import { Effect } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode/util/observability"
@@ -131,15 +130,5 @@ Effect.gen(function* () {
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
// runMain's default reporter logs the fatal cause to stdout. Write it to stderr instead: the
// desktop and `Service.ensure` only capture stderr from `serve --service`, so this is the only
// channel through which a startup failure's reason reaches the user.
Effect.tapCause((cause) =>
Effect.sync(() => {
if (Cause.hasInterruptsOnly(cause)) return
if (!getErrorReported(Cause.squash(cause))) return
process.stderr.write(Cause.pretty(cause) + "\n")
}),
),
NodeRuntime.runMain({ disableErrorReporting: true }),
NodeRuntime.runMain,
)
+1 -1
View File
@@ -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": {
+7 -13
View File
@@ -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 -1
View File
@@ -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.
+1 -14
View File
@@ -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",
-7
View File
@@ -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 => [
-1
View File
@@ -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,
+2 -2
View File
@@ -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,
-2
View File
@@ -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()),
-10
View File
@@ -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(`
+1 -80
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.11",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.11",
"version": "2.0.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.11",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -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",
File diff suppressed because one or more lines are too long
+13 -28
View File
@@ -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) =>
+2 -2
View File
@@ -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"))
@@ -1,55 +0,0 @@
import { expect } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { Tool } from "@opencode/core/tool"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
it.live("Promise tool executors receive interruption through their AbortSignal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const started = yield* Deferred.make<AbortSignal>()
yield* PluginPromise.fromPromise({
id: "cancel-tool",
async setup(context) {
await context.tool.transform((editor) =>
editor.add({
name: "wait",
description: "Wait until cancelled",
input: { type: "object", properties: {}, additionalProperties: false },
options: { codemode: false },
execute: (_input, context) =>
new Promise<never>((_resolve, reject) => {
context.signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true })
Effect.runSync(Deferred.succeed(started, context.signal))
}),
}),
)
},
}).effect(yield* PluginHost.make(plugins))
const snapshot = yield* tools.snapshot()
const fiber = yield* snapshot
.execute({
sessionID: Session.ID.make("ses_promise_tool_cancel"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool_cancel"),
call: { type: "tool-call", id: "call_promise_tool_cancel", name: "wait", input: {} },
})
.pipe(Effect.forkScoped)
const signal = yield* Deferred.await(started)
expect(signal.aborted).toBe(false)
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(signal.aborted).toBe(true)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}),
)
-9
View File
@@ -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 -1
View File
@@ -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",
+6 -44
View File
@@ -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
+2 -1
View File
@@ -5,7 +5,7 @@ import { NodeChildProcessSpawner } from "@effect/platform-node"
import { FetchHttpClient } from "effect/unstable/http"
import { app, shell, type WebContents } from "electron"
import { homedir } from "node:os"
import { SshConfig, type SshState } from "@opencode/app/ssh"
import { SshConfig, type SshState } from "@opencode/app/ssh/schema"
import { SshChanged } from "../../shared/ipc-rpc/events"
import { DesktopCli } from "../service/desktop-cli"
import { Shutdown } from "../lifecycle/shutdown"
@@ -88,3 +88,4 @@ export const layer = Layer.effect(
return service
}),
).pipe(Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(FetchHttpClient.layer))
@@ -3,7 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
import { SshState } from "@opencode/app/ssh"
import { SshState } from "@opencode/app/ssh/schema"
export class SshChanged extends Schema.TaggedClass<SshChanged>()("SshChanged", { state: SshState }) {}
@@ -64,3 +64,4 @@ export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
+2 -1
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { SshHttp, SshStart, SshState } from "@opencode/app/ssh"
import { SshHttp, SshStart, SshState } from "@opencode/app/ssh/schema"
export const SshRpcs = RpcGroup.make(
Rpc.make("SshGetState", { success: SshState }),
@@ -15,3 +15,4 @@ export const SshRpcs = RpcGroup.make(
Rpc.make("SshForget", { payload: { id: Schema.String } }),
Rpc.make("SshOpenConfig"),
)
-31
View File
@@ -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 () => ({
-53
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.11",
"version": "2.0.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
-1
View File
@@ -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,

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