From cdd67cf30fc01ea383ae5a6ff582709f9ee07f5d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 05:08:54 +0200 Subject: [PATCH 1/9] feat(sdk): add HttpApi clients and embedded host (#33445) --- .github/workflows/test.yml | 5 + CONTEXT.md | 77 ++ bun.lock | 55 + packages/client/README.md | 27 + packages/client/package.json | 39 + packages/client/script/build.ts | 23 + packages/client/src/contract.ts | 19 + packages/client/src/effect.ts | 12 + .../generated-effect/.httpapi-codegen.json | 5 + .../src/generated-effect/client-error.ts | 5 + .../client/src/generated-effect/client.ts | 164 +++ packages/client/src/generated-effect/index.ts | 2 + .../src/generated/.httpapi-codegen.json | 6 + packages/client/src/generated/client-error.ts | 11 + packages/client/src/generated/client.ts | 349 +++++ packages/client/src/generated/index.ts | 3 + packages/client/src/generated/types.ts | 631 +++++++++ packages/client/src/index.ts | 1 + .../client/test/contract-identity.test.ts | 60 + packages/client/test/effect.test.ts | 98 ++ .../client/test/import-boundaries.test.ts | 68 + packages/client/test/promise.test.ts | 117 ++ packages/client/tsconfig.json | 9 + packages/core/package.json | 1 - packages/core/src/public/agent.ts | 6 - packages/core/src/public/index.ts | 9 - packages/core/src/public/location.ts | 6 - packages/core/src/public/model.ts | 9 - packages/core/src/public/opencode.ts | 76 -- packages/core/src/public/session.ts | 96 -- packages/core/src/public/tool.ts | 17 - packages/core/test/application-tools.test.ts | 2 +- packages/core/test/location-layer.test.ts | 2 +- packages/core/test/public-opencode.test.ts | 76 -- packages/core/test/public-tool.test.ts | 13 - packages/httpapi-codegen/README.md | 42 + packages/httpapi-codegen/package.json | 22 + packages/httpapi-codegen/src/index.ts | 1147 +++++++++++++++++ packages/httpapi-codegen/test/effect.ts | 28 + packages/httpapi-codegen/test/fixture.ts | 45 + .../httpapi-codegen/test/generate.test.ts | 897 +++++++++++++ .../test/generated-consumer.ts | 28 + .../test/generated/client-error.ts | 5 + .../httpapi-codegen/test/generated/client.ts | 16 + .../httpapi-codegen/test/generated/event.ts | 39 + .../httpapi-codegen/test/generated/index.ts | 2 + .../httpapi-codegen/test/generated/session.ts | 96 ++ .../httpapi-codegen/test/generated/system.ts | 25 + packages/httpapi-codegen/test/write.test.ts | 160 +++ packages/httpapi-codegen/tsconfig.json | 8 + packages/sdk-next/README.md | 27 + packages/sdk-next/package.json | 25 + packages/sdk-next/src/index.ts | 16 + packages/sdk-next/src/opencode.ts | 53 + packages/sdk-next/src/tool.ts | 2 + packages/sdk-next/test/embedded.test.ts | 84 ++ .../sdk-next/test/import-boundaries.test.ts | 53 + packages/sdk-next/tsconfig.json | 8 + packages/server/src/routes.ts | 18 +- 59 files changed, 4629 insertions(+), 316 deletions(-) create mode 100644 packages/client/README.md create mode 100644 packages/client/package.json create mode 100644 packages/client/script/build.ts create mode 100644 packages/client/src/contract.ts create mode 100644 packages/client/src/effect.ts create mode 100644 packages/client/src/generated-effect/.httpapi-codegen.json create mode 100644 packages/client/src/generated-effect/client-error.ts create mode 100644 packages/client/src/generated-effect/client.ts create mode 100644 packages/client/src/generated-effect/index.ts create mode 100644 packages/client/src/generated/.httpapi-codegen.json create mode 100644 packages/client/src/generated/client-error.ts create mode 100644 packages/client/src/generated/client.ts create mode 100644 packages/client/src/generated/index.ts create mode 100644 packages/client/src/generated/types.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/test/contract-identity.test.ts create mode 100644 packages/client/test/effect.test.ts create mode 100644 packages/client/test/import-boundaries.test.ts create mode 100644 packages/client/test/promise.test.ts create mode 100644 packages/client/tsconfig.json delete mode 100644 packages/core/src/public/agent.ts delete mode 100644 packages/core/src/public/index.ts delete mode 100644 packages/core/src/public/location.ts delete mode 100644 packages/core/src/public/model.ts delete mode 100644 packages/core/src/public/opencode.ts delete mode 100644 packages/core/src/public/session.ts delete mode 100644 packages/core/src/public/tool.ts delete mode 100644 packages/core/test/public-opencode.test.ts delete mode 100644 packages/core/test/public-tool.test.ts create mode 100644 packages/httpapi-codegen/README.md create mode 100644 packages/httpapi-codegen/package.json create mode 100644 packages/httpapi-codegen/src/index.ts create mode 100644 packages/httpapi-codegen/test/effect.ts create mode 100644 packages/httpapi-codegen/test/fixture.ts create mode 100644 packages/httpapi-codegen/test/generate.test.ts create mode 100644 packages/httpapi-codegen/test/generated-consumer.ts create mode 100644 packages/httpapi-codegen/test/generated/client-error.ts create mode 100644 packages/httpapi-codegen/test/generated/client.ts create mode 100644 packages/httpapi-codegen/test/generated/event.ts create mode 100644 packages/httpapi-codegen/test/generated/index.ts create mode 100644 packages/httpapi-codegen/test/generated/session.ts create mode 100644 packages/httpapi-codegen/test/generated/system.ts create mode 100644 packages/httpapi-codegen/test/write.test.ts create mode 100644 packages/httpapi-codegen/tsconfig.json create mode 100644 packages/sdk-next/README.md create mode 100644 packages/sdk-next/package.json create mode 100644 packages/sdk-next/src/index.ts create mode 100644 packages/sdk-next/src/opencode.ts create mode 100644 packages/sdk-next/src/tool.ts create mode 100644 packages/sdk-next/test/embedded.test.ts create mode 100644 packages/sdk-next/test/import-boundaries.test.ts create mode 100644 packages/sdk-next/tsconfig.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13a76c0a1f0..c69de1d93b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index 4b5fabd93c4..97919b63f66 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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?" diff --git a/bun.lock b/bun.lock index 580e4f9625e..78f9fc99bc7 100644 --- a/bun.lock +++ b/bun.lock @@ -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"], diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 00000000000..fc167950751 --- /dev/null +++ b/packages/client/README.md @@ -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" }) }) +``` diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 00000000000..4f2445ca9d7 --- /dev/null +++ b/packages/client/package.json @@ -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:" + } +} diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts new file mode 100644 index 00000000000..35ce8387a8e --- /dev/null +++ b/packages/client/script/build.ts @@ -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)), +) diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts new file mode 100644 index 00000000000..2190c130ca6 --- /dev/null +++ b/packages/client/src/contract.ts @@ -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()( + "@opencode-ai/client/LocationMiddleware", +) {} + +class SessionLocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode-ai/client/SessionLocationMiddleware", + { error: [InvalidRequestError, SessionNotFoundError] }, +) {} + +const Api = makeDefaultApi({ + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) + +export const SessionGroup = Api.groups["server.session"] diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts new file mode 100644 index 00000000000..6c0aca564e2 --- /dev/null +++ b/packages/client/src/effect.ts @@ -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" diff --git a/packages/client/src/generated-effect/.httpapi-codegen.json b/packages/client/src/generated-effect/.httpapi-codegen.json new file mode 100644 index 00000000000..958eb566dbd --- /dev/null +++ b/packages/client/src/generated-effect/.httpapi-codegen.json @@ -0,0 +1,5 @@ +[ + "client-error.ts", + "client.ts", + "index.ts" +] diff --git a/packages/client/src/generated-effect/client-error.ts b/packages/client/src/generated-effect/client-error.ts new file mode 100644 index 00000000000..bcc65d9bdd2 --- /dev/null +++ b/packages/client/src/generated-effect/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts new file mode 100644 index 00000000000..9088c533faf --- /dev/null +++ b/packages/client/src/generated-effect/client.ts @@ -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 + +const mapClientError = (error: E) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : error + +type Endpoint0_0Request = Parameters[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[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[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[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[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[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[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[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[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[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[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[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)) diff --git a/packages/client/src/generated-effect/index.ts b/packages/client/src/generated-effect/index.ts new file mode 100644 index 00000000000..bc0dbc9fa4d --- /dev/null +++ b/packages/client/src/generated-effect/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export * as OpenCode from "./client" diff --git a/packages/client/src/generated/.httpapi-codegen.json b/packages/client/src/generated/.httpapi-codegen.json new file mode 100644 index 00000000000..25700fc72da --- /dev/null +++ b/packages/client/src/generated/.httpapi-codegen.json @@ -0,0 +1,6 @@ +[ + "client-error.ts", + "client.ts", + "index.ts", + "types.ts" +] diff --git a/packages/client/src/generated/client-error.ts b/packages/client/src/generated/client-error.ts new file mode 100644 index 00000000000..c278f0ddc80 --- /dev/null +++ b/packages/client/src/generated/client-error.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) + } +} diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts new file mode 100644 index 00000000000..781ba9730c7 --- /dev/null +++ b/packages/client/src/generated/client.ts @@ -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 + readonly headers?: Record + readonly body?: unknown + readonly successStatus: number + readonly declaredStatuses: ReadonlyArray + 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 => { + 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 (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => { + 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 = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({ + 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( + { + 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( + { + 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( + { + 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( + { + 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( + { + 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( + { + 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( + { + 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 { + 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 +} diff --git a/packages/client/src/generated/index.ts b/packages/client/src/generated/index.ts new file mode 100644 index 00000000000..2570372cf81 --- /dev/null +++ b/packages/client/src/generated/index.ts @@ -0,0 +1,3 @@ +export { ClientError, type ClientErrorReason } from "./client-error" +export * as OpenCode from "./client" +export * from "./types" diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts new file mode 100644 index 00000000000..ac888719f48 --- /dev/null +++ b/packages/client/src/generated/types.ts @@ -0,0 +1,631 @@ +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { 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 | 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 | 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"] diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 00000000000..92e36b1c605 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1 @@ +export * from "./generated/index" diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts new file mode 100644 index 00000000000..8c9ca70e2d0 --- /dev/null +++ b/packages/client/test/contract-identity.test.ts @@ -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) +}) diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts new file mode 100644 index 00000000000..5613c3ce776 --- /dev/null +++ b/packages/client/test/effect.test.ts @@ -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, + }, +} diff --git a/packages/client/test/import-boundaries.test.ts b/packages/client/test/import-boundaries.test.ts new file mode 100644 index 00000000000..4875a3a5dc0 --- /dev/null +++ b/packages/client/test/import-boundaries.test.ts @@ -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, directory: string) { + const prefix = directory.endsWith(sep) ? directory : directory + sep + return inputs.filter((input) => input === directory || input.startsWith(prefix)) +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts new file mode 100644 index 00000000000..a7fac9780fa --- /dev/null +++ b/packages/client/test/promise.test.ts @@ -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, + }, +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 00000000000..47fc90bc55b --- /dev/null +++ b/packages/client/tsconfig.json @@ -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"] +} diff --git a/packages/core/package.json b/packages/core/package.json index c177d940182..19854c7a62b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" diff --git a/packages/core/src/public/agent.ts b/packages/core/src/public/agent.ts deleted file mode 100644 index ade2096f899..00000000000 --- a/packages/core/src/public/agent.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as Agent from "./agent" - -import { AgentV2 } from "../agent" - -export const ID = AgentV2.ID -export type ID = AgentV2.ID diff --git a/packages/core/src/public/index.ts b/packages/core/src/public/index.ts deleted file mode 100644 index 2229039b9af..00000000000 --- a/packages/core/src/public/index.ts +++ /dev/null @@ -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" diff --git a/packages/core/src/public/location.ts b/packages/core/src/public/location.ts deleted file mode 100644 index aab15181d19..00000000000 --- a/packages/core/src/public/location.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as Location from "./location" - -import { Location } from "../location" - -export const Ref = Location.Ref -export type Ref = Location.Ref diff --git a/packages/core/src/public/model.ts b/packages/core/src/public/model.ts deleted file mode 100644 index ab92b8dfe76..00000000000 --- a/packages/core/src/public/model.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts deleted file mode 100644 index 7d32556340f..00000000000 --- a/packages/core/src/public/opencode.ts +++ /dev/null @@ -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()("@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. diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts deleted file mode 100644 index 212583b5593..00000000000 --- a/packages/core/src/public/session.ts +++ /dev/null @@ -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 - readonly get: (sessionID: ID) => Effect.Effect - readonly list: (input?: ListInput) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect - readonly switchModel: (input: SwitchModelInput) => Effect.Effect - /** 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 - readonly messages: (input: MessagesInput) => Effect.Effect - readonly message: (input: MessageInput) => Effect.Effect - readonly context: (sessionID: ID) => Effect.Effect - readonly events: (input: EventsInput) => Stream.Stream -} diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts deleted file mode 100644 index 97b436fed92..00000000000 --- a/packages/core/src/public/tool.ts +++ /dev/null @@ -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>) => Effect.Effect -} diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index 38b0c957048..dac711f3c69 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -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" diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 14b2ed73784..6dba16ece68 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -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" diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts deleted file mode 100644 index fb9397e5727..00000000000 --- a/packages/core/test/public-opencode.test.ts +++ /dev/null @@ -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) - }), - ) -}) diff --git a/packages/core/test/public-tool.test.ts b/packages/core/test/public-tool.test.ts deleted file mode 100644 index d3f444dd097..00000000000 --- a/packages/core/test/public-tool.test.ts +++ /dev/null @@ -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"]) - }) -}) diff --git a/packages/httpapi-codegen/README.md b/packages/httpapi-codegen/README.md new file mode 100644 index 00000000000..14a4dd3932a --- /dev/null +++ b/packages/httpapi-codegen/README.md @@ -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`. +- 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. diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json new file mode 100644 index 00000000000..84aa736531d --- /dev/null +++ b/packages/httpapi-codegen/package.json @@ -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:" + } +} diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts new file mode 100644 index 00000000000..d576b24cd19 --- /dev/null +++ b/packages/httpapi-codegen/src/index.ts @@ -0,0 +1,1147 @@ +import { isAbsolute, join } from "node:path" +import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" +import { HttpMethod, type HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { format } from "prettier" + +export type InputField = { + readonly name: string + readonly source: "params" | "query" | "headers" | "payload" +} + +export type Operation = { + readonly group: string + readonly name: string + readonly input: ReadonlyArray + readonly inputMode: "none" | "optional" | "required" + readonly success: "value" | "void" | "stream" + readonly errors: ReadonlyArray +} + +export type Output = { + readonly operations: ReadonlyArray + readonly files: ReadonlyArray<{ + readonly path: string + readonly content: string + }> +} + +export type Contract = { + readonly groups: ReadonlyArray +} + +export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { + reason: Schema.String, +}) { + override get message() { + return this.reason + } +} + +export type Endpoint = { + readonly group: string + readonly sourceGroup: string + readonly topLevel: boolean + readonly endpoint: HttpApiEndpoint.AnyWithProps + readonly params: Schema.Top | undefined + readonly query: Schema.Top | undefined + readonly headers: Schema.Top | undefined + readonly payloads: ReadonlyArray + readonly operation: Operation + readonly input: ReadonlyArray + readonly unwrapData: boolean + readonly errors: ReadonlyArray + readonly successes: ReadonlyArray + readonly effectPortable: boolean +} + +export type Group = { + readonly identifier: string + readonly sourceIdentifier: string + readonly module: string + readonly endpoints: ReadonlyArray +} + +type Slot = { + readonly name: string + readonly schema: Schema.Top +} + +const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") +const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") +const Manifest = Schema.fromJsonString(Schema.Array(Schema.String)) +const manifestName = ".httpapi-codegen.json" + +export function compile( + api: HttpApi.HttpApi, + options?: { readonly groupNames?: Readonly> }, +): Contract { + const endpoints: Array = [] + const portable = new Map() + + HttpApi.reflect(api, { + onGroup() {}, + onEndpoint({ endpoint, errors, group, middleware }) { + const groupName = options?.groupNames?.[group.identifier] ?? group.identifier + const name = `${groupName}.${endpoint.name}` + const required = Array.from(middleware).find((item) => item.requiredForClient) + if (required !== undefined) { + throw new GenerationError({ reason: `Client middleware requires adapter: ${required.key}` }) + } + + const successSchemas = Array.from(endpoint.success) + if (successSchemas.length === 0) successSchemas.push(HttpApiSchema.NoContent) + if (successSchemas.length > 1) throw new GenerationError({ reason: `Multiple success schemas: ${name}` }) + + const params = normalizeTransport(endpoint.params, "params", endpoint, name) + const query = normalizeTransport(endpoint.query, "query", endpoint, name) + const headers = normalizeTransport(endpoint.headers, "headers", endpoint, name) + const sourcePayloads = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas) + if (sourcePayloads.length > 1) { + throw new GenerationError({ reason: `Multiple payload schemas: ${name}` }) + } + const payloads = sourcePayloads.map((schema) => normalizeTransport(schema, "payload", endpoint, name)!) + const success = normalizeTransport(successSchemas[0], "success", endpoint, name)! + const errorSchemas = Array.from(errors.values()).flatMap((schemas) => + schemas.map((schema) => normalizeTransport(schema, "error", endpoint, name)!), + ) + const inputs = [ + ...inputFields(params?.schema, "params", name), + ...inputFields(query?.schema, "query", name), + ...inputFields(headers?.schema, "headers", name), + ...payloads.flatMap((item) => inputFields(item.schema, "payload", name)), + ] + const names = new Set() + for (const field of inputs) { + if (names.has(field.name)) throw new GenerationError({ reason: `Input field collision: ${field.name}` }) + names.add(field.name) + } + + const schemaPaths: Array = [ + ...(params === undefined ? [] : [[`${name}.params`, params.schema] as const]), + ...(query === undefined ? [] : [[`${name}.query`, query.schema] as const]), + ...(headers === undefined ? [] : [[`${name}.headers`, headers.schema] as const]), + ...payloads.map((item) => [`${name}.payload`, item.schema] as const), + ...responseSchemas(success.schema, `${name}.success`), + ...errorSchemas.map((item) => [`${name}.error`, item.schema] as const), + ] + const effectPortable = [params, query, headers, ...payloads, success, ...errorSchemas].every( + (item) => item?.effectPortable !== false, + ) + if (effectPortable) { + for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) + } + + endpoints.push({ + group: groupName, + sourceGroup: group.identifier, + topLevel: group.topLevel, + endpoint, + params: params?.schema, + query: query?.schema, + headers: headers?.schema, + payloads: payloads.map((item) => item.schema), + input: inputs, + unwrapData: isDataEnvelope(success.schema), + successes: [success.schema], + errors: errorSchemas.map((item) => item.schema), + effectPortable, + operation: { + group: groupName, + name: clientEndpointName(endpoint.name), + input: inputs.map(({ name, source }) => ({ name, source })), + inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", + success: isStreamSchema(success.schema) + ? "stream" + : HttpApiSchema.isNoContent(success.schema.ast) + ? "void" + : "value", + errors: [ + ...new Set([ + ...errorSchemas.flatMap((item) => { + const identifier = SchemaAST.resolveIdentifier(item.schema.ast) + return identifier === undefined ? [] : [identifier] + }), + "ClientError", + ]), + ], + }, + }) + }, + }) + + const modules = new Set(["client", "client-error", "index"]) + const groups = Array.from( + Map.groupBy(endpoints, (endpoint) => endpoint.group), + ([identifier, endpoints], index) => { + if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) { + throw new GenerationError({ reason: `Client group name collision: ${identifier}` }) + } + const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}` + const module = uniqueModule(base, index, modules) + modules.add(module.toLowerCase()) + return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints } + }, + ) + const publicNames = new Set() + for (const group of groups) { + const endpointNames = new Set() + for (const endpoint of group.endpoints) { + if (endpointNames.has(endpoint.operation.name)) { + throw new GenerationError({ + reason: `Client endpoint name collision: ${group.identifier}.${endpoint.operation.name}`, + }) + } + endpointNames.add(endpoint.operation.name) + } + const names = group.endpoints[0]?.topLevel ? group.endpoints.map((item) => item.operation.name) : [group.identifier] + for (const name of names) { + if (publicNames.has(name)) throw new GenerationError({ reason: `Client name collision: ${name}` }) + publicNames.add(name) + } + } + return { + groups, + } +} + +export function emitEffect(contract: Contract): Output { + const endpoint = contract.groups.flatMap((group) => group.endpoints).find((endpoint) => !endpoint.effectPortable) + if (endpoint !== undefined) { + throw new GenerationError({ + reason: `Effect schema requires authoritative import: ${endpoint.group}.${endpoint.endpoint.name}`, + }) + } + return { operations: operations(contract.groups), files: renderEffectFiles(contract.groups) } +} + +export function emitEffectImported( + contract: Contract, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } + | { readonly module: string; readonly endpoints: Readonly> }, +): Output { + return { + operations: operations(contract.groups), + files: renderImportedEffectFiles(contract.groups, options), + } +} + +export function emitPromise(contract: Contract): Output { + const groups = contract.groups + for (const group of groups) { + for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint) + } + return { + operations: operations(groups), + files: [ + { path: "types.ts", content: renderPromiseTypes(groups) }, + { + path: "client-error.ts", + content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, + }, + { + path: "client.ts", + content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult", "let next"), + }, + { + path: "index.ts", + content: + 'export { ClientError, type ClientErrorReason } from "./client-error"\nexport * as OpenCode from "./client"\nexport * from "./types"\n', + }, + ], + } +} + +function assertPromiseEndpoint(endpoint: Endpoint) { + const name = `${endpoint.group}.${endpoint.endpoint.name}` + const payload = endpoint.payloads[0] + const payloadEncoding = payload === undefined ? undefined : resolveHttpApiEncoding(payload.ast) + if ( + payload !== undefined && + (payloadEncoding?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded")) !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise payload encoding: ${name}` }) + } + const success = endpoint.successes[0] + if (isStreamSchema(success)) { + if ( + success._tag !== "StreamSse" || + success.sseMode !== "data" || + !SchemaAST.isNever(Schema.toType(success.error).ast) + ) { + throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` }) + } + } else if ( + !HttpApiSchema.isNoContent(success.ast) && + (resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) + } + for (const error of endpoint.errors) { + if (taggedErrorFields(error) === undefined) { + throw new GenerationError({ reason: `Promise error must be tagged: ${name}` }) + } + if ((resolveHttpApiEncoding(error.ast)?._tag ?? "Json") !== "Json") { + throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` }) + } + } +} + +function operations(groups: ReadonlyArray) { + return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation)) +} + +function renderEffectFiles(groups: ReadonlyArray): Output["files"] { + return [ + ...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })), + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: renderClient(groups) }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderImportedEffectFiles( + groups: ReadonlyArray, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } + | { readonly module: string; readonly endpoints: Readonly> }, +): Output["files"] { + const adapters = groups.map((group, groupIndex) => { + const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]` + const methods = group.endpoints.map((item, endpointIndex) => { + const prefix = `Endpoint${groupIndex}_${endpointIndex}` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const fields = item.input.filter((field) => field.source === source) + if (fields.length === 0) return [] + return [ + `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : "."}${field.name}`).join(", ")} }`, + ] + }) + .join(", ") + const input = item.input + .map( + (field) => + `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, + ) + .join("; ") + const argument = + item.operation.inputMode === "none" + ? "" + : `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const rawCall = `raw[${JSON.stringify(item.endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` + }) + return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.operation.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })` + }) + const fields = groups.flatMap((group, index) => + group.endpoints[0]?.topLevel + ? [`...adaptGroup${index}(raw)`] + : [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`], + ) + const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream")) + const imported = "api" in options + const projection = imported + ? undefined + : "group" in options + ? renderImportedGroup(options.group) + : renderImportedProjection(groups, options.endpoints) + const api = imported ? options.api : "Api" + const imports = + projection === undefined + ? `import { ${api} } from ${JSON.stringify(options.module)}` + : `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}` + const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : "" + const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\n${httpApiImport}${imports}\nimport { ClientError } from "./client-error"\n\n${projection?.source ?? ""}type RawClient = HttpApiClient.ForApi\n\nconst mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nconst adaptClient = (raw: RawClient) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${api}, options).pipe(Effect.map(adaptClient))\n` + return [ + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: client }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderImportedGroup(group: string) { + return { + imports: [group], + source: `const Api = HttpApi.make("generated").add(${group})\n\n`, + } +} + +function renderImportedProjection(groups: ReadonlyArray, endpoints: Readonly>) { + const imports = groups.flatMap((group) => + group.endpoints.map((endpoint) => { + const name = endpoints[`${group.identifier}.${endpoint.endpoint.name}`] + if (name === undefined) { + throw new GenerationError({ + reason: `Missing imported endpoint: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return name + }), + ) + const source = `const Api = HttpApi.make("generated").${groups + .map((group) => { + const options = group.endpoints[0]?.topLevel ? ", { topLevel: true }" : "" + return `add(HttpApiGroup.make(${JSON.stringify(group.identifier)}${options})${group.endpoints.map((endpoint) => `.add(${endpoints[`${group.identifier}.${endpoint.endpoint.name}`]})`).join("")})` + }) + .join(".")}\n\n` + return { imports: [...new Set(imports)], source } +} + +function renderPromiseTypes(groups: ReadonlyArray) { + const types = new Map() + const typeOf = (schema: Schema.Top) => { + const encoded = Schema.toEncoded(schema) + const cached = types.get(encoded.ast) + if (cached !== undefined) return cached + const type = structuralType(encoded) + types.set(encoded.ast, type) + return type + } + const errors = new Map( + groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => + endpoint.errors.flatMap((schema) => { + const tagged = taggedErrorFields(schema) + return tagged === undefined ? [] : [[tagged.tag, tagged] as const] + }), + ), + ), + ) + const errorTypes = Array.from(errors.values()).map((error) => { + const fields = error.fields + .map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`) + .join("; ") + return `export type ${error.identifier} = { readonly _tag: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && "_tag" in value && value._tag === ${JSON.stringify(error.tag)}` + }) + const operations = groups + .flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const schemas = { + params: endpoint.params, + query: endpoint.query, + headers: endpoint.headers, + payload: endpoint.payloads[0], + } + const input = endpoint.input + .map((field) => { + const schema = schemas[field.source] + if (schema === undefined) + throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]` + }) + .join("; ") + const successSchema = endpoint.successes[0] + const success = typeOf( + isStreamSchema(successSchema) && successSchema._tag === "StreamSse" + ? successSchema.sseMode === "data" + ? streamDataSchema(successSchema) + : successSchema.events + : successSchema, + ) + return [ + ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), + `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, + ] + }), + ) + .join("\n\n") + const json = operations.includes("JsonValue") + ? "export type JsonValue = null | boolean | number | string | ReadonlyArray | { readonly [key: string]: JsonValue }" + : "" + return [json, ...errorTypes, operations].filter(Boolean).join("\n\n") +} + +function renderPromiseClient(groups: ReadonlyArray) { + const imports = groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] + }), + ) + const fields = groups.map((group) => { + const methods = group.endpoints.map((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const argument = + endpoint.operation.inputMode === "none" + ? "requestOptions?: RequestOptions" + : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions` + const path = promisePath(endpoint.endpoint.path, endpoint.input) + const access = (name: string) => `input${endpoint.operation.inputMode === "optional" ? "?." : "."}${name}` + const part = (source: InputField["source"]) => { + const inputs = endpoint.input.filter((field) => field.source === source) + return inputs.length === 0 + ? undefined + : `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }` + } + const parts = [ + endpoint.query === undefined ? undefined : `query: ${part("query")}`, + endpoint.headers === undefined ? undefined : `headers: ${part("headers")}`, + endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`, + ].filter((value): value is string => value !== undefined) + const declaredStatuses = [ + ...new Set( + endpoint.errors.map((schema) => resolveHttpApiStatus(schema.ast)).filter((status) => status !== undefined), + ), + ] + const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }` + if (endpoint.operation.success === "stream") { + const success = endpoint.successes[0] + if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") { + throw new GenerationError({ + reason: `Promise stream emission is not implemented: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return `${JSON.stringify(endpoint.operation.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` + } + const unwrap = endpoint.unwrapData ? ".then((value) => value.data)" : "" + return `${JSON.stringify(endpoint.operation.name)}: (${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}` + }) + if (group.endpoints[0]?.topLevel) return methods.join(", ") + return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }` + }) + return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined || value === null) return\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` +} + +function promiseTypePrefix(group: string, endpoint: string) { + return `${identifierPart(group)}${identifierPart(endpoint)}` +} + +function clientEndpointName(name: string) { + return name.slice(name.lastIndexOf(".") + 1) +} + +function identifierPart(value: string) { + return value + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join("") +} + +function structuralType(schema: Schema.Top) { + const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast])) + if ( + document.artifacts.some( + (artifact) => + artifact._tag !== "Import" || artifact.importDeclaration !== 'import type * as Brand from "effect/Brand"', + ) || + Object.keys(document.references.recursives).length > 0 + ) { + throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) + } + const references = new Map( + document.references.nonRecursives.map((reference) => [reference.$ref, reference.code.Type]), + ) + const expand = (type: string, seen = new Set()): string => { + for (const [reference, value] of references) { + if (!type.includes(reference)) continue + if (seen.has(reference)) throw new GenerationError({ reason: "Recursive Promise types are not implemented" }) + type = type.replaceAll(reference, `(${expand(value, new Set([...seen, reference]))})`) + } + return type + } + return expand(document.codes[0].Type) + .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") + .replaceAll("Schema.Json", "JsonValue") +} + +function promisePath(path: string, input: ReadonlyArray) { + const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name)) + const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean) + const template = segments + .map((segment) => { + if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`") + const name = segment.slice(1) + if (!fields.has(name)) throw new GenerationError({ reason: `Missing path parameter: ${name}` }) + return `\${encodeURIComponent(input.${name})}` + }) + .join("") + return `\`${template}\`` +} + +function uniqueModule(base: string, index: number, modules: ReadonlySet) { + if (!modules.has(base.toLowerCase())) return base + const seed = `${base}-${index}` + let suffix = 0 + while (modules.has(`${seed}${suffix === 0 ? "" : `-${suffix}`}`.toLowerCase())) suffix++ + return `${seed}${suffix === 0 ? "" : `-${suffix}`}` +} + +function normalizeTransport( + schema: Schema.Top | undefined, + source: InputField["source"] | "success" | "error", + endpoint: HttpApiEndpoint.AnyWithProps, + operation: string, +) { + if (schema === undefined) return undefined + if (isStreamSchema(schema)) return { schema, effectPortable: true } as const + if (!metadataPortable(schema.ast, new Set())) { + throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + } + const decoded = Schema.toType(schema) + if (!isPathInput(endpoint.path)) { + throw new GenerationError({ reason: `Invalid endpoint path: ${operation}` }) + } + const rebuilt = HttpApiEndpoint.make(endpoint.method)(endpoint.name, endpoint.path, { + ...(source === "params" ? { params: decoded } : undefined), + ...(source === "query" ? { query: decoded } : undefined), + ...(source === "headers" ? { headers: decoded } : undefined), + ...(source === "payload" ? { payload: decoded } : undefined), + ...(source === "success" ? { success: decoded } : { success: Schema.String }), + ...(source === "error" ? { error: decoded } : undefined), + }) + const normalized = + source === "params" + ? rebuilt.params + : source === "query" + ? rebuilt.query + : source === "headers" + ? rebuilt.headers + : source === "payload" + ? Array.from(rebuilt.payload.values())[0]?.schemas[0] + : source === "success" + ? Array.from(rebuilt.success)[0] + : Array.from(rebuilt.error)[0] + if (normalized === undefined) throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + if (!sameEncoding(schema.ast, normalized.ast)) return { schema, effectPortable: false } as const + return { schema: decoded, effectPortable: true } as const +} + +function isPathInput(path: string): path is HttpRouter.PathInput { + return path === "*" || path.startsWith("/") +} + +function sameEncoding(left: SchemaAST.AST, right: SchemaAST.AST): boolean { + if (left._tag !== right._tag || left.encoding?.length !== right.encoding?.length) return false + if ( + left.encoding?.some((link, index) => { + const other = right.encoding?.[index] + return other === undefined || link.transformation !== other.transformation || !sameEncoding(link.to, other.to) + }) + ) + return false + if (!sameChecks(left.checks, right.checks) || !sameContext(left.context, right.context)) return false + if (SchemaAST.isSuspend(left) && SchemaAST.isSuspend(right)) return sameEncoding(left.thunk(), right.thunk()) + if (SchemaAST.isUnion(left) && SchemaAST.isUnion(right)) { + return ( + left.types.length === right.types.length && + left.types.every((ast, index) => sameEncoding(ast, right.types[index])) + ) + } + if (SchemaAST.isArrays(left) && SchemaAST.isArrays(right)) { + return ( + left.elements.length === right.elements.length && + left.rest.length === right.rest.length && + left.elements.every((ast, index) => sameEncoding(ast, right.elements[index])) && + left.rest.every((ast, index) => sameEncoding(ast, right.rest[index])) + ) + } + if (SchemaAST.isObjects(left) && SchemaAST.isObjects(right)) { + return ( + left.propertySignatures.length === right.propertySignatures.length && + left.indexSignatures.length === right.indexSignatures.length && + left.propertySignatures.every((field, index) => sameEncoding(field.type, right.propertySignatures[index].type)) && + left.indexSignatures.every( + (field, index) => + sameEncoding(field.parameter, right.indexSignatures[index].parameter) && + sameEncoding(field.type, right.indexSignatures[index].type), + ) + ) + } + return true +} + +function sameChecks(left: SchemaAST.Checks | undefined, right: SchemaAST.Checks | undefined): boolean { + if (left?.length !== right?.length) return false + if (left === undefined || right === undefined) return true + return left.every((check, index) => { + const other = right[index] + if (other === undefined || check._tag !== other._tag) return false + if (check._tag === "Filter" && other._tag === "Filter") { + return check.run === other.run && check.aborted === other.aborted + } + return check._tag === "FilterGroup" && other._tag === "FilterGroup" && sameChecks(check.checks, other.checks) + }) +} + +function sameContext(left: SchemaAST.Context | undefined, right: SchemaAST.Context | undefined) { + return left?.isOptional === right?.isOptional && left?.isMutable === right?.isMutable +} + +export function write( + output: Output, + directory: string, +): Effect.Effect { + return Effect.gen(function* () { + const paths = new Set() + const normalizedPaths = new Set() + for (const file of output.files) { + if (!isSafeOutputPath(file.path)) yield* new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + const path = file.path.toLowerCase() + if (normalizedPaths.has(path)) yield* new GenerationError({ reason: `Duplicate output path: ${file.path}` }) + normalizedPaths.add(path) + paths.add(file.path) + } + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(directory, { recursive: true }) + const manifest = join(directory, manifestName) + const previous = (yield* fs.exists(manifest)) + ? yield* fs.readFileString(manifest).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Manifest)), + Effect.mapError(() => new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` })), + ) + : [] + if (previous.some((path) => !isSafeOutputPath(path))) { + yield* new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` }) + } + yield* Effect.forEach( + previous.filter((path) => !paths.has(path)), + (path) => fs.remove(join(directory, path), { force: true }), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + fs.exists(join(directory, file.path)).pipe( + Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.succeed(undefined))), + Effect.flatMap((info) => + info?.type === "SymbolicLink" + ? new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + : Effect.void, + ), + ), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + Effect.tryPromise({ + try: () => format(file.content, { filepath: file.path, parser: "typescript", semi: false, printWidth: 120 }), + catch: (error) => new GenerationError({ reason: `Failed to format ${file.path}: ${String(error)}` }), + }).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))), + { concurrency: 8, discard: true }, + ) + yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n") + }) +} + +function isSafeOutputPath(path: string) { + return path !== manifestName && !isAbsolute(path) && path !== "." && path !== ".." && !/[\\/]/.test(path) +} + +export function generate( + api: HttpApi.HttpApi, + options: { readonly directory: string }, +): Effect.Effect { + return Effect.try({ + try: () => emitEffect(compile(api)), + catch: (error) => (error instanceof GenerationError ? error : new GenerationError({ reason: String(error) })), + }).pipe(Effect.flatMap((output) => write(output, options.directory))) +} + +function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) { + if (schema === undefined) return [] + const ast = Schema.toType(schema).ast + if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) { + throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` }) + } + return ast.propertySignatures.map((field) => { + if (typeof field.name !== "string") { + throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` }) + } + return { + name: field.name, + source, + optional: SchemaAST.isOptional(field.type), + } + }) +} + +function responseSchemas(schema: Schema.Top, path: string): Array { + if (HttpApiSchema.isNoContent(schema.ast)) return [] + if (!isStreamSchema(schema)) return [[path, schema]] + if (schema._tag === "StreamUint8Array") return [] + const value = schema.sseMode === "data" ? streamDataSchema(schema) : schema.events + const rebuilt = + schema.sseMode === "data" + ? HttpApiSchema.StreamSse({ data: value, error: schema.error, contentType: schema.contentType }) + : HttpApiSchema.StreamSse({ + events: schema.events, + error: schema.error, + contentType: schema.contentType, + }) + if (!sameEncoding(schema.events.ast, rebuilt.events.ast)) { + throw new GenerationError({ reason: `Unportable schema: ${path}.${schema.sseMode}` }) + } + return [ + [`${path}.${schema.sseMode}`, value], + [`${path}.error`, schema.error], + ] +} + +function assertPortable(schema: Schema.Top, path: string, portable: Map) { + const visiting = new Set() + const taggedError = taggedErrorFields(schema) + const visit = (ast: SchemaAST.AST): boolean => { + const cached = portable.get(ast) + if (cached !== undefined) return cached + if (visiting.has(ast)) return true + visiting.add(ast) + const result = visitCurrent(ast) + visiting.delete(ast) + portable.set(ast, result) + return result + } + const visitCurrent = (ast: SchemaAST.AST): boolean => { + if (!annotationsPortable(ast.annotations)) return false + if (!checksPortable(ast.checks) || ("encodingChecks" in ast && !checksPortable(ast.encodingChecks))) return false + if (SchemaAST.isDeclaration(ast)) { + return generationPortable(ast.annotations?.generation) && ast.typeParameters.every(visit) + } + if (ast.encoding !== undefined && ast.annotations?.generation === undefined) return false + if (SchemaAST.isSuspend(ast)) return visit(ast.thunk()) + if (SchemaAST.isUnion(ast)) return ast.types.every(visit) + if (SchemaAST.isArrays(ast)) { + return ast.elements.every(visit) && ast.rest.every(visit) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => visit(field.type)) && + ast.indexSignatures.every((index) => visit(index.parameter) && visit(index.type)) + ) + } + if (SchemaAST.isTemplateLiteral(ast)) return ast.parts.every(visit) + return true + } + if (taggedError !== undefined && SchemaAST.isDeclaration(schema.ast)) { + if ( + schema.ast.checks !== undefined || + ("encodingChecks" in schema.ast && !checksPortable(schema.ast.encodingChecks)) || + schema.ast.typeParameters.some((ast) => ast.checks !== undefined) || + !schema.ast.typeParameters.every(visit) + ) { + throw new GenerationError({ reason: `Unportable schema: ${path}` }) + } + return + } + if (!visit(schema.ast)) throw new GenerationError({ reason: `Unportable schema: ${path}` }) +} + +function checksPortable(checks: SchemaAST.Checks | undefined): boolean { + if (checks === undefined) return true + return checks.every((check) => + check._tag === "Filter" + ? !check.aborted && + check.annotations?.meta !== undefined && + typeof check.annotations.arbitrary === "object" && + check.annotations.arbitrary !== null && + "constraint" in check.annotations.arbitrary + : checksPortable(check.checks), + ) +} + +function metadataPortable(ast: SchemaAST.AST, seen: Set): boolean { + if (seen.has(ast)) return true + seen.add(ast) + if (!annotationsPortable(ast.annotations) || !checksPortable(ast.checks)) return false + if ("encodingChecks" in ast && !checksPortable(ast.encodingChecks)) return false + if (ast.encoding?.some((link) => !metadataPortable(link.to, seen))) return false + if (SchemaAST.isDeclaration(ast)) return ast.typeParameters.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isSuspend(ast)) return metadataPortable(ast.thunk(), seen) + if (SchemaAST.isUnion(ast)) return ast.types.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isArrays(ast)) { + return ( + ast.elements.every((item) => metadataPortable(item, seen)) && + ast.rest.every((item) => metadataPortable(item, seen)) + ) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => metadataPortable(field.type, seen)) && + ast.indexSignatures.every( + (field) => metadataPortable(field.parameter, seen) && metadataPortable(field.type, seen), + ) + ) + } + return true +} + +function generationPortable(generation: unknown): boolean { + if (typeof generation !== "object" || generation === null) return false + const value = generation as { + readonly runtime?: unknown + readonly Type?: unknown + readonly importDeclaration?: unknown + } + if (typeof value.runtime !== "string" || typeof value.Type !== "string") return false + if (value.importDeclaration !== undefined) { + if ( + typeof value.importDeclaration !== "string" || + !/from ["']effect(?:\/[^"']+)?["']$/.test(value.importDeclaration) + ) { + return false + } + } + const namespace = + typeof value.importDeclaration === "string" + ? /import(?: type)? \* as ([A-Za-z_$][\w$]*)/.exec(value.importDeclaration)?.[1] + : undefined + return value.runtime.startsWith("Schema.") || (namespace !== undefined && value.runtime.startsWith(`${namespace}.`)) +} + +function annotationsPortable(annotations: Schema.Annotations.Annotations | undefined) { + if (annotations === undefined) return true + return Object.entries(annotations).every(([key, value]) => { + if ( + ["toCodec", "toCodecJson", "toArbitrary", "toFormatter", "toEquivalence", "~effect/Schema/Class"].includes(key) + ) { + return true + } + if (key === "generation") return generationPortable(value) + return serializable(value) + }) +} + +function serializable(value: unknown): boolean { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) return true + if (Array.isArray(value)) return value.every(serializable) + if (typeof value !== "object") return false + return Object.values(value).every(serializable) +} + +function taggedErrorFields(schema: Schema.Top) { + if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) { + return undefined + } + const fields = schema.ast.typeParameters[0] + if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined + const tag = fields.propertySignatures.find((field) => field.name === "_tag")?.type + if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined + return { + tag: tag.literal, + identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal, + fields: fields.propertySignatures.flatMap((field) => + field.name === "_tag" || typeof field.name !== "string" + ? [] + : [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const], + ), + } +} + +function isDataEnvelope(schema: Schema.Top) { + if (isStreamSchema(schema) || HttpApiSchema.isNoContent(schema.ast)) return false + const ast = Schema.toType(schema).ast + return ( + SchemaAST.isObjects(ast) && + ast.indexSignatures.length === 0 && + ast.propertySignatures.length === 1 && + ast.propertySignatures[0]?.name === "data" + ) +} + +function isStreamSchema(schema: Schema.Top): schema is HttpApiSchema.StreamSchema { + return "_tag" in schema && (schema._tag === "StreamSse" || schema._tag === "StreamUint8Array") +} + +function streamDataSchema(schema: Extract) { + const ast = Schema.toType(schema.events).ast + if (!SchemaAST.isObjects(ast)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const data = ast.propertySignatures.find((field) => field.name === "data")?.type + if (data === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return Schema.make(data) +} + +function renderGroup(group: Group, groupIndex: number) { + const slots: Array = [] + const adapters: Array = [] + const endpointSources = group.endpoints.map((operation, endpointIndex) => { + const { + endpoint, + errors, + headers: endpointHeaders, + params: endpointParams, + payloads: endpointPayloads, + query: endpointQuery, + successes, + } = operation + const prefix = `Endpoint${endpointIndex}` + const params = addSlot(endpointParams, `${prefix}Params`) + const query = addSlot(endpointQuery, `${prefix}Query`) + const headers = addSlot(endpointHeaders, `${prefix}Headers`) + const payloads = endpointPayloads.map((schema, index) => addSlot(schema, `${prefix}Payload${index}`)!) + const success = renderSuccess(successes[0], `${prefix}Success`) + const errorSlots = errors.map((schema, index) => addSlot(schema, `${prefix}Error${index}`)!) + const options = [ + params === undefined ? undefined : `params: ${params.name}`, + query === undefined ? undefined : `query: ${query.name}`, + headers === undefined ? undefined : `headers: ${headers.name}`, + payloads.length === 0 + ? undefined + : `payload: ${payloads.length === 1 ? payloads[0].name : `[${payloads.map((slot) => slot.name).join(", ")}]`}`, + `success: ${success.source}`, + errorSlots.length === 0 + ? undefined + : `error: ${errorSlots.length === 1 ? errorSlots[0].name : `[${errorSlots.map((slot) => slot.name).join(", ")}]`}`, + ].filter((option): option is string => option !== undefined) + const schemaBySource = { params, query, headers, payload: payloads[0] } + const inputType = operation.input + .map((field) => { + const slot = schemaBySource[field.source] + if (slot === undefined) { + throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.name}` }) + } + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]` + }) + .join("; ") + const argument = + operation.operation.inputMode === "none" + ? "" + : `input${operation.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const slot = schemaBySource[source] + if (slot === undefined) return [] + const fields = operation.input + .filter((field) => field.source === source) + .map( + (field) => + `${JSON.stringify(field.name)}: input${operation.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`, + ) + return [`${source}: { ${fields.join(", ")} }`] + }) + .join(", ") + const declared = [...errorSlots, ...(success.streamError === undefined ? [] : [success.streamError])] + const declaredSchema = + declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])` + const rawCall = `raw[${JSON.stringify(endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n` + adapters.push( + `${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`, + ) + return `HttpApiEndpoint.make(${JSON.stringify(endpoint.method)})(${JSON.stringify(endpoint.name)}, ${JSON.stringify(endpoint.path)}, { ${options.join(", ")} })` + }) + + function addSlot(schema: Schema.Top | undefined, name: string) { + if (schema === undefined) return undefined + const slot = { name, schema } + slots.push(slot) + return slot + } + + function renderSuccess(schema: Schema.Top, name: string) { + if (!isStreamSchema(schema)) return { source: addSlot(schema, name)!.name } + const status = resolveHttpApiStatus(schema.ast) ?? 200 + const annotate = status === 200 ? "" : `.pipe(HttpApiSchema.status(${status}))` + if (schema._tag === "StreamUint8Array") { + return { + source: `HttpApiSchema.StreamUint8Array({ contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + } + } + const value = addSlot( + schema.sseMode === "data" ? streamDataSchema(schema) : schema.events, + `${name}${schema.sseMode === "data" ? "Data" : "Events"}`, + )! + const error = addSlot(schema.error, `${name}Error`)! + return { + source: `HttpApiSchema.StreamSse({ ${schema.sseMode}: ${value.name}, error: ${error.name}, contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + streamError: error, + } + } + + const declarations = renderSchemas(slots) + const groupSource = `HttpApiGroup.make(${JSON.stringify(group.identifier)}, { topLevel: ${group.endpoints[0]?.topLevel ?? false} })${endpointSources.map((endpoint) => `.add(${endpoint})`).join("")}` + const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema.")) + const methods = group.endpoints + .map((item, index) => `${JSON.stringify(item.operation.name)}: Endpoint${index}(raw)`) + .join(", ") + const rawGroup = group.endpoints[0]?.topLevel + ? `HttpApiClient.Client` + : `HttpApiClient.Client.Group` + const usesStream = group.endpoints.some((item) => item.operation.success === "stream") + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n` +} + +function renderSchemas(slots: ReadonlyArray) { + if (slots.length === 0) return "" + const classes = new Map( + slots.flatMap((slot, index) => { + const tagged = taggedErrorFields(slot.schema) + return tagged === undefined ? [] : [[index, tagged] as const] + }), + ) + const expanded = [ + ...slots.map((slot, index) => (classes.has(index) ? { name: slot.name, schema: Schema.Never } : slot)), + ...Array.from(classes.values()).flatMap((tagged, classIndex) => + tagged.fields.map(([name, schema]) => ({ name: `Class${classIndex}${name}`, schema })), + ), + ] + const [first, ...rest] = expanded + const document = SchemaRepresentation.toCodeDocument( + SchemaRepresentation.fromASTs([first.schema.ast, ...rest.map((slot) => slot.schema.ast)]), + ) + const artifacts = document.artifacts.flatMap((artifact) => { + if (artifact._tag === "Import") return [artifact.importDeclaration] + if (artifact._tag === "Enum") return [artifact.generation.runtime] + return [`const ${artifact.identifier} = ${artifact.generation.runtime}`] + }) + const references = [ + ...document.references.nonRecursives.map(({ $ref, code }) => `const ${$ref} = ${code.runtime}`), + ...Object.entries(document.references.recursives).map( + ([$ref, code]) => `type ${$ref} = ${code.Type}\nconst ${$ref}: Schema.Codec<${$ref}> = ${code.runtime}`, + ), + ] + let fieldIndex = slots.length + const declarations = slots.map((slot, index) => { + const tagged = classes.get(index) + if (tagged === undefined) return `const ${slot.name} = ${document.codes[index].runtime}` + const fields = tagged.fields + .map(([name]) => `${JSON.stringify(name)}: ${document.codes[fieldIndex++].runtime}`) + .join(", ") + const annotations = Object.entries({ + httpApiStatus: resolveHttpApiStatus(slot.schema.ast), + "~httpApiEncoding": resolveHttpApiEncoding(slot.schema.ast), + }).filter((entry) => entry[1] !== undefined) + const annotate = + annotations.length === 0 + ? "" + : `.annotate({ ${annotations.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`).join(", ")} })` + return `class ${slot.name}Class extends Schema.TaggedErrorClass<${slot.name}Class>(${JSON.stringify(tagged.identifier)})(${JSON.stringify(tagged.tag)}, { ${fields} }) {}\nconst ${slot.name} = ${slot.name}Class${annotate}` + }) + return [...artifacts, ...references, ...declarations].join("\n\n") +} + +function renderClient(groups: ReadonlyArray) { + const imports = groups + .map((group, index) => `import { adaptGroup${index}, Group${index} } from ${JSON.stringify(`./${group.module}`)}`) + .join("\n") + const api = `HttpApi.make("generated")${groups.map((_, index) => `.add(Group${index})`).join("")}` + const fields = groups.flatMap((group, index) => { + if (!group.endpoints[0]?.topLevel) { + return [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`] + } + const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.name)}: raw[${JSON.stringify(item.endpoint.name)}]`).join(", ")} }` + return [`...adaptGroup${index}(${raw})`] + }) + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\nconst adaptClient = (raw: HttpApiClient.ForApi) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))\n` +} diff --git a/packages/httpapi-codegen/test/effect.ts b/packages/httpapi-codegen/test/effect.ts new file mode 100644 index 00000000000..3accad3cc93 --- /dev/null +++ b/packages/httpapi-codegen/test/effect.ts @@ -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 = Effect.Effect | (() => Effect.Effect) + +const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +const effect = (name: string, body: Body, options?: Parameters[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 } diff --git a/packages/httpapi-codegen/test/fixture.ts b/packages/httpapi-codegen/test/fixture.ts new file mode 100644 index 00000000000..9fb7fedda10 --- /dev/null +++ b/packages/httpapi-codegen/test/fixture.ts @@ -0,0 +1,45 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" + +export class Missing extends Schema.TaggedErrorClass()("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 }), + ), + ) diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts new file mode 100644 index 00000000000..8254652ec55 --- /dev/null +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -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(source: HttpApi.HttpApi) { + 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", + ) + }) + + 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", + { 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", {}) {} + class Authorization extends HttpApiMiddleware.Service()("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("Unauthorized")', + ) + }) + + test("preserves tagged error response statuses", () => { + class Missing extends Schema.TaggedErrorClass()("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 + 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", { + 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 })", + ) + }) +}) diff --git a/packages/httpapi-codegen/test/generated-consumer.ts b/packages/httpapi-codegen/test/generated-consumer.ts new file mode 100644 index 00000000000..448db01be82 --- /dev/null +++ b/packages/httpapi-codegen/test/generated-consumer.ts @@ -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 = health + const _list: Effect.Effect, ClientError> = list + const _filtered: Effect.Effect, ClientError> = filtered + const _get: Effect.Effect = get + const _interrupt: Effect.Effect = interrupt + const _status: Effect.Effect = status + const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe + + return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe } + }), +) + +const _requiresHttpClient: Effect.Effect = program diff --git a/packages/httpapi-codegen/test/generated/client-error.ts b/packages/httpapi-codegen/test/generated/client-error.ts new file mode 100644 index 00000000000..bcc65d9bdd2 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/httpapi-codegen/test/generated/client.ts b/packages/httpapi-codegen/test/generated/client.ts new file mode 100644 index 00000000000..f67fc00a99a --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client.ts @@ -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) => ({ + 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)) diff --git a/packages/httpapi-codegen/test/generated/event.ts b/packages/httpapi-codegen/test/generated/event.ts new file mode 100644 index 00000000000..764f2fd1c70 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/event.ts @@ -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 + +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) }) diff --git a/packages/httpapi-codegen/test/generated/index.ts b/packages/httpapi-codegen/test/generated/index.ts new file mode 100644 index 00000000000..bc0dbc9fa4d --- /dev/null +++ b/packages/httpapi-codegen/test/generated/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export * as OpenCode from "./client" diff --git a/packages/httpapi-codegen/test/generated/session.ts b/packages/httpapi-codegen/test/generated/session.ts new file mode 100644 index 00000000000..6a1937c49e2 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/session.ts @@ -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("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 + +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), +}) diff --git a/packages/httpapi-codegen/test/generated/system.ts b/packages/httpapi-codegen/test/generated/system.ts new file mode 100644 index 00000000000..6ba523d2c0e --- /dev/null +++ b/packages/httpapi-codegen/test/generated/system.ts @@ -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 + +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) }) diff --git a/packages/httpapi-codegen/test/write.test.ts b/packages/httpapi-codegen/test/write.test.ts new file mode 100644 index 00000000000..f0704abea2b --- /dev/null +++ b/packages/httpapi-codegen/test/write.test.ts @@ -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 = [] + 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 = [] + 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 = [] + 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(), + }), + }), + ), + ), + ) +}) diff --git a/packages/httpapi-codegen/tsconfig.json b/packages/httpapi-codegen/tsconfig.json new file mode 100644 index 00000000000..00ef1254685 --- /dev/null +++ b/packages/httpapi-codegen/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md new file mode 100644 index 00000000000..449bd8f6dac --- /dev/null +++ b/packages/sdk-next/README.md @@ -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. diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json new file mode 100644 index 00000000000..55a891a10f7 --- /dev/null +++ b/packages/sdk-next/package.json @@ -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:" + } +} diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts new file mode 100644 index 00000000000..ec948e03cad --- /dev/null +++ b/packages/sdk-next/src/index.ts @@ -0,0 +1,16 @@ +export * as OpenCode from "./opencode" +export * as Tool from "./tool" + +export { ClientError } from "@opencode-ai/client/effect" +export { + AbsolutePath, + Agent, + Location, + Model, + Prompt, + Provider, + RelativePath, + Session, + SessionInput, + SessionMessage, +} from "@opencode-ai/client/effect" diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts new file mode 100644 index 00000000000..ad89ae49aa0 --- /dev/null +++ b/packages/sdk-next/src/opencode.ts @@ -0,0 +1,53 @@ +import { OpenCode } from "@opencode-ai/client/effect" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { createEmbeddedRoutes } from "@opencode-ai/server/routes" +import { Cause, Context, Effect, Layer } from "effect" +import { + HttpClient, + HttpRouter, + HttpServer, + HttpServerError, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" + +export const create = Effect.fn("OpenCode.create")(function* () { + const applicationTools = ApplicationTools.layer + const { handler, permissions, tools } = yield* Effect.all({ + // Reusing this Layer value lets registration and every Location share one memoized host-level registry. + handler: HttpRouter.toHttpEffect( + createEmbeddedRoutes().pipe(Layer.provide(applicationTools), Layer.provide(HttpServer.layerServices)), + ), + permissions: PermissionSaved.Service, + tools: ApplicationTools.Service, + }).pipe(Effect.provide(Layer.merge(applicationTools, PermissionSaved.defaultLayer))) + const httpClient = HttpClient.make( + Effect.fnUntraced(function* (request) { + const response = yield* handler.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)), + Effect.provideService(ApplicationTools.Service, tools), + Effect.provideService(PermissionSaved.Service, permissions), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : HttpServerError.causeResponse(cause).pipe(Effect.map(([response]) => response)), + ), + ) + return HttpServerResponse.toClientResponse(response, { request }) + }, Effect.scoped), + ) + const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ) + return { + ...client, + tools: { register: tools.register }, + } +}) + +export type Interface = Effect.Success> + +export class Service extends Context.Service()("@opencode-ai/sdk-next/OpenCode") {} + +export const layer = Layer.effect(Service, create()) diff --git a/packages/sdk-next/src/tool.ts b/packages/sdk-next/src/tool.ts new file mode 100644 index 00000000000..4b572a6260c --- /dev/null +++ b/packages/sdk-next/src/tool.ts @@ -0,0 +1,2 @@ +export { Failure, RegistrationError, make } from "@opencode-ai/core/tool/tool" +export type { AnyTool, Content, Context, Definition } from "@opencode-ai/core/tool/tool" diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts new file mode 100644 index 00000000000..e163d12c375 --- /dev/null +++ b/packages/sdk-next/test/embedded.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Effect, Schema } from "effect" + +test("embedded client uses the real router and handlers", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") }) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + yield* opencode.tools.register({ + embedded_tool: Tool.make({ + description: "Embedded test tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + }) + + const created = yield* opencode.sessions.create({ + id: sessionID, + agent: Agent.ID.make("build"), + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* opencode.sessions.switchModel({ sessionID, model }) + const selected = yield* opencode.sessions.get({ sessionID }) + const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) }) + const admitted = yield* opencode.sessions.prompt({ + sessionID, + prompt: Prompt.make({ text: "Do not run" }), + resume: false, + }) + const context = yield* opencode.sessions.context({ sessionID }) + const missing = yield* Effect.flip( + opencode.sessions.get({ sessionID: Session.ID.make(`ses_missing_${crypto.randomUUID()}`) }), + ) + + expect(created.id).toBe(sessionID) + expect(selected.model?.id).toBe(model.id) + expect(selected.model?.providerID).toBe(model.providerID) + expect(page.data.some((session) => session.id === sessionID)).toBe(true) + expect(admitted.sessionID).toBe(sessionID) + expect(context.some((message) => message.type === "model-switched")).toBe(true) + expect(missing._tag).toBe("SessionNotFoundError") + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}) + +test("embedded client is available as a Layer service", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Location, OpenCode, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const created = await Effect.runPromise( + Effect.gen(function* () { + const opencode = yield* OpenCode.Service + return yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + }).pipe(Effect.provide(OpenCode.layer), Effect.scoped), + ) + + expect(created.id).toBe(sessionID) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/sdk-next/test/import-boundaries.test.ts b/packages/sdk-next/test/import-boundaries.test.ts new file mode 100644 index 00000000000..f6a7d178e20 --- /dev/null +++ b/packages/sdk-next/test/import-boundaries.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join, resolve, sep } from "node:path" + +const directory = resolve(import.meta.dir, "..") +const client = resolve(import.meta.dir, "../../client") +const core = resolve(import.meta.dir, "../../core") +const server = resolve(import.meta.dir, "../../server") + +test("bundles the client and in-memory host", async () => { + const inputs = await bundleInputs() + + expect(within(inputs, client).length).toBeGreaterThan(0) + expect(within(inputs, core).length).toBeGreaterThan(0) + expect(within(inputs, server).length).toBeGreaterThan(0) +}) + +async function bundleInputs() { + 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 "@opencode-ai/sdk-next"') + const child = Bun.spawn( + [ + process.execPath, + "build", + entrypoint, + "--target=bun", + "--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, directory: string) { + const prefix = directory.endsWith(sep) ? directory : directory + sep + return inputs.filter((input) => input === directory || input.startsWith(prefix)) +} diff --git a/packages/sdk-next/tsconfig.json b/packages/sdk-next/tsconfig.json new file mode 100644 index 00000000000..00ef1254685 --- /dev/null +++ b/packages/sdk-next/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index da5cda13991..7db252a9bfc 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -12,16 +12,24 @@ import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" export function createRoutes(password?: string) { + return makeRoutes( + password + ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) + : ServerAuth.Config.defaultLayer, + ) +} + +export function createEmbeddedRoutes() { + return makeRoutes(ServerAuth.Config.layer({ username: "opencode", password: Option.none() })) +} + +function makeRoutes(auth: Layer.Layer) { return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), Layer.provide(PtyEnvironment.defaultLayer), Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), - Layer.provide( - password - ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) - : ServerAuth.Config.defaultLayer, - ), + Layer.provide(auth), Layer.provide(LocationServiceMap.layer), Layer.provide(Database.defaultLayer), Layer.provide(EventV2.defaultLayer), From 546527b7d6385605b90338c99bf01bfec9b28872 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 25 Jun 2026 03:10:23 +0000 Subject: [PATCH 2/9] chore: generate --- packages/client/src/generated-effect/.httpapi-codegen.json | 6 +----- packages/client/src/generated/.httpapi-codegen.json | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/client/src/generated-effect/.httpapi-codegen.json b/packages/client/src/generated-effect/.httpapi-codegen.json index 958eb566dbd..e73015e3413 100644 --- a/packages/client/src/generated-effect/.httpapi-codegen.json +++ b/packages/client/src/generated-effect/.httpapi-codegen.json @@ -1,5 +1 @@ -[ - "client-error.ts", - "client.ts", - "index.ts" -] +["client-error.ts", "client.ts", "index.ts"] diff --git a/packages/client/src/generated/.httpapi-codegen.json b/packages/client/src/generated/.httpapi-codegen.json index 25700fc72da..f09b399f079 100644 --- a/packages/client/src/generated/.httpapi-codegen.json +++ b/packages/client/src/generated/.httpapi-codegen.json @@ -1,6 +1 @@ -[ - "client-error.ts", - "client.ts", - "index.ts", - "types.ts" -] +["client-error.ts", "client.ts", "index.ts", "types.ts"] From dc569b5a5f461e75a93ba3c642d8bc9cec027c1d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 25 Jun 2026 03:26:09 +0000 Subject: [PATCH 3/9] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 35ecfaa04b1..b0b7856a039 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -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=" } } From cfd75d62fe91f8953c815a0e4432c12850b9fa2a Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:42:06 +1000 Subject: [PATCH 4/9] fix(app): separate provider lifetimes and reactive ownership (#33739) --- packages/app/package.json | 4 +- packages/app/src/app.tsx | 12 +- .../components/dialog-connect-provider.tsx | 8 +- .../src/components/dialog-custom-provider.tsx | 5 +- .../src/components/dialog-manage-models.tsx | 4 +- .../components/dialog-select-model-unpaid.tsx | 11 +- .../src/components/dialog-select-model.tsx | 9 +- .../src/components/dialog-select-provider.tsx | 10 +- packages/app/src/components/prompt-input.tsx | 24 +-- .../components/prompt-input/attachments.ts | 103 ++++++---- .../prompt-input/submission-state.ts | 31 +++ .../components/prompt-input/submit.test.ts | 1 + .../app/src/components/prompt-input/submit.ts | 59 +++--- .../prompt-input/transient-state.ts | 43 +++++ .../src/components/session-context-usage.tsx | 4 +- .../session/session-context-tab.tsx | 4 +- packages/app/src/context/directory-sync.ts | 6 +- packages/app/src/context/local.tsx | 2 +- packages/app/src/context/models.tsx | 6 +- packages/app/src/context/prompt.tsx | 38 ++-- .../app/src/hooks/provider-catalog.test.ts | 59 ++++++ packages/app/src/hooks/provider-catalog.ts | 27 +++ packages/app/src/hooks/use-providers.ts | 26 ++- packages/app/src/pages/directory-layout.tsx | 20 +- packages/app/src/pages/session.tsx | 176 ++++++++++-------- .../composer/session-composer-region.tsx | 2 +- .../src/pages/session/session-ownership.ts | 37 ++++ .../pages/session/use-session-commands.tsx | 108 +++++++---- packages/app/src/utils/session-route.test.ts | 8 +- packages/app/src/utils/session-route.ts | 9 + .../test-browser/prompt-attachments.test.ts | 90 +++++++++ .../app/test-browser/prompt-scope.test.ts | 14 ++ .../prompt-submission-state.test.ts | 56 ++++++ .../prompt-transient-state.test.ts | 36 ++++ .../test-browser/session-ownership.test.ts | 45 +++++ .../generated-effect/.httpapi-codegen.json | 6 +- .../src/generated/.httpapi-codegen.json | 7 +- 37 files changed, 837 insertions(+), 273 deletions(-) create mode 100644 packages/app/src/components/prompt-input/submission-state.ts create mode 100644 packages/app/src/components/prompt-input/transient-state.ts create mode 100644 packages/app/src/hooks/provider-catalog.test.ts create mode 100644 packages/app/src/hooks/provider-catalog.ts create mode 100644 packages/app/src/pages/session/session-ownership.ts create mode 100644 packages/app/test-browser/prompt-attachments.test.ts create mode 100644 packages/app/test-browser/prompt-scope.test.ts create mode 100644 packages/app/test-browser/prompt-submission-state.test.ts create mode 100644 packages/app/test-browser/prompt-transient-state.test.ts create mode 100644 packages/app/test-browser/session-ownership.test.ts diff --git a/packages/app/package.json b/packages/app/package.json index 1c5d3a80bf0..2fcba573d8b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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": "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", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 181f7c1594d..19c8d933612 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -51,7 +51,7 @@ 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, requireServerKey, selectSessionLineage, sessionHref } from "./utils/session-route" import Session from "@/pages/session" import { NewHome, LegacyHome } from "@/pages/home" @@ -95,7 +95,7 @@ const TargetSessionRoute = () => { }) return ( - + @@ -119,7 +119,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 +134,7 @@ function ResolvedTargetSessionRoute() { return ( params.id}> - }> + }> - {props.children} + {props.children} @@ -323,7 +323,7 @@ function TargetServerScopedProviders(props: ServerScopedShellProps) { return ( - {props.children} + {props.children} ) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 76da7d5b429..0fb89d29f92 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -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 }) { 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(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index f2b0432dde3..647e5002a29 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -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 } export function DialogCustomProvider(props: Props) { @@ -40,7 +41,7 @@ export function DialogCustomProvider(props: Props) { dialog.close() return } - dialog.show(() => ) + dialog.show(() => ) } const addModel = () => { diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index b46034bbad9..a0821ef1a36 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -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(() => ) + dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index fae743bb81b..bcb4a2fbcbf 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -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["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(() => ) + dialog.show(() => ) }) } const all = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index ca2643c3b5b..e16d9a66571 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -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(() => ) + dialog.show(() => ) }) } 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(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index 89310286daf..ff0ab5d2bf0 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -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 }> = (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(() => ) + dialog.show(() => ) return } - dialog.show(() => ) + dialog.show(() => ) }} > {(i) => ( diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 98e39313727..302bf35c353 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -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 = (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: "", diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 3087c774b76..82b2d906a89 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -25,6 +25,19 @@ function dataUrl(file: File, mime: string) { }) } +type PromptTarget = Pick["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 + getPathForFile?: (file: File) => string +} + type PromptAttachmentsInput = { prompt: ReturnType 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, 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 } diff --git a/packages/app/src/components/prompt-input/submission-state.ts b/packages/app/src/components/prompt-input/submission-state.ts new file mode 100644 index 00000000000..ebc4c507151 --- /dev/null +++ b/packages/app/src/components/prompt-input/submission-state.ts @@ -0,0 +1,31 @@ +import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt" + +type PromptTarget = ReturnType["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 } + }, + } +} diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 43bdafefa41..6e940040c71 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -41,6 +41,7 @@ const prompt = { replaceComments: () => undefined, items: () => [], }, + capture: () => prompt, } const clientFor = (directory: string) => { diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 814c7c8409b..2a23a560a0f 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -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["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["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) }) } diff --git a/packages/app/src/components/prompt-input/transient-state.ts b/packages/app/src/components/prompt-input/transient-state.ts new file mode 100644 index 00000000000..c8abf9a7668 --- /dev/null +++ b/packages/app/src/components/prompt-input/transient-state.ts @@ -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) { + setStore({ + popover: null, + historyIndex: -1, + savedPrompt: null, + draggingType: null, + mode: "normal", + applyingHistory: false, + variantOpen: false, + }) +} + +export function createPromptInputTransientState(identity: Accessor, placeholder: number) { + const [store, setStore] = createStore({ + 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 +} diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c61afb262c0..95a76d6e848 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -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") diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index a44bfa2ec73..e5fa48a914a 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -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)) diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 45cdb611f41..68e6b19cef1 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -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 diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 6b768457232..9a16b4edf6c 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -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) diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index 21816ab12dc..322a514a789 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -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 } = {}) => { + const providers = useProviders(props.directory) const [store, setStore, _, ready] = persisted( Persist.global("model", ["model.v1"]), diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 682439e9254..104d4f839a5 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -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) { 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

value, } + return value } export function createPromptState() { @@ -301,21 +312,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const owner = getOwner() - const tab = createMemo(() => { - 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(), diff --git a/packages/app/src/hooks/provider-catalog.test.ts b/packages/app/src/hooks/provider-catalog.test.ts new file mode 100644 index 00000000000..04fbe5ab66e --- /dev/null +++ b/packages/app/src/hooks/provider-catalog.test.ts @@ -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) +}) diff --git a/packages/app/src/hooks/provider-catalog.ts b/packages/app/src/hooks/provider-catalog.ts new file mode 100644 index 00000000000..01b966eda63 --- /dev/null +++ b/packages/app/src/hooks/provider-catalog.ts @@ -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 +} diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index fcd567b9133..982c26ca971 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -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) { 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, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 58b127a0571..77c120e57b6 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -58,14 +58,18 @@ export function DirectoryDataProvider( }) return ( - navigate(href(sessionID))} - onSessionHref={href} - > - {props.children} - + + {(directory) => ( + navigate(href(sessionID))} + onSessionHref={href} + > + {props.children} + + )} + ) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 12960d436da..b7188aff81d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -67,6 +67,7 @@ import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError } from "@/utils/server-errors" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" +import { createSessionOwnership } from "./session/session-ownership" type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick @@ -75,6 +76,35 @@ const emptyFollowups: FollowupItem[] = [] type ChangeMode = "git" | "branch" | "turn" type VcsMode = "git" | "branch" +const sessionViewState = () => ({ + messageId: undefined as string | undefined, + mobileTab: "session" as "session" | "changes", + changes: "git" as ChangeMode, +}) + +async function runPromptRollbackMutation(input: { + capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void } + optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void + request: () => Promise + complete: (result: R) => void + rollback: () => void + fail: (error: unknown) => void +}) { + const prompt = input.capturePrompt() + const previous = prompt.current().slice() + batch(() => input.optimistic(prompt)) + await input + .request() + .then(input.complete) + .catch((error) => { + batch(() => { + input.rollback() + prompt.set(previous) + }) + input.fail(error) + }) +} + export default function Page() { const serverSync = useServerSync() const layout = useLayout() @@ -94,6 +124,7 @@ export default function Page() { const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() + const sessionOwnership = createSessionOwnership(sessionKey) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) createEffect(() => { @@ -256,9 +287,7 @@ export default function Page() { ) const [store, setStore] = createStore({ - messageId: undefined as string | undefined, - mobileTab: "session" as "session" | "changes", - changes: "git" as ChangeMode, + ...sessionViewState(), newSessionWorktree: "main", deferRender: false, }) @@ -282,8 +311,9 @@ export default function Page() { const key = sessionKey() if (key !== prev) { setStore("deferRender", true) + const owner = sessionOwnership.capture() requestAnimationFrame(() => { - setTimeout(() => setStore("deferRender", false), 0) + setTimeout(() => owner.run(() => setStore("deferRender", false)), 0) }) } return key @@ -549,8 +579,7 @@ export default function Page() { on( sessionKey, () => { - setStore("messageId", undefined) - setStore("changes", "git") + setStore(sessionViewState()) setUi("pendingMessage", undefined) }, { defer: true }, @@ -1127,27 +1156,37 @@ export default function Page() { let captureHistoryAnchor = () => {} let restoreHistoryAnchor = (_done: boolean) => {} - let historyRequest = false + const historyRequests = new Set() let historyContinuationFrame: number | undefined const loadOlder = async () => { - if (historyRequest || historyLoading()) return - historyRequest = true + const owner = sessionOwnership.capture() + if (historyLoading() || historyRequests.has(owner.key)) return + historyRequests.add(owner.key) const before = timeline.messages().length try { - await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor }) + await timeline.history.loadOlder({ + before: () => owner.run(captureHistoryAnchor), + after: (done) => owner.run(() => restoreHistoryAnchor(done)), + }) } finally { - historyRequest = false + historyRequests.delete(owner.key) } - if (timeline.messages().length <= before) return + if (!owner.current() || timeline.messages().length <= before) return if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) historyContinuationFrame = requestAnimationFrame(() => { historyContinuationFrame = undefined - onHistoryScroll() + owner.run(onHistoryScroll) }) } const onHistoryScroll = () => { - if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) + if ( + historyRequests.has(sessionOwnership.key()) || + historyLoading() || + !autoScroll.userScrolled() || + !scroller || + scroller.scrollTop >= 200 + ) return void loadOlder() } @@ -1218,23 +1257,13 @@ export default function Page() { }) } - const merge = (next: NonNullable>) => - sync().set("session", (list) => { - const idx = list.findIndex((item) => item.id === next.id) - if (idx < 0) return list - const out = list.slice() - out[idx] = next - return out - }) + const merge = (next: NonNullable>, target = sync()) => target.session.remember(next) - const roll = (sessionID: string, next: NonNullable>["revert"]) => - sync().set("session", (list) => { - const idx = list.findIndex((item) => item.id === sessionID) - if (idx < 0) return list - const out = list.slice() - out[idx] = { ...out[idx], revert: next } - return out - }) + const roll = (sessionID: string, next: NonNullable>["revert"], target = sync()) => { + const session = target.session.get(sessionID) + if (!session) return + target.session.remember({ ...session, revert: next }) + } const busy = (sessionID: string) => sync().data.session_working(sessionID) @@ -1252,6 +1281,7 @@ export default function Page() { const followupMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { + const owner = sessionOwnership.capture() const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) if (!item) return @@ -1272,7 +1302,7 @@ export default function Page() { if (!ok) return setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) - if (input.manual) resumeScroll() + if (input.manual) owner.run(resumeScroll) }, })) @@ -1361,25 +1391,23 @@ export default function Page() { const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const prev = prompt.current().slice() - const last = info()?.revert + const client = sdk().client + const target = sync() + const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) - batch(() => { - roll(input.sessionID, { messageID: input.messageID }) - prompt.set(value) + await runPromptRollbackMutation({ + capturePrompt: prompt.capture, + optimistic: (prompt) => { + roll(input.sessionID, { messageID: input.messageID }, target) + prompt.set(value) + }, + request: () => halt(input.sessionID).then(() => client.session.revert(input)), + complete: (result) => { + if (result.data) merge(result.data, target) + }, + rollback: () => roll(input.sessionID, last, target), + fail, }) - await halt(input.sessionID) - .then(() => sdk().client.session.revert(input)) - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(input.sessionID, last) - prompt.set(prev) - }) - fail(err) - }) }, })) @@ -1388,39 +1416,31 @@ export default function Page() { const sessionID = params.id if (!sessionID) return + const client = sdk().client + const target = sync() const next = userMessages().find((item) => item.id > id) - const prev = prompt.current().slice() - const last = info()?.revert + const last = target.session.get(sessionID)?.revert - batch(() => { - roll(sessionID, next ? { messageID: next.id } : undefined) - if (next) { - prompt.set(draft(next.id)) - return - } - prompt.reset() + await runPromptRollbackMutation({ + capturePrompt: prompt.capture, + optimistic: (promptSession) => { + roll(sessionID, next ? { messageID: next.id } : undefined, target) + if (next) { + promptSession.set(draft(next.id)) + return + } + promptSession.reset() + }, + request: () => + !next + ? halt(sessionID).then(() => client.session.unrevert({ sessionID })) + : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })), + complete: (result) => { + if (result.data) merge(result.data, target) + }, + rollback: () => roll(sessionID, last, target), + fail, }) - - const task = !next - ? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => - sdk().client.session.revert({ - sessionID, - messageID: next.id, - }), - ) - - await task - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(sessionID, last) - prompt.set(prev) - }) - fail(err) - }) }, })) diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index ca4fee89021..a2b32ae2dbd 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -70,7 +70,7 @@ export function SessionComposerRegion(props: { const sdk = useSDK() const queryOptions = useQueryOptions() const local = useLocal() - const providers = useProviders() + const providers = useProviders(() => sdk().directory) const settings = useSettings() const server = useServer() const tabs = useTabs() diff --git a/packages/app/src/pages/session/session-ownership.ts b/packages/app/src/pages/session/session-ownership.ts new file mode 100644 index 00000000000..831b5faaf01 --- /dev/null +++ b/packages/app/src/pages/session/session-ownership.ts @@ -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(action: () => T) { + transition() + if (generation !== captured) return + return action() + }, + } + }, + } +} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index fa35204596c..f302643284f 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -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 (load: () => Promise, show: (value: T) => void) => { + const owner = sessionOwnership.capture() + const value = await load() + owner.run(() => show(value)) + } + const runCommand = async (input: { + owner: ReturnType["capture"]> + prompt: T + request: () => Promise + 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(() => ) - }) + void openDialog( + () => import("@/components/dialog-select-file"), + (x) => dialog.show(() => ), + ) } const closeTab = () => { @@ -253,15 +272,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const chooseModel = () => { - void import("@/components/dialog-select-model").then((x) => { - dialog.show(() => ) - }) + void openDialog( + () => import("@/components/dialog-select-model"), + (x) => dialog.show(() => ), + ) } const chooseMcp = () => { - void import("@/components/dialog-select-mcp").then((x) => { - dialog.show(() => ) - }) + void openDialog( + () => import("@/components/dialog-select-mcp"), + (x) => dialog.show(() => ), + ) } 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(() => ) - }) + void openDialog( + () => import("@/components/dialog-fork"), + (x) => dialog.show(() => ), + ) } const shareCmds = () => { diff --git a/packages/app/src/utils/session-route.test.ts b/packages/app/src/utils/session-route.test.ts index b7230b8f5aa..271f2cc7039 100644 --- a/packages/app/src/utils/session-route.test.ts +++ b/packages/app/src/utils/session-route.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { ServerConnection } from "@/context/server" -import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route" +import { legacySessionHref, requireServerKey, rootSession, selectSessionLineage, sessionHref } from "./session-route" describe("session routes", () => { test("builds and decodes a server-keyed session route", () => { @@ -45,4 +45,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() + }) }) diff --git a/packages/app/src/utils/session-route.ts b/packages/app/src/utils/session-route.ts index d4b2759e398..8f040a35f37 100644 --- a/packages/app/src/utils/session-route.ts +++ b/packages/app/src/utils/session-route.ts @@ -18,6 +18,15 @@ export function requireServerKey(segment: string | undefined) { type SessionParent = { id: string; parentID?: string } +export function selectSessionLineage( + 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(session: T, get: (sessionID: string) => Promise) { const seen = new Set([session.id]) let current = session diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts new file mode 100644 index 00000000000..ca7686598e9 --- /dev/null +++ b/packages/app/test-browser/prompt-attachments.test.ts @@ -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() + 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) { + return prompt.current().filter((part) => part.type === "image") +} diff --git a/packages/app/test-browser/prompt-scope.test.ts b/packages/app/test-browser/prompt-scope.test.ts new file mode 100644 index 00000000000..fad19dcff77 --- /dev/null +++ b/packages/app/test-browser/prompt-scope.test.ts @@ -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]) +}) diff --git a/packages/app/test-browser/prompt-submission-state.test.ts b/packages/app/test-browser/prompt-submission-state.test.ts new file mode 100644 index 00000000000..da90c04516c --- /dev/null +++ b/packages/app/test-browser/prompt-submission-state.test.ts @@ -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" }) + }) +}) diff --git a/packages/app/test-browser/prompt-transient-state.test.ts b/packages/app/test-browser/prompt-transient-state.test.ts new file mode 100644 index 00000000000..55ef8d3922a --- /dev/null +++ b/packages/app/test-browser/prompt-transient-state.test.ts @@ -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() + }) +}) diff --git a/packages/app/test-browser/session-ownership.test.ts b/packages/app/test-browser/session-ownership.test.ts new file mode 100644 index 00000000000..2f81c7d5954 --- /dev/null +++ b/packages/app/test-browser/session-ownership.test.ts @@ -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() + }) + }) +}) diff --git a/packages/client/src/generated-effect/.httpapi-codegen.json b/packages/client/src/generated-effect/.httpapi-codegen.json index e73015e3413..958eb566dbd 100644 --- a/packages/client/src/generated-effect/.httpapi-codegen.json +++ b/packages/client/src/generated-effect/.httpapi-codegen.json @@ -1 +1,5 @@ -["client-error.ts", "client.ts", "index.ts"] +[ + "client-error.ts", + "client.ts", + "index.ts" +] diff --git a/packages/client/src/generated/.httpapi-codegen.json b/packages/client/src/generated/.httpapi-codegen.json index f09b399f079..25700fc72da 100644 --- a/packages/client/src/generated/.httpapi-codegen.json +++ b/packages/client/src/generated/.httpapi-codegen.json @@ -1 +1,6 @@ -["client-error.ts", "client.ts", "index.ts", "types.ts"] +[ + "client-error.ts", + "client.ts", + "index.ts", + "types.ts" +] From 259e153d1087f3502425cfc5c141ddd1a003d7b5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 25 Jun 2026 04:43:34 +0000 Subject: [PATCH 5/9] chore: generate --- packages/client/src/generated-effect/.httpapi-codegen.json | 6 +----- packages/client/src/generated/.httpapi-codegen.json | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/client/src/generated-effect/.httpapi-codegen.json b/packages/client/src/generated-effect/.httpapi-codegen.json index 958eb566dbd..e73015e3413 100644 --- a/packages/client/src/generated-effect/.httpapi-codegen.json +++ b/packages/client/src/generated-effect/.httpapi-codegen.json @@ -1,5 +1 @@ -[ - "client-error.ts", - "client.ts", - "index.ts" -] +["client-error.ts", "client.ts", "index.ts"] diff --git a/packages/client/src/generated/.httpapi-codegen.json b/packages/client/src/generated/.httpapi-codegen.json index 25700fc72da..f09b399f079 100644 --- a/packages/client/src/generated/.httpapi-codegen.json +++ b/packages/client/src/generated/.httpapi-codegen.json @@ -1,6 +1 @@ -[ - "client-error.ts", - "client.ts", - "index.ts", - "types.ts" -] +["client-error.ts", "client.ts", "index.ts", "types.ts"] From fed8f0101696d8728b99cbd562dd24e13ea02ffe Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:58:46 +0800 Subject: [PATCH 6/9] refactor(app): lift parent session navigation (#33782) --- packages/app/src/pages/session.tsx | 11 ++++++++++- .../session/composer/session-composer-region.tsx | 16 +++------------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b7188aff81d..84fb555b310 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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" @@ -66,6 +66,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" import { createSessionOwnership } from "./session/session-ownership" @@ -123,6 +124,7 @@ export default function Page() { const terminal = useTerminal() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() + const navigate = useNavigate() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) @@ -1562,6 +1564,13 @@ 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), + ) + }} inputRef={(el) => { inputRef = el }} diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index a2b32ae2dbd..fdb99332406 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -29,7 +29,6 @@ import { ServerConnection, useServer } from "@/context/server" import { type DraftTab, useTabs } from "@/context/tabs" import { useDirectoryPicker } from "@/components/directory-picker" import { base64Encode } from "@opencode-ai/core/util/encode" -import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { useGlobal } from "@/context/global" export function SessionComposerRegion(props: { @@ -37,6 +36,7 @@ export function SessionComposerRegion(props: { ready: boolean centered: boolean placement?: "dock" | "inline" + openParent?: () => void inputRef: (el: HTMLDivElement) => void newSessionWorktree: string onNewSessionWorktreeReset: () => void @@ -225,16 +225,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( - route.params.serverKey - ? sessionHref(requireServerKey(route.params.serverKey), id) - : legacySessionHref(sdk().directory, id), - ) - } - createEffect(() => { const el = store.body if (!el) return @@ -392,11 +382,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" > {language.t("session.child.promptDisabled")} - + From 5d4f160d64e2f6c103c431e7a8362dadfd135d41 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 25 Jun 2026 05:00:23 +0000 Subject: [PATCH 7/9] chore: generate --- packages/app/src/pages/session.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 84fb555b310..b3f2cc31a7c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1568,7 +1568,9 @@ export default function Page() { const id = info()?.parentID if (!id) return navigate( - params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id), + params.serverKey + ? sessionHref(requireServerKey(params.serverKey), id) + : legacySessionHref(sdk().directory, id), ) }} inputRef={(el) => { From d5aa5ff8fb728aee43eb6659103770de13e08026 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:36:49 +0530 Subject: [PATCH 8/9] feat(app): draggable tabs (#31364) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> --- .../regression/cross-server-tab-close.spec.ts | 135 ++++ packages/app/src/app.tsx | 18 +- .../src/components/titlebar-tab-drag.test.ts | 119 ++++ .../app/src/components/titlebar-tab-drag.ts | 135 ++++ .../src/components/titlebar-tab-gesture.ts | 37 ++ .../app/src/components/titlebar-tab-nav.css | 68 ++ .../app/src/components/titlebar-tab-nav.tsx | 344 +++++++++++ .../app/src/components/titlebar-tab-strip.tsx | 581 ++++++++++++++++++ packages/app/src/components/titlebar.tsx | 400 +----------- packages/app/src/context/tabs.tsx | 10 + packages/app/src/utils/session-route.test.ts | 32 +- packages/app/src/utils/session-route.ts | 9 + 12 files changed, 1504 insertions(+), 384 deletions(-) create mode 100644 packages/app/e2e/regression/cross-server-tab-close.spec.ts create mode 100644 packages/app/src/components/titlebar-tab-drag.test.ts create mode 100644 packages/app/src/components/titlebar-tab-drag.ts create mode 100644 packages/app/src/components/titlebar-tab-gesture.ts create mode 100644 packages/app/src/components/titlebar-tab-nav.css create mode 100644 packages/app/src/components/titlebar-tab-nav.tsx create mode 100644 packages/app/src/components/titlebar-tab-strip.tsx diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts new file mode 100644 index 00000000000..031440f1660 --- /dev/null +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -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" }) +} diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 19c8d933612..dea5ee62ba6 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -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, selectSessionLineage, 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 + const sessionID = params.id + return ( + + {(_) => { + const persisted = tabs.store.filter((item) => item.type === "session") + return + }} + + ) } // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) diff --git a/packages/app/src/components/titlebar-tab-drag.test.ts b/packages/app/src/components/titlebar-tab-drag.test.ts new file mode 100644 index 00000000000..5c1451fc305 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-drag.test.ts @@ -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 = 'Session' + 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) + }) +}) diff --git a/packages/app/src/components/titlebar-tab-drag.ts b/packages/app/src/components/titlebar-tab-drag.ts new file mode 100644 index 00000000000..fb1cf7fe73c --- /dev/null +++ b/packages/app/src/components/titlebar-tab-drag.ts @@ -0,0 +1,135 @@ +export type TabDragLayout = { + tabWidthById: Map + 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() + const slots = list.querySelectorAll("[data-titlebar-tab-slot]") + for (const slot of slots) { + const id = slot.dataset.tabKey + if (!id) continue + const tab = slot.querySelector("[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("[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 +} diff --git a/packages/app/src/components/titlebar-tab-gesture.ts b/packages/app/src/components/titlebar-tab-gesture.ts new file mode 100644 index 00000000000..02866d41116 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-gesture.ts @@ -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 | 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 +} diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css new file mode 100644 index 00000000000..ed82f417038 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -0,0 +1,68 @@ +[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); +} diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx new file mode 100644 index 00000000000..aa821678475 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -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 + 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 ( +

{ + 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) + }} + > + + {(session) => { + return ( + { + 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]" + > + + + + { + 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() + }} + /> + + ) + }} + + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={closeTab} + icon={} + /> +
+
+ ) +} + +export function DraftTabItem(props: { + ref?: Ref + 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 ( +
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) + }} + > + { + 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)]" + > + + + + {props.title} + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onClick={closeTab} + icon={} + aria-label="Close tab" + /> +
+
+ ) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx new file mode 100644 index 00000000000..c5e39a26887 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -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 ( +
+ { + 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()} + /> +
+ ) +} + +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() + 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("[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 ( + <> +
+
+
+ + {(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 ( + 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 ( +
{ + if (dragged()) return + onPointerDown(id, event) + }} + > + props.onNavigate(tab, ref)} + onClose={() => props.onClose(tab)} + suppressNavigation={() => suppressNavigation()} + active={props.currentTab() === tab} + pressed={pressedId() === id} + hidden={dragged()} + /> +
+ ) + }} +
+
+
+