Merge remote-tracking branch 'origin/dev' into todo-dock-motion

# Conflicts:
#	packages/app/package.json
#	packages/app/src/pages/session.tsx
#	packages/app/src/pages/session/composer/session-composer-region.tsx
This commit is contained in:
Brendan Allan
2026-06-25 13:25:38 +08:00
108 changed files with 6879 additions and 920 deletions
+5
View File
@@ -69,6 +69,11 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
+77
View File
@@ -60,6 +60,21 @@ A temporary file created under OpenCode's shared tool-output directory to retain
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
**OpenCode Client**:
The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers.
_Avoid_: Remote client
**SDK Contract IR**:
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
**Embedded OpenCode**:
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
_Avoid_: Local implementation
**Page**:
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
_Avoid_: Response envelope
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@@ -108,6 +123,51 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
@@ -124,6 +184,23 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
Before stabilizing the client API:
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
+55
View File
@@ -110,6 +110,29 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/client": {
"name": "@opencode-ai/client",
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.83",
},
"optionalPeers": [
"effect",
],
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.10",
@@ -479,6 +502,18 @@
"effect": "4.0.0-beta.83",
},
},
"packages/httpapi-codegen": {
"name": "@opencode-ai/httpapi-codegen",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.10",
@@ -690,6 +725,20 @@
"@types/semver": "^7.5.8",
},
},
"packages/sdk-next": {
"name": "@opencode-ai/sdk-next",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.10",
@@ -1847,6 +1896,8 @@
"@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"],
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
@@ -1873,6 +1924,8 @@
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
@@ -1885,6 +1938,8 @@
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Q0CW2wMhVtRM3uILV1kvKAzpQJqBxTp4MLsGlsSoT1s=",
"aarch64-linux": "sha256-UHpoDqqV+umG47LENB7DLxbCqvwjTCDjHWIFiv6Vmk8=",
"aarch64-darwin": "sha256-rqlFositEiB1wzx6Jcp5yVQlp8LLYBW+79KXmRpXh2Q=",
"x86_64-darwin": "sha256-BKr4YQio3eP0VLZ0z+oyaM93H8dAbSwmlzxT8PH6W8M="
"x86_64-linux": "sha256-4RYkrGAbsrUw/n0ecPJpntSZYuV6GsmMMjK9R6MbMxU=",
"aarch64-linux": "sha256-kwSkouFxbEYzYAsr9gaVUQrT7YfbcoKb4kB9dhNtaFM=",
"aarch64-darwin": "sha256-mukRph5X1noBRhd5+0Ct7ZshTYxooGcoY+tZEXzfSvo=",
"x86_64-darwin": "sha256-CE8JgBAfZQmUnunPPw4XPPw3bkso1OALmNgmyGgJr1k="
}
}
@@ -0,0 +1,135 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
const requests: string[] = []
await mockServers(page, requests)
await page.addInitScript(
({ serverB, sessionA, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify([
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB },
]),
)
},
{ serverB, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(hrefA)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
await tabA.locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
expect(
requests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
}),
).toBe(true)
})
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
await mockServers(page, [])
await page.addInitScript(
({ serverB, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
)
},
{ serverB, sessionB: sessionB.id },
)
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
function session(id: string, directory: string, title: string) {
return {
id,
slug: id,
projectID: `project-${id}`,
directory,
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}
async function mockServers(page: Page, requests: string[]) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
requests.push(url.toString())
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") return json(route, [current])
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
const project = {
id: current.projectID,
worktree: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
}
if (url.pathname === "/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
return json(route, {})
})
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
+2 -2
View File
@@ -17,9 +17,9 @@
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:virtualizer",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts ./test-browser/motion-spring.test.ts",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
+21 -7
View File
@@ -51,7 +51,13 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, sessionHref } from "./utils/session-route"
import {
legacySessionHref,
legacySessionServer,
requireServerKey,
selectSessionLineage,
sessionHref,
} from "./utils/session-route"
import Session from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
@@ -67,7 +73,15 @@ const SessionRoute = () => {
const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
return <Navigate href={sessionHref(server.key, params.id)} />
const sessionID = params.id
return (
<Show when={tabs.ready()}>
{(_) => {
const persisted = tabs.store.filter((item) => item.type === "session")
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
}}
</Show>
)
}
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
@@ -95,7 +109,7 @@ const TargetSessionRoute = () => {
})
return (
<Show when={`${params.serverKey}\0${params.id}`} keyed>
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute />
@@ -119,7 +133,7 @@ function ResolvedTargetSessionRoute() {
},
({ id, sync }) => sync.session.lineage.resolve(id),
)
const current = createMemo(() => cached() ?? resolved())
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()!
@@ -134,7 +148,7 @@ function ResolvedTargetSessionRoute() {
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
@@ -294,7 +308,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
<PermissionProvider directory={props.directory}>
<LayoutProvider>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
@@ -323,7 +337,7 @@ function TargetServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider>
)
@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
@@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
export function DialogConnectProvider(props: { provider: string }) {
export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const language = useLanguage()
const providers = useProviders()
const providers = useProviders(props.directory)
const all = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
})
}
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js"
import { type Accessor, batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
@@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider"
type Props = {
back?: "providers" | "close"
directory?: Accessor<string | undefined>
}
export function DialogCustomProvider(props: Props) {
@@ -40,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
dialog.close()
return
}
dialog.show(() => <DialogSelectProvider />)
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
}
const addModel = () => {
@@ -9,14 +9,16 @@ import { popularProviders } from "@/hooks/use-providers"
import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "./dialog-select-provider"
import { decode64 } from "@/utils/base64"
export const DialogManageModels: Component = () => {
const local = useLocal()
const language = useLanguage()
const dialog = useDialog()
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider />)
dialog.show(() => <DialogSelectProvider directory={directory} />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
@@ -10,24 +10,27 @@ import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
type ModelState = ReturnType<typeof useLocal>["model"]
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
const model = props.model ?? useLocal().model
const local = useLocal()
const model = props.model ?? local.model
const dialog = useDialog()
const providers = useProviders()
const directory = () => decode64(local.slug())
const providers = useProviders(directory)
const language = useLanguage()
const connect = (provider: string) => {
void import("./dialog-connect-provider").then((x) => {
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
})
}
const all = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
})
}
@@ -12,6 +12,7 @@ import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
@@ -104,6 +105,8 @@ export function ModelSelectorPopover(props: {
dismiss: null,
})
const dialog = useDialog()
const local = useLocal()
const directory = () => decode64(local.slug())
const close = (dismiss: Dismiss) => {
setStore("dismiss", dismiss)
@@ -120,7 +123,7 @@ export function ModelSelectorPopover(props: {
const handleConnectProvider = () => {
close("provider")
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
})
}
const language = useLanguage()
@@ -199,10 +202,12 @@ export function ModelSelectorPopover(props: {
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
const directory = () => decode64(local.slug())
const provider = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
})
}
@@ -1,4 +1,4 @@
import { Component, Show } from "solid-js"
import { type Accessor, Component, Show } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider"
const CUSTOM_ID = "_custom"
export const DialogSelectProvider: Component = () => {
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
const dialog = useDialog()
const providers = useProviders()
const providers = useProviders(props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
@@ -56,10 +56,10 @@ export const DialogSelectProvider: Component = () => {
onSelect={(x) => {
if (!x) return
if (x.id === CUSTOM_ID) {
dialog.show(() => <DialogCustomProvider back="providers" />)
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
return
}
dialog.show(() => <DialogConnectProvider provider={x.id} />)
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
}}
>
{(i) => (
+5 -19
View File
@@ -67,6 +67,7 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { pathKey } from "@/utils/path-key"
@@ -346,25 +347,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const [store, setStore] = createStore<{
popover: "at" | "slash" | null
historyIndex: number
savedPrompt: PromptHistoryEntry | null
placeholder: number
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}>({
popover: null,
historyIndex: -1,
savedPrompt: null as PromptHistoryEntry | null,
placeholder: Math.floor(Math.random() * EXAMPLES.length),
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
const [store, setStore] = createPromptInputTransientState(
() => prompt.capture(),
Math.floor(Math.random() * EXAMPLES.length),
)
const [picker, setPicker] = createStore({
projectOpen: false,
projectSearch: "",
@@ -25,6 +25,19 @@ function dataUrl(file: File, mime: string) {
})
}
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
type PromptAttachmentsCoreInput = {
capture: () => PromptTarget
editor: () => HTMLDivElement | undefined
focusEditor?: () => void
addPart?: (part: ContentPart) => boolean
warn?: () => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
}
type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined
@@ -36,27 +49,22 @@ type PromptAttachmentsInput = {
getPathForFile?: (file: File) => string
}
export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = input.prompt
const language = useLanguage()
const warn = () => {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
const capture = (): AttachmentTarget | undefined => {
const prompt = input.capture()
const editor = input.editor()
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
}
const add = async (file: File, toast = true) => {
const add = async (file: File, toast = true, target = capture()) => {
if (!target) return false
const mime = await attachmentMime(file)
if (!mime) {
if (toast) warn()
if (toast) input.warn?.()
return false
}
const editor = input.editor()
if (!editor) return false
const url = await dataUrl(file, mime)
if (!url) return false
@@ -68,34 +76,42 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
mime,
dataUrl: url,
}
const cursor = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursor)
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => {
const addAttachments = async (files: File[], toast = true, target = capture()) => {
let found = false
for (const file of files) {
const ok = await add(file, false)
const ok = await add(file, false, target)
if (ok) found = true
}
if (!found && files.length > 0 && toast) warn()
if (!found && files.length > 0 && toast) input.warn?.()
return found
}
const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
const file = await pending
if (!file) return false
return add(file, true, target)
}
const removeAttachment = (id: string) => {
const current = prompt.current()
const target = input.capture()
const current = target.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
prompt.set(next, prompt.cursor())
target.set(next, target.cursor())
}
const handlePaste = async (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
if (!clipboardData) return
const target = capture()
if (!target) return
event.preventDefault()
event.stopPropagation()
@@ -107,7 +123,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
})
if (files.length > 0) {
await addAttachments(files)
await addAttachments(files, true, target)
return
}
@@ -115,11 +131,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file) {
await addAttachment(file)
return
}
if (await addClipboardAttachment(input.readClipboardImage(), target)) return
}
if (!plainText) return
@@ -127,9 +139,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const text = normalizePaste(plainText)
const put = () => {
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor()
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor?.()
return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
}
if (pasteMode(text) === "manual") {
@@ -143,6 +155,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
put()
}
return {
addAttachment,
addAttachments,
addClipboardAttachment,
removeAttachment,
handlePaste,
}
}
export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage()
const attachments = createPromptAttachmentsCore({
...input,
capture: input.prompt.capture,
warn: () => {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
},
})
const handleGlobalDragOver = (event: DragEvent) => {
if (input.isDialogActive()) return
@@ -181,7 +215,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files
if (!dropped) return
await addAttachments(Array.from(dropped))
await attachments.addAttachments(Array.from(dropped))
}
onMount(() => {
@@ -190,10 +224,5 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
makeEventListener(document, "drop", handleGlobalDrop)
})
return {
addAttachment,
addAttachments,
removeAttachment,
handlePaste,
}
return attachments
}
@@ -0,0 +1,31 @@
import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt"
type PromptTarget = ReturnType<ReturnType<typeof usePrompt>["capture"]>
export function createPromptSubmissionState(input: {
target: PromptTarget
prompt: Prompt
context: (ContextItem & { key: string })[]
}) {
let target = input.target
let cleared: Prompt | undefined
return {
prompt: input.prompt,
context: input.context,
target: () => target,
clear() {
target.reset()
cleared = target.current()
},
retarget(next: PromptTarget) {
input.context.forEach(next.context.add)
target = next
},
current: (value: PromptTarget) => target === value,
restore() {
if (cleared !== undefined && target.current() !== cleared) return
return { target, prompt: input.prompt, context: input.context }
},
}
}
@@ -41,6 +41,7 @@ const prompt = {
replaceComments: () => undefined,
items: () => [],
},
capture: () => prompt,
}
const clientFor = (directory: string) => {
@@ -4,7 +4,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
@@ -21,6 +20,7 @@ import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
type PendingPrompt = {
abort: AbortController
@@ -194,15 +194,6 @@ type PromptSubmitInput = {
onSubmit?: () => void
}
type CommentItem = {
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate()
const sdk = useSDK()
@@ -251,9 +242,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch(() => {})
}
const restoreCommentItems = (items: CommentItem[]) => {
const restoreCommentItems = (
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
items: (ContextItem & { key: string })[],
) => {
for (const item of items) {
prompt.context.add({
target.context.add({
type: "file",
path: item.path,
selection: item.selection,
@@ -265,15 +259,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const removeCommentItems = (items: { key: string }[]) => {
for (const item of items) {
prompt.context.remove(item.key)
}
}
const clearContext = () => {
for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
for (const item of target.context.items()) {
target.context.remove(item.key)
}
}
@@ -295,7 +283,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const handleSubmit = async (event: Event) => {
event.preventDefault()
const currentPrompt = prompt.current()
const target = prompt.capture()
const submission = createPromptSubmissionState({
target,
prompt: target.current(),
context: target.context.items().slice(),
})
const currentPrompt = submission.prompt
const context = submission.context
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = input.imageAttachments().slice()
const mode = input.mode()
@@ -387,6 +382,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
}
}
if (!session) {
@@ -402,7 +398,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const context = prompt.context.items().slice()
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
@@ -414,13 +409,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const clearInput = () => {
prompt.reset()
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
const restoreInput = () => {
prompt.set(currentPrompt, input.promptLength(currentPrompt))
const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode)
input.setPopover(null)
requestAnimationFrame(() => {
@@ -430,11 +428,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll()
})
return true
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext()
clearContext(submission.target())
clearInput()
return
}
@@ -504,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
}
removeCommentItems(commentItems)
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
const waitForWorktree = async () => {
@@ -521,8 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sync().set("session_status", session.id, { type: "idle" })
}
removeOptimisticMessage()
restoreCommentItems(commentItems)
restoreInput()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
}
pending.set(pendingKey(session.id), { abort: controller, cleanup })
@@ -584,8 +582,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
description: errorMessage(err),
})
removeOptimisticMessage()
restoreCommentItems(commentItems)
restoreInput()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
})
}
@@ -0,0 +1,43 @@
import { createComputed, on, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { PromptHistoryEntry } from "./history"
export type PromptInputTransientState = {
popover: "at" | "slash" | null
historyIndex: number
savedPrompt: PromptHistoryEntry | null
placeholder: number
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
setStore({
popover: null,
historyIndex: -1,
savedPrompt: null,
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
}
export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
const [store, setStore] = createStore<PromptInputTransientState>({
popover: null,
historyIndex: -1,
savedPrompt: null,
placeholder,
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
return [store, setStore] as const
}
@@ -8,6 +8,7 @@ import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -33,7 +34,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const file = useFile()
const layout = useLayout()
const language = useLanguage()
const providers = useProviders()
const sdk = useSDK()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button")
@@ -13,6 +13,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
@@ -93,7 +94,8 @@ const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const sync = useSync()
const language = useLanguage()
const providers = useProviders()
const sdk = useSDK()
const providers = useProviders(() => sdk().directory)
const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
@@ -0,0 +1,119 @@
import { describe, expect, test } from "bun:test"
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
import {
canOpenTabRename,
captureTabPointerDown,
canStartTabDrag,
createTabDragPreview,
forwardTabRef,
isPrimaryPointerPressed,
isTabCloseTarget,
} from "./titlebar-tab-gesture"
describe("titlebar tab drag", () => {
const layout = {
listLeft: 100,
dividerWidth: 13,
tabWidthById: new Map([
["a", 40],
["b", 40],
["c", 40],
["d", 40],
]),
}
test("moves across multiple tabs from one pointer update", () => {
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
})
test("keeps the current index inside the left hysteresis deadband", () => {
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
})
test("includes slot margins in captured divider width", () => {
const list = document.createElement("div")
const first = document.createElement("div")
const second = document.createElement("div")
const firstTab = document.createElement("div")
const secondTab = document.createElement("div")
first.dataset.titlebarTabSlot = ""
first.dataset.tabKey = "a"
second.dataset.titlebarTabSlot = ""
second.dataset.tabKey = "b"
second.style.marginLeft = "6px"
firstTab.dataset.titlebarTab = ""
secondTab.dataset.titlebarTab = ""
first.append(firstTab)
second.append(secondTab)
list.append(first, second)
document.body.append(list)
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
list.remove()
})
})
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
const close = document.createElement("div")
const button = document.createElement("button")
const link = document.createElement("a")
close.dataset.slot = "tab-close"
close.append(button)
expect(isTabCloseTarget(close)).toBe(true)
expect(isTabCloseTarget(button)).toBe(true)
expect(isTabCloseTarget(link)).toBe(false)
})
test("forwards component refs", () => {
const element = document.createElement("div")
let received: HTMLDivElement | undefined
forwardTabRef((value) => (received = value), element)
expect(received).toBe(element)
})
test("does not reopen rename while a save is pending", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("keeps the rendered tab content in the drag preview", () => {
const tab = document.createElement("div")
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
const preview = createTabDragPreview(tab)
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
})
test("captures the grab offset before navigation scrolls the tab", () => {
const tab = document.createElement("div")
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
startX: 100,
startY: 20,
grabOffsetX: 20,
grabOffsetY: 10,
width: 120,
element: tab,
})
})
test("detects when the primary pointer button was released outside the window", () => {
expect(isPrimaryPointerPressed(1)).toBe(true)
expect(isPrimaryPointerPressed(3)).toBe(true)
expect(isPrimaryPointerPressed(0)).toBe(false)
expect(isPrimaryPointerPressed(2)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -0,0 +1,135 @@
export type TabDragLayout = {
tabWidthById: Map<string, number>
dividerWidth: number
listLeft: number
}
export const ACTIVATION_DISTANCE = 4
export const HYSTERESIS_DEADBAND = 8
export const AUTOSCROLL_EDGE = 24
export const AUTOSCROLL_MAX_SPEED = 8
export const FLOATER_OVERSHOOT_MAX = 8
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
const dx = x2 - x1
const dy = y2 - y1
return Math.sqrt(dx * dx + dy * dy)
}
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
const tabWidthById = new Map<string, number>()
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
for (const slot of slots) {
const id = slot.dataset.tabKey
if (!id) continue
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) continue
tabWidthById.set(id, tab.getBoundingClientRect().width)
}
let dividerWidth = 0
if (order.length >= 2) {
const secondId = order[1]
for (const slot of slots) {
if (slot.dataset.tabKey !== secondId) continue
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) break
const style = getComputedStyle(slot)
dividerWidth =
slot.getBoundingClientRect().width -
tab.getBoundingClientRect().width +
(Number.parseFloat(style.marginLeft) || 0) +
(Number.parseFloat(style.marginRight) || 0)
break
}
}
return {
tabWidthById,
dividerWidth,
listLeft: list.getBoundingClientRect().left,
}
}
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
layout.listLeft = list.getBoundingClientRect().left
}
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
const id = order[index]
if (!id) return 0
const tabWidth = layout.tabWidthById.get(id) ?? 0
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
}
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
let left = layout.listLeft
for (let i = 0; i < index; i++) {
left += slotWidthAt(order, i, layout)
}
return left
}
export function insertIndexFromVirtualLayout(
pointerX: number,
order: readonly string[],
draggedId: string,
currentIndex: number,
layout: TabDragLayout,
deadband = HYSTERESIS_DEADBAND,
) {
if (order.length === 0) return 0
const others = order.filter((id) => id !== draggedId)
let target = currentIndex
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
return target
}
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
const fromIndex = order.indexOf(draggedId)
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
const next = [...order]
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
return next
}
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
return final.some((key, index) => key !== initial[index])
}
function easeOvershoot(overshoot: number) {
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
}
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
const stripWidth = stripRight - stripLeft
if (width >= stripWidth) return stripLeft
const maxLeft = stripRight - width
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
return left
}
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
const leftEdge = containerLeft + AUTOSCROLL_EDGE
const rightEdge = containerRight - AUTOSCROLL_EDGE
if (pointerX < leftEdge) {
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}
if (pointerX > rightEdge) {
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}
return 0
}
@@ -0,0 +1,37 @@
import type { Ref } from "solid-js"
export function isTabCloseTarget(target: EventTarget | null) {
return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
}
export function canStartTabDrag(pointerType: string) {
return pointerType !== "touch"
}
export function isPrimaryPointerPressed(buttons: number) {
return (buttons & 1) !== 0
}
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
const rect = element.getBoundingClientRect()
return {
startX: clientX,
startY: clientY,
grabOffsetX: clientX - rect.left,
grabOffsetY: clientY - rect.top,
width: rect.width,
element,
}
}
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
if (typeof ref === "function") ref(element)
}
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
return !dragging && !editing && !committing
}
export function createTabDragPreview(element: HTMLDivElement) {
return element.cloneNode(true) as HTMLDivElement
}
@@ -0,0 +1,69 @@
[data-titlebar-tab] [data-slot="tab-close"] {
position: absolute;
top: 0;
right: 0;
display: flex;
height: 28px;
width: 28px;
align-items: center;
justify-content: center;
}
[data-titlebar-tab] [data-slot="tab-close"]::before {
content: "";
position: absolute;
top: 0;
height: 28px;
width: 16px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--tab-bg));
display: none;
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
display: block;
right: 0;
}
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
[data-slot="tab-close"]::before {
right: 100%;
}
[data-titlebar-tab] [data-slot="tab-link"] {
padding-right: 22px;
}
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
padding-right: 0;
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-link"] {
padding-right: 22px;
}
[data-titlebar-tab] [data-slot="tab-title"] {
min-width: 0;
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"] {
min-width: 0;
max-width: 100%;
flex: 1 1 auto;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scrollbar-width: none;
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"]::-webkit-scrollbar {
display: none;
}
[data-titlebar-tab]:hover [data-slot="tab-close"],
[data-titlebar-tab][data-active="true"] [data-slot="tab-close"],
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
background: var(--tab-bg);
}
@@ -0,0 +1,344 @@
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import "./titlebar-tab-nav.css"
export function TabNavItem(props: {
ref?: Ref<HTMLDivElement>
href: string
server: ServerConnection.Key
session: () => Session | undefined
onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void
onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
suppressNavigation?: () => boolean
dragging?: boolean
pressed?: boolean
hidden?: boolean
}) {
const language = useLanguage()
const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
let tabRoot!: HTMLDivElement
let titleEl!: HTMLSpanElement
let committing = false
let measureFrame: number | undefined
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.ensureServerCtx(conn)
})
const project = createMemo(() => {
const session = props.session()
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const measureTitleOverflow = () => {
if (!titleEl || editing()) {
setTitleOverflowing(false)
return
}
setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth)
}
const scheduleTitleOverflow = () => {
if (measureFrame !== undefined) return
measureFrame = requestAnimationFrame(() => {
measureFrame = undefined
measureTitleOverflow()
})
}
createEffect(() => {
props.session()?.title
props.forceTruncate
editing()
scheduleTitleOverflow()
})
createResizeObserver(() => tabRoot, scheduleTitleOverflow)
onCleanup(() => {
if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
})
const selectTitle = () => {
const range = document.createRange()
range.selectNodeContents(titleEl)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
const rename = async (title: string) => {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
await client.session.update({ sessionID: session.id, title })
}
const closeRename = async (save: boolean) => {
if (committing || !editing()) return
committing = true
const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0
if (save && next && next !== original) props.onTitleChange?.(next)
setEditing(false)
if (!save || !next || next === original) {
committing = false
return
}
try {
await rename(next)
} catch (err) {
props.onTitleChangeFailed?.(original)
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
committing = false
}
createEffect(() => {
if (editing()) return
if (!titleEl) return
const title = props.session()?.title
if (title === undefined) return
titleEl.textContent = title
})
const openRename = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), committing)) return
const session = props.session()
if (!session) return
titleEl.textContent = session.title
setEditing(true)
requestAnimationFrame(() => {
titleEl.focus()
selectTitle()
})
}
createEffect(() => {
if (!editing()) return
const cleanup = makeEventListener(
document,
"pointerdown",
(event) => {
const target = event.target
if (!(target instanceof Node)) return
if (tabRoot.contains(target)) return
void closeRename(true)
},
{ capture: true },
)
onCleanup(cleanup)
})
return (
<div
ref={(el) => {
tabRoot = el
forwardTabRef(props.ref, el)
}}
data-titlebar-tab
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 min-w-24 max-w-60 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={props.session()}>
{(session) => {
return (
<a
data-slot="tab-link"
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span data-slot="project-avatar-slot">
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = session().title
}}
data-slot="tab-title"
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = session().title
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
)
}}
</Show>
<div data-slot="tab-close">
<IconButtonV2
size="small"
variant="ghost-muted"
class="relative z-10 opacity-0 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
)
}
export function DraftTabItem(props: {
ref?: Ref<HTMLDivElement>
href: string
title: string
active?: boolean
onNavigate: () => void
onClose: () => void
suppressNavigation?: () => boolean
dragging?: boolean
pressed?: boolean
hidden?: boolean
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
data-slot="tab-link"
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div data-slot="tab-close" class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
@@ -0,0 +1,581 @@
import {
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
onCleanup,
onMount,
Show,
} from "solid-js"
import { Portal } from "solid-js/web"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
import { ServerConnection } from "@/context/server"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
import { useGlobal, type ServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode"
import {
captureTabPointerDown,
canStartTabDrag,
createTabDragPreview,
isPrimaryPointerPressed,
isTabCloseTarget,
} from "./titlebar-tab-gesture"
import {
ACTIVATION_DISTANCE,
autoscrollSpeed,
captureTabDragLayout,
clampFloaterLeft,
draftOrderChanged,
insertIndexFromVirtualLayout,
movePlaceholder,
pointerDistance,
syncLayoutScroll,
type TabDragLayout,
} from "@/components/titlebar-tab-drag"
function SessionTabSlot(props: {
tab: SessionTab
id: string
first: () => boolean
active: () => boolean
activeServerKey: ServerConnection.Key
forceTruncate: boolean
dragActive: boolean
dragged: () => boolean
pressed: () => boolean
serverCtx: () => ServerCtx | undefined
suppressNavigation: () => boolean
onPointerDown: (event: PointerEvent) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx()
return ctx ? { id: props.tab.sessionId, ctx } : null
},
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
let prefetched = false
createEffect(() => {
const ctx = props.serverCtx()
const value = session()
if (!ctx || !value || prefetched) return
prefetched = true
createRoot((dispose) => {
try {
void ctx.sync
.ensureDirSyncContext(value.directory)
.session.sync(value.id)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
createEffect(() => {
const value = session()
const current = sdk()
if (!value || !current) return
createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.directory),
id: value.id,
})
})
return (
<div
data-titlebar-tab-slot
data-tab-key={props.id}
class="flex shrink-0"
classList={{
hidden: !session(),
"ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !props.first(),
"pointer-events-none": props.dragActive,
}}
onPointerDown={props.onPointerDown}
>
<TabNavItem
ref={ref}
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onTitleChangeFailed={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
activeServer={props.tab.server === props.activeServerKey}
forceTruncate={props.forceTruncate}
suppressNavigation={props.suppressNavigation}
pressed={props.pressed()}
hidden={props.dragged()}
/>
</div>
)
}
export function TitlebarTabStrip(props: {
tabs: Tab[]
currentTab: () => Tab | undefined
activeServerKey: ServerConnection.Key
forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void
onReorder: (keys: string[]) => void
onOverflowChange: (overflowing: boolean) => void
}) {
const global = useGlobal()
const language = useLanguage()
const [drag, setDrag] = createStore({
active: false,
draggedId: undefined as string | undefined,
placeholderIndex: 0,
draftOrder: [] as string[],
initialOrder: [] as string[],
draggedWidth: 0,
pointerX: 0,
grabOffsetX: 0,
floaterTop: 0,
})
const [gesture, setGesture] = createStore({
pending: undefined as
| {
id: string
startX: number
startY: number
grabOffsetX: number
grabOffsetY: number
pointerId: number
width: number
element: HTMLDivElement
}
| undefined,
})
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
const [pressedId, setPressedId] = createSignal<string | undefined>()
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement
let dragLayout: TabDragLayout | undefined
let dragPointerId: number | undefined
let autoscrollFrame: number | undefined
let resizeFrame: number | undefined
let dragPreview: HTMLDivElement | undefined
const tabIds = () => props.tabs.map(tabKey)
const displayTabs = createMemo(() => {
if (!drag.active || drag.draftOrder.length === 0) return props.tabs
const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
})
function refreshOverflow() {
if (!scrollRef) return
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
}
createResizeObserver(
() => [scrollRef, listRef],
() => {
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
refreshOverflow()
if (!drag.active || !listRef) return
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
updateInsertIndex()
})
},
)
function syncScroll() {
if (!scrollRef || !listRef || !dragLayout) return
syncLayoutScroll(listRef, dragLayout)
setStripScrollLeft(scrollRef.scrollLeft)
updateInsertIndex()
}
function stopAutoscroll() {
if (autoscrollFrame === undefined) return
cancelAnimationFrame(autoscrollFrame)
autoscrollFrame = undefined
}
function tickAutoscroll() {
if (!drag.active || !scrollRef) return
const strip = scrollRef.getBoundingClientRect()
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
if (speed !== 0) {
scrollRef.scrollLeft += speed
syncScroll()
}
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
}
function startAutoscroll() {
stopAutoscroll()
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
}
function applyPlaceholderIndex(nextIndex: number) {
const id = drag.draggedId
if (!id) return
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
setDrag({
draftOrder: next,
placeholderIndex: nextIndex,
})
}
function updateInsertIndex() {
if (!drag.active || !dragLayout) return
const draggedId = drag.draggedId
if (!draggedId) return
const nextIndex = insertIndexFromVirtualLayout(
drag.pointerX,
drag.draftOrder,
draggedId,
drag.placeholderIndex,
dragLayout,
)
if (nextIndex === drag.placeholderIndex) return
applyPlaceholderIndex(nextIndex)
}
function startDrag(id: string) {
const order = tabIds()
const index = order.indexOf(id)
const pending = gesture.pending
if (index === -1 || !pending || !listRef || !scrollRef) return
dragLayout = captureTabDragLayout(listRef, order)
dragPreview = createTabDragPreview(pending.element)
dragPointerId = pending.pointerId
setGesture("pending", undefined)
setDrag({
active: true,
draggedId: id,
placeholderIndex: index,
draftOrder: order,
initialOrder: order,
draggedWidth: pending.width,
pointerX: pending.startX,
grabOffsetX: pending.grabOffsetX,
floaterTop: pending.startY - pending.grabOffsetY,
})
setPressedId(undefined)
setStripScrollLeft(scrollRef.scrollLeft)
startAutoscroll()
}
function endDrag(commit: boolean) {
const initial = drag.initialOrder
const final = drag.draftOrder
const moved = drag.active
if (commit && moved && draftOrderChanged(initial, final)) {
props.onReorder(final)
}
if (moved) setSuppressNavigation(true)
setDrag({
active: false,
draggedId: undefined,
placeholderIndex: 0,
draftOrder: [],
initialOrder: [],
draggedWidth: 0,
pointerX: 0,
grabOffsetX: 0,
floaterTop: 0,
})
dragLayout = undefined
dragPreview = undefined
dragPointerId = undefined
setGesture("pending", undefined)
setPressedId(undefined)
stopAutoscroll()
refreshOverflow()
requestAnimationFrame(() => setSuppressNavigation(false))
}
function onPointerDown(id: string, event: PointerEvent) {
if (event.button !== 0 || drag.active) return
if (!canStartTabDrag(event.pointerType)) return
if (isTabCloseTarget(event.target)) return
const tabEl = (event.currentTarget as HTMLElement).querySelector<HTMLDivElement>("[data-titlebar-tab]")
if (!tabEl) return
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
const tab = props.tabs.find((item) => tabKey(item) === id)
if (!tab) return
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
setSuppressNavigation(true)
props.onNavigate(tab, tabEl)
setPressedId(id)
setGesture("pending", {
id,
pointerId: event.pointerId,
...pointer,
})
}
function onPointerMove(event: PointerEvent) {
const pending = gesture.pending
if (pending && event.pointerId !== pending.pointerId) return
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
if (!isPrimaryPointerPressed(event.buttons)) {
if (drag.active) endDrag(true)
if (pending) {
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
return
}
if (pending && !drag.active) {
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
startDrag(pending.id)
}
if (!drag.active) return
setDrag("pointerX", event.clientX)
syncScroll()
}
function onPointerUp(event: PointerEvent) {
if (drag.active) {
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
setDrag("pointerX", event.clientX)
syncScroll()
endDrag(true)
return
}
const pending = gesture.pending
if (pending && event.pointerId !== pending.pointerId) return
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
function onPointerCancel(event: PointerEvent) {
if (drag.active) {
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
endDrag(false)
return
}
if (!gesture.pending) return
if (gesture.pending.pointerId !== event.pointerId) return
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
onMount(() => {
const cleanups = [
makeEventListener(window, "pointermove", onPointerMove),
makeEventListener(window, "pointerup", onPointerUp),
makeEventListener(window, "pointercancel", onPointerCancel),
]
refreshOverflow()
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
})
onCleanup(stopAutoscroll)
onCleanup(() => {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
})
createEffect(() => {
props.tabs.length
tabIds()
refreshOverflow()
})
createEffect(() => {
if (!drag.active || !scrollRef) return
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
})
const floaterStyle = () => {
stripScrollLeft()
const strip = scrollRef?.getBoundingClientRect()
const left = strip
? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
: drag.pointerX - drag.grabOffsetX
return {
position: "fixed" as const,
top: `${drag.floaterTop}px`,
left: `${left}px`,
width: `${drag.draggedWidth}px`,
"z-index": "10000",
"pointer-events": "none" as const,
}
}
const draggedTab = createMemo(() => {
const id = drag.draggedId
if (!id) return
return props.tabs.find((tab) => tabKey(tab) === id)
})
return (
<>
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={scrollRef}
>
<div class="flex min-w-0 flex-row items-center" ref={listRef}>
<For each={displayTabs()}>
{(tab, index) => {
const id = tabKey(tab)
const first = () => index() === 0
let ref!: HTMLDivElement
useTabShortcut(index, () => props.onNavigate(tab, ref))
const dragged = () => drag.active && drag.draggedId === id
const serverCtx = createMemo(() => {
if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
if (conn) return global.ensureServerCtx(conn)
})
if (tab.type === "session") {
return (
<SessionTabSlot
tab={tab}
id={id}
first={first}
active={() => props.currentTab() === tab}
activeServerKey={props.activeServerKey}
forceTruncate={props.forceTruncate}
dragActive={drag.active}
dragged={dragged}
pressed={() => pressedId() === id}
serverCtx={serverCtx}
suppressNavigation={() => suppressNavigation()}
onPointerDown={(event) => {
if (dragged()) return
onPointerDown(id, event)
}}
onNavigate={(element) => props.onNavigate(tab, element)}
onClose={() => props.onClose(tab)}
/>
)
}
return (
<div
data-titlebar-tab-slot
data-tab-key={id}
class="flex shrink-0"
classList={{
"ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !first(),
"pointer-events-none": drag.active,
}}
onPointerDown={(event) => {
if (dragged()) return
onPointerDown(id, event)
}}
>
<DraftTabItem
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
onNavigate={() => props.onNavigate(tab, ref)}
onClose={() => props.onClose(tab)}
suppressNavigation={() => suppressNavigation()}
active={props.currentTab() === tab}
pressed={pressedId() === id}
hidden={dragged()}
/>
</div>
)
}}
</For>
</div>
</div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</div>
<Show when={drag.active && draggedTab() && dragPreview}>
{(_) => (
<Portal>
<div data-titlebar-tab-preview style={floaterStyle()}>
{dragPreview}
</div>
</Portal>
)}
</Show>
</>
)
}
function useTabShortcut(index: () => number, onSelect: () => void) {
const command = useCommand()
command.register(() => {
const number = index() + 1
if (number > 9) return []
return [
{
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
hidden: true,
onSelect,
},
]
})
}
+19 -381
View File
@@ -1,16 +1,4 @@
import {
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
Match,
onMount,
Show,
Switch,
untrack,
} from "solid-js"
import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -30,19 +18,14 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs } from "@/context/tabs"
import { tabKey, useTabs } from "@/context/tabs"
import "./titlebar.css"
import { Session } from "@opencode-ai/sdk/v2"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createTabPromptState } from "@/context/prompt"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -256,7 +239,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch>
<Match when={useV2Titlebar()}>
{(_) => {
const navigate = useNavigate()
const layout = useLayout()
const global = useGlobal()
@@ -422,11 +404,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
})
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
let tabScrollRef!: HTMLDivElement
function refreshTabsAreOverflowing() {
setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth)
}
return (
<div
@@ -465,161 +442,22 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
/>
</TooltipV2>
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={(el) => {
tabScrollRef = el
createResizeObserver(el, refreshTabsAreOverflowing)
}}
>
<div
class="flex min-w-0 flex-row items-center gap-1.5"
ref={(el) => createResizeObserver(el, refreshTabsAreOverflowing)}
>
<For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
useTabShortcut(i, () => tabs.select(tab))
const divider = () =>
i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)
if (tab.type === "draft") {
return (
<>
{divider()}
<DraftTabItem
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
active={currentTab() === tab}
onNavigate={() => {
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
/>
</>
)
}
const serverCtx = createMemo(() => {
const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
return conn ? global.ensureServerCtx(conn) : undefined
})
const sdk = createMemo(() => serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => serverCtx()?.sync.session.peek(tab.sessionId))
const [loadedSession] = createResource(
() => {
const id = tab.sessionId
const ctx = serverCtx()
return ctx ? { id, ctx } : null
},
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
let prefetched = false
createEffect(() => {
const ctx = serverCtx()
const sess = session()
if (!ctx || !sess || prefetched) return
prefetched = true
createRoot((dispose) => {
try {
void ctx.sync
.ensureDirSyncContext(sess.directory)
.session.sync(sess.id)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
createEffect(() => {
if (tab.type !== "session") return
const _sdk = sdk()
if (!_sdk) return
const sess = session()
if (!sess) return
createTabPromptState(tabs, tab, _sdk.scope, {
dir: base64Encode(sess.directory),
id: sess.id,
})
})
return (
<>
{divider()}
<Show when={session()}>
{(session) => (
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
sessionId={tab.sessionId}
session={session()}
onNavigate={() => {
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
)}
</Show>
</>
)
}}
</For>
<Show when={creating() && params.dir}>
{(_) => {
let ref!: HTMLDivElement
onMount(() => {
ref.scrollIntoView({ behavior: "instant" })
})
return (
<>
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
<NewSessionTabItem
ref={ref}
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => {
const tab = tabsStore.at(-1)
if (tab) tabs.select(tab)
else navigate("/")
}}
/>
</>
)
}}
</Show>
</div>
</div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</div>
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab}
activeServerKey={server.key}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.removeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Show when={!(creating() && params.dir)}>
<IconButtonV2
type="button"
@@ -852,206 +690,6 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function useTabShortcut(index: () => number, onSelect: () => void) {
const command = useCommand()
command.register(() => {
const number = index() + 1
if (number > 9) return []
return [
{
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
hidden: true,
onSelect,
},
]
})
}
function TabNavItem(props: {
ref?: HTMLDivElement
href: string
server: ServerConnection.Key
sessionId?: string
hideClose?: boolean
onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
session: Session
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.ensureServerCtx(conn)
})
return (
<div
ref={props.ref}
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={props.session}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
return (
<a
href={props.href}
onClick={(event) => {
event.preventDefault()
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<span data-slot="project-avatar-slot">
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span>
<span class="min-w-0 flex-1">{session().title}</span>
</a>
)
}}
</Show>
<div
class="absolute not-group-hover:not-group-data-[active=true]:not-data-[truncate=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 data-[truncate=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"
data-truncate={props.forceTruncate}
>
<div
class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
}}
/>
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
)
}
function DraftTabItem(props: {
ref?: HTMLDivElement
href: string
title: string
active?: boolean
onNavigate: () => void
onClose: () => void
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={props.ref}
data-active={props.active}
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
href={props.href}
onClick={(event) => {
event.preventDefault()
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={props.ref}
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
href={props.href}
aria-current="page"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
function ChannelIndicator() {
return (
<>
+5 -1
View File
@@ -1,5 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
@@ -73,6 +73,10 @@ export const createDirSyncContext = (
if (match.found) return serverSync.data.project[match.index]
},
session: {
remember(session: Session) {
serverSync.session.remember(session)
index(session.id)
},
get(sessionID: string) {
const session = serverSync.session.get(sessionID)
if (session?.directory === directory) return session
+1 -1
View File
@@ -60,7 +60,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK()
const sync = useSync()
const serverSDK = useServerSDK()
const providers = useProviders()
const providers = useProviders(() => sdk().directory)
const models = useModels()
const id = createMemo(() => params.id || undefined)
+3 -3
View File
@@ -1,4 +1,4 @@
import { createMemo } from "solid-js"
import { type Accessor, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
@@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models",
gate: false,
init: () => {
const providers = useProviders()
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
const providers = useProviders(props.directory)
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),
+19 -19
View File
@@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import { useServer } from "./server"
import { ServerConnection, useServer } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
@@ -169,6 +169,15 @@ type PromptStore = {
type Scope = { draftID: string } | { dir: string; id?: string }
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
if (!scope.id) return
return (
tabs.find((tab) => tab.type === "session" && tab.server === server && tab.sessionId === scope.id) ??
({ type: "session", server, sessionId: scope.id } satisfies Tab)
)
}
function scopeKey(scope: Scope) {
if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
@@ -213,7 +222,7 @@ function promptStore(): PromptStore {
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
return {
const value = {
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
@@ -250,7 +259,9 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
export function createPromptState() {
@@ -301,21 +312,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const owner = getOwner()
const tab = createMemo<Tab | undefined>(() => {
if (!settings.general.newLayoutDesigns()) return
if (search.draftId) {
return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId)
}
if (!params.id) return
const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key
return (
tabs.store.find(
(item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id,
) ?? { type: "session", server: serverKey, sessionId: params.id }
)
})
const serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key)
const scope = () =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: Scope) => {
const current = tab()
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
@@ -341,14 +342,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return entry.value
}
const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
)
const session = createMemo(() => load(scope()))
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
return {
ready,
capture: (scope?: Scope) => pick(scope).capture(),
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
+10
View File
@@ -125,6 +125,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
return next
},
reorder(keys: string[]) {
setStore(
produce((tabs) => {
const byKey = new Map(tabs.map((tab) => [tabKey(tab), tab]))
const next = keys.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
if (next.length !== tabs.length) return
tabs.splice(0, tabs.length, ...next)
}),
)
},
draft(draftID: string) {
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
@@ -0,0 +1,59 @@
import { expect, test } from "bun:test"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { selectProviderCatalog } from "./provider-catalog"
const catalog = (id: string): NormalizedProviderListResponse => ({
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
connected: [id],
default: { [id]: `${id}-model` },
})
test("selects the ready catalog for an explicit directory", () => {
const directory = catalog("directory")
expect(
selectProviderCatalog({
explicit: true,
directory: "/repo",
catalog: { ready: true, providers: directory },
}),
).toBe(directory)
})
test("returns an empty catalog while an explicit directory is unresolved", () => {
expect(selectProviderCatalog({ explicit: true })).toEqual({ all: new Map(), connected: [], default: {} })
expect(
selectProviderCatalog({
explicit: true,
directory: "/repo",
catalog: { ready: false, providers: catalog("directory") },
}),
).toEqual({ all: new Map(), connected: [], default: {} })
})
test("uses the route catalog when it is ready", () => {
const directory = catalog("directory")
expect(
selectProviderCatalog({
explicit: false,
directory: "/repo",
catalog: { ready: true, providers: directory },
global: catalog("global"),
}),
).toBe(directory)
})
test("falls back to the global catalog for route consumers", () => {
const global = catalog("global")
expect(selectProviderCatalog({ explicit: false, global })).toBe(global)
expect(
selectProviderCatalog({
explicit: false,
directory: "/repo",
catalog: { ready: false, providers: catalog("directory") },
global,
}),
).toBe(global)
})
@@ -0,0 +1,27 @@
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
type DirectoryCatalog = {
ready: boolean
providers: NormalizedProviderListResponse
}
type ProviderCatalogInput =
| {
explicit: true
directory?: string
catalog?: DirectoryCatalog
}
| {
explicit: false
directory?: string
catalog?: DirectoryCatalog
global: NormalizedProviderListResponse
}
export function selectProviderCatalog(input: ProviderCatalogInput) {
if (input.directory && input.catalog?.ready) return input.catalog.providers
if (input.explicit) return emptyProviderCatalog
return input.global
}
+18 -8
View File
@@ -2,7 +2,8 @@ import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect"
import { createMemo } from "solid-js"
import type { Accessor } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog"
export const popularProviders = [
"opencode",
@@ -16,16 +17,25 @@ export const popularProviders = [
]
const popularProviderSet = new Set(popularProviders)
export function useProviders() {
export function useProviders(directory?: Accessor<string | undefined>) {
const serverSync = useServerSync()
const params = useParams()
const dir = createMemo(() => decode64(params.dir) ?? "")
const dir = () => (directory ? directory() : decode64(params.dir))
const providers = () => {
if (dir()) {
const [projectStore] = serverSync().child(dir())
if (projectStore.provider_ready) return projectStore.provider
}
return serverSync().data.provider
const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined
if (directory)
return selectProviderCatalog({
explicit: true,
directory: value,
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
})
return selectProviderCatalog({
explicit: false,
directory: value,
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
global: serverSync().data.provider,
})
}
return {
all: () => providers().all,
+12 -8
View File
@@ -58,14 +58,18 @@ export function DirectoryDataProvider(
})
return (
<DataProvider
data={sync().data}
directory={directory()}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
<Show when={directory()} keyed>
{(directory) => (
<DataProvider
data={sync().data}
directory={directory}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)}
</Show>
)
}
-1
View File
@@ -72,7 +72,6 @@ export default function NewSessionPage() {
state={composer}
sessionKey={route.sessionKey()}
sessionID={route.params.id}
serverKey={route.params.serverKey}
controls={composerControls()}
promptInput={{
ref: (el) => {
+12 -2
View File
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router"
import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { useServerSync } from "@/context/server-sync"
@@ -70,6 +70,7 @@ import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
type FollowupItem = FollowupDraft & { id: string }
@@ -80,6 +81,7 @@ type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
export default function Page() {
const navigate = useNavigate()
const serverSync = useServerSync()
const layout = useLayout()
const local = useLocal()
@@ -1550,7 +1552,6 @@ export default function Page() {
state={composer}
sessionKey={sessionKey()}
sessionID={params.id}
serverKey={params.serverKey}
controls={composerControls()}
promptInput={{
ref: (el) => {
@@ -1570,6 +1571,15 @@ export default function Page() {
ready={!store.deferRender && messagesReady()}
centered={placement === "dock" && centered()}
placement={placement}
openParent={() => {
const id = info()?.parentID
if (!id) return
navigate(
params.serverKey
? sessionHref(requireServerKey(params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
}}
onResponseSubmit={resumeScroll}
followup={
params.id && !isChildSession()
@@ -1,6 +1,5 @@
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
@@ -16,14 +15,11 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { FollowupDraft } from "@/components/prompt-input/submit"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { useSDK } from "@/context/sdk"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
export function SessionComposerRegion(props: {
state: SessionComposerState
sessionKey: string
sessionID?: string
serverKey?: string
controls: PromptInputControls
promptInput: Omit<PromptInputProps, "controls" | "variant">
todo: {
@@ -33,6 +29,7 @@ export function SessionComposerRegion(props: {
ready: boolean
centered: boolean
placement?: "dock" | "inline"
openParent?: () => void
onResponseSubmit: () => void
followup?: {
queue: () => boolean
@@ -53,11 +50,9 @@ export function SessionComposerRegion(props: {
}
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const navigate = useNavigate()
const prompt = props.promptInput.state ?? usePrompt()
const language = useLanguage()
const sync = useSync()
const sdk = useSDK()
const handoffPrompt = createMemo(() => getSessionHandoff(props.sessionKey)?.prompt)
const info = createMemo(() => (props.sessionID ? sync().session.get(props.sessionID) : undefined))
@@ -137,14 +132,6 @@ export function SessionComposerRegion(props: {
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
const full = createMemo(() => Math.max(78, store.height))
const openParent = () => {
const id = parentID()
if (!id) return
navigate(
props.serverKey ? sessionHref(requireServerKey(props.serverKey), id) : legacySessionHref(sdk().directory, id),
)
}
createEffect(() => {
const el = store.body
if (!el) return
@@ -301,11 +288,11 @@ export function SessionComposerRegion(props: {
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
>
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={parentID()}>
<Show when={parentID() && props.openParent}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={openParent}
onClick={props.openParent}
>
{language.t("session.child.backToParent")}
</button>
@@ -62,6 +62,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
const [store, setStore] = createStore({
sessionID: params.id,
responding: undefined as string | undefined,
dock: todos().length > 0 && !done() && live(),
closing: false,
@@ -132,7 +133,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
if (!previous || previous[0] !== id) {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ dock: todoDockAtBoundary(next), closing: false, opening: false })
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
if (next === "clear") clear()
return
}
@@ -191,9 +192,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
permissionResponding,
decide,
todos,
dock: () => store.dock,
closing: () => store.closing,
opening: () => store.opening,
dock: () =>
store.sessionID === params.id
? store.dock
: todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
closing: () => store.sessionID === params.id && store.closing,
opening: () => store.sessionID === params.id && store.opening,
}
}
@@ -0,0 +1,37 @@
import { createComputed, onCleanup } from "solid-js"
export function createSessionOwnership(sessionKey: () => string) {
let current = sessionKey()
let generation = 0
const transition = () => {
const next = sessionKey()
if (next === current) return
current = next
generation++
}
createComputed(transition)
onCleanup(() => generation++)
return {
key: () => {
transition()
return `${generation}:${current}`
},
capture() {
transition()
const captured = generation
return {
key: `${captured}:${current}`,
current: () => {
transition()
return generation === captured
},
run<T>(action: () => T) {
transition()
if (generation !== captured) return
return action()
},
}
},
}
}
@@ -20,6 +20,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useTabs } from "@/context/tabs"
import { requireServerKey } from "@/utils/session-route"
import { createSessionOwnership } from "./session-ownership"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -50,7 +51,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionTabs = useTabs()
const layout = useLayout()
const navigate = useNavigate()
const { params, tabs, view } = useSessionLayout()
const { params, sessionKey, tabs, view } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
const owner = sessionOwnership.capture()
const value = await load()
owner.run(() => show(value))
}
const runCommand = async <T,>(input: {
owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>
prompt: T
request: () => Promise<unknown>
updatePrompt: (prompt: T) => void
updateViewport: () => void
}) => {
await input.request()
input.updatePrompt(input.prompt)
input.owner.run(input.updateViewport)
}
const info = () => {
const id = params.id
@@ -217,9 +235,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const openFile = () => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
})
void openDialog(
() => import("@/components/dialog-select-file"),
(x) => dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />),
)
}
const closeTab = () => {
@@ -253,15 +272,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const chooseModel = () => {
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
void openDialog(
() => import("@/components/dialog-select-model"),
(x) => dialog.show(() => <x.DialogSelectModel model={local.model} />),
)
}
const chooseMcp = () => {
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
void openDialog(
() => import("@/components/dialog-select-mcp"),
(x) => dialog.show(() => <x.DialogSelectMcp />),
)
}
const toggleAutoAccept = () => {
@@ -285,47 +306,61 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const undo = async () => {
const sessionID = params.id
if (!sessionID) return
if (sync().data.session_working(params.id ?? "")) {
await sdk()
.client.session.abort({ sessionID })
.catch(() => {})
}
const owner = sessionOwnership.capture()
const client = sdk().client
const directory = sdk().directory
const promptSession = prompt.capture()
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
const messages = userMessages()
const message = findLast(messages, (x) => !revert || x.id < revert)
if (!message) return
await sdk().client.session.revert({ sessionID, messageID: message.id })
const parts = sync().data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk().directory })
prompt.set(restored)
if (sync().data.session_working(sessionID)) {
await client.session.abort({ sessionID }).catch(() => {})
}
const prev = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(prev)
await runCommand({
owner,
prompt: promptSession,
request: () => client.session.revert({ sessionID, messageID: message.id }),
updatePrompt: (promptSession) => {
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
},
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)),
})
}
const redo = async () => {
const sessionID = params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const client = sdk().client
const messages = userMessages()
const promptSession = prompt.capture()
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const next = userMessages().find((x) => x.id > revertMessageID)
const next = messages.find((x) => x.id > revertMessageID)
if (!next) {
await sdk().client.session.unrevert({ sessionID })
prompt.reset()
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(last)
await runCommand({
owner,
prompt: promptSession,
request: () => client.session.unrevert({ sessionID }),
updatePrompt: (promptSession) => promptSession.reset(),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
})
return
}
await sdk().client.session.revert({ sessionID, messageID: next.id })
const prev = findLast(userMessages(), (x) => x.id < next.id)
setActiveMessage(prev)
await runCommand({
owner,
prompt: promptSession,
request: () => client.session.revert({ sessionID, messageID: next.id }),
updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
})
}
const compact = async () => {
@@ -349,9 +384,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const fork = () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
void openDialog(
() => import("@/components/dialog-fork"),
(x) => dialog.show(() => <x.DialogFork />),
)
}
const shareCmds = () => {
+37 -1
View File
@@ -1,8 +1,38 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
import {
legacySessionHref,
legacySessionServer,
requireServerKey,
rootSession,
selectSessionLineage,
sessionHref,
} from "./session-route"
describe("session routes", () => {
test("uses the unique persisted server for a legacy session route", () => {
expect(
legacySessionServer(
[{ type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" }],
"session-1",
ServerConnection.Key.make("server-a"),
),
).toBe(ServerConnection.Key.make("server-b"))
})
test("prefers the active server when a legacy session ID is ambiguous", () => {
expect(
legacySessionServer(
[
{ type: "session", server: ServerConnection.Key.make("server-a"), sessionId: "session-1" },
{ type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" },
],
"session-1",
ServerConnection.Key.make("server-b"),
),
).toBe(ServerConnection.Key.make("server-b"))
})
test("builds and decodes a server-keyed session route", () => {
const server = ServerConnection.Key.make("https://example.com:4096")
const href = sessionHref(server, "session-1")
@@ -45,4 +75,10 @@ describe("session routes", () => {
expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child")
})
test("ignores a resolved lineage retained from the previous route", () => {
const previous = { session: { id: "A" }, root: { id: "A" } }
expect(selectSessionLineage("B", undefined, previous)).toBeUndefined()
})
})
+18
View File
@@ -16,8 +16,26 @@ export function requireServerKey(segment: string | undefined) {
return ServerConnection.Key.make(key)
}
export function legacySessionServer(
tabs: readonly { type: "session"; server: ServerConnection.Key; sessionId: string }[],
sessionID: string,
active: ServerConnection.Key,
) {
const matches = tabs.filter((tab) => tab.sessionId === sessionID)
return matches.find((tab) => tab.server === active)?.server ?? (matches.length === 1 ? matches[0]?.server : active)
}
type SessionParent = { id: string; parentID?: string }
export function selectSessionLineage<T extends { session: { id: string } }>(
sessionID: string,
cached: T | undefined,
resolved: T | undefined,
) {
if (cached?.session.id === sessionID) return cached
if (resolved?.session.id === sessionID) return resolved
}
export async function rootSession<T extends SessionParent>(session: T, get: (sessionID: string) => Promise<T>) {
const seen = new Set([session.id])
let current = session
@@ -15,7 +15,6 @@ test("snaps spring progress when the session changes", async () => {
expect(state.progress()).toBe(0)
state.setSession("session-b")
await Bun.sleep(0)
expect(state.progress()).toBe(1)
state.dispose()
})
@@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
import { createPromptState } from "@/context/prompt"
describe("prompt attachment session ownership", () => {
test("adds an asynchronously read image to the session where the read started", async () => {
await createRoot(async (dispose) => {
const sessions = { A: createPromptState(), B: createPromptState() }
let active: "A" | "B" = "A"
const attachments = createPromptAttachmentsCore({
capture: () => sessions[active].capture(),
editor: () => document.createElement("div"),
})
const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" }))
active = "B"
await pending
expect(images(sessions.A)).toHaveLength(1)
expect(images(sessions.B)).toHaveLength(0)
dispose()
})
})
test("finishes the captured attachment after the active editor is removed", async () => {
await createRoot(async (dispose) => {
const prompt = createPromptState()
let editor: HTMLDivElement | undefined = document.createElement("div")
const attachments = createPromptAttachmentsCore({
capture: prompt.capture,
editor: () => editor,
})
const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" }))
editor = undefined
await pending
expect(images(prompt)).toHaveLength(1)
dispose()
})
})
test("keeps every file in a batch on the session where the batch started", async () => {
await createRoot(async (dispose) => {
const sessions = { A: createPromptState(), B: createPromptState() }
let active: "A" | "B" = "A"
const attachments = createPromptAttachmentsCore({
capture: () => sessions[active].capture(),
editor: () => document.createElement("div"),
})
const pending = attachments.addAttachments([
new File([new Uint8Array(1024 * 1024)], "first.png", { type: "image/png" }),
new File([new Uint8Array(1024 * 1024)], "second.png", { type: "image/png" }),
])
active = "B"
await pending
expect(images(sessions.A)).toHaveLength(2)
expect(images(sessions.B)).toHaveLength(0)
dispose()
})
})
test("keeps a delayed native clipboard image on the session where paste started", async () => {
await createRoot(async (dispose) => {
const sessions = { A: createPromptState(), B: createPromptState() }
const read = Promise.withResolvers<File | null>()
let active: "A" | "B" = "A"
const attachments = createPromptAttachmentsCore({
capture: () => sessions[active].capture(),
editor: () => document.createElement("div"),
})
const pending = attachments.addClipboardAttachment(read.promise)
active = "B"
read.resolve(new File([new Uint8Array(1024 * 1024)], "clipboard.png", { type: "image/png" }))
await pending
expect(images(sessions.A)).toHaveLength(1)
expect(images(sessions.B)).toHaveLength(0)
dispose()
})
})
})
function images(prompt: ReturnType<typeof createPromptState>) {
return prompt.current().filter((part) => part.type === "image")
}
@@ -0,0 +1,14 @@
import { expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { selectPromptTab } from "@/context/prompt"
import type { Tab } from "@/context/tabs"
test("selects the explicitly scoped session tab instead of the active tab", () => {
const server = ServerConnection.Key.make("local")
const tabs: Tab[] = [
{ type: "session", server, sessionId: "A" },
{ type: "session", server, sessionId: "B" },
]
expect(selectPromptTab(tabs, { dir: "repo", id: "B" }, server)).toBe(tabs[1])
})
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test"
import { createPromptState } from "@/context/prompt"
import { createPromptSubmissionState } from "@/components/prompt-input/submission-state"
describe("prompt submission state", () => {
test("keeps failed submission restoration with the prompt where it started", () => {
const target = createPromptState()
const submission = createPromptSubmissionState({
target,
prompt: [{ type: "text", content: "prompt-A", start: 0, end: 8 }],
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
})
expect(submission.restore()).toEqual({
target,
prompt: [{ type: "text", content: "prompt-A", start: 0, end: 8 }],
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
})
})
test("moves first-submit restoration and context to the promoted session", () => {
const draft = createPromptState()
const session = createPromptState()
const submission = createPromptSubmissionState({
target: draft,
prompt: [{ type: "text", content: "first prompt", start: 0, end: 12 }],
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
})
submission.retarget(session)
expect(submission.restore()).toEqual({
target: session,
prompt: [{ type: "text", content: "first prompt", start: 0, end: 12 }],
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
})
expect(session.context.items()).toHaveLength(1)
expect(session.context.items()[0]).toMatchObject({ type: "file", path: "src/index.ts" })
})
test("does not restore over a prompt edited after submission", () => {
const target = createPromptState()
target.set([{ type: "text", content: "submitted", start: 0, end: 9 }])
const submission = createPromptSubmissionState({
target,
prompt: target.current(),
context: [],
})
submission.clear()
target.set([{ type: "text", content: "new draft", start: 0, end: 9 }])
expect(submission.restore()).toBeUndefined()
expect(target.current()[0]).toMatchObject({ type: "text", content: "new draft" })
})
})
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createPromptInputTransientState } from "@/components/prompt-input/transient-state"
test("resets transient prompt input state when the prompt session changes", () => {
createRoot((dispose) => {
const [identity, setIdentity] = createSignal("A")
const [state, setState] = createPromptInputTransientState(identity, 3)
setState({
popover: "slash",
historyIndex: 2,
savedPrompt: {
prompt: [{ type: "text", content: "draft-A", start: 0, end: 7 }],
comments: [],
},
draggingType: "image",
mode: "shell",
applyingHistory: true,
variantOpen: true,
})
setIdentity("B")
expect(state).toMatchObject({
popover: null,
historyIndex: -1,
savedPrompt: null,
placeholder: 3,
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
dispose()
})
})
@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createSessionOwnership } from "@/pages/session/session-ownership"
describe("createSessionOwnership", () => {
test("invalidates captured work when its Solid owner is disposed", () => {
let current = true
createRoot((dispose) => {
const owner = createSessionOwnership(() => "A").capture()
dispose()
current = owner.current()
})
expect(current).toBe(false)
})
test("does not run a continuation after navigation", () => {
createRoot((dispose) => {
const [session, setSession] = createSignal("A")
const owner = createSessionOwnership(session).capture()
let ran = false
setSession("B")
owner.run(() => {
ran = true
})
expect(ran).toBe(false)
dispose()
})
})
test("does not revive a continuation after A to B to A navigation", () => {
createRoot((dispose) => {
const [session, setSession] = createSignal("A")
const owner = createSessionOwnership(session).capture()
setSession("B")
setSession("A")
expect(owner.current()).toBe(false)
dispose()
})
})
})
+27
View File
@@ -0,0 +1,27 @@
# @opencode-ai/client
Private generation target for clients derived directly from OpenCode's authoritative Effect `HttpApi`.
## Entrypoints
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
The generated surface starts with the Session group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
The Promise root remains structural and has no Core or Effect runtime dependency. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce both import graphs.
Effect consumers construct canonical decoded inputs:
```ts
import { AbsolutePath, Location, OpenCode, Prompt } from "@opencode-ai/client/effect"
const client = yield * OpenCode.make({ baseUrl: "https://opencode.example" })
yield *
client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }),
})
yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) })
```
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/client",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts",
"./effect": "./src/effect.ts"
},
"scripts": {
"generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect",
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.83"
},
"peerDependenciesMeta": {
"effect": {
"optional": true
}
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NodeFileSystem } from "@effect/platform-node"
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
import { Api } from "@opencode-ai/server/api"
import { Effect } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { fileURLToPath } from "url"
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
groupNames: { "server.session": "sessions" },
})
await Effect.runPromise(
Effect.all(
[
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
write(
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
),
],
{ concurrency: 2, discard: true },
).pipe(Effect.provide(NodeFileSystem.layer)),
)
+19
View File
@@ -0,0 +1,19 @@
import { makeDefaultApi } from "@opencode-ai/protocol/api"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
"@opencode-ai/client/LocationMiddleware",
) {}
class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
"@opencode-ai/client/SessionLocationMiddleware",
{ error: [InvalidRequestError, SessionNotFoundError] },
) {}
const Api = makeDefaultApi({
locationMiddleware: LocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware,
})
export const SessionGroup = Api.groups["server.session"]
+12
View File
@@ -0,0 +1,12 @@
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
export * from "./generated-effect/index"
export { Agent } from "@opencode-ai/schema/agent"
export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInput } from "@opencode-ai/schema/session-input"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Prompt } from "@opencode-ai/schema/prompt"
@@ -0,0 +1 @@
["client-error.ts", "client.ts", "index.ts"]
@@ -0,0 +1,5 @@
import { Schema } from "effect"
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
cause: Schema.Defect(),
}) {}
@@ -0,0 +1,164 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect, Schema } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { SessionGroup } from "../contract"
import { ClientError } from "./client-error"
const Api = HttpApi.make("generated").add(SessionGroup)
type RawClient = HttpApiClient.ForApi<typeof Api>
const mapClientError = <E>(error: E) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: error
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
type Endpoint0_0Input = {
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
readonly limit?: Endpoint0_0Request["query"]["limit"]
readonly order?: Endpoint0_0Request["query"]["order"]
readonly search?: Endpoint0_0Request["query"]["search"]
readonly directory?: Endpoint0_0Request["query"]["directory"]
readonly project?: Endpoint0_0Request["query"]["project"]
readonly subpath?: Endpoint0_0Request["query"]["subpath"]
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
}
const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) =>
raw["session.list"]({
query: {
workspace: input?.workspace,
limit: input?.limit,
order: input?.order,
search: input?.search,
directory: input?.directory,
project: input?.project,
subpath: input?.subpath,
cursor: input?.cursor,
},
}).pipe(Effect.mapError(mapClientError))
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
type Endpoint0_1Input = {
readonly id?: Endpoint0_1Request["payload"]["id"]
readonly agent?: Endpoint0_1Request["payload"]["agent"]
readonly model?: Endpoint0_1Request["payload"]["model"]
readonly location?: Endpoint0_1Request["payload"]["location"]
}
const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) =>
raw["session.create"]({
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint0_3Input = {
readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
readonly agent: Endpoint0_3Request["payload"]["agent"]
}
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint0_4Input = {
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
readonly model: Endpoint0_4Request["payload"]["model"]
}
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_5Input = {
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
readonly id?: Endpoint0_5Request["payload"]["id"]
readonly prompt: Endpoint0_5Request["payload"]["prompt"]
readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
readonly resume?: Endpoint0_5Request["payload"]["resume"]
}
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
raw["session.prompt"]({
params: { sessionID: input.sessionID },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint0_8Input = {
readonly sessionID: Endpoint0_8Request["params"]["sessionID"]
readonly messageID: Endpoint0_8Request["payload"]["messageID"]
readonly files?: Endpoint0_8Request["payload"]["files"]
}
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
raw["session.revert.stage"]({
params: { sessionID: input.sessionID },
payload: { messageID: input.messageID, files: input.files },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
list: Endpoint0_0(raw),
create: Endpoint0_1(raw),
get: Endpoint0_2(raw),
switchAgent: Endpoint0_3(raw),
switchModel: Endpoint0_4(raw),
prompt: Endpoint0_5(raw),
compact: Endpoint0_6(raw),
wait: Endpoint0_7(raw),
stage: Endpoint0_8(raw),
clear: Endpoint0_9(raw),
commit: Endpoint0_10(raw),
context: Endpoint0_11(raw),
})
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
export const make = (options?: { readonly baseUrl?: URL | string }) =>
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
@@ -0,0 +1,2 @@
export { ClientError } from "./client-error"
export * as OpenCode from "./client"
@@ -0,0 +1 @@
["client-error.ts", "client.ts", "index.ts", "types.ts"]
@@ -0,0 +1,11 @@
export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"
export class ClientError extends Error {
override readonly name = "ClientError"
constructor(
readonly reason: ClientErrorReason,
options?: ErrorOptions,
) {
super(reason, options)
}
}
+349
View File
@@ -0,0 +1,349 @@
import type {
SessionsListInput,
SessionsListOutput,
SessionsCreateInput,
SessionsCreateOutput,
SessionsGetInput,
SessionsGetOutput,
SessionsSwitchAgentInput,
SessionsSwitchAgentOutput,
SessionsSwitchModelInput,
SessionsSwitchModelOutput,
SessionsPromptInput,
SessionsPromptOutput,
SessionsCompactInput,
SessionsCompactOutput,
SessionsWaitInput,
SessionsWaitOutput,
SessionsStageInput,
SessionsStageOutput,
SessionsClearInput,
SessionsClearOutput,
SessionsCommitInput,
SessionsCommitOutput,
SessionsContextInput,
SessionsContextOutput,
} from "./types"
import { ClientError } from "./client-error"
export interface ClientOptions {
readonly baseUrl: string
readonly fetch?: typeof globalThis.fetch
readonly headers?: HeadersInit
}
export interface RequestOptions {
readonly signal?: AbortSignal
readonly headers?: HeadersInit
}
interface RequestDescriptor {
readonly method: string
readonly path: string
readonly query?: Record<string, unknown>
readonly headers?: Record<string, unknown>
readonly body?: unknown
readonly successStatus: number
readonly declaredStatuses: ReadonlyArray<number>
readonly empty: boolean
}
export function make(options: ClientOptions) {
const fetch = options.fetch ?? globalThis.fetch
const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
const url = new URL(descriptor.path, options.baseUrl)
for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)
const headers = new Headers(options.headers)
for (const [key, value] of Object.entries(descriptor.headers ?? {})) {
if (value !== undefined && value !== null) headers.set(key, String(value))
}
for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)
if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
return {
url,
init: {
method: descriptor.method,
signal: requestOptions?.signal,
headers,
body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),
} satisfies RequestInit,
}
}
const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
try {
const prepared = prepare(descriptor, requestOptions)
return await fetch(prepared.url, prepared.init)
} catch (cause) {
throw new ClientError("Transport", { cause })
}
}
const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })
}
const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
const response = await execute(descriptor, requestOptions)
if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
if (descriptor.empty) {
try {
await response.body?.cancel()
} catch {}
return undefined as A
}
return (await json(response)) as A
}
const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => ({
async *[Symbol.asyncIterator]() {
const response = await execute(descriptor, requestOptions)
if (response.status !== descriptor.successStatus) await responseError(response, descriptor)
if (!isContentType(response, "text/event-stream")) {
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnsupportedContentType")
}
if (response.body === null) throw new ClientError("MalformedResponse")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
try {
while (true) {
let next
try {
next = await reader.read()
} catch (cause) {
throw new ClientError("Transport", { cause })
}
buffer += decoder.decode(next.value, { stream: !next.done })
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
if (trailingCarriageReturn) buffer += "\r"
if (next.done && buffer !== "") buffer += "\n\n"
let boundary = buffer.indexOf("\n\n")
while (boundary >= 0) {
const block = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const data = block
.split("\n")
.flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : []))
.join("\n")
if (data !== "") {
try {
yield JSON.parse(data) as A
} catch (cause) {
throw new ClientError("MalformedResponse", { cause })
}
}
boundary = buffer.indexOf("\n\n")
}
if (next.done) return
}
} finally {
try {
await reader.cancel()
} catch {}
reader.releaseLock()
}
},
})
return {
sessions: {
list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
request<SessionsListOutput>(
{
method: "GET",
path: `/api/session`,
query: {
workspace: input?.workspace,
limit: input?.limit,
order: input?.order,
search: input?.search,
directory: input?.directory,
project: input?.project,
subpath: input?.subpath,
cursor: input?.cursor,
},
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsCreateOutput }>(
{
method: "POST",
path: `/api/session`,
body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) =>
request<SessionsSwitchAgentOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
body: { agent: input.agent },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) =>
request<SessionsSwitchModelOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
body: { model: input.model },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsPromptOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
successStatus: 200,
declaredStatuses: [409, 404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) =>
request<SessionsCompactOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
successStatus: 204,
declaredStatuses: [404, 503, 400, 401],
empty: true,
},
requestOptions,
),
wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) =>
request<SessionsWaitOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`,
successStatus: 204,
declaredStatuses: [404, 503, 400, 401],
empty: true,
},
requestOptions,
),
stage: (input: SessionsStageInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsStageOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
body: { messageID: input.messageID, files: input.files },
successStatus: 200,
declaredStatuses: [404, 500, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
clear: (input: SessionsClearInput, requestOptions?: RequestOptions) =>
request<SessionsClearOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
successStatus: 204,
declaredStatuses: [404, 500, 400, 401],
empty: true,
},
requestOptions,
),
commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) =>
request<SessionsCommitOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
context: (input: SessionsContextInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsContextOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
successStatus: 200,
declaredStatuses: [404, 500, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
}
}
function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
if (value === undefined || value === null) return
if (Array.isArray(value)) {
for (const item of value) appendQuery(params, key, item)
return
}
if (typeof value === "object") {
for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item)
return
}
params.append(key, String(value))
}
async function json(response: Response): Promise<unknown> {
if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnsupportedContentType")
}
let text: string
try {
text = await response.text()
} catch (cause) {
throw new ClientError("Transport", { cause })
}
if (text === "") throw new ClientError("MalformedResponse")
try {
return JSON.parse(text)
} catch (cause) {
throw new ClientError("MalformedResponse", { cause })
}
}
function isContentType(response: Response, expected: string) {
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
}
+3
View File
@@ -0,0 +1,3 @@
export { ClientError, type ClientErrorReason } from "./client-error"
export * as OpenCode from "./client"
export * from "./types"
+631
View File
@@ -0,0 +1,631 @@
export type JsonValue =
| null
| boolean
| number
| string
| ReadonlyArray<JsonValue>
| { readonly [key: string]: JsonValue }
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidCursorError"
export type InvalidRequestError = {
readonly _tag: "InvalidRequestError"
readonly message: string
readonly kind?: string | undefined
readonly field?: string | undefined
}
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidRequestError"
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnauthorizedError"
export type SessionNotFoundError = {
readonly _tag: "SessionNotFoundError"
readonly sessionID: string
readonly message: string
}
export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionNotFoundError"
export type ConflictError = {
readonly _tag: "ConflictError"
readonly message: string
readonly resource?: string | undefined
}
export const isConflictError = (value: unknown): value is ConflictError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ServiceUnavailableError"
export type MessageNotFoundError = {
readonly _tag: "MessageNotFoundError"
readonly sessionID: string
readonly messageID: string
readonly message: string
}
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "MessageNotFoundError"
export type UnknownError = {
readonly _tag: "UnknownError"
readonly message: string
readonly ref?: string | undefined
}
export const isUnknownError = (value: unknown): value is UnknownError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnknownError"
export type SessionsListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["workspace"]
readonly limit?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["limit"]
readonly order?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["order"]
readonly search?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["search"]
readonly directory?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["directory"]
readonly project?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["project"]
readonly subpath?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["subpath"]
readonly cursor?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["cursor"]
}
export type SessionsListOutput = {
readonly data: ReadonlyArray<{
readonly id: string
readonly parentID?: string
readonly projectID: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
readonly title: string
readonly location: { readonly directory: string; readonly workspaceID?: string | null | null }
readonly subpath?: string | null
readonly revert?: {
readonly messageID: string
readonly partID?: string | null
readonly snapshot?: string | null
readonly diff?: string | null
readonly files?: ReadonlyArray<{
readonly path: string
readonly status: "added" | "modified" | "deleted"
readonly additions: number
readonly deletions: number
readonly patch: string
}> | null
} | null
}>
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
}
export type SessionsCreateInput = {
readonly id?: {
readonly id?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
}["id"]
readonly agent?: {
readonly id?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
}["agent"]
readonly model?: {
readonly id?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
}["model"]
readonly location?: {
readonly id?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
}["location"]
}
export type SessionsCreateOutput = {
readonly data: {
readonly id: string
readonly parentID?: string
readonly projectID: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
readonly title: string
readonly location: { readonly directory: string; readonly workspaceID?: string | null | null }
readonly subpath?: string | null
readonly revert?: {
readonly messageID: string
readonly partID?: string | null
readonly snapshot?: string | null
readonly diff?: string | null
readonly files?: ReadonlyArray<{
readonly path: string
readonly status: "added" | "modified" | "deleted"
readonly additions: number
readonly deletions: number
readonly patch: string
}> | null
} | null
}
}["data"]
export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsGetOutput = {
readonly data: {
readonly id: string
readonly parentID?: string
readonly projectID: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
readonly title: string
readonly location: { readonly directory: string; readonly workspaceID?: string | null | null }
readonly subpath?: string | null
readonly revert?: {
readonly messageID: string
readonly partID?: string | null
readonly snapshot?: string | null
readonly diff?: string | null
readonly files?: ReadonlyArray<{
readonly path: string
readonly status: "added" | "modified" | "deleted"
readonly additions: number
readonly deletions: number
readonly patch: string
}> | null
} | null
}
}["data"]
export type SessionsSwitchAgentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly agent: { readonly agent: string }["agent"]
}
export type SessionsSwitchAgentOutput = void
export type SessionsSwitchModelInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly model: {
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
}["model"]
}
export type SessionsSwitchModelOutput = void
export type SessionsPromptInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
readonly id?: string | undefined
readonly prompt: {
readonly text: string
readonly files?:
| ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | undefined
readonly description?: string | undefined
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
readonly agents?:
| ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
}
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}["id"]
readonly prompt: {
readonly id?: string | undefined
readonly prompt: {
readonly text: string
readonly files?:
| ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | undefined
readonly description?: string | undefined
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
readonly agents?:
| ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
}
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}["prompt"]
readonly delivery?: {
readonly id?: string | undefined
readonly prompt: {
readonly text: string
readonly files?:
| ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | undefined
readonly description?: string | undefined
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
readonly agents?:
| ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
}
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}["delivery"]
readonly resume?: {
readonly id?: string | undefined
readonly prompt: {
readonly text: string
readonly files?:
| ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | undefined
readonly description?: string | undefined
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
readonly agents?:
| ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
}
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}["resume"]
}
export type SessionsPromptOutput = {
readonly data: {
readonly admittedSeq: number
readonly id: string
readonly sessionID: string
readonly prompt: {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | null
readonly description?: string | null
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
}> | null
readonly agents?: ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
}> | null
}
readonly delivery: "steer" | "queue"
readonly timeCreated: number
readonly promotedSeq?: number | null
}
}["data"]
export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsCompactOutput = void
export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsWaitOutput = void
export type SessionsStageInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"]
readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"]
}
export type SessionsStageOutput = {
readonly data: {
readonly messageID: string
readonly partID?: string | undefined
readonly snapshot?: string | undefined
readonly diff?: string | undefined
readonly files?:
| ReadonlyArray<{
readonly path: string
readonly status: "added" | "modified" | "deleted"
readonly additions: number
readonly deletions: number
readonly patch: string
}>
| undefined
}
}["data"]
export type SessionsClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsClearOutput = void
export type SessionsCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsCommitOutput = void
export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsContextOutput = {
readonly data: ReadonlyArray<
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | null
readonly description?: string | null
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
}> | null
readonly agents?: ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
}> | null
readonly type: "user"
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
readonly sessionID: string
readonly text: string
readonly type: "synthetic"
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number; readonly completed?: number | null }
readonly type: "shell"
readonly callID: string
readonly command: string
readonly output: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number; readonly completed?: number | null }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly id: string; readonly text: string }
| {
readonly type: "reasoning"
readonly id: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly provider?: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
} | null
readonly state:
| { readonly status: "pending"; readonly input: string }
| {
readonly status: "running"
readonly input: { readonly [x: string]: JsonValue }
readonly structured: { readonly [x: string]: any }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>
}
| {
readonly status: "completed"
readonly input: { readonly [x: string]: JsonValue }
readonly attachments?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | null
readonly description?: string | null
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
}> | null
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>
readonly outputPaths?: ReadonlyArray<string> | null
readonly structured: { readonly [x: string]: any }
readonly result?: JsonValue | null
}
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>
readonly structured: { readonly [x: string]: any }
readonly error: { readonly type: "unknown"; readonly message: string }
readonly result?: JsonValue | null
}
readonly time: {
readonly created: number
readonly ran?: number | null
readonly completed?: number | null
readonly pruned?: number | null
}
}
>
readonly snapshot?: {
readonly start?: string | null
readonly end?: string | null
readonly files?: ReadonlyArray<string> | null
} | null
readonly finish?: string | null
readonly cost?: number | null
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
} | null
readonly error?: { readonly type: "unknown"; readonly message: string } | null
}
| {
readonly type: "compaction"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
}
>
}["data"]
+1
View File
@@ -0,0 +1 @@
export * from "./generated/index"
@@ -0,0 +1,60 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Location as CoreLocation } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
import { Prompt as CorePrompt } from "@opencode-ai/core/session/prompt"
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { HttpApi } from "effect/unstable/httpapi"
import { SessionGroup } from "../src/contract"
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID)
expect(CoreLocation.Ref).toBe(Location.Ref)
expect(ModelV2.Ref).toBe(Model.Ref)
expect(SessionV2.Info).toBe(Session.Info)
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
expect(CorePrompt).toBe(Prompt)
expect(Api.groups["server.session"].identifier).toBe("server.session")
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
expect(Session.ID.create()).toStartWith("ses_")
expect(Project.ID.global).toBe("global")
expect(Provider.ID.anthropic).toBe("anthropic")
expect(Workspace.ID.create()).toStartWith("wrk_")
})
test("client and Server Session contracts generate identically", () => {
const options = { groupNames: { "server.session": "sessions" } }
const server = compile(HttpApi.make("server").add(Api.groups["server.session"]), options)
const client = compile(HttpApi.make("client").add(SessionGroup), options)
expect(emitPromise(client)).toEqual(emitPromise(server))
})
test("shared DTO schemas construct and decode plain objects", () => {
const made = Prompt.make({ text: "hello" })
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" })
expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)
expect(Object.getPrototypeOf(content)).toBe(Object.prototype)
expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText)
})
+98
View File
@@ -0,0 +1,98 @@
import { expect, test } from "bun:test"
import { DateTime, Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect"
test("sessions.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") })
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
})
test("session methods retain decoded Effect inputs and outputs", async () => {
const httpClient = HttpClient.make((request) => {
const url = request.url
if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
}
if (url.includes("/context")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
}
if (request.method === "POST" && url.endsWith("/api/session")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
}
if (request.method === "POST") {
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
}
return Effect.succeed(
HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
)
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const page = yield* client.sessions.list({ limit: 10 })
const created = yield* client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.sessions.switchModel({
sessionID: Session.ID.make("ses_test"),
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
})
const admitted = yield* client.sessions.prompt({
sessionID: Session.ID.make("ses_test"),
prompt: Prompt.make({ text: "Hello" }),
resume: false,
})
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
return { page, created, admitted, context }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test")
expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
})
const session = {
data: {
id: "ses_test",
projectID: "project",
cost: 0,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: { read: 4, write: 5 },
},
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
},
title: "Test",
location: { directory: "/tmp/project" },
},
}
const admission = {
data: {
admittedSeq: 0,
id: "msg_test",
sessionID: "ses_test",
prompt: { text: "Hello" },
delivery: "steer",
timeCreated: 1_717_171_717_000,
},
}
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test"
import { realpathSync } from "node:fs"
import { mkdtemp, rm } from "node:fs/promises"
import { join, resolve, sep } from "node:path"
const directory = resolve(import.meta.dir, "..")
const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect"))
const schema = resolve(import.meta.dir, "../../schema")
const protocol = resolve(import.meta.dir, "../../protocol")
const core = resolve(import.meta.dir, "../../core")
const server = resolve(import.meta.dir, "../../server")
describe("public import boundaries", () => {
test("isolates each public entrypoint", async () => {
const root = await bundleInputs("@opencode-ai/client", "browser")
expect(within(root, effect)).toEqual([])
expect(within(root, schema)).toEqual([])
expect(within(root, protocol)).toEqual([])
expect(within(root, core)).toEqual([])
expect(within(root, server)).toEqual([])
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
expect(within(network, effect).length).toBeGreaterThan(0)
expect(within(network, schema).length).toBeGreaterThan(0)
expect(within(network, protocol).length).toBeGreaterThan(0)
expect(within(network, core)).toEqual([])
expect(within(network, server)).toEqual([])
})
})
async function bundleInputs(specifier: string, target: "browser" | "bun") {
const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
const entrypoint = join(temporary, "index.ts")
const metafile = join(temporary, "meta.json")
try {
await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`)
const child = Bun.spawn(
[
process.execPath,
"build",
entrypoint,
`--target=${target}`,
"--format=esm",
"--packages=bundle",
`--metafile=${metafile}`,
`--outdir=${join(temporary, "out")}`,
],
{ cwd: directory, stdout: "pipe", stderr: "pipe" },
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(stdout + stderr)
const metadata = await Bun.file(metafile).json()
return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function within(inputs: ReadonlyArray<string>, directory: string) {
const prefix = directory.endsWith(sep) ? directory : directory + sep
return inputs.filter((input) => input === directory || input.startsWith(prefix))
}
+117
View File
@@ -0,0 +1,117 @@
import { expect, test } from "bun:test"
import { isUnauthorizedError, OpenCode } from "../src"
test("sessions.get returns the wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
expect(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).toBe(
"http://localhost:3000/api/session/ses_test",
)
return Response.json(session)
},
})
const result = await client.sessions.get({ sessionID: "ses_test" })
expect(result.time.created).toBe(1_717_171_717_000)
})
test("session methods use the public HTTP contract", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
requests.push({ url, init })
if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } })
},
})
const page = await client.sessions.list({ limit: "10", order: "desc" })
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.sessions.switchModel({
sessionID: "ses_test",
model: { id: "claude", providerID: "anthropic" },
})
const admitted = await client.sessions.prompt({
sessionID: "ses_test",
prompt: { text: "Hello" },
resume: false,
})
await client.sessions.compact({ sessionID: "ses_test" })
await client.sessions.wait({ sessionID: "ses_test" })
const context = await client.sessions.context({ sessionID: "ses_test" })
expect(page.cursor.next).toBe("next")
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(context).toEqual([])
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
["POST", "http://localhost:3000/api/session/ses_test/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"],
])
const body = requests[4]?.init?.body
if (typeof body !== "string") throw new Error("Expected JSON request body")
expect(JSON.parse(body)).toEqual({
prompt: { text: "Hello" },
resume: false,
})
})
test("middleware errors remain declared client errors", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 }),
})
try {
await client.sessions.create({})
throw new Error("Expected request to fail")
} catch (error) {
expect(isUnauthorizedError(error)).toBe(true)
}
})
const session = {
data: {
id: "ses_test",
projectID: "project",
cost: 0,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: { read: 4, write: 5 },
},
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
},
title: "Test",
location: { directory: "/tmp/project" },
},
}
const admission = {
data: {
admittedSeq: 0,
id: "msg_test",
sessionID: "ses_test",
prompt: { text: "Hello" },
delivery: "steer",
timeCreated: 1_717_171_717_000,
},
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
},
"include": ["src"]
}
-1
View File
@@ -16,7 +16,6 @@
"opencode": "./bin/opencode"
},
"exports": {
"./public": "./src/public/index.ts",
"./session/runner": "./src/session/runner/index.ts",
"./system-context": "./src/system-context/index.ts",
"./*": "./src/*.ts"
-6
View File
@@ -1,6 +0,0 @@
export * as Agent from "./agent"
import { AgentV2 } from "../agent"
export const ID = AgentV2.ID
export type ID = AgentV2.ID
-9
View File
@@ -1,9 +0,0 @@
/** Intentional supported native API. Other core subpaths remain internal implementation surfaces. */
export { Agent } from "./agent"
export { Model } from "./model"
export { OpenCode } from "./opencode"
export { Session } from "./session"
export { Tool } from "./tool"
export { Location } from "./location"
export { Prompt } from "../session/prompt"
export { AbsolutePath } from "../schema"
-6
View File
@@ -1,6 +0,0 @@
export * as Location from "./location"
import { Location } from "../location"
export const Ref = Location.Ref
export type Ref = Location.Ref
-9
View File
@@ -1,9 +0,0 @@
export * as Model from "./model"
import { ModelV2 } from "../model"
export const ID = ModelV2.ID
export type ID = ModelV2.ID
export const Ref = ModelV2.Ref
export type Ref = ModelV2.Ref
-76
View File
@@ -1,76 +0,0 @@
export * as OpenCode from "./opencode"
import { Context, Effect, Layer } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { LocationServiceMap } from "../location-layer"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import * as SessionExecutionLocal from "../session/execution/local"
import { SessionProjector } from "../session/projector"
import { SessionStore } from "../session/store"
import { ApplicationTools } from "../tool/application-tools"
import { Session } from "./session"
import { Tool } from "./tool"
export interface Interface {
readonly sessions: Session.Interface
readonly tools: Tool.Interface
}
/** Intentional public native API for Effect applications embedding OpenCode. */
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
const SessionsLayer = SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer),
Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
Layer.orDie,
)
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const tools = yield* ApplicationTools.Service
return Service.of({
tools: { register: tools.register },
sessions: {
create: (input) =>
sessions.create({
id: input.id,
agent: input.agent,
model: input.model,
location: input.location,
}),
get: sessions.get,
list: sessions.list,
switchModel: sessions.switchModel,
interrupt: sessions.interrupt,
prompt: (input) =>
sessions.prompt({
id: input.id,
sessionID: input.sessionID,
prompt: input.prompt,
delivery: input.delivery,
}),
messages: (input) =>
sessions.messages({
sessionID: input.sessionID,
limit: input.limit,
order: input.order,
cursor: input.cursor,
}),
message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }),
context: sessions.context,
events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }),
},
})
}),
).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer)))
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.
-96
View File
@@ -1,96 +0,0 @@
export * as Session from "./session"
import { Effect, Stream } from "effect"
import { SessionV2 } from "../session"
import { MessageDecodeError } from "../session/error"
import { SessionEvent } from "../session/event"
import { SessionInput } from "../session/input"
import { SessionMessage } from "../session/message"
import { Prompt } from "../session/prompt"
import { Agent } from "./agent"
import { Location } from "./location"
import { Model } from "./model"
export const ID = SessionV2.ID
export type ID = SessionV2.ID
export const Info = SessionV2.Info
export type Info = SessionV2.Info
export const MessageID = SessionMessage.ID
export type MessageID = SessionMessage.ID
export const Message = SessionMessage.Message
export type Message = SessionMessage.Message
export const Admission = SessionInput.Admitted
export type Admission = SessionInput.Admitted
export const Delivery = SessionInput.Delivery
export type Delivery = SessionInput.Delivery
export const ListInput = SessionV2.ListInput
export type ListInput = SessionV2.ListInput
export type Event = SessionEvent.DurableEvent
export const NotFoundError = SessionV2.NotFoundError
export type NotFoundError = SessionV2.NotFoundError
export const PromptConflictError = SessionV2.PromptConflictError
export type PromptConflictError = SessionV2.PromptConflictError
export { MessageDecodeError }
export interface CreateInput {
readonly id?: ID
readonly agent?: Agent.ID
readonly model?: Model.Ref
readonly location: Location.Ref
}
export interface PromptInput {
readonly id?: MessageID
readonly sessionID: ID
readonly prompt: Prompt
readonly delivery?: Delivery
}
export interface SwitchModelInput {
readonly sessionID: ID
readonly model: Model.Ref
}
export interface MessagesInput {
readonly sessionID: ID
readonly limit?: number
readonly order?: "asc" | "desc"
readonly cursor?: {
readonly id: MessageID
readonly direction: "previous" | "next"
}
}
export interface MessageInput {
readonly sessionID: ID
readonly messageID: MessageID
}
export interface EventsInput {
readonly sessionID: ID
readonly after?: number
}
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
readonly message: (input: MessageInput) => Effect.Effect<Message | undefined>
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
readonly events: (input: EventsInput) => Stream.Stream<Event, NotFoundError>
}
-17
View File
@@ -1,17 +0,0 @@
export * as Tool from "./tool"
import { Effect, Scope } from "effect"
import type { AnyTool, RegistrationError } from "../tool/tool"
export { Failure, RegistrationError, make } from "../tool/tool"
export type { AnyTool, Content, Context, Definition } from "../tool/tool"
export interface Interface {
/**
* Register same-process tools on this OpenCode instance for the current Scope.
* Location tools with the same name take precedence where they are installed.
* Closing the Scope removes the tools immediately, so calls that have not
* started settling may fail because the tool is no longer available.
*/
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/public"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
import { Tool } from "@opencode-ai/core/public"
import { Tool } from "@opencode-ai/core/tool/tool"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -1,76 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
import { testEffect } from "./lib/effect"
const it = testEffect(OpenCode.layer)
describe("public native OpenCode API", () => {
it.effect("exposes only the intentional Session capabilities", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"])
expect(Object.keys(opencode.sessions).sort()).toEqual([
"context",
"create",
"events",
"get",
"interrupt",
"list",
"message",
"messages",
"prompt",
"switchModel",
])
expect(Session.ID.create()).toStartWith("ses_")
expect(Session.MessageID.create()).toStartWith("msg_")
expect(yield* opencode.sessions.list()).toBeArray()
yield* opencode.tools.register({
public_tool: Tool.make({
description: "Public tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
}),
)
it.effect("records model selection without resolving the Location catalog", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_deferred")
const model = Schema.decodeUnknownSync(Model.Ref)({
id: "missing",
providerID: "missing",
variant: "unknown",
})
yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }),
})
yield* opencode.sessions.switchModel({ sessionID, model })
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
}),
)
it.effect("preserves the typed not-found error for a missing Session", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_missing")
const error = yield* opencode.sessions
.switchModel({
sessionID,
model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }),
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Session.NotFoundError)
if (error instanceof Session.NotFoundError) expect(error.sessionID).toBe(sessionID)
}),
)
})
-13
View File
@@ -1,13 +0,0 @@
import { describe, expect, it } from "bun:test"
import { Tool } from "@opencode-ai/core/public"
import { Effect } from "effect"
describe("public Tool API", () => {
it("keeps the public registration capability narrow", () => {
const tools = {
register: () => Effect.void,
} satisfies Tool.Interface
expect(Object.keys(tools)).toEqual(["register"])
})
})
+42
View File
@@ -0,0 +1,42 @@
# @opencode-ai/httpapi-codegen
Build-time source generation for domain-oriented Promise and Effect APIs derived directly from `HttpApi` and Effect Schema contracts.
The package is private while its API is explored. Its tests are the executable specification for the generator. It must remain independent of OpenCode Core and use synthetic `HttpApi` fixtures.
## Settled rules
- Reflect one authoritative `HttpApi` into a shared contract with `compile(Api)`.
- Emit clients independently with `emitPromise(contract)` and `emitEffect(contract)`.
- Give each emitter its own public type projection; the shared contract, not a generated type package, is the common source.
- Generate a rich Effect client with decoded Effect-native values, runtime schemas, preserved transformations, and `HttpApiClient`.
- Generate a zero-Effect Promise client with structural wire-oriented values, direct `fetch`, and syntax parsing without runtime structural validation.
- Keep the Promise surface domain-oriented rather than Hey API compatible: methods return unwrapped values and reject with tagged declared errors or `ClientError`.
- Return Promise streams as lazy `AsyncIterable` values and Effect streams as `Stream` values. Neither runtime reconnects automatically.
- Flatten path, query, header, and payload fields into one input object.
- Reject duplicate field names across input channels.
- Emit no method argument for zero fields, an optional object when every field is optional, and a required object when any field is required.
- Unwrap exact `{ data: A }` success envelopes.
- Map no-content success to `void`.
- Preserve other single success values.
- Reject ambiguous multiple-success contracts.
- Expose streaming success as `Stream`, not `Effect<Stream>`.
- Reject schemas whose wire/domain transformation cannot be generated exactly.
- Map transport, unexpected-status, and response-decoding failures to one stable generated `ClientError`.
- Commit generated source for review; CI regenerates and fails when the worktree changes.
- Track generated files in `.httpapi-codegen.json` so regeneration removes only stale files previously owned by the generator.
## Boundary
This package generates only client APIs derived from `HttpApi`. It does not generate embedded-only capabilities. Networked and embedded OpenCode use the same generated Effect client against network and in-memory `HttpClient` transports respectively; the embedded host structurally extends that client with same-process capabilities.
Codegen generates every endpoint in the `HttpApi` it receives. OpenCode owns the product decision by composing the exact remote API before invoking the generator; the generic package has no endpoint filtering policy.
The existing public `generate(Api, { directory })` operation writes the rich Effect output and remains an Effect requiring `FileSystem`. The staged API uses pure `compile(Api)`, `emitEffect(contract)`, and `emitPromise(contract)` phases before `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`.
Generation formats TypeScript with Prettier before writing. Output paths are flat, unique, and checked against traversal, reserved manifest names, and existing symbolic links.
Generated source starts with one self-contained module per `HttpApiGroup`, plus root client and index modules. Schema dependencies may be duplicated across group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it.
Codegen preserves group and endpoint identifiers exactly. The composed remote `HttpApi` owns public names such as `session` and `get`; the generator performs no prefix stripping, casing conversion, or public-name annotation mapping.
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/httpapi-codegen",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "bun test --timeout 5000 --only-failures",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
File diff suppressed because one or more lines are too long
+28
View File
@@ -0,0 +1,28 @@
import { test } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import type { Scope } from "effect/Scope"
import { TestClock, TestConsole } from "effect/testing"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
const effect = <A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
test(
name,
() =>
Effect.gen(function* () {
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
Effect.scoped,
Effect.provide(layer),
Effect.exit,
)
if (Exit.isFailure(exit)) {
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
}
return yield* exit
}).pipe(Effect.runPromise),
options,
)
export const it = { effect }
+45
View File
@@ -0,0 +1,45 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
export class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {
message: Schema.String,
}) {}
export const Api = HttpApi.make("fixture")
.add(
HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("health", "/session/health", { success: Schema.String }))
.add(
HttpApiEndpoint.get("list", "/session", {
query: { archived: Schema.optional(Schema.Boolean) },
success: Schema.Array(Schema.String),
}),
)
.add(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
error: Missing.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
params: { sessionID: Schema.String },
success: HttpApiSchema.NoContent,
}),
),
)
.add(
HttpApiGroup.make("event").add(
HttpApiEndpoint.get("subscribe", "/event", {
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }).pipe(
HttpApiSchema.status(202),
),
}),
),
)
.add(
HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
),
)
@@ -0,0 +1,897 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi"
import { format } from "prettier"
import {
compile as compileContract,
emitEffect,
emitEffectImported,
emitPromise,
generate,
GenerationError,
} from "../src"
import { it } from "./effect"
import { Api as FixtureApi, Missing } from "./fixture"
function api(endpoint: HttpApiEndpoint.Any) {
return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint))
}
function compile<Id extends string, Groups extends HttpApiGroup.Any>(source: HttpApi.HttpApi<Id, Groups>) {
return emitEffect(compileContract(source))
}
describe("HttpApiCodegen.generate", () => {
test("compiles one contract for Promise and Effect emitters", () => {
const contract = compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
)
const promise = emitPromise(contract)
const effect = emitEffect(contract)
expect(promise.operations).toEqual(effect.operations)
expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"])
const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content
expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`")
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
'params: { "sessionID": input["sessionID"] }',
)
})
test("emits an Effect client against an imported authoritative API", () => {
const output = emitEffectImported(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
),
{ module: "@example/api", api: "Api" },
)
expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"])
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
'import { Api } from "@example/api"',
)
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
"HttpApiClient.ForApi<typeof Api>",
)
})
test("projects imported endpoint constants into a generated API", () => {
const output = emitEffectImported(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
),
{ module: "@example/api", endpoints: { "session.get": "SessionGet" } },
)
const client = output.files.find((file) => file.path === "client.ts")?.content
expect(client).toContain('import { SessionGet } from "@example/api"')
expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))')
})
test("imports an authoritative group without reconstructing it", () => {
const output = emitEffectImported(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.String,
}),
),
),
{ module: "@example/api", group: "SessionGroup" },
)
const client = output.files.find((file) => file.path === "client.ts")?.content
expect(client).toContain('import { SessionGroup } from "@example/api"')
expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)')
expect(client).not.toContain("HttpApiGroup")
})
test("separates hosted and consumer group names", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("server.session").add(
HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }),
),
)
const contract = compileContract(source, { groupNames: { "server.session": "sessions" } })
expect(contract.groups[0]?.identifier).toBe("sessions")
expect(contract.groups[0]?.sourceIdentifier).toBe("server.session")
expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" })
})
test("rejects consumer group name collisions", () => {
const source = HttpApi.make("test")
.add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String })))
.add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String })))
expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow(
"Client group name collision: same",
)
})
test("uses the unqualified endpoint name for the public client", () => {
const contract = compileContract(
api(
HttpApiEndpoint.get("session.get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.String,
}),
),
)
const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content
const effect = emitEffectImported(contract, {
module: "@example/api",
endpoints: { "session.session.get": "SessionGet" },
}).files.find((file) => file.path === "client.ts")?.content
expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get")
expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })')
expect(effect).toContain('raw["session.get"]')
})
test("preserves optional keys in Promise error types", () => {
class OptionalError extends Schema.TaggedErrorClass<OptionalError>()(
"OptionalError",
{ message: Schema.String, detail: Schema.String.pipe(Schema.optional) },
{ httpApiStatus: 400 },
) {}
const output = emitPromise(
compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))),
)
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
'readonly "message": string; readonly "detail"?: string | undefined',
)
})
test("erases brands from Promise wire types", () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) },
success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }),
}),
),
),
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain('readonly "sessionID": string')
expect(types).not.toContain("Brand")
})
test("inlines non-recursive references in Promise wire types", () => {
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.Struct({ data: Referenced }),
}),
),
),
)
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]',
)
})
test("emits Effect Json schemas as standalone Promise types", () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.Json,
}),
),
),
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain("export type JsonValue =")
expect(types).toContain("{ readonly [key: string]: JsonValue }")
expect(types).not.toContain("Schema.Json")
})
test("emits an optional Promise input when every field is optional", () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("list", "/session", {
query: { limit: Schema.optional(Schema.Number) },
success: Schema.Array(Schema.String),
}),
),
),
)
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
'"list": (input?: SessionListInput, requestOptions?: RequestOptions)',
)
})
test("rejects Promise transports that are not implemented", () => {
expect(() =>
emitPromise(
compileContract(
api(
HttpApiEndpoint.get("text", "/text", {
success: Schema.String.pipe(HttpApiSchema.asText()),
}),
),
),
),
).toThrow("Unsupported Promise success encoding: session.text")
expect(() =>
emitPromise(
compileContract(
api(
HttpApiEndpoint.get("events", "/events", {
success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }),
}),
),
),
),
).toThrow("Unsupported Promise stream: session.events")
})
test("executes an emitted Promise GET through fetch", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("maps an emitted no-content response to undefined", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
params: { sessionID: Schema.String },
success: HttpApiSchema.NoContent,
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("serializes flattened query, header, and JSON payload inputs", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.post("prompt", "/session/:sessionID", {
params: { sessionID: Schema.String },
query: { resume: Schema.optional(Schema.Boolean) },
headers: { traceID: Schema.String },
payload: Schema.Struct({ prompt: Schema.String }),
success: Schema.Struct({ data: Schema.String }),
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: "admitted" })
},
})
expect(
await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
).toBe("admitted")
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("rejects with declared tagged errors and exports a type guard", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
error: Missing.pipe(HttpApiSchema.status(404)),
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(generated.isMissing(error)).toBeTrue()
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("iterates an emitted SSE stream lazily without reconnecting", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("subscribe", "/event", {
query: { after: Schema.optional(Schema.Number) },
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let requests = 0
let url: string | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("preserves public group and endpoint identifiers exactly", () => {
const output = compile(
HttpApi.make("test").add(
HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })),
),
)
expect(output.operations[0]).toMatchObject({ group: "session", name: "get" })
})
test("emits one client module per HttpApi group", () => {
const source = HttpApi.make("test")
.add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
.add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String })))
const output = compile(source)
expect(output.files.map((file) => file.path)).toEqual([
"session.ts",
"tool.ts",
"client-error.ts",
"client.ts",
"index.ts",
])
})
test("emits syntactically valid TypeScript modules", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
)
const transpiler = new Bun.Transpiler({ loader: "ts" })
for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
})
it.effect("keeps the strict generated-consumer fixture current", () =>
Effect.gen(function* () {
const output = compile(FixtureApi)
const actual = yield* Effect.promise(() =>
Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)),
)
expect(actual.sort((a, b) => a.localeCompare(b))).toEqual(
output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)),
)
yield* Effect.forEach(output.files, (file) =>
Effect.tryPromise(() =>
Promise.all([
Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(),
format(file.content, { parser: "typescript", semi: false, printWidth: 120 }),
]),
).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))),
)
}),
)
test("flattens transport input channels into one domain input", () => {
const output = compile(
api(
HttpApiEndpoint.post("prompt", "/session/:sessionID", {
params: { sessionID: Schema.String },
query: { resume: Schema.String },
headers: { traceID: Schema.String },
payload: Schema.Struct({ prompt: Schema.String }),
success: Schema.Struct({ data: Schema.String }),
}),
),
)
expect(output.operations[0]?.input).toEqual([
{ name: "sessionID", source: "params" },
{ name: "resume", source: "query" },
{ name: "traceID", source: "headers" },
{ name: "prompt", source: "payload" },
])
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'params: { "sessionID": input["sessionID"] }',
)
})
test("uses no argument when an operation has no input fields", () => {
const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
expect(output.operations[0]?.inputMode).toBe("none")
})
test("uses an optional object when every input field is optional", () => {
const output = compile(
api(
HttpApiEndpoint.get("list", "/session", {
query: { limit: Schema.optional(Schema.String) },
success: Schema.Array(Schema.String),
}),
),
)
expect(output.operations[0]?.inputMode).toBe("optional")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]')
})
test("regenerates standard HttpApi transport codecs from decoded schemas", () => {
const output = compile(
api(
HttpApiEndpoint.get("list", "/session", {
query: { archived: Schema.optional(Schema.Boolean) },
success: Schema.String,
}),
),
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean")
})
test("uses a required object when any input field is required", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
query: { includeArchived: Schema.optional(Schema.String) },
success: Schema.String,
}),
),
)
expect(output.operations[0]?.inputMode).toBe("required")
})
test("rejects colliding input names across transport channels", () => {
expect(() =>
compile(
api(
HttpApiEndpoint.post("prompt", "/session/:id", {
params: { id: Schema.String },
payload: Schema.Struct({ id: Schema.String }),
success: Schema.Void,
}),
),
),
).toThrow("Input field collision: id")
})
test("rejects multiple payload alternatives until selection semantics are explicit", () => {
expect(() =>
compile(
api(
HttpApiEndpoint.post("prompt", "/session", {
payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })],
success: Schema.String,
}),
),
),
).toThrow("Multiple payload schemas: session.prompt")
})
test("unwraps an exact data success envelope", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
)
expect(output.operations[0]?.success).toBe("value")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
"Effect.map((value) => value.data)",
)
})
test("maps no-content success to void", () => {
const output = compile(
api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })),
)
expect(output.operations[0]?.success).toBe("void")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204')
})
test("preserves non-default empty response statuses", () => {
const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created })))
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201')
})
test("returns a non-envelope success unchanged", () => {
const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
expect(output.operations[0]?.success).toBe("value")
})
test("rejects multiple success shapes until their public semantics are explicit", () => {
expect(() =>
compile(
api(
HttpApiEndpoint.get("get", "/session", {
success: [Schema.String, Schema.Number],
}),
),
),
).toThrow("Multiple success schemas: session.get")
})
test("models an SSE success as a direct stream", () => {
const output = compile(
api(
HttpApiEndpoint.get("subscribe", "/event", {
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
}),
),
)
expect(output.operations[0]?.success).toBe("stream")
})
test("preserves annotated stream response statuses", () => {
const output = compile(
api(
HttpApiEndpoint.get("subscribe", "/event", {
success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)),
}),
),
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
".pipe(HttpApiSchema.status(202))",
)
})
test("rejects schemas whose semantics cannot be emitted exactly", () => {
const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL)
expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow(
"Unportable schema: session.get.success",
)
})
test("rejects custom transformations hidden beneath standard HttpApi codecs", () => {
const QueryBoolean = Schema.Literals(["yes", "no"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "yes"),
encode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
}),
)
expect(() =>
compile(
api(
HttpApiEndpoint.get("get", "/session", {
query: { archived: QueryBoolean },
success: Schema.String,
}),
),
),
).toThrow("Effect schema requires authoritative import: session.get")
})
test("rejects custom validation checks without portable metadata", () => {
const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive")))
expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow(
"Unportable schema: session.get.success",
)
})
test("rejects spoofed and aborted validation checks", () => {
const Spoofed = Schema.Number.check(
Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }),
)
const Aborted = Schema.Number.check(Schema.isFinite().abort())
expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow(
"Unportable schema: session.spoofed.success",
)
expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow(
"Unportable schema: session.aborted.success",
)
})
test("rejects altered wire-side schemas even when the codec transformation is canonical", () => {
const JsonNumber = Schema.toCodecJson(Schema.Number)
const link = JsonNumber.ast.encoding?.[0]
if (link === undefined) throw new Error("Expected JSON number encoding")
// This helper is present at runtime but omitted from the public declaration surface.
const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding")
if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding")
const ast: unknown = replaceEncoding(JsonNumber.ast, [
new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation),
])
if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST")
const Altered = Schema.make(ast)
expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow(
"Effect schema requires authoritative import: session.get",
)
})
test("rejects lexical generation and annotation values", () => {
const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({
generation: { runtime: "LocalOnly", Type: "string" },
})
const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({
custom: () => "local",
})
expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow(
"Unportable schema: session.generated.success",
)
expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow(
"Unportable schema: session.annotated.success",
)
})
test("preserves errors from server-only middleware", () => {
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {}) {}
class Authorization extends HttpApiMiddleware.Service<Authorization>()("Authorization", {
error: Unauthorized,
}) {}
const output = compile(
api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)),
)
expect(output.operations[0]).toBeDefined()
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'extends Schema.TaggedErrorClass<Endpoint0Error0Class>("Unauthorized")',
)
})
test("preserves tagged error response statuses", () => {
class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {}) {}
const output = compile(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.String,
error: Missing.pipe(HttpApiSchema.status(404)),
}),
),
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
)
})
test("supports every HttpApi method through the generic constructor", () => {
const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String })))
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
})
test("uses safe unique module paths without changing public group identifiers", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
.add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
})
test("reserves support module names case-insensitively", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
})
test("keeps searching when a reserved-name fallback is also occupied", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
})
test("rejects collisions in the flattened client namespace", () => {
expect(() =>
compile(
HttpApi.make("test")
.add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String })))
.add(
HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
),
),
),
).toThrow("Client name collision: status")
})
test("emits a usable raw type for top-level groups", () => {
const output = compile(
HttpApi.make("test").add(
HttpApiGroup.make("health", { topLevel: true }).add(
HttpApiEndpoint.get("check", "/health", { success: Schema.String }),
),
),
)
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
})
it.effect("reports compiler failures in the generate Effect", () =>
Effect.gen(function* () {
const error = yield* generate(
api(
HttpApiEndpoint.get("get", "/url", {
success: Schema.declare((input): input is URL => input instanceof URL),
}),
),
{
directory: "/generated",
},
).pipe(Effect.flip)
expect(error).toBeInstanceOf(GenerationError)
if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success")
}).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))),
)
test("rejects required client middleware without an adapter", () => {
class SignedRequest extends HttpApiMiddleware.Service<SignedRequest>()("SignedRequest", {
requiredForClient: true,
}) {}
expect(() =>
compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))),
).toThrow("Client middleware requires adapter: SignedRequest")
})
test("maps transport and decode failures to one stable client error", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.String,
}),
),
)
expect(output.operations[0]?.errors).toContain("ClientError")
expect(output.operations[0]?.errors).not.toContain("HttpClientError")
expect(output.operations[0]?.errors).not.toContain("SchemaError")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
"new ClientError({ cause: error })",
)
})
})
@@ -0,0 +1,28 @@
import { Effect, Stream } from "effect"
import { HttpClient } from "effect/unstable/http"
import { ClientError, OpenCode } from "./generated"
import { Missing } from "./fixture"
export const program = OpenCode.make().pipe(
Effect.map((client) => {
const health = client.session.health()
const list = client.session.list()
const filtered = client.session.list({ archived: true })
const get = client.session.get({ sessionID: "session" })
const interrupt = client.session.interrupt({ sessionID: "session" })
const status = client.status()
const subscribe = client.event.subscribe()
const _health: Effect.Effect<string, ClientError> = health
const _list: Effect.Effect<ReadonlyArray<string>, ClientError> = list
const _filtered: Effect.Effect<ReadonlyArray<string>, ClientError> = filtered
const _get: Effect.Effect<string, Missing | ClientError> = get
const _interrupt: Effect.Effect<void, ClientError> = interrupt
const _status: Effect.Effect<string, ClientError> = status
const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe
return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe }
}),
)
const _requiresHttpClient: Effect.Effect<unknown, never, HttpClient.HttpClient> = program
@@ -0,0 +1,5 @@
import { Schema } from "effect"
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
cause: Schema.Defect(),
}) {}
@@ -0,0 +1,16 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect } from "effect"
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { adaptGroup0, Group0 } from "./session"
import { adaptGroup1, Group1 } from "./event"
import { adaptGroup2, Group2 } from "./system"
const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2)
const adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({
session: adaptGroup0(raw["session"]),
event: adaptGroup1(raw["event"]),
...adaptGroup2({ status: raw["status"] }),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
@@ -0,0 +1,39 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect, Schema, Stream } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { ClientError } from "./client-error"
const Endpoint0SuccessData = Schema.Struct({ type: Schema.String })
const Endpoint0SuccessError = Schema.Never
export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add(
HttpApiEndpoint.make("GET")("subscribe", "/event", {
success: HttpApiSchema.StreamSse({
data: Endpoint0SuccessData,
error: Endpoint0SuccessError,
contentType: "text/event-stream",
}).pipe(HttpApiSchema.status(202)),
}),
)
type RawGroup = HttpApiClient.Client.Group<typeof Group1, "event", never, never>
const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError])
const mapEndpoint0Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () =>
Stream.unwrap(
raw["subscribe"]({}).pipe(
Effect.mapError(mapEndpoint0Error),
Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))),
),
)
export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) })
@@ -0,0 +1,2 @@
export { ClientError } from "./client-error"
export * as OpenCode from "./client"
@@ -0,0 +1,96 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect, Schema } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { ClientError } from "./client-error"
const Endpoint0Success = Schema.String
const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
const Endpoint1Success = Schema.Array(Schema.String)
const Endpoint2Params = Schema.Struct({ sessionID: Schema.String })
const Endpoint2Success = Schema.Struct({ data: Schema.String })
class Endpoint2Error0Class extends Schema.TaggedErrorClass<Endpoint2Error0Class>("Missing")("Missing", {
message: Schema.String,
}) {}
const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 })
const Endpoint3Params = Schema.Struct({ sessionID: Schema.String })
const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 })
export const Group0 = HttpApiGroup.make("session", { topLevel: false })
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success }))
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success }))
.add(
HttpApiEndpoint.make("GET")("get", "/session/:sessionID", {
params: Endpoint2Params,
success: Endpoint2Success,
error: Endpoint2Error0,
}),
)
.add(
HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", {
params: Endpoint3Params,
success: Endpoint3Success,
}),
)
type RawGroup = HttpApiClient.Client.Group<typeof Group0, "session", never, never>
const Endpoint0DeclaredError = Schema.Never
const mapEndpoint0Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error))
type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] }
const Endpoint1DeclaredError = Schema.Never
const mapEndpoint1Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint1DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) =>
raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error))
type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] }
const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0])
const mapEndpoint2Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint2DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) =>
raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapEndpoint2Error),
Effect.map((value) => value.data),
)
type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] }
const Endpoint3DeclaredError = Schema.Never
const mapEndpoint3Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint3DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) =>
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error))
export const adaptGroup0 = (raw: RawGroup) => ({
health: Endpoint0(raw),
list: Endpoint1(raw),
get: Endpoint2(raw),
interrupt: Endpoint3(raw),
})
@@ -0,0 +1,25 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect, Schema } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { ClientError } from "./client-error"
const Endpoint0Success = Schema.String
export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }),
)
type RawGroup = HttpApiClient.Client<typeof Group2>
const Endpoint0DeclaredError = Schema.Never
const mapEndpoint0Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error))
export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) })
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect } from "bun:test"
import { Effect, FileSystem, Option } from "effect"
import { write, type Output } from "../src"
import { it } from "./effect"
describe("HttpApiCodegen.write", () => {
it.effect("writes compiled files beneath the output directory", () => {
const writes: Array<{ readonly path: string; readonly content: string }> = []
const output: Output = {
operations: [],
files: [{ path: "session.ts", content: "export const session = {}" }],
}
return Effect.gen(function* () {
yield* write(output, "/generated")
expect(writes).toEqual([
{ path: "/generated/session.ts", content: "export const session = {}\n" },
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
])
}).pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
exists: () => Effect.succeed(false),
makeDirectory: () => Effect.void,
writeFileString: (path, content) => {
writes.push({ path, content })
return Effect.void
},
}),
),
)
})
it.effect("removes only stale files owned by the previous manifest", () => {
const removed: Array<string> = []
return write(
{
operations: [],
files: [{ path: "session.ts", content: "" }],
},
"/generated",
).pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
exists: (path) => Effect.succeed(path.endsWith(".httpapi-codegen.json")),
makeDirectory: () => Effect.void,
readFileString: () => Effect.succeed('["old.ts", "session.ts"]'),
remove: (path) => {
removed.push(path)
return Effect.void
},
writeFileString: () => Effect.void,
}),
),
Effect.tap(() => Effect.sync(() => expect(removed).toEqual(["/generated/old.ts"]))),
)
})
it.effect("rejects unsafe and duplicate output paths before writing", () => {
const writes: Array<string> = []
return Effect.gen(function* () {
const error = yield* write(
{
operations: [],
files: [
{ path: "../outside.ts", content: "" },
{ path: "client.ts", content: "" },
{ path: "CLIENT.ts", content: "" },
],
},
"/generated",
).pipe(Effect.flip)
expect(error._tag).toBe("GenerationError")
expect(writes).toEqual([])
}).pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
writeFileString: (path) => {
writes.push(path)
return Effect.void
},
}),
),
)
})
it.effect("rejects case-insensitive duplicate output paths", () => {
const writes: Array<string> = []
return Effect.gen(function* () {
const error = yield* write(
{
operations: [],
files: [
{ path: "client.ts", content: "" },
{ path: "CLIENT.ts", content: "" },
],
},
"/generated",
).pipe(Effect.flip)
expect(error._tag).toBe("GenerationError")
expect(error.reason).toBe("Duplicate output path: CLIENT.ts")
expect(writes).toEqual([])
}).pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
writeFileString: (path) => {
writes.push(path)
return Effect.void
},
}),
),
)
})
it.effect("reserves the private manifest path", () =>
write({ operations: [], files: [{ path: ".httpapi-codegen.json", content: "" }] }, "/generated").pipe(
Effect.flip,
Effect.tap((error) => Effect.sync(() => expect(error.reason).toContain("Unsafe output path"))),
Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
),
)
it.effect("rejects existing symbolic-link output targets", () =>
write({ operations: [], files: [{ path: "session.ts", content: "" }] }, "/generated").pipe(
Effect.flip,
Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("Unsafe output path: session.ts"))),
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
exists: (path) => Effect.succeed(path.endsWith("session.ts")),
makeDirectory: () => Effect.void,
stat: () =>
Effect.succeed({
type: "SymbolicLink",
mtime: Option.none(),
atime: Option.none(),
birthtime: Option.none(),
dev: 0,
ino: Option.none(),
mode: 0,
nlink: Option.none(),
uid: Option.none(),
gid: Option.none(),
rdev: Option.none(),
size: FileSystem.Size(0),
blksize: Option.none(),
blocks: Option.none(),
}),
}),
),
),
)
})
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+27
View File
@@ -0,0 +1,27 @@
# @opencode-ai/sdk-next
Effect-native scoped OpenCode host for in-process applications. This transitional package will replace the existing generated `@opencode-ai/sdk` after its consumers migrate.
The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client.
```ts
import { OpenCode } from "@opencode-ai/sdk-next"
const opencode = yield * OpenCode.create()
const session = yield * opencode.sessions.get({ sessionID })
```
It also exposes local-only `tools.register(...)`. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
The same constructor is available as a service Layer:
```ts
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.Service
return yield* opencode.sessions.get({ sessionID })
})
yield * program.pipe(Effect.provide(OpenCode.layer))
```
`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk-next",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}

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