Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a211114c8b | |||
| 845c0d47f7 | |||
| bbf1021d4a | |||
| 602c470d37 | |||
| 9ad36251a7 | |||
| 7e8f35e29e | |||
| 22ee85671b | |||
| 51e5e4186f | |||
| 9c20973501 | |||
| 07a65d5a16 | |||
| 905cd60017 | |||
| e03f5dd331 | |||
| 924e00776b | |||
| 66bcddd059 | |||
| b241e38cc8 |
@@ -69,6 +69,11 @@ jobs:
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Check generated client
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/client
|
||||
run: bun run check:generated
|
||||
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/opencode
|
||||
|
||||
+77
@@ -67,6 +67,21 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
|
||||
**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**.
|
||||
@@ -117,6 +132,51 @@ The host-supplied environment overlay applied by the server when creating a PTY,
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- 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.
|
||||
- The public `HttpApi` is authoritative for shared **OpenCode Client** capabilities: the server and code generation consume the same hosted `SessionGroup`. Codegen may assign a separate consumer-facing group name without reconstructing group membership or endpoint contracts.
|
||||
- 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 the canonical Protocol `HttpApi`; the Promise emitter still derives zero-Effect structural wire types from the same IR.
|
||||
- `@opencode-ai/protocol` owns the authoritative Session `HttpApi`. The server and client generator consume the exact hosted `SessionGroup`, including `compact`, `wait`, and `context`.
|
||||
- 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.
|
||||
@@ -133,6 +193,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.
|
||||
- Replace the transitional server-internal Session location middleware tag when the remaining location-scoped groups move to Protocol or gain a narrow request-location service contract.
|
||||
- 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?"
|
||||
|
||||
@@ -109,6 +109,27 @@
|
||||
"@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/httpapi-codegen": "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.9",
|
||||
@@ -477,11 +498,22 @@
|
||||
"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.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"aws4fetch": "1.0.20",
|
||||
@@ -643,9 +675,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.2",
|
||||
"@opentui/keymap": ">=0.4.2",
|
||||
"@opentui/solid": ">=0.4.2",
|
||||
"@opentui/core": ">=0.3.4",
|
||||
"@opentui/keymap": ">=0.3.4",
|
||||
"@opentui/solid": ">=0.3.4",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -653,9 +685,22 @@
|
||||
"@opentui/solid",
|
||||
],
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode-ai/protocol",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode-ai/schema",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -674,6 +719,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.9",
|
||||
@@ -694,6 +753,7 @@
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -957,12 +1017,13 @@
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@lydell/node-pty": "1.2.0-beta.12",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1787,6 +1848,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"],
|
||||
@@ -1813,16 +1876,22 @@
|
||||
|
||||
"@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"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
|
||||
|
||||
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
|
||||
|
||||
"@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/slack": ["@opencode-ai/slack@workspace:packages/slack"],
|
||||
@@ -1869,27 +1938,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.4.2", "", { "dependencies": { "bun-ffi-structs": "0.2.3", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.2", "@opentui/core-darwin-x64": "0.4.2", "@opentui/core-linux-arm64": "0.4.2", "@opentui/core-linux-arm64-musl": "0.4.2", "@opentui/core-linux-x64": "0.4.2", "@opentui/core-linux-x64-musl": "0.4.2", "@opentui/core-win32-arm64": "0.4.2", "@opentui/core-win32-x64": "0.4.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ulx6RMqftf2fm7Itf9e81GcCDMNY6NAhmnKYhllDOMYD+PxYXR+vomy2bxQNV5ow31RE7s8WQFnb7hWTRUbx2g=="],
|
||||
"@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0s/ooK+AhWP306By3gu+XhzcVEThC2sqKMPK1nQmGDujQhd+xOrtbtfCVcJSx62UzAovC2VNqypvP8vHByOg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cjv6Bv7l3p/KLNJr5RyqCS0FmRlAGJnkA2IK3S+HkHhCOv/O02S1G+DBUY6POnyjp1eNy95vauustApobhdbig=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.4.2", "", { "dependencies": { "@opentui/core": "0.4.2" }, "peerDependencies": { "@opentui/react": "0.4.2", "@opentui/solid": "0.4.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wxBEFfWgm3feqCRLckWg1JH4tbMJinpyK3yobkLTsWJ7PDsM+fPoFMyQ8ieKVdUL2eP6ELTmHvM1bHKShZ7SUQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.4.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zuYXsnrlsMtnXrS7QCYBdPzMtUSonG2LqnJikBR2NjEE2O4zEKvJd48n3eB1igcxjv96tiotTXRNCylYS0SNdQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -3067,7 +3136,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -5425,6 +5494,8 @@
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
@@ -256,7 +256,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
SECRET.UpstashRedisRestToken,
|
||||
AUTH_API_URL,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
SECRET.SupportApiKey,
|
||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||
SECRET.HoneycombWebhookSecret,
|
||||
STRIPE_SECRET_KEY,
|
||||
|
||||
@@ -7,7 +7,6 @@ new sst.cloudflare.x.SolidStart("Teams", {
|
||||
domain: shortDomain,
|
||||
path: "packages/enterprise",
|
||||
buildCommand: "bun run build:cloudflare",
|
||||
link: [SECRET.SupportApiKey],
|
||||
environment: {
|
||||
OPENCODE_STORAGE_ADAPTER: "r2",
|
||||
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
|
||||
|
||||
@@ -9,7 +9,6 @@ export const SECRET = {
|
||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
|
||||
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
||||
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-JJnJVqP2+NiiRVoTKXjGD09lPRyfz8YMfx0m3jBaUzU=",
|
||||
"aarch64-linux": "sha256-tY3/IA+iZbOQQGxrcHMvQ1xqByskZyVI+4LZkU0wVjs=",
|
||||
"aarch64-darwin": "sha256-9qMRrQKKOgQuOcAgAq8oZacVFD7G4nbAfZawmCRuJdY=",
|
||||
"x86_64-darwin": "sha256-dR2VGxDLoAPEmk8SsEF4dhlPBcb9ubztAuVfwxuAD4M="
|
||||
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
|
||||
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
|
||||
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
|
||||
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -34,14 +34,15 @@
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
@@ -18,8 +18,6 @@ bun run test:bench
|
||||
The suite contains:
|
||||
|
||||
- cold and hot session-tab timing
|
||||
- home-session click timing split between content and titlebar-tab paint
|
||||
- single-session tab close timing through stable home restoration
|
||||
- cached session repaint and mutation tracing
|
||||
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { measureFirstNavigation } from "./first-navigation-probe"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import {
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressDraftHref,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
|
||||
const draftID = "draft_first_navigation"
|
||||
|
||||
benchmark.describe("performance: first navigation paint", () => {
|
||||
benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
|
||||
await setup(page, draftID)
|
||||
const href = stressDraftHref(draftID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||
destinationSelector: '[data-component="prompt-input"]',
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.childID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||
destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!),
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click()
|
||||
await expectSessionTitle(page, fixture.expected.childTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(page: Parameters<typeof mockStressTimeline>[0], draft?: string) {
|
||||
await mockStressTimeline(page)
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page, draft ? { draftID: draft } : undefined)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
}
|
||||
|
||||
function messageSelector(id: string) {
|
||||
return `[data-message-id="${id}"]`
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export type FirstNavigationSample = {
|
||||
observedAtMs: number
|
||||
source: boolean
|
||||
destination: boolean
|
||||
content: boolean
|
||||
pathname?: string
|
||||
center?: string
|
||||
}
|
||||
|
||||
function category(sample: FirstNavigationSample) {
|
||||
if (sample.destination && !sample.source) return "destination"
|
||||
if (sample.source && !sample.destination) return "source"
|
||||
if (!sample.content) return "blank"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export function summarizeFirstNavigation(samples: FirstNavigationSample[]) {
|
||||
const categories = samples.map(category)
|
||||
const stable = categories.findIndex(
|
||||
(value, index) =>
|
||||
value === "destination" && categories[index + 1] === "destination" && categories[index + 2] === "destination",
|
||||
)
|
||||
return {
|
||||
samples: samples.length,
|
||||
firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null,
|
||||
stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
|
||||
sourceSamples: categories.filter((value) => value === "source").length,
|
||||
blankSamples: categories.filter((value) => value === "blank").length,
|
||||
unknownSamples: categories.filter((value) => value === "unknown").length,
|
||||
destinationSamples: categories.filter((value) => value === "destination").length,
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics"
|
||||
|
||||
type FirstNavigationProbe = {
|
||||
samples: FirstNavigationSample[]
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export async function measureFirstNavigation(
|
||||
page: Page,
|
||||
input: {
|
||||
href: string
|
||||
destinationPath: string
|
||||
sourceSelector: string
|
||||
destinationSelector: string
|
||||
contentSelector: string
|
||||
navigate: () => Promise<void>
|
||||
},
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => {
|
||||
const samples: FirstNavigationSample[] = []
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const visible = (selector: string) =>
|
||||
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
|
||||
})
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
source: visible(sourceSelector),
|
||||
destination: `${location.pathname}${location.search}` === destinationPath && visible(destinationSelector),
|
||||
content: visible(contentSelector),
|
||||
pathname: `${location.pathname}${location.search}`,
|
||||
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
|
||||
})
|
||||
sample()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||
if (link?.getAttribute("href") !== href) return
|
||||
started = performance.now()
|
||||
sample()
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
href: input.href,
|
||||
destinationPath: input.destinationPath,
|
||||
sourceSelector: input.sourceSelector,
|
||||
destinationSelector: input.destinationSelector,
|
||||
contentSelector: input.contentSelector,
|
||||
},
|
||||
)
|
||||
await input.navigate()
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe
|
||||
?.samples
|
||||
if (!samples) return false
|
||||
return samples.length >= 3 && samples.slice(-3).every((sample) => sample.destination && !sample.source)
|
||||
})
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe!
|
||||
probe.stop()
|
||||
return probe.samples
|
||||
})
|
||||
return { summary: summarizeFirstNavigation(samples), samples }
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { measureNavigationMilestones } from "./navigation-milestones"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import {
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const homeRow = '[data-component="home-session-row"]'
|
||||
const homeShell = '[data-component="home-session-search"]'
|
||||
|
||||
benchmark.describe("performance: home and tab navigation", () => {
|
||||
benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => {
|
||||
await setup(page, [])
|
||||
await page.goto("/")
|
||||
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
||||
await expect(row).toBeVisible()
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const result = await measureNavigationMilestones(page, {
|
||||
triggerSelector: homeRow,
|
||||
milestones: {
|
||||
content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) },
|
||||
tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` },
|
||||
},
|
||||
navigate: async () => {
|
||||
await row.click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText(
|
||||
fixture.expected.targetTitle,
|
||||
)
|
||||
})
|
||||
|
||||
benchmark("stages the review body after cold session content", async ({ page, report }) => {
|
||||
await setup(page, [])
|
||||
await page.goto("/")
|
||||
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
||||
await expect(row).toBeVisible()
|
||||
const result = await page.evaluate(
|
||||
({ rowSelector, title, contentSelector }) =>
|
||||
new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => {
|
||||
let samples = 0
|
||||
const sample = () => {
|
||||
samples++
|
||||
const content = !!document.querySelector(contentSelector)
|
||||
const review = !!document.querySelector('[data-component="session-review"]')
|
||||
if (content && !review) {
|
||||
resolve({ contentBeforeReview: true, samples })
|
||||
return
|
||||
}
|
||||
if (content && review) {
|
||||
resolve({ contentBeforeReview: false, samples })
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
const target = [...document.querySelectorAll<HTMLElement>(rowSelector)].find((item) =>
|
||||
item.textContent?.includes(title),
|
||||
)
|
||||
if (!target) throw new Error(`Home session row not found: ${title}`)
|
||||
target.click()
|
||||
requestAnimationFrame(sample)
|
||||
}),
|
||||
{
|
||||
rowSelector: homeRow,
|
||||
title: fixture.expected.targetTitle,
|
||||
contentSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
||||
},
|
||||
)
|
||||
report(result)
|
||||
expect(result.contentBeforeReview).toBe(true)
|
||||
await expect(page.locator('[data-component="session-review"]')).toBeVisible()
|
||||
})
|
||||
|
||||
benchmark("closes the only session tab and paints home", async ({ page, report }) => {
|
||||
await setup(page, [fixture.sourceID])
|
||||
const href = stressSessionHref(fixture.sourceID)
|
||||
await page.goto(href)
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
const close = tab.locator("..").locator('[data-component="icon-button-v2"]')
|
||||
await expect(close).toBeVisible()
|
||||
const result = await measureNavigationMilestones(page, {
|
||||
triggerSelector: '[data-slot="titlebar-tabs"] [data-component="icon-button-v2"]',
|
||||
milestones: {
|
||||
home: { selector: homeShell },
|
||||
row: { selector: homeRow },
|
||||
tabRemoved: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]`, visible: false },
|
||||
},
|
||||
navigate: async () => {
|
||||
await close.click()
|
||||
await expect(page).toHaveURL("/")
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(page: Parameters<typeof mockStressTimeline>[0], sessionIDs: string[]) {
|
||||
await mockStressTimeline(page)
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page, { sessionIDs })
|
||||
}
|
||||
|
||||
function messageSelector(id: string) {
|
||||
return `[data-message-id="${id}"]`
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export type NavigationMilestoneSample = {
|
||||
observedAtMs: number
|
||||
milestones: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function summarizeNavigationMilestones(samples: NavigationMilestoneSample[]) {
|
||||
const names = Object.keys(samples[0]?.milestones ?? {})
|
||||
const summarize = (matches: (sample: NavigationMilestoneSample) => boolean) => {
|
||||
const first = samples.find(matches)
|
||||
const stable = samples.findIndex(
|
||||
(sample, index) =>
|
||||
index + 2 < samples.length && matches(sample) && matches(samples[index + 1]!) && matches(samples[index + 2]!),
|
||||
)
|
||||
return {
|
||||
firstObservedMs: first?.observedAtMs ?? null,
|
||||
stableObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
|
||||
}
|
||||
}
|
||||
return {
|
||||
samples: samples.length,
|
||||
milestones: Object.fromEntries(
|
||||
names.map((name) => [name, summarize((sample) => sample.milestones[name] === true)]),
|
||||
),
|
||||
all: summarize((sample) => names.every((name) => sample.milestones[name] === true)),
|
||||
}
|
||||
}
|
||||
|
||||
type NavigationMilestoneProbe = {
|
||||
samples: NavigationMilestoneSample[]
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export async function measureNavigationMilestones(
|
||||
page: Page,
|
||||
input: {
|
||||
triggerSelector: string
|
||||
milestones: Record<string, { selector: string; visible?: boolean }>
|
||||
navigate: () => Promise<void>
|
||||
},
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ triggerSelector, milestones }) => {
|
||||
const samples: NavigationMilestoneSample[] = []
|
||||
const streaks = new Map<string, number>()
|
||||
const marked = new Set<string>()
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const visible = (selector: string) =>
|
||||
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
|
||||
})
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
const current = Object.fromEntries(
|
||||
Object.entries(milestones).map(([name, milestone]) => [
|
||||
name,
|
||||
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
|
||||
]),
|
||||
)
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
milestones: current,
|
||||
})
|
||||
Object.entries(current).forEach(([name, value]) => {
|
||||
if (!value) {
|
||||
streaks.set(name, 0)
|
||||
return
|
||||
}
|
||||
if (!marked.has(`${name}.first`)) {
|
||||
performance.mark(`opencode.navigation.${name}.first`)
|
||||
marked.add(`${name}.first`)
|
||||
}
|
||||
const streak = (streaks.get(name) ?? 0) + 1
|
||||
streaks.set(name, streak)
|
||||
if (streak === 3) performance.mark(`opencode.navigation.${name}.stable`)
|
||||
})
|
||||
const all = Object.values(current).every(Boolean)
|
||||
const allStreak = all ? (streaks.get("all") ?? 0) + 1 : 0
|
||||
streaks.set("all", allStreak)
|
||||
if (all && !marked.has("all.first")) {
|
||||
performance.mark("opencode.navigation.all.first")
|
||||
marked.add("all.first")
|
||||
}
|
||||
if (allStreak === 3) performance.mark("opencode.navigation.all.stable")
|
||||
sample()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
|
||||
started = performance.now()
|
||||
performance.mark("opencode.navigation.click")
|
||||
sample()
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
},
|
||||
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
|
||||
)
|
||||
await input.navigate()
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
|
||||
?.samples
|
||||
if (!samples || samples.length < 3) return false
|
||||
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
|
||||
})
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
|
||||
probe.stop()
|
||||
return probe.samples
|
||||
})
|
||||
return { summary: summarizeNavigationMilestones(samples), samples }
|
||||
}
|
||||
@@ -47,21 +47,3 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
|
||||
report(compressCachedRepaintTrace(result))
|
||||
expect(result.samples.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
benchmark("prefetches every open session tab", async ({ page, report }) => {
|
||||
const prefetched = new Set<string>()
|
||||
await mockStressTimeline(page, {
|
||||
onMessages: (input) => {
|
||||
if (!input.before && input.phase === "start") prefetched.add(input.sessionID)
|
||||
},
|
||||
})
|
||||
await installStressSessionTabs(page, {
|
||||
sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID],
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
|
||||
await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
|
||||
report({ prefetched: [...prefetched] })
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ const words = [
|
||||
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const childID = "ses_smoke_child"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
const projectID = "proj_smoke_timeline"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
@@ -127,11 +126,9 @@ function toolPart(
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
outputLength = 160,
|
||||
metadataOverride?: Record<string, unknown>,
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
metadataOverride ??
|
||||
(tool === "apply_patch"
|
||||
tool === "apply_patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
@@ -141,7 +138,7 @@ function toolPart(
|
||||
}
|
||||
: tool === "question"
|
||||
? { answers: [["Proceed"], ["Keep sample output"]] }
|
||||
: {})
|
||||
: {}
|
||||
return {
|
||||
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||
type: "tool",
|
||||
@@ -247,25 +244,7 @@ function turn(index: number): Message[] {
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [
|
||||
textPart(index + 1000, 0, 240),
|
||||
...(index === 11
|
||||
? [
|
||||
toolPart(
|
||||
index + 1000,
|
||||
1,
|
||||
"task",
|
||||
{ description: "Inspect child navigation", subagent_type: "explore" },
|
||||
160,
|
||||
{ sessionId: childID },
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
]).flat()
|
||||
const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||
userMessage(childID, index + 2000, 120),
|
||||
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
@@ -319,32 +298,19 @@ export const fixture = {
|
||||
version: "dev",
|
||||
time: { created: 1700000001000, updated: 1700000001000 },
|
||||
},
|
||||
{
|
||||
id: childID,
|
||||
parentID: sourceID,
|
||||
slug: "child",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Inspect child navigation",
|
||||
version: "dev",
|
||||
time: { created: 1700000002000, updated: 1700000002000 },
|
||||
},
|
||||
],
|
||||
sourceID,
|
||||
targetID,
|
||||
childID,
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
childTitle: "Inspect child navigation",
|
||||
sourceMessageIDs: sourceMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) =>
|
||||
orderedParts(message)
|
||||
.filter(renderable)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
|
||||
export async function installTimelineSettings(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
@@ -9,7 +9,6 @@ export async function installTimelineSettings(page: Page) {
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
newLayoutDesigns: true,
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
@@ -20,24 +19,20 @@ export async function installTimelineSettings(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
export function mockStressTimeline(
|
||||
page: Page,
|
||||
input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void },
|
||||
) {
|
||||
export function mockStressTimeline(page: Page) {
|
||||
return mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
onMessages: input?.onMessages,
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) {
|
||||
const server = stressServer()
|
||||
export async function installStressSessionTabs(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, sessionIDs, dirBase64, server, draftID }) => {
|
||||
({ directory, sourceID, targetID, dirBase64, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -47,35 +42,26 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
JSON.stringify([
|
||||
...sessionIDs.map((sessionId) => ({
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
server,
|
||||
dirBase64,
|
||||
sessionId,
|
||||
})),
|
||||
...(draftID ? [{ type: "draft", draftID, server, directory }] : []),
|
||||
]),
|
||||
),
|
||||
)
|
||||
},
|
||||
{
|
||||
directory: fixture.directory,
|
||||
sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID],
|
||||
sourceID: fixture.sourceID,
|
||||
targetID: fixture.targetID,
|
||||
dirBase64: base64Encode(fixture.directory),
|
||||
server,
|
||||
draftID: input?.draftID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function stressSessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(stressServer())}/session/${sessionID}`
|
||||
}
|
||||
|
||||
export function stressDraftHref(draftID: string) {
|
||||
return `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||
}
|
||||
|
||||
function stressServer() {
|
||||
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/${base64Encode(fixture.directory)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics"
|
||||
|
||||
test("reports blank frames before first destination and stable paint", () => {
|
||||
expect(
|
||||
summarizeFirstNavigation([
|
||||
{ observedAtMs: 16, source: true, destination: false, content: true },
|
||||
{ observedAtMs: 32, source: false, destination: false, content: false },
|
||||
{ observedAtMs: 48, source: false, destination: true, content: true },
|
||||
{ observedAtMs: 64, source: false, destination: true, content: true },
|
||||
{ observedAtMs: 80, source: false, destination: true, content: true },
|
||||
]),
|
||||
).toEqual({
|
||||
samples: 5,
|
||||
firstDestinationObservedMs: 48,
|
||||
stableDestinationObservedMs: 80,
|
||||
sourceSamples: 1,
|
||||
blankSamples: 1,
|
||||
unknownSamples: 0,
|
||||
destinationSamples: 3,
|
||||
})
|
||||
})
|
||||
|
||||
test("does not report stability for interrupted destination frames", () => {
|
||||
expect(
|
||||
summarizeFirstNavigation([
|
||||
{ observedAtMs: 16, source: false, destination: true, content: true },
|
||||
{ observedAtMs: 32, source: false, destination: false, content: true },
|
||||
{ observedAtMs: 48, source: false, destination: true, content: true },
|
||||
]).stableDestinationObservedMs,
|
||||
).toBeNull()
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { summarizeNavigationMilestones } from "../timeline/navigation-milestones"
|
||||
|
||||
test("reports first and stable paint for each navigation milestone", () => {
|
||||
expect(
|
||||
summarizeNavigationMilestones([
|
||||
{ observedAtMs: 16, milestones: { content: false, tab: false } },
|
||||
{ observedAtMs: 32, milestones: { content: true, tab: false } },
|
||||
{ observedAtMs: 48, milestones: { content: true, tab: true } },
|
||||
{ observedAtMs: 64, milestones: { content: true, tab: true } },
|
||||
{ observedAtMs: 80, milestones: { content: true, tab: true } },
|
||||
]),
|
||||
).toEqual({
|
||||
samples: 5,
|
||||
milestones: {
|
||||
content: { firstObservedMs: 32, stableObservedMs: 64 },
|
||||
tab: { firstObservedMs: 48, stableObservedMs: 80 },
|
||||
},
|
||||
all: { firstObservedMs: 48, stableObservedMs: 80 },
|
||||
})
|
||||
})
|
||||
|
||||
test("reports missing stability when a milestone appears in the final samples", () => {
|
||||
expect(
|
||||
summarizeNavigationMilestones([
|
||||
{ observedAtMs: 16, milestones: { content: false } },
|
||||
{ observedAtMs: 32, milestones: { content: true } },
|
||||
]),
|
||||
).toEqual({
|
||||
samples: 2,
|
||||
milestones: { content: { firstObservedMs: 32, stableObservedMs: null } },
|
||||
all: { firstObservedMs: 32, stableObservedMs: null },
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||
|
||||
test("builds stress session links for the benchmark server", () => {
|
||||
expect(stressSessionHref(fixture.sourceID)).toBe(
|
||||
`/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
|
||||
)
|
||||
})
|
||||
@@ -57,8 +57,8 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
|
||||
await page.mouse.move(0, 0)
|
||||
await lineNumber.hover()
|
||||
await expect(comment).toBeVisible({ timeout: 500 })
|
||||
await comment.click({ timeout: 500 })
|
||||
}).toPass()
|
||||
await comment.click()
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -58,18 +58,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.mouse.wheel(0, -120)
|
||||
await page.waitForTimeout(20)
|
||||
}
|
||||
const keys = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
||||
.filter((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
.map((row) => row.dataset.timelinePartId)
|
||||
.filter((id): id is string => !!id)
|
||||
.slice(0, 3)
|
||||
})
|
||||
expect(keys.length).toBeGreaterThan(0)
|
||||
const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"]
|
||||
const positions = () =>
|
||||
scroller.evaluate((element, keys) => {
|
||||
const top = element.getBoundingClientRect().top
|
||||
@@ -729,6 +718,7 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
|
||||
}
|
||||
|
||||
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
||||
console.log(process.env)
|
||||
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
|
||||
@@ -26,8 +26,6 @@ export interface MockServerConfig {
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const cursors = new Map<string, string>()
|
||||
let nextCursor = 0
|
||||
const staticRoutes: Record<string, unknown> = {
|
||||
"/provider": config.provider,
|
||||
"/path": {
|
||||
@@ -70,18 +68,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const token = url.searchParams.get("before") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
const before = url.searchParams.get("before") ?? undefined
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
if (!pageData.cursor) return json(route, pageData.items)
|
||||
const cursor = `cursor_${++nextCursor}`
|
||||
cursors.set(cursor, pageData.cursor)
|
||||
return json(route, pageData.items, { "x-next-cursor": cursor })
|
||||
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
|
||||
}
|
||||
|
||||
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
||||
@@ -89,9 +82,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
|
||||
+183
-177
@@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
@@ -32,7 +32,7 @@ import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { GlobalProvider } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
@@ -53,127 +53,56 @@ import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
|
||||
|
||||
import Session from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
|
||||
const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
|
||||
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
const SessionRoute = () => {
|
||||
const settings = useSettings()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const SessionRoute = Object.assign(
|
||||
() => {
|
||||
const settings = useSettings()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
if (params.id && settings.general.newLayoutDesigns()) {
|
||||
return <Navigate href={sessionHref(server.key, params.id)} />
|
||||
}
|
||||
if (params.id && settings.general.newLayoutDesigns()) {
|
||||
return <Navigate href={sessionHref(server.key, params.id)} />
|
||||
}
|
||||
|
||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||
// is replaced by a draft at /new-session?draftId=…
|
||||
createEffect(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (params.id || search.draftId) return
|
||||
if (!tabs.ready() || !sdk().directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const server = useServer()
|
||||
const conn = createMemo(() => {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return server.list.find((item) => ServerConnection.key(item) === key)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={`${params.serverKey}\0${params.id}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||
const placement = createMemo(() => global.sessionPlacement.get(serverKey(), params.id))
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (placement()) return
|
||||
return { id: params.id, sdk: serverSDK() }
|
||||
},
|
||||
async ({ id, sdk }) => {
|
||||
const session = (await sdk.client.session.get({ sessionID: id })).data!
|
||||
const root = await rootSession(session, (sessionID) =>
|
||||
sdk.client.session.get({ sessionID }).then((result) => result.data!),
|
||||
)
|
||||
return global.sessionPlacement.set({
|
||||
server: serverKey(),
|
||||
leafID: session.id,
|
||||
rootID: root.id,
|
||||
directory: session.directory,
|
||||
})
|
||||
},
|
||||
)
|
||||
const directory = createMemo(() => placement()?.directory ?? resolved()?.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const current = placement() ?? resolved()
|
||||
if (!current) return
|
||||
tabs.addSessionTab({
|
||||
server: serverKey(),
|
||||
sessionId: current.rootID,
|
||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||
// is replaced by a draft at /new-session?draftId=…
|
||||
createEffect(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (params.id || search.draftId) return
|
||||
if (!tabs.ready() || !sdk().directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</TargetServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
const TargetSessionRoute = Object.assign(
|
||||
() => {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
@@ -196,42 +125,123 @@ function LegacyServerLayout(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Wraps /new-session. It resolves the draft's target server and provides the
|
||||
// server-scoped shell for that server — without ServerKey, so the page never depends
|
||||
// on the globally "selected" server.
|
||||
function TargetServerLayout(props: ParentProps) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ serverKey?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const conn = createMemo(() => {
|
||||
if (params.serverKey) {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return server.list.find((item) => ServerConnection.key(item) === key)
|
||||
}
|
||||
const id = search.draftId
|
||||
if (!id) return undefined
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
|
||||
if (!draft) return undefined
|
||||
return server.list.find((c) => ServerConnection.key(c) === draft.server)
|
||||
})
|
||||
|
||||
return (
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetDirectoryLayout(props: ParentProps) {
|
||||
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverKey = createMemo(() => {
|
||||
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||
if (!search.draftId) return undefined
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server
|
||||
})
|
||||
|
||||
const resolved = useQuery(() => ({
|
||||
queryKey: [serverSDK().scope, "session-route", params.id] as const,
|
||||
enabled: !!params.serverKey && !!params.id,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data!
|
||||
const root = await rootSession(session, (sessionID) =>
|
||||
serverSDK()
|
||||
.client.session.get({ sessionID })
|
||||
.then((result) => result.data!),
|
||||
)
|
||||
return { session, rootID: root.id }
|
||||
},
|
||||
}))
|
||||
const resolvedDirectory = createMemo(() => {
|
||||
if (params.serverKey) return resolved.data?.session.directory
|
||||
if (!search.draftId) return undefined
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
|
||||
})
|
||||
const directory = createMemo<string | undefined>((prev) =>
|
||||
search.draftId ? resolvedDirectory() : (prev ?? resolvedDirectory()),
|
||||
)
|
||||
const home = () => !params.serverKey && !search.draftId
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const current = resolved.data
|
||||
const key = serverKey()
|
||||
if (!current || !key) return
|
||||
tabs.addSessionTab({
|
||||
server: key,
|
||||
sessionId: current.rootID,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
|
||||
<Show when={!home()} fallback={props.children}>
|
||||
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={!params.serverKey || settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</NewServerScopedShell>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Show
|
||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
keyed
|
||||
fallback={<Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
||||
<ResolvedDraftRoute />
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const server = useServer()
|
||||
const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
const directory = () => props.draft.directory
|
||||
const serverKey = () => props.draft.server
|
||||
|
||||
function ResolvedDraftRoute() {
|
||||
return (
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetServerScopedProviders directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</TargetServerScopedProviders>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -294,7 +304,9 @@ function SharedProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
|
||||
// each per-route server layout so they resolve to that route's server (selected vs
|
||||
// draft). The Layout remounts when crossing between those groups.
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
sessionID?: () => string | undefined
|
||||
@@ -320,23 +332,11 @@ function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function NewAppLayout(props: ParentProps) {
|
||||
function NewServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<ServerScopedProviders>
|
||||
<NewLayout>{props.children}</NewLayout>
|
||||
</ServerScopedProviders>
|
||||
</SelectedServerProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||
<NewLayout>{props.children}</NewLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -522,9 +522,11 @@ export function AppInterface(props: {
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
}) {
|
||||
// The visual new layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
|
||||
// Command/Highlights) and stays mounted across every route. The server-scoped
|
||||
// providers and the visual Layout live in the per-route layouts below, so they
|
||||
// resolve to that route's server (selected for most routes, the draft's server for
|
||||
// /new-session). appChildren is server-agnostic, so it renders here once.
|
||||
const ServerShell = (shellProps: ParentProps) => (
|
||||
<QueryProvider>
|
||||
<SharedProviders>
|
||||
@@ -548,11 +550,7 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
<ServerShell>{routerProps.children}</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
@@ -578,20 +576,28 @@ function Routes() {
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route
|
||||
path="/:dir/session/:id"
|
||||
component={() => {
|
||||
const server = useServer()
|
||||
const { id } = useParams()
|
||||
<Route component={TargetServerLayout}>
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
{
|
||||
<>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route
|
||||
path="/session/:id"
|
||||
component={() => {
|
||||
const server = useServer()
|
||||
const { id } = useParams()
|
||||
|
||||
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
</>
|
||||
}
|
||||
</Show>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</Route>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
const serverCtx = createMemo(() => global.createServerCtx(props.server))
|
||||
const serverSDK = () => serverCtx().sdk
|
||||
const serverSync = () => serverCtx().sync
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
pickerMode,
|
||||
preloadTreeDirectories,
|
||||
cleanPickerInput,
|
||||
createPriorityTaskQueue,
|
||||
createDirectorySearch,
|
||||
currentPickerSuggestions,
|
||||
displayPickerPath,
|
||||
@@ -39,7 +38,7 @@ interface DialogSelectDirectoryV2Props {
|
||||
|
||||
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const global = useGlobal()
|
||||
const { sync, sdk } = global.ensureServerCtx(props.server)
|
||||
const { sync, sdk } = global.createServerCtx(props.server)
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const policy = pickerMode(props.mode ?? "directory", props.start)
|
||||
@@ -56,7 +55,6 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const [error, setError] = createSignal(false)
|
||||
const [rootValid, setRootValid] = createSignal(false)
|
||||
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
|
||||
const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
|
||||
const advanced = new Set<string>()
|
||||
let tree: FileTree | undefined
|
||||
let container: HTMLDivElement | undefined
|
||||
@@ -104,21 +102,16 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
})
|
||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
||||
|
||||
async function load(path: string, generation: number, eager = false) {
|
||||
async function load(path: string, generation: number, preload = true) {
|
||||
const key = path.replace(/\/+$/, "")
|
||||
setError(false)
|
||||
const absolute = absoluteTreePath(root(), key)
|
||||
const existing = listings.get(key)
|
||||
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
||||
const request =
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
.catch(() => undefined)
|
||||
})
|
||||
listings.get(key) ??
|
||||
sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
.catch(() => undefined)
|
||||
listings.set(key, request)
|
||||
const nodes = await request
|
||||
if (!activeTreeNavigation(generation, navigation)) return false
|
||||
@@ -128,8 +121,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
return false
|
||||
}
|
||||
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
||||
if (!eager && advanceTreePreload(advanced, key)) {
|
||||
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
||||
if (preload && advanceTreePreload(advanced, key)) {
|
||||
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ function uniqueRows(rows: Row[]) {
|
||||
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const global = useGlobal()
|
||||
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
|
||||
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
treePathWithin,
|
||||
currentPickerSuggestions,
|
||||
createDirectorySearch,
|
||||
createPriorityTaskQueue,
|
||||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
@@ -169,41 +168,6 @@ test("advances preloading once for every expanded directory", () => {
|
||||
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
|
||||
})
|
||||
|
||||
test("limits background tasks and prioritizes newly requested work", async () => {
|
||||
const queue = createPriorityTaskQueue<void>(2)
|
||||
const first = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
const started: string[] = []
|
||||
let active = 0
|
||||
let maximum = 0
|
||||
const task = (name: string, blocker?: Promise<void>) => async () => {
|
||||
started.push(name)
|
||||
active++
|
||||
maximum = Math.max(maximum, active)
|
||||
await blocker
|
||||
active--
|
||||
}
|
||||
|
||||
const running = [
|
||||
queue.schedule("first", "background", task("first", first.promise)),
|
||||
queue.schedule("second", "background", task("second", second.promise)),
|
||||
queue.schedule("preload", "background", task("preload")),
|
||||
queue.schedule("opened", "user", task("opened")),
|
||||
]
|
||||
await Promise.resolve()
|
||||
expect(started).toEqual(["first", "second"])
|
||||
|
||||
first.resolve()
|
||||
await running[0]
|
||||
await Promise.resolve()
|
||||
expect(started).toEqual(["first", "second", "opened"])
|
||||
|
||||
second.resolve()
|
||||
await Promise.all(running)
|
||||
expect(started).toEqual(["first", "second", "opened", "preload"])
|
||||
expect(maximum).toBe(2)
|
||||
})
|
||||
|
||||
test("clamps bridged tree wheel scrolling", () => {
|
||||
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
|
||||
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
|
||||
|
||||
@@ -138,79 +138,6 @@ export function activeTreeNavigation(request: number, current: number) {
|
||||
return request === current
|
||||
}
|
||||
|
||||
export function createPriorityTaskQueue<T>(concurrency: number) {
|
||||
type Job = {
|
||||
key: string
|
||||
priority: "user" | "background"
|
||||
promise: Promise<T>
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const jobs = new Map<string, Job>()
|
||||
const user: Job[] = []
|
||||
const background: Job[] = []
|
||||
let active = 0
|
||||
|
||||
const drain = () => {
|
||||
while (active < concurrency) {
|
||||
const job = user.pop() ?? background.shift()
|
||||
if (!job) return
|
||||
active++
|
||||
job.run()
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = (key: string, priority: Job["priority"], task: () => Promise<T>) => {
|
||||
const existing = jobs.get(key)
|
||||
if (existing) {
|
||||
if (priority === "user") promote(key)
|
||||
return existing.promise
|
||||
}
|
||||
|
||||
const deferred = Promise.withResolvers<T>()
|
||||
const job: Job = {
|
||||
key,
|
||||
priority,
|
||||
promise: deferred.promise,
|
||||
run: () => {
|
||||
const complete = () => {
|
||||
active--
|
||||
jobs.delete(key)
|
||||
drain()
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(task)
|
||||
.then(
|
||||
(value) => {
|
||||
complete()
|
||||
deferred.resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
complete()
|
||||
deferred.reject(error)
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
jobs.set(key, job)
|
||||
;(priority === "user" ? user : background).push(job)
|
||||
drain()
|
||||
return job.promise
|
||||
}
|
||||
|
||||
const promote = (key: string) => {
|
||||
const job = jobs.get(key)
|
||||
if (!job || job.priority === "user") return
|
||||
const index = background.indexOf(job)
|
||||
if (index === -1) return
|
||||
background.splice(index, 1)
|
||||
job.priority = "user"
|
||||
user.push(job)
|
||||
}
|
||||
|
||||
return { schedule, promote }
|
||||
}
|
||||
|
||||
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
|
||||
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SettingsServerScope(props: ParentProps) {
|
||||
|
||||
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||
const global = useGlobal()
|
||||
const serverCtx = () => global.ensureServerCtx(props.server)
|
||||
const serverCtx = () => global.createServerCtx(props.server)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={serverCtx().queryClient}>
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
@@ -40,9 +39,8 @@ import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabHref, useTabs } from "@/context/tabs"
|
||||
import "./titlebar.css"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { Session } from "@opencode-ai/sdk/v2"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createTabPromptState } from "@/context/prompt"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -101,6 +99,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||
const minHeight = () => {
|
||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
if (useV2Titlebar() && mobile()) {
|
||||
const inset = bottom() ? "env(safe-area-inset-bottom, 0px)" : "env(safe-area-inset-top, 0px)"
|
||||
return `calc(${height}px + ${inset})`
|
||||
}
|
||||
if (mac()) return `${height / zoom()}px`
|
||||
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||
return undefined
|
||||
@@ -242,6 +244,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
"padding-top": useV2Titlebar() && mobile() && !bottom() ? "env(safe-area-inset-top, 0px)" : undefined,
|
||||
"padding-bottom": bottom() ? "env(safe-area-inset-bottom, 0px)" : undefined,
|
||||
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
|
||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||
"max-width": electronWindows()
|
||||
@@ -256,6 +260,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<Switch>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const serverSdk = useServerSDK()
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const global = useGlobal()
|
||||
@@ -266,15 +271,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "session") return undefined
|
||||
const conn = global.servers
|
||||
.list()
|
||||
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
|
||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||
return route.type === "session" ? route : undefined
|
||||
},
|
||||
({ route, sdk }) =>
|
||||
sdk.client.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
(route) =>
|
||||
serverSdk()
|
||||
.client.session.get({ sessionID: route.sessionId })
|
||||
.then((x) => x.data)
|
||||
.catch(() => {}),
|
||||
)
|
||||
@@ -342,7 +343,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}
|
||||
|
||||
const fallback = global.servers.list().flatMap((conn) => {
|
||||
const project = global.ensureServerCtx(conn).projects.list()[0]
|
||||
const project = global.createServerCtx(conn).projects.list()[0]
|
||||
return project ? [{ server: ServerConnection.key(conn), project }] : []
|
||||
})[0]
|
||||
if (!fallback) return
|
||||
@@ -418,6 +419,22 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
return {
|
||||
id: `tab.${number}`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
const tab = tabsStore[index]
|
||||
if (tab) tabs.select(tab)
|
||||
},
|
||||
}
|
||||
}),
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
|
||||
@@ -481,7 +498,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<For each={tabsStore}>
|
||||
{(tab, i) => {
|
||||
let ref!: HTMLDivElement
|
||||
useTabShortcut(i, () => tabs.select(tab))
|
||||
|
||||
const divider = () =>
|
||||
i() !== 0 && (
|
||||
@@ -507,76 +523,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
)
|
||||
}
|
||||
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
|
||||
return conn ? global.ensureServerCtx(conn) : undefined
|
||||
})
|
||||
const sdk = createMemo(() => serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => {
|
||||
const placement = global.sessionPlacement.get(tab.server, tab.sessionId)
|
||||
const ctx = serverCtx()
|
||||
if (!placement || !ctx) return
|
||||
return ctx.sync
|
||||
.child(placement.directory, { bootstrap: false })[0]
|
||||
.session.find((session) => session.id === tab.sessionId)
|
||||
})
|
||||
|
||||
const [loadedSession] = createResource(
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
if (cachedSession()) return null
|
||||
const id = tab.sessionId
|
||||
const ctx = serverCtx()
|
||||
return ctx ? { id, ctx } : null
|
||||
const conn = server.list.find((s) => ServerConnection.key(s) === tab.server)
|
||||
if (!conn) return null
|
||||
const { sdk } = global.createServerCtx(conn)
|
||||
return { id, sdk }
|
||||
},
|
||||
({ id, ctx }) =>
|
||||
ctx.sdk.client.session
|
||||
({ id, sdk }) =>
|
||||
sdk.client.session
|
||||
.get({ sessionID: id })
|
||||
.then((x) => {
|
||||
const session = x.data
|
||||
if (!session) return
|
||||
if (!session.parentID)
|
||||
global.sessionPlacement.set({
|
||||
server: tab.server,
|
||||
leafID: session.id,
|
||||
rootID: session.id,
|
||||
directory: session.directory,
|
||||
})
|
||||
return session
|
||||
})
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
let prefetched = false
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = serverCtx()
|
||||
const sess = session()
|
||||
if (!ctx || !sess || prefetched) return
|
||||
prefetched = true
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync
|
||||
.ensureDirSyncContext(sess.directory)
|
||||
.session.sync(sess.id)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (tab.type !== "session") return
|
||||
const _sdk = sdk()
|
||||
if (!_sdk) return
|
||||
const sess = session()
|
||||
if (!sess) return
|
||||
createTabPromptState(tabs, tab, _sdk.scope, {
|
||||
dir: base64Encode(sess.directory),
|
||||
id: sess.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -875,26 +835,6 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function useTabShortcut(index: () => number, onSelect: () => void) {
|
||||
const command = useCommand()
|
||||
|
||||
command.register(() => {
|
||||
const number = index() + 1
|
||||
if (number > 9) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: `tab.${number}`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+${number}`,
|
||||
hidden: true,
|
||||
onSelect,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function TabNavItem(props: {
|
||||
ref?: HTMLDivElement
|
||||
href: string
|
||||
@@ -917,13 +857,13 @@ function TabNavItem(props: {
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
if (conn) return global.createServerCtx(conn)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={props.active}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
@@ -997,7 +937,7 @@ function DraftTabItem(props: {
|
||||
<div
|
||||
ref={props.ref}
|
||||
data-active={props.active}
|
||||
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
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)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
@@ -1042,7 +982,7 @@ function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: s
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
|
||||
@@ -187,7 +187,7 @@ export const createDirSyncContext = (
|
||||
return serverSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const initialMessagePageSize = 2
|
||||
const initialMessagePageSize = 80
|
||||
const historyMessagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
|
||||
@@ -8,13 +8,11 @@ import { createServerSyncContext } from "./server-sync"
|
||||
import { getOwner } from "solid-js/web"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createSessionPlacementStore } from "@/utils/session-placement"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
init: () => {
|
||||
const server = useServer()
|
||||
const sessionPlacement = createSessionPlacementStore()
|
||||
const serverHealth = useServerHealth(
|
||||
() => server.list,
|
||||
() => true,
|
||||
@@ -87,8 +85,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionPlacement,
|
||||
ensureServerCtx(conn: ServerConnection.Any) {
|
||||
createServerCtx(conn: ServerConnection.Any) {
|
||||
return ensureServerCtx(conn)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useServerSync } from "./server-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
@@ -207,12 +208,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const sync = serverSync().ensureDirSyncContext(directory)
|
||||
const session = sync.session.get(sessionID)
|
||||
if (session) return session
|
||||
return sync.session
|
||||
.sync(sessionID)
|
||||
.then(() => sync.session.get(sessionID))
|
||||
const [syncStore] = serverSync().child(directory, { bootstrap: false })
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
if (match.found) return syncStore.session[match.index]
|
||||
return serverSDK()
|
||||
.client.session.get({ directory, sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,6 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
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 { requireServerKey } from "@/utils/session-route"
|
||||
import { useSettings } from "./settings"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -262,23 +258,14 @@ export function createPromptState() {
|
||||
}
|
||||
}
|
||||
|
||||
export const createTabPromptState = (
|
||||
tabs: ReturnType<typeof useTabs>,
|
||||
tab: Tab,
|
||||
...args: Parameters<typeof createPromptSession>
|
||||
) => tabs.state(tab, "prompt", () => createPromptSession(...args))
|
||||
|
||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||
name: "Prompt",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const settings = useSettings()
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
|
||||
const disposeAll = () => {
|
||||
@@ -301,25 +288,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const owner = getOwner()
|
||||
const tab = createMemo<Tab | undefined>(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (search.draftId) {
|
||||
return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId)
|
||||
}
|
||||
if (!params.id) return
|
||||
const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key
|
||||
return (
|
||||
tabs.store.find(
|
||||
(item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id,
|
||||
) ?? { type: "session", server: serverKey, sessionId: params.id }
|
||||
)
|
||||
})
|
||||
const load = (scope: Scope) => {
|
||||
const current = tab()
|
||||
if (current) {
|
||||
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
}
|
||||
|
||||
const key = scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type ServerSDK, useServerSDK } from "./server-sdk"
|
||||
|
||||
export type DirectorySDK = ReturnType<ServerSDK["ensureDirSdkContext"]>
|
||||
export type DirectorySDK = ReturnType<ServerSDK["createDirSdkContext"]>
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
@@ -11,7 +11,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
const serverSDK = useServerSDK()
|
||||
return createMemo(() => {
|
||||
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
||||
return serverSDK().ensureDirSdkContext(directory)
|
||||
return serverSDK().createDirSdkContext(directory)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -289,13 +289,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
|
||||
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
createDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
|
||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
||||
const sdk = createServerSdkContextBase(server, scope)
|
||||
return Object.assign(sdk, {
|
||||
ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
||||
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
return createMemo<ServerSDK>(() => {
|
||||
const conn = props.server?.() ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.ensureServerCtx(conn).sdk
|
||||
return global.createServerCtx(conn).sdk
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -509,7 +509,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
export function createServerSyncContext(serverSDK: ServerSDK) {
|
||||
const inner = createServerSyncContextInner(serverSDK)
|
||||
return Object.assign(inner, {
|
||||
ensureDirSyncContext: createRefCountMap(
|
||||
createDirSyncContext: createRefCountMap(
|
||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
||||
(dir) => inner.disableMcp(dir),
|
||||
directoryKey,
|
||||
@@ -531,7 +531,7 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
|
||||
return createMemo<ServerSync>(() => {
|
||||
const conn = props.server?.() ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.ensureServerCtx(conn).sync
|
||||
return global.createServerCtx(conn).sync
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -113,7 +113,7 @@ export const useSync = () => {
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
return createMemo(() => serverSync().ensureDirSyncContext(sdk().directory))
|
||||
return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
|
||||
}
|
||||
|
||||
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createRoot, type Owner } from "solid-js"
|
||||
|
||||
type Entry = {
|
||||
value: unknown
|
||||
dispose: VoidFunction
|
||||
}
|
||||
|
||||
export function createTabMemory(owner: Owner | null) {
|
||||
const entries = new Map<string, Map<string, Entry>>()
|
||||
|
||||
const remove = (key: string) => {
|
||||
const state = entries.get(key)
|
||||
if (!state) return
|
||||
for (const entry of state.values()) entry.dispose()
|
||||
entries.delete(key)
|
||||
}
|
||||
|
||||
return {
|
||||
ensure<T>(key: string, name: string, init: () => T) {
|
||||
const state = entries.get(key) ?? new Map<string, Entry>()
|
||||
if (!entries.has(key)) entries.set(key, state)
|
||||
const existing = state.get(name)
|
||||
if (existing) return existing.value as T
|
||||
const entry = createRoot((dispose) => ({ value: init(), dispose }), owner)
|
||||
state.set(name, entry)
|
||||
return entry.value
|
||||
},
|
||||
remove,
|
||||
dispose() {
|
||||
for (const key of entries.keys()) remove(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
|
||||
describe("tab memory", () => {
|
||||
test("keeps state until its tab is removed", () => {
|
||||
createRoot((dispose) => {
|
||||
const memory = createTabMemory(getOwner())
|
||||
let disposed = 0
|
||||
const first = memory.ensure("tab", "prompt", () => {
|
||||
onCleanup(() => disposed++)
|
||||
return { value: "prompt" }
|
||||
})
|
||||
|
||||
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
|
||||
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
|
||||
|
||||
memory.remove("tab")
|
||||
expect(disposed).toBe(1)
|
||||
expect(memory.ensure("tab", "prompt", () => ({ value: "new" }))).not.toBe(first)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,13 +3,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
|
||||
import { createEffect, startTransition } from "solid-js"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { usePlatform } from "./platform"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
@@ -67,7 +66,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const memory = createTabMemory(getOwner())
|
||||
|
||||
const closing = new Set<string>()
|
||||
let recentWrite = 0
|
||||
@@ -91,18 +89,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||
}
|
||||
|
||||
onCleanup(memory.dispose)
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !recentReady()) return
|
||||
const servers = new Set(server.list.map(ServerConnection.key))
|
||||
const next = store.filter((tab) => servers.has(tab.server))
|
||||
if (next.length !== store.length) {
|
||||
for (const tab of store) {
|
||||
if (!servers.has(tab.server)) memory.remove(tabKey(tab))
|
||||
}
|
||||
setStore(() => next)
|
||||
}
|
||||
if (next.length !== store.length) setStore(() => next)
|
||||
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
|
||||
})
|
||||
|
||||
@@ -115,15 +106,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const actions = {
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
const next = { type: "session" as const, ...tab }
|
||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||
if (existing) return existing
|
||||
if (closing.has(tabKey(next))) return
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
||||
tabs.push(next)
|
||||
}),
|
||||
)
|
||||
return next
|
||||
},
|
||||
draft(draftID: string) {
|
||||
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
||||
@@ -162,7 +151,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
|
||||
if (active) navigateTab(next)
|
||||
})
|
||||
memory.remove(`draft:${draftID}`)
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
removeTab: (index: number) => {
|
||||
@@ -182,14 +170,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
},
|
||||
removeServer(key: ServerConnection.Key) {
|
||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||
const removed = store.filter((tab) => tab.server === key).map(tabKey)
|
||||
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
||||
for (const key of removed) memory.remove(key)
|
||||
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
||||
for (const draftID of drafts) removeDraftPersisted(draftID)
|
||||
if (server.key === key) navigate("/")
|
||||
@@ -241,7 +227,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
)
|
||||
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
||||
})
|
||||
for (const key of removed) memory.remove(key)
|
||||
},
|
||||
select: navigateTab,
|
||||
remember(tab: Tab) {
|
||||
@@ -261,9 +246,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}
|
||||
navigate("/")
|
||||
},
|
||||
state<T>(tab: Tab, name: string, init: () => T) {
|
||||
return memory.ensure(tabKey(tab), name, init)
|
||||
},
|
||||
}
|
||||
|
||||
return { ...actions, store, ready, recentReady }
|
||||
|
||||
@@ -15,13 +15,6 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
/* WebKit excludes safe-area insets from dvh in installed apps. */
|
||||
#root {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@keyframes session-progress-whip {
|
||||
0% {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { decode64 } from "@/utils/base64"
|
||||
import { Schema } from "effect"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { useGlobal } from "@/context/global"
|
||||
|
||||
export function DirectoryDataProvider(
|
||||
props: ParentProps<{
|
||||
@@ -24,7 +23,6 @@ export function DirectoryDataProvider(
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const global = useGlobal()
|
||||
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
|
||||
const slug = createMemo(() => base64Encode(directory()))
|
||||
const href = (sessionID: string) => {
|
||||
@@ -54,11 +52,7 @@ export function DirectoryDataProvider(
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={directory()}
|
||||
onNavigateToSession={(sessionID: string) => {
|
||||
const server = props.server?.()
|
||||
if (server && params.id) global.sessionPlacement.inherit(server, params.id, sessionID)
|
||||
navigate(href(sessionID))
|
||||
}}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRoot,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
startTransition,
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { batch, createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
@@ -45,6 +32,7 @@ import {
|
||||
displayName,
|
||||
getProjectAvatarSource,
|
||||
homeProjectDirectories,
|
||||
homeProjectNavigation,
|
||||
type HomeProjectSelection,
|
||||
projectForSession,
|
||||
sortedRootSessions,
|
||||
@@ -59,8 +47,6 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
import { preloadMarkdown } from "@opencode-ai/ui/markdown-cache"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_ROW_LAYOUT =
|
||||
@@ -137,10 +123,8 @@ export function NewHome() {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const tabs = useTabs()
|
||||
const command = useCommand()
|
||||
const notification = useNotification()
|
||||
const marked = useMarked()
|
||||
let focusSessionSearch: (() => void) | undefined
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
@@ -154,7 +138,7 @@ export function NewHome() {
|
||||
const focusedServerCtx = createMemo(() => {
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
return global.ensureServerCtx(conn)
|
||||
return global.createServerCtx(conn)
|
||||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||
@@ -203,41 +187,6 @@ export function NewHome() {
|
||||
})
|
||||
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
const prefetched = new Set<string>()
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = focusedServerCtx()
|
||||
if (!ctx) return
|
||||
records()
|
||||
.slice(0, 2)
|
||||
.forEach((record) => {
|
||||
const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
|
||||
if (prefetched.has(key)) return
|
||||
prefetched.add(key)
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
const directory = ctx.sync.ensureDirSyncContext(record.session.directory)
|
||||
void directory.session
|
||||
.sync(record.session.id)
|
||||
.then(() => {
|
||||
const store = ctx.sync.child(record.session.directory)[0]
|
||||
return Promise.all(
|
||||
(store.message[record.session.id] ?? []).flatMap((message) =>
|
||||
(store.part[message.id] ?? []).flatMap((part) => {
|
||||
if (part.type !== "text" || !part.text) return []
|
||||
return preloadMarkdown(part.text, part.id, marked)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
batch(() => {
|
||||
@@ -288,7 +237,7 @@ export function NewHome() {
|
||||
const key = ServerConnection.key(conn)
|
||||
if (
|
||||
!global
|
||||
.ensureServerCtx(conn)
|
||||
.createServerCtx(conn)
|
||||
.projects.list()
|
||||
.some((project) => project.worktree === directory)
|
||||
)
|
||||
@@ -299,7 +248,7 @@ export function NewHome() {
|
||||
function addProjects(conn: ServerConnection.Any, directories: string[]) {
|
||||
const directory = directories[0]
|
||||
if (!directory) return
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const ctx = global.createServerCtx(conn)
|
||||
directories.forEach(ctx.projects.open)
|
||||
ctx.projects.touch(directory)
|
||||
setSelection({ server: ServerConnection.key(conn), directory })
|
||||
@@ -312,11 +261,21 @@ export function NewHome() {
|
||||
openProjectNewSession(conn, project.worktree)
|
||||
}
|
||||
|
||||
function navigateOnServer(conn: ServerConnection.Any, href: string) {
|
||||
const next = homeProjectNavigation(server.key, ServerConnection.key(conn), href)
|
||||
if (!next.server) {
|
||||
navigate(next.href)
|
||||
return
|
||||
}
|
||||
pendingHomeNavigation = next
|
||||
server.setActive(next.server)
|
||||
}
|
||||
|
||||
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const ctx = global.createServerCtx(conn)
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
tabs.newDraft({ server: ServerConnection.key(conn), directory })
|
||||
navigateOnServer(conn, `/${base64Encode(directory)}/session`)
|
||||
}
|
||||
|
||||
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
||||
@@ -342,19 +301,10 @@ export function NewHome() {
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
const directory = project?.worktree ?? session.directory
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
global.sessionPlacement.set({
|
||||
server: ServerConnection.key(conn),
|
||||
leafID: session.id,
|
||||
rootID: session.id,
|
||||
directory: session.directory,
|
||||
})
|
||||
const ctx = global.createServerCtx(conn)
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
||||
tabs.select(tab)
|
||||
})
|
||||
navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`)
|
||||
}
|
||||
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
@@ -362,6 +312,8 @@ export function NewHome() {
|
||||
addProjects(conn, homeProjectDirectories(result))
|
||||
}
|
||||
|
||||
const server = global.createServerCtx(conn)
|
||||
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
@@ -391,7 +343,7 @@ export function NewHome() {
|
||||
const next = closeHomeProject(
|
||||
state.selection,
|
||||
ServerConnection.key(conn),
|
||||
global.ensureServerCtx(conn).projects,
|
||||
global.createServerCtx(conn).projects,
|
||||
directory,
|
||||
)
|
||||
if (next) setSelection(next)
|
||||
@@ -528,7 +480,7 @@ function HomeProjectColumn(props: {
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const healthy = () => !!global.servers.health[key]?.healthy
|
||||
const serverCtx = global.ensureServerCtx(item)
|
||||
const serverCtx = global.createServerCtx(item)
|
||||
const collapsed = () => !!state.collapsed[key]
|
||||
return (
|
||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
@@ -1204,7 +1156,7 @@ export function LegacyHome() {
|
||||
})
|
||||
|
||||
function openProject(server: ServerConnection.Any, directory: string) {
|
||||
const serverCtx = global.ensureServerCtx(server)
|
||||
const serverCtx = global.createServerCtx(server)
|
||||
serverCtx.projects.open(directory)
|
||||
serverCtx.projects.touch(directory)
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const notification = useNotification()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams<{ id?: string }>()
|
||||
setNavigate(navigate)
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
createEffect(() => {
|
||||
if (!notification.ready() || !params.id) return
|
||||
notification.session.markViewed(params.id)
|
||||
})
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
version: () => {
|
||||
@@ -32,13 +25,7 @@ export default function NewLayout(props: ParentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
|
||||
style={{
|
||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
}}
|
||||
>
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<Titlebar update={update} />
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
|
||||
@@ -1299,15 +1299,15 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
const openSession = async (target: { directory: string; id: string }) => {
|
||||
if (!canOpen(target.directory)) return false
|
||||
const sync = serverSync().ensureDirSyncContext(target.directory)
|
||||
if (sync.session.get(target.id)) {
|
||||
const [data] = serverSync().child(target.directory, { bootstrap: false })
|
||||
if (data.session.some((item) => item.id === target.id)) {
|
||||
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
||||
return true
|
||||
}
|
||||
const resolved = await sync.session
|
||||
.sync(target.id)
|
||||
.then(() => sync.session.get(target.id))
|
||||
const resolved = await serverSDK()
|
||||
.client.session.get({ sessionID: target.id })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!resolved?.directory) return false
|
||||
if (!canOpen(resolved.directory)) return false
|
||||
|
||||
@@ -287,7 +287,7 @@ export default function Page() {
|
||||
})
|
||||
}
|
||||
return key
|
||||
})
|
||||
}, sessionKey())
|
||||
|
||||
let reviewFrame: number | undefined
|
||||
let todoFrame: number | undefined
|
||||
@@ -693,7 +693,6 @@ export default function Page() {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!sync().project) return
|
||||
const list = changesOptions()
|
||||
if (list.includes(store.changes)) return
|
||||
const next = list[0]
|
||||
@@ -1129,35 +1128,13 @@ export default function Page() {
|
||||
|
||||
let captureHistoryAnchor = () => {}
|
||||
let restoreHistoryAnchor = (_done: boolean) => {}
|
||||
let historyRequest = false
|
||||
let historyContinuationFrame: number | undefined
|
||||
const loadOlder = async () => {
|
||||
if (historyRequest || historyLoading()) return
|
||||
historyRequest = true
|
||||
const before = timeline.messages().length
|
||||
try {
|
||||
await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
|
||||
} finally {
|
||||
historyRequest = false
|
||||
}
|
||||
if (timeline.messages().length <= before) return
|
||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
|
||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||
historyContinuationFrame = requestAnimationFrame(() => {
|
||||
historyContinuationFrame = undefined
|
||||
onHistoryScroll()
|
||||
})
|
||||
}
|
||||
const loadOlder = () =>
|
||||
timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
|
||||
const onHistoryScroll = () => {
|
||||
if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200)
|
||||
return
|
||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
|
||||
void loadOlder()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||
})
|
||||
|
||||
fill = () => {
|
||||
if (fillFrame !== undefined) return
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export function SessionComposerRegion(props: {
|
||||
})
|
||||
const projectServerCtx = createMemo(() => {
|
||||
const conn = projectServer()
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
if (conn) return global.createServerCtx(conn)
|
||||
})
|
||||
const projects = createMemo(() =>
|
||||
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
|
||||
|
||||
@@ -15,29 +15,37 @@ describe("timeline model", () => {
|
||||
expect(selectVisibleUserMessages(users)).toBe(users)
|
||||
})
|
||||
|
||||
test("loads exactly one opaque cursor page", async () => {
|
||||
test("loads pages until a visible user turn is added", async () => {
|
||||
let loaded = 10
|
||||
let visible = 2
|
||||
let calls = 0
|
||||
const anchors: Array<string | boolean> = []
|
||||
|
||||
await loadOlderTimeline({
|
||||
sessionID: () => "ses_test",
|
||||
loaded: () => loaded,
|
||||
visible: () => visible,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
calls += 1
|
||||
loaded += 3
|
||||
if (calls === 2) visible += 1
|
||||
},
|
||||
before: () => anchors.push("before"),
|
||||
after: (done) => anchors.push("after", done),
|
||||
})
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(anchors).toEqual(["before", "after", true])
|
||||
expect(calls).toBe(2)
|
||||
expect(anchors).toEqual(["before", "after", false, "after", true])
|
||||
})
|
||||
|
||||
test("stops when a page adds no raw messages", async () => {
|
||||
let calls = 0
|
||||
await loadOlderTimeline({
|
||||
sessionID: () => "ses_test",
|
||||
loaded: () => 10,
|
||||
visible: () => 2,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
@@ -54,6 +62,8 @@ describe("timeline model", () => {
|
||||
|
||||
await loadOlderTimeline({
|
||||
sessionID: () => sessionID,
|
||||
loaded: () => 10,
|
||||
visible: () => 2,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
@@ -73,6 +83,8 @@ describe("timeline model", () => {
|
||||
await expect(
|
||||
loadOlderTimeline({
|
||||
sessionID: () => "ses_test",
|
||||
loaded: () => 10,
|
||||
visible: () => 2,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
|
||||
@@ -74,6 +74,8 @@ export function createTimelineModel(input: {
|
||||
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
|
||||
return loadOlderTimeline({
|
||||
sessionID: input.sessionID,
|
||||
loaded: () => messages().length,
|
||||
visible: () => visibleUserMessages().length,
|
||||
more,
|
||||
loading,
|
||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||
@@ -113,6 +115,8 @@ export function selectVisibleUserMessages(messages: UserMessage[], revertMessage
|
||||
|
||||
export async function loadOlderTimeline(input: {
|
||||
sessionID: Accessor<string | undefined>
|
||||
loaded: Accessor<number>
|
||||
visible: Accessor<number>
|
||||
more: Accessor<boolean>
|
||||
loading: Accessor<boolean>
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
@@ -122,11 +126,23 @@ export async function loadOlderTimeline(input: {
|
||||
const id = input.sessionID()
|
||||
if (!id || !input.more() || input.loading()) return
|
||||
|
||||
// A history page may contain only assistant messages or user turns hidden by a revert boundary.
|
||||
const beforeVisible = input.visible()
|
||||
let loaded = input.loaded()
|
||||
input.before?.()
|
||||
await input.loadMore(id).catch((error) => {
|
||||
if (input.sessionID() === id) input.after?.(true)
|
||||
throw error
|
||||
})
|
||||
if (input.sessionID() !== id) return
|
||||
input.after?.(true)
|
||||
while (true) {
|
||||
await input.loadMore(id).catch((error) => {
|
||||
if (input.sessionID() === id) input.after?.(true)
|
||||
throw error
|
||||
})
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const nextLoaded = input.loaded()
|
||||
const growth = input.visible() - beforeVisible
|
||||
const raw = nextLoaded - loaded
|
||||
loaded = nextLoaded
|
||||
const done = growth > 0 || raw <= 0 || !input.more()
|
||||
input.after?.(done)
|
||||
if (done) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { createSessionPlacementStore } from "./session-placement"
|
||||
|
||||
describe("session placement", () => {
|
||||
const local = ServerConnection.Key.make("http://localhost:4096")
|
||||
const remote = ServerConnection.Key.make("https://example.com")
|
||||
|
||||
test("aliases a leaf and root without crossing servers", () => {
|
||||
const store = createSessionPlacementStore()
|
||||
store.set({ server: local, leafID: "child", rootID: "root", directory: "/repo" })
|
||||
store.set({ server: remote, leafID: "child", rootID: "other", directory: "/remote" })
|
||||
|
||||
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
|
||||
expect(store.get(local, "root")).toEqual({ rootID: "root", directory: "/repo" })
|
||||
expect(store.get(remote, "child")).toEqual({ rootID: "other", directory: "/remote" })
|
||||
})
|
||||
|
||||
test("inherits known placement for in-app child navigation", () => {
|
||||
const store = createSessionPlacementStore()
|
||||
store.set({ server: local, leafID: "parent", rootID: "root", directory: "/repo" })
|
||||
|
||||
expect(store.inherit(local, "parent", "child")).toEqual({ rootID: "root", directory: "/repo" })
|
||||
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
|
||||
expect(store.inherit(local, "missing", "unknown")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("bounds retained placement aliases", () => {
|
||||
const store = createSessionPlacementStore()
|
||||
for (let index = 0; index < 300; index++) {
|
||||
store.set({ server: local, leafID: `leaf-${index}`, rootID: `root-${index}`, directory: `/repo/${index}` })
|
||||
}
|
||||
|
||||
expect(store.size()).toBeLessThanOrEqual(256)
|
||||
expect(store.get(local, "leaf-0")).toBeUndefined()
|
||||
expect(store.get(local, "leaf-299")).toEqual({ rootID: "root-299", directory: "/repo/299" })
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export type SessionPlacement = {
|
||||
rootID: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
export function createSessionPlacementStore() {
|
||||
const placements = new Map<string, SessionPlacement>()
|
||||
const limit = 256
|
||||
const key = (server: ServerConnection.Key, sessionID: string) => `${server}\0${sessionID}`
|
||||
const write = (id: string, placement: SessionPlacement) => {
|
||||
placements.delete(id)
|
||||
placements.set(id, placement)
|
||||
while (placements.size > limit) placements.delete(placements.keys().next().value!)
|
||||
}
|
||||
|
||||
return {
|
||||
get(server: ServerConnection.Key, sessionID: string) {
|
||||
const id = key(server, sessionID)
|
||||
const placement = placements.get(id)
|
||||
if (placement) write(id, placement)
|
||||
return placement
|
||||
},
|
||||
set(input: SessionPlacement & { server: ServerConnection.Key; leafID: string }) {
|
||||
const placement = { rootID: input.rootID, directory: input.directory }
|
||||
write(key(input.server, input.leafID), placement)
|
||||
write(key(input.server, input.rootID), placement)
|
||||
return placement
|
||||
},
|
||||
inherit(server: ServerConnection.Key, sourceID: string, leafID: string) {
|
||||
const placement = placements.get(key(server, sourceID))
|
||||
if (!placement) return
|
||||
write(key(server, leafID), placement)
|
||||
return placement
|
||||
},
|
||||
size() {
|
||||
return placements.size
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,6 @@ beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useParams: () => ({}),
|
||||
useSearchParams: () => [{}],
|
||||
useLocation: () => ({ pathname: "", query: {} }),
|
||||
useNavigate: () => () => undefined,
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
|
||||
@@ -6,22 +6,6 @@ declare const OPENCODE_CLI_NAME: string | undefined
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
commands: [
|
||||
Spec.make("api", {
|
||||
description: "Make a request to the running server",
|
||||
params: {
|
||||
request: Argument.string("operation | method path").pipe(
|
||||
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
|
||||
Argument.variadic({ min: 1, max: 2 }),
|
||||
),
|
||||
data: Flag.string("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional),
|
||||
header: Flag.string("header").pipe(
|
||||
Flag.withAlias("H"),
|
||||
Flag.withDescription("Request header in name:value form"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
param: Flag.keyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("debug", {
|
||||
description: "Debugging and troubleshooting tools",
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { rawRequest, resolveOperation } from "./api"
|
||||
|
||||
describe("api request resolution", () => {
|
||||
test("resolves an operation ID with path and query parameters", () => {
|
||||
expect(
|
||||
resolveOperation(
|
||||
{
|
||||
paths: {
|
||||
"/api/session/{sessionID}": {
|
||||
get: { operationId: "v2.session.get" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"v2.session.get",
|
||||
{ sessionID: "ses/a", workspace: "work" },
|
||||
),
|
||||
).toEqual({ method: "GET", path: "/api/session/ses%2Fa?workspace=work" })
|
||||
})
|
||||
|
||||
test("rejects a missing path parameter", () => {
|
||||
expect(() =>
|
||||
resolveOperation(
|
||||
{ paths: { "/api/session/{sessionID}": { get: { operationId: "v2.session.get" } } } },
|
||||
"v2.session.get",
|
||||
{},
|
||||
),
|
||||
).toThrow("Missing path parameter: sessionID")
|
||||
})
|
||||
|
||||
test("resolves curl-like method and path input", () => {
|
||||
expect(rawRequest(["post", "/api/foo"])).toEqual({ method: "POST", path: "/api/foo" })
|
||||
expect(rawRequest(["v2.session.list"])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
|
||||
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
||||
|
||||
type Operation = {
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
type OpenApi = {
|
||||
paths?: Record<string, Record<string, Operation>>
|
||||
}
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.api,
|
||||
Effect.fn("cli.api")(function* (input) {
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* daemon.transport()
|
||||
const params = Option.getOrElse(input.param, () => ({}))
|
||||
const request = yield* resolveRequest(transport, input.request, params)
|
||||
const headers = new Headers(transport.headers)
|
||||
for (const header of input.header) {
|
||||
const index = header.indexOf(":")
|
||||
if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`))
|
||||
headers.set(header.slice(0, index).trim(), header.slice(index + 1).trim())
|
||||
}
|
||||
const body = Option.getOrUndefined(input.data)
|
||||
if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
|
||||
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL(request.path, transport.url), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
}),
|
||||
)
|
||||
const output = yield* Effect.promise(() => response.text())
|
||||
if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL))
|
||||
}),
|
||||
)
|
||||
|
||||
export function resolveOperation(spec: OpenApi, operationID: string, params: Record<string, string>) {
|
||||
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(operations)) {
|
||||
if (!methods.has(method) || operation.operationId !== operationID) continue
|
||||
return { method: method.toUpperCase(), path: interpolate(path, params) }
|
||||
}
|
||||
}
|
||||
throw new Error(`Operation not found: ${operationID}`)
|
||||
}
|
||||
|
||||
export function rawRequest(input: readonly string[]) {
|
||||
if (input.length !== 2 || !methods.has(input[0].toLowerCase()) || !input[1].startsWith("/")) return
|
||||
return { method: input[0].toUpperCase(), path: input[1] }
|
||||
}
|
||||
|
||||
function resolveRequest(
|
||||
transport: { url: string; headers: RequestInit["headers"] },
|
||||
input: readonly string[],
|
||||
params: Record<string, string>,
|
||||
) {
|
||||
const raw = rawRequest(input)
|
||||
if (raw) return Effect.succeed(raw)
|
||||
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
|
||||
return Effect.tryPromise(async () => {
|
||||
const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers })
|
||||
if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`)
|
||||
return resolveOperation((await response.json()) as OpenApi, input[0], params)
|
||||
})
|
||||
}
|
||||
|
||||
function interpolate(path: string, params: Record<string, string>) {
|
||||
const used = new Set<string>()
|
||||
const pathname = path.replaceAll(/\{([^}]+)\}/g, (_, name: string) => {
|
||||
const value = params[name]
|
||||
if (value === undefined) throw new Error(`Missing path parameter: ${name}`)
|
||||
used.add(name)
|
||||
return encodeURIComponent(value)
|
||||
})
|
||||
const query = new URLSearchParams(Object.entries(params).filter(([name]) => !used.has(name))).toString()
|
||||
return query ? `${pathname}?${query}` : pathname
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { Daemon } from "./services/daemon"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
api: () => import("./commands/handlers/api"),
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
},
|
||||
|
||||
@@ -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 contains `sessions.list`, `create`, `get`, `switchAgent`, `switchModel`, `prompt`, `compact`, `wait`, and `context`. The server and generator consume the exact same hosted `SessionGroup`. Run `bun run generate` after changing that 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. The authoritative `SessionGroup` lives in `@opencode-ai/protocol`; Server hosts that exact group and adapts it to Core.
|
||||
|
||||
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" }) })
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$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/httpapi-codegen": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { SessionGroup } from "@opencode-ai/protocol/session"
|
||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApi } from "effect/unstable/httpapi"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const contract = compile(HttpApi.make("opencode-client").add(SessionGroup), {
|
||||
groupNames: { "server.session": "sessions" },
|
||||
})
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
[
|
||||
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
|
||||
write(
|
||||
emitEffectImported(contract, {
|
||||
module: "@opencode-ai/protocol/session",
|
||||
group: "SessionGroup",
|
||||
}),
|
||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||
),
|
||||
],
|
||||
{ concurrency: 2, discard: true },
|
||||
).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
|
||||
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
||||
export * from "./generated-effect/index"
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"client-error.ts",
|
||||
"client.ts",
|
||||
"index.ts"
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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 "@opencode-ai/protocol/session"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
const Api = HttpApi.make("generated").add(SessionGroup)
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof Api>
|
||||
|
||||
const mapClientError = <E>(error: E) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: error
|
||||
|
||||
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
|
||||
type Endpoint0_0Input = {
|
||||
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
|
||||
readonly limit?: Endpoint0_0Request["query"]["limit"]
|
||||
readonly order?: Endpoint0_0Request["query"]["order"]
|
||||
readonly search?: Endpoint0_0Request["query"]["search"]
|
||||
readonly directory?: Endpoint0_0Request["query"]["directory"]
|
||||
readonly project?: Endpoint0_0Request["query"]["project"]
|
||||
readonly subpath?: Endpoint0_0Request["query"]["subpath"]
|
||||
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
|
||||
}
|
||||
const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) =>
|
||||
raw["session.list"]({
|
||||
query: {
|
||||
workspace: input?.workspace,
|
||||
limit: input?.limit,
|
||||
order: input?.order,
|
||||
search: input?.search,
|
||||
directory: input?.directory,
|
||||
project: input?.project,
|
||||
subpath: input?.subpath,
|
||||
cursor: input?.cursor,
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
|
||||
type Endpoint0_1Input = {
|
||||
readonly id?: Endpoint0_1Request["payload"]["id"]
|
||||
readonly agent?: Endpoint0_1Request["payload"]["agent"]
|
||||
readonly model?: Endpoint0_1Request["payload"]["model"]
|
||||
readonly location?: Endpoint0_1Request["payload"]["location"]
|
||||
}
|
||||
const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) =>
|
||||
raw["session.create"]({
|
||||
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
|
||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
|
||||
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint0_3Input = {
|
||||
readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint0_3Request["payload"]["agent"]
|
||||
}
|
||||
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
||||
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint0_4Input = {
|
||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||
readonly model: Endpoint0_4Request["payload"]["model"]
|
||||
}
|
||||
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
||||
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint0_5Input = {
|
||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint0_5Request["payload"]["id"]
|
||||
readonly prompt: Endpoint0_5Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint0_5Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
|
||||
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
||||
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
|
||||
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
||||
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),
|
||||
context: Endpoint0_8(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ClientError } from "./client-error"
|
||||
export * as OpenCode from "./client"
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
"client-error.ts",
|
||||
"client.ts",
|
||||
"index.ts",
|
||||
"types.ts"
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"
|
||||
|
||||
export class ClientError extends Error {
|
||||
override readonly name = "ClientError"
|
||||
constructor(
|
||||
readonly reason: ClientErrorReason,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(reason, options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import type {
|
||||
SessionsListInput,
|
||||
SessionsListOutput,
|
||||
SessionsCreateInput,
|
||||
SessionsCreateOutput,
|
||||
SessionsGetInput,
|
||||
SessionsGetOutput,
|
||||
SessionsSwitchAgentInput,
|
||||
SessionsSwitchAgentOutput,
|
||||
SessionsSwitchModelInput,
|
||||
SessionsSwitchModelOutput,
|
||||
SessionsPromptInput,
|
||||
SessionsPromptOutput,
|
||||
SessionsCompactInput,
|
||||
SessionsCompactOutput,
|
||||
SessionsWaitInput,
|
||||
SessionsWaitOutput,
|
||||
SessionsContextInput,
|
||||
SessionsContextOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
export interface ClientOptions {
|
||||
readonly baseUrl: string
|
||||
readonly fetch?: typeof globalThis.fetch
|
||||
readonly headers?: HeadersInit
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
readonly signal?: AbortSignal
|
||||
readonly headers?: HeadersInit
|
||||
}
|
||||
|
||||
interface RequestDescriptor {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly query?: Record<string, unknown>
|
||||
readonly headers?: Record<string, unknown>
|
||||
readonly body?: unknown
|
||||
readonly successStatus: number
|
||||
readonly declaredStatuses: ReadonlyArray<number>
|
||||
readonly empty: boolean
|
||||
}
|
||||
|
||||
export function make(options: ClientOptions) {
|
||||
const fetch = options.fetch ?? globalThis.fetch
|
||||
|
||||
const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
|
||||
const url = new URL(descriptor.path, options.baseUrl)
|
||||
for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)
|
||||
const headers = new Headers(options.headers)
|
||||
for (const [key, value] of Object.entries(descriptor.headers ?? {})) {
|
||||
if (value !== undefined && value !== null) headers.set(key, String(value))
|
||||
}
|
||||
for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)
|
||||
if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
|
||||
return {
|
||||
url,
|
||||
init: {
|
||||
method: descriptor.method,
|
||||
signal: requestOptions?.signal,
|
||||
headers,
|
||||
body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),
|
||||
} satisfies RequestInit,
|
||||
}
|
||||
}
|
||||
|
||||
const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
|
||||
try {
|
||||
const prepared = prepare(descriptor, requestOptions)
|
||||
return await fetch(prepared.url, prepared.init)
|
||||
} catch (cause) {
|
||||
throw new ClientError("Transport", { cause })
|
||||
}
|
||||
}
|
||||
|
||||
const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
|
||||
if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
|
||||
try {
|
||||
await response.body?.cancel()
|
||||
} catch {}
|
||||
throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })
|
||||
}
|
||||
|
||||
const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
|
||||
const response = await execute(descriptor, requestOptions)
|
||||
if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
|
||||
if (descriptor.empty) {
|
||||
try {
|
||||
await response.body?.cancel()
|
||||
} catch {}
|
||||
return undefined as A
|
||||
}
|
||||
return (await json(response)) as A
|
||||
}
|
||||
|
||||
const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const response = await execute(descriptor, requestOptions)
|
||||
if (response.status !== descriptor.successStatus) await responseError(response, descriptor)
|
||||
if (!isContentType(response, "text/event-stream")) {
|
||||
try {
|
||||
await response.body?.cancel()
|
||||
} catch {}
|
||||
throw new ClientError("UnsupportedContentType")
|
||||
}
|
||||
if (response.body === null) throw new ClientError("MalformedResponse")
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
try {
|
||||
while (true) {
|
||||
let next
|
||||
try {
|
||||
next = await reader.read()
|
||||
} catch (cause) {
|
||||
throw new ClientError("Transport", { cause })
|
||||
}
|
||||
buffer += decoder.decode(next.value, { stream: !next.done })
|
||||
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
|
||||
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
|
||||
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
|
||||
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
||||
if (trailingCarriageReturn) buffer += "\r"
|
||||
if (next.done && buffer !== "") buffer += "\n\n"
|
||||
let boundary = buffer.indexOf("\n\n")
|
||||
while (boundary >= 0) {
|
||||
const block = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const data = block
|
||||
.split("\n")
|
||||
.flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : []))
|
||||
.join("\n")
|
||||
if (data !== "") {
|
||||
try {
|
||||
yield JSON.parse(data) as A
|
||||
} catch (cause) {
|
||||
throw new ClientError("MalformedResponse", { cause })
|
||||
}
|
||||
}
|
||||
boundary = buffer.indexOf("\n\n")
|
||||
}
|
||||
if (next.done) return
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch {}
|
||||
reader.releaseLock()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session`,
|
||||
query: {
|
||||
workspace: input?.workspace,
|
||||
limit: input?.limit,
|
||||
order: input?.order,
|
||||
search: input?.search,
|
||||
directory: input?.directory,
|
||||
project: input?.project,
|
||||
subpath: input?.subpath,
|
||||
cursor: input?.cursor,
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsCreateOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session`,
|
||||
body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
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: [400, 404, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsSwitchAgentOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
|
||||
body: { agent: input.agent },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 404, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsSwitchModelOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
|
||||
body: { model: input.model },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 404, 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, 400, 404, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsCompactOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsWaitOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
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, 401, 500, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
|
||||
if (value === undefined || value === null) return
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) appendQuery(params, key, item)
|
||||
return
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item)
|
||||
return
|
||||
}
|
||||
params.append(key, String(value))
|
||||
}
|
||||
|
||||
async function json(response: Response): Promise<unknown> {
|
||||
if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {
|
||||
try {
|
||||
await response.body?.cancel()
|
||||
} catch {}
|
||||
throw new ClientError("UnsupportedContentType")
|
||||
}
|
||||
let text: string
|
||||
try {
|
||||
text = await response.text()
|
||||
} catch (cause) {
|
||||
throw new ClientError("Transport", { cause })
|
||||
}
|
||||
if (text === "") throw new ClientError("MalformedResponse")
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch (cause) {
|
||||
throw new ClientError("MalformedResponse", { cause })
|
||||
}
|
||||
}
|
||||
|
||||
function isContentType(response: Response, expected: string) {
|
||||
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ClientError, type ClientErrorReason } from "./client-error"
|
||||
export * as OpenCode from "./client"
|
||||
export * from "./types"
|
||||
@@ -0,0 +1,555 @@
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| ReadonlyArray<JsonValue>
|
||||
| { readonly [key: string]: JsonValue }
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidCursorError"
|
||||
|
||||
export type InvalidRequestError = {
|
||||
readonly _tag: "InvalidRequestError"
|
||||
readonly message: string
|
||||
readonly kind?: string | undefined
|
||||
readonly field?: string | undefined
|
||||
}
|
||||
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidRequestError"
|
||||
|
||||
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnauthorizedError"
|
||||
|
||||
export type SessionNotFoundError = {
|
||||
readonly _tag: "SessionNotFoundError"
|
||||
readonly sessionID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionNotFoundError"
|
||||
|
||||
export type ConflictError = {
|
||||
readonly _tag: "ConflictError"
|
||||
readonly message: string
|
||||
readonly resource?: string | undefined
|
||||
}
|
||||
export const isConflictError = (value: unknown): value is ConflictError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ServiceUnavailableError"
|
||||
|
||||
export type 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 }
|
||||
readonly subpath?: string | null
|
||||
}>
|
||||
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
|
||||
}
|
||||
|
||||
export type SessionsCreateInput = {
|
||||
readonly id?: {
|
||||
readonly id?: string | undefined
|
||||
readonly agent?: string | undefined
|
||||
readonly model?:
|
||||
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
|
||||
| undefined
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
|
||||
}["id"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | undefined
|
||||
readonly agent?: string | undefined
|
||||
readonly model?:
|
||||
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
|
||||
| undefined
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly id?: string | undefined
|
||||
readonly agent?: string | undefined
|
||||
readonly model?:
|
||||
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
|
||||
| undefined
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
|
||||
}["model"]
|
||||
readonly location?: {
|
||||
readonly id?: string | undefined
|
||||
readonly agent?: string | undefined
|
||||
readonly model?:
|
||||
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
|
||||
| undefined
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
|
||||
}["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 }
|
||||
readonly subpath?: string | 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 }
|
||||
readonly subpath?: string | 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 SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionsContextOutput = {
|
||||
readonly data: ReadonlyArray<
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "model-switched"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
readonly description?: string | null
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
|
||||
}> | null
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
|
||||
}> | null
|
||||
readonly type: "user"
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
readonly sessionID: string
|
||||
readonly text: string
|
||||
readonly type: "synthetic"
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "system"
|
||||
readonly text: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number; readonly completed?: number | null }
|
||||
readonly type: "shell"
|
||||
readonly callID: string
|
||||
readonly command: string
|
||||
readonly output: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number; readonly completed?: number | null }
|
||||
readonly type: "assistant"
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly id: string; readonly text: string }
|
||||
| {
|
||||
readonly type: "reasoning"
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly provider?: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
|
||||
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
|
||||
} | null
|
||||
readonly state:
|
||||
| { readonly status: "pending"; readonly input: string }
|
||||
| {
|
||||
readonly status: "running"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly structured: { readonly [x: string]: any }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
>
|
||||
}
|
||||
| {
|
||||
readonly status: "completed"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly attachments?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
readonly description?: string | null
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
|
||||
}> | null
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
>
|
||||
readonly outputPaths?: ReadonlyArray<string> | null
|
||||
readonly structured: { readonly [x: string]: any }
|
||||
readonly result?: JsonValue | null
|
||||
}
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
>
|
||||
readonly structured: { readonly [x: string]: any }
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly result?: JsonValue | null
|
||||
}
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly ran?: number | null
|
||||
readonly completed?: number | null
|
||||
readonly pruned?: number | null
|
||||
}
|
||||
}
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string | null; readonly end?: string | null } | 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"]
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./generated/index"
|
||||
@@ -0,0 +1,50 @@
|
||||
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 { SessionGroup } from "@opencode-ai/protocol/session"
|
||||
import { SessionGroup as ServerSessionGroup } from "@opencode-ai/server/groups/session"
|
||||
|
||||
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(ServerSessionGroup).toBe(SessionGroup)
|
||||
expect(Session.ID.create()).toStartWith("ses_")
|
||||
expect(Session.ID.fromExternal({ namespace: "opencord.agent-thread", key: "thread-1" })).toMatch(/^ses_[a-f0-9]{64}$/)
|
||||
expect(Project.ID.global).toBe("global")
|
||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
||||
expect(Workspace.ID.create()).toStartWith("wrk_")
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect"
|
||||
|
||||
test("sessions.get returns the decoded Effect projection", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
|
||||
)
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") })
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const url = request.url
|
||||
if (url.includes("/prompt")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||
}
|
||||
if (url.includes("/context")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
|
||||
}
|
||||
if (request.method === "POST" && url.endsWith("/api/session")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
|
||||
}
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
|
||||
)
|
||||
})
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const page = yield* client.sessions.list({ limit: 10 })
|
||||
const created = yield* client.sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
||||
})
|
||||
yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
|
||||
yield* client.sessions.switchModel({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
|
||||
})
|
||||
const admitted = yield* client.sessions.prompt({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: Prompt.make({ text: "Hello" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
||||
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
||||
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
||||
return { page, created, admitted, context }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||
expect(result.created.id).toBe("ses_test")
|
||||
expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||
expect(result.context).toEqual([])
|
||||
})
|
||||
|
||||
const session = {
|
||||
data: {
|
||||
id: "ses_test",
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
reasoning: 3,
|
||||
cache: { read: 4, write: 5 },
|
||||
},
|
||||
time: {
|
||||
created: 1_717_171_717_000,
|
||||
updated: 1_717_171_717_000,
|
||||
},
|
||||
title: "Test",
|
||||
location: { directory: "/tmp/project" },
|
||||
},
|
||||
}
|
||||
|
||||
const admission = {
|
||||
data: {
|
||||
admittedSeq: 0,
|
||||
id: "msg_test",
|
||||
sessionID: "ses_test",
|
||||
prompt: { text: "Hello" },
|
||||
delivery: "steer",
|
||||
timeCreated: 1_717_171_717_000,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { realpathSync } from "node:fs"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { join, resolve, sep } from "node:path"
|
||||
|
||||
const directory = resolve(import.meta.dir, "..")
|
||||
const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect"))
|
||||
const schema = resolve(import.meta.dir, "../../schema")
|
||||
const protocol = resolve(import.meta.dir, "../../protocol")
|
||||
const core = resolve(import.meta.dir, "../../core")
|
||||
const server = resolve(import.meta.dir, "../../server")
|
||||
|
||||
describe("public import boundaries", () => {
|
||||
test("isolates each public entrypoint", async () => {
|
||||
const root = await bundleInputs("@opencode-ai/client", "browser")
|
||||
|
||||
expect(within(root, effect)).toEqual([])
|
||||
expect(within(root, schema)).toEqual([])
|
||||
expect(within(root, protocol)).toEqual([])
|
||||
expect(within(root, core)).toEqual([])
|
||||
expect(within(root, server)).toEqual([])
|
||||
|
||||
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
|
||||
|
||||
expect(within(network, effect).length).toBeGreaterThan(0)
|
||||
expect(within(network, schema).length).toBeGreaterThan(0)
|
||||
expect(within(network, protocol).length).toBeGreaterThan(0)
|
||||
expect(within(network, core)).toEqual([])
|
||||
expect(within(network, server)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
async function bundleInputs(specifier: string, target: "browser" | "bun") {
|
||||
const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
|
||||
const entrypoint = join(temporary, "index.ts")
|
||||
const metafile = join(temporary, "meta.json")
|
||||
try {
|
||||
await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`)
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
"build",
|
||||
entrypoint,
|
||||
`--target=${target}`,
|
||||
"--format=esm",
|
||||
"--packages=bundle",
|
||||
`--metafile=${metafile}`,
|
||||
`--outdir=${join(temporary, "out")}`,
|
||||
],
|
||||
{ cwd: directory, stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||
const metadata = await Bun.file(metafile).json()
|
||||
return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function within(inputs: ReadonlyArray<string>, directory: string) {
|
||||
const prefix = directory.endsWith(sep) ? directory : directory + sep
|
||||
return inputs.filter((input) => input === directory || input.startsWith(prefix))
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import z from "zod"
|
||||
|
||||
const Body = z.object({
|
||||
inviterWorkspaceID: z.string().startsWith("wrk_"),
|
||||
inviteeWorkspaceID: z.string().startsWith("wrk_"),
|
||||
})
|
||||
|
||||
export async function POST(event: APIEvent) {
|
||||
if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = Body.safeParse(await event.request.json().catch(() => undefined))
|
||||
if (!body.success) {
|
||||
return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 })
|
||||
}
|
||||
return Referral.create(body.data)
|
||||
.then((result) => Response.json({ success: true, message: "Referral created", result }))
|
||||
.catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }))
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Account } from "@opencode-ai/console-core/account.js"
|
||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import z from "zod"
|
||||
|
||||
const Body = z.object({ email: z.email() })
|
||||
|
||||
export async function DELETE(event: APIEvent) {
|
||||
if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = Body.safeParse(await event.request.json().catch(() => undefined))
|
||||
if (!body.success) {
|
||||
return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 })
|
||||
}
|
||||
return Account.remove(body.data.email)
|
||||
.then(() => Response.json({ success: true, message: "Account deleted" }))
|
||||
.catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }))
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||
|
||||
const DISCORD_ALERT_ROLE_ID = "1511795723262365887"
|
||||
const DISCORD_ALERT_ROLE_ID = "1501447160175136838"
|
||||
|
||||
const basePayload = z.object({
|
||||
name: z.string().optional(),
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"update-limits": "script/update-limits.ts",
|
||||
"promote-limits-to-dev": "script/promote-limits.ts dev",
|
||||
"promote-limits-to-prod": "script/promote-limits.ts production",
|
||||
"referral-backfill": "script/referral-backfill.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { and, Database, eq, inArray, isNull } from "../src/drizzle/index.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { Referral } from "../src/referral.js"
|
||||
import { LiteTable } from "../src/schema/billing.sql.js"
|
||||
import { ReferralRewardTable, ReferralTable } from "../src/schema/referral.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
|
||||
const backfills = [
|
||||
{
|
||||
inviterWorkspaceID: "wrk_00000000000000000000000000",
|
||||
inviteeWorkspaceID: "wrk_00000000000000000000000000",
|
||||
inviteeAccountID: "acc_00000000000000000000000000",
|
||||
},
|
||||
]
|
||||
|
||||
console.log(`Backfilling ${backfills.length} referrals`)
|
||||
|
||||
for (const [index, backfill] of backfills.entries()) {
|
||||
console.log(`[${index + 1}/${backfills.length}] ${backfill.inviterWorkspaceID} -> ${backfill.inviteeWorkspaceID}`)
|
||||
console.log(` invitee account: ${backfill.inviteeAccountID}`)
|
||||
|
||||
const result = await Database.transaction(async (tx) => {
|
||||
if (backfill.inviterWorkspaceID === backfill.inviteeWorkspaceID) throw new Error("Self-referral workspace mismatch")
|
||||
|
||||
const inviterWorkspace = await tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.id, backfill.inviterWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!inviterWorkspace) throw new Error(`Inviter workspace not found: ${backfill.inviterWorkspaceID}`)
|
||||
|
||||
const inviteeWorkspace = await tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.id, backfill.inviteeWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!inviteeWorkspace) throw new Error(`Invitee workspace not found: ${backfill.inviteeWorkspaceID}`)
|
||||
|
||||
const inviteeUser = await tx
|
||||
.select({ id: UserTable.id })
|
||||
.from(UserTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.workspaceID, backfill.inviteeWorkspaceID),
|
||||
eq(UserTable.accountID, backfill.inviteeAccountID),
|
||||
eq(UserTable.role, "admin"),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (!inviteeUser) throw new Error(`Invitee workspace owner not found: ${backfill.inviteeAccountID}`)
|
||||
|
||||
const inviterUser = await tx
|
||||
.select({ id: UserTable.id })
|
||||
.from(UserTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.workspaceID, backfill.inviterWorkspaceID),
|
||||
eq(UserTable.accountID, backfill.inviteeAccountID),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (inviterUser) throw new Error(`Self-referral is not allowed: ${backfill.inviteeAccountID}`)
|
||||
|
||||
const lite = await tx
|
||||
.select({ id: LiteTable.id })
|
||||
.from(LiteTable)
|
||||
.where(
|
||||
and(
|
||||
eq(LiteTable.workspaceID, backfill.inviteeWorkspaceID),
|
||||
eq(LiteTable.userID, inviteeUser.id),
|
||||
isNull(LiteTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (!lite) throw new Error(`Invitee Lite subscription not found: ${backfill.inviteeWorkspaceID}`)
|
||||
|
||||
const existingReferral = await tx
|
||||
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, backfill.inviteeAccountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (existingReferral && existingReferral.workspaceID !== backfill.inviterWorkspaceID) {
|
||||
throw new Error(`Referral already belongs to ${existingReferral.workspaceID}: ${existingReferral.id}`)
|
||||
}
|
||||
|
||||
const referralID = existingReferral?.id ?? Identifier.create("referral")
|
||||
if (!existingReferral) {
|
||||
await tx.insert(ReferralTable).ignore().values({
|
||||
workspaceID: backfill.inviterWorkspaceID,
|
||||
id: referralID,
|
||||
inviteeAccountID: backfill.inviteeAccountID,
|
||||
})
|
||||
|
||||
const referral = await tx
|
||||
.select({ id: ReferralTable.id })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, backfill.inviteeAccountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!referral) throw new Error(`Referral not created: ${backfill.inviteeAccountID}`)
|
||||
if (referral.id !== referralID) throw new Error(`Referral already redeemed: ${referral.id}`)
|
||||
}
|
||||
|
||||
const rewardInsert = await tx
|
||||
.insert(ReferralRewardTable)
|
||||
.ignore()
|
||||
.values([
|
||||
{
|
||||
workspaceID: backfill.inviterWorkspaceID,
|
||||
referralID,
|
||||
amount: Referral.REWARD_AMOUNT,
|
||||
},
|
||||
{
|
||||
workspaceID: backfill.inviteeWorkspaceID,
|
||||
referralID,
|
||||
amount: Referral.REWARD_AMOUNT,
|
||||
},
|
||||
])
|
||||
|
||||
const rewards = await tx
|
||||
.select({ workspaceID: ReferralRewardTable.workspaceID, amount: ReferralRewardTable.amount })
|
||||
.from(ReferralRewardTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ReferralRewardTable.referralID, referralID),
|
||||
inArray(ReferralRewardTable.workspaceID, [backfill.inviterWorkspaceID, backfill.inviteeWorkspaceID]),
|
||||
isNull(ReferralRewardTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
if (rewards.length !== 2) throw new Error(`Referral rewards not created: ${referralID}`)
|
||||
if (rewards.some((reward) => reward.amount !== Referral.REWARD_AMOUNT)) {
|
||||
throw new Error(`Referral reward amount mismatch: ${referralID}`)
|
||||
}
|
||||
|
||||
return {
|
||||
referralID,
|
||||
createdReferral: !existingReferral,
|
||||
createdRewards: rewardInsert.rowsAffected,
|
||||
inviteeUserID: inviteeUser.id,
|
||||
liteID: lite.id,
|
||||
rewardWorkspaces: rewards.map((reward) => reward.workspaceID),
|
||||
}
|
||||
})
|
||||
|
||||
console.log(` invitee user: ${result.inviteeUserID}`)
|
||||
console.log(` lite: ${result.liteID}`)
|
||||
console.log(` referral: ${result.referralID} (${result.createdReferral ? "created" : "existing"})`)
|
||||
console.log(` rewards: ${result.rewardWorkspaces.join(", ")} (${result.createdRewards} inserted)`)
|
||||
}
|
||||
|
||||
console.log("Referral backfill complete")
|
||||
@@ -1,13 +1,9 @@
|
||||
import { z } from "zod"
|
||||
import { and, eq, inArray, sql } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { fn } from "./util/fn"
|
||||
import { Database } from "./drizzle"
|
||||
import { Identifier } from "./identifier"
|
||||
import { AccountTable } from "./schema/account.sql"
|
||||
import { AuthTable } from "./schema/auth.sql"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
import { KeyTable } from "./schema/key.sql"
|
||||
import { CouponTable } from "./schema/billing.sql"
|
||||
|
||||
export namespace Account {
|
||||
export const create = fn(
|
||||
@@ -24,52 +20,6 @@ export namespace Account {
|
||||
}),
|
||||
)
|
||||
|
||||
export const remove = fn(z.email(), async (email) => {
|
||||
await Database.transaction(async (tx) => {
|
||||
const account = await tx
|
||||
.select({ id: AccountTable.id })
|
||||
.from(AuthTable)
|
||||
.innerJoin(AccountTable, eq(AccountTable.id, AuthTable.accountID))
|
||||
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
|
||||
.then((rows) => rows[0])
|
||||
if (!account) throw new Error("Account not found")
|
||||
|
||||
const emails = await tx
|
||||
.select({ email: AuthTable.subject })
|
||||
.from(AuthTable)
|
||||
.where(and(eq(AuthTable.accountID, account.id), eq(AuthTable.provider, "email")))
|
||||
const users = await tx.select({ id: UserTable.id }).from(UserTable).where(eq(UserTable.accountID, account.id))
|
||||
if (users.length > 0) {
|
||||
await tx
|
||||
.update(KeyTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
inArray(
|
||||
KeyTable.userID,
|
||||
users.map((user) => user.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
await tx
|
||||
.update(UserTable)
|
||||
.set({ accountID: null, email: null, name: "", timeDeleted: sql`now()` })
|
||||
.where(eq(UserTable.accountID, account.id))
|
||||
if (emails.length > 0) {
|
||||
await tx.delete(CouponTable).where(
|
||||
inArray(
|
||||
CouponTable.email,
|
||||
emails.map((row) => row.email),
|
||||
),
|
||||
)
|
||||
}
|
||||
await tx.delete(AuthTable).where(eq(AuthTable.accountID, account.id))
|
||||
await tx
|
||||
.update(AccountTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(AccountTable.id, account.id))
|
||||
})
|
||||
})
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
|
||||
@@ -362,119 +362,6 @@ export namespace Referral {
|
||||
})
|
||||
}
|
||||
|
||||
export async function create(input: { inviterWorkspaceID: string; inviteeWorkspaceID: string }) {
|
||||
return Database.transaction(async (tx) => {
|
||||
if (input.inviterWorkspaceID === input.inviteeWorkspaceID) throw new Error("Self-referral workspace mismatch")
|
||||
|
||||
const inviterWorkspace = await tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.id, input.inviterWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!inviterWorkspace) throw new Error(`Inviter workspace not found: ${input.inviterWorkspaceID}`)
|
||||
|
||||
const inviteeWorkspace = await tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.id, input.inviteeWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!inviteeWorkspace) throw new Error(`Invitee workspace not found: ${input.inviteeWorkspaceID}`)
|
||||
|
||||
const invitee = await tx
|
||||
.select({ accountID: UserTable.accountID, userID: UserTable.id, liteID: LiteTable.id })
|
||||
.from(UserTable)
|
||||
.innerJoin(
|
||||
LiteTable,
|
||||
and(
|
||||
eq(LiteTable.workspaceID, UserTable.workspaceID),
|
||||
eq(LiteTable.userID, UserTable.id),
|
||||
isNull(LiteTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.workspaceID, input.inviteeWorkspaceID),
|
||||
eq(UserTable.role, "admin"),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (!invitee?.accountID) throw new Error(`Invitee Lite workspace owner not found: ${input.inviteeWorkspaceID}`)
|
||||
|
||||
const inviterUser = await tx
|
||||
.select({ id: UserTable.id })
|
||||
.from(UserTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.workspaceID, input.inviterWorkspaceID),
|
||||
eq(UserTable.accountID, invitee.accountID),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (inviterUser) throw new Error(`Self-referral is not allowed: ${invitee.accountID}`)
|
||||
|
||||
const existingReferral = await tx
|
||||
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (existingReferral && existingReferral.workspaceID !== input.inviterWorkspaceID) {
|
||||
throw new Error(`Referral already belongs to ${existingReferral.workspaceID}: ${existingReferral.id}`)
|
||||
}
|
||||
|
||||
const referralID = existingReferral?.id ?? Identifier.create("referral")
|
||||
if (!existingReferral) {
|
||||
await tx.insert(ReferralTable).ignore().values({
|
||||
workspaceID: input.inviterWorkspaceID,
|
||||
id: referralID,
|
||||
inviteeAccountID: invitee.accountID,
|
||||
})
|
||||
|
||||
const referral = await tx
|
||||
.select({ id: ReferralTable.id })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!referral) throw new Error(`Referral not created: ${invitee.accountID}`)
|
||||
if (referral.id !== referralID) throw new Error(`Referral already redeemed: ${referral.id}`)
|
||||
}
|
||||
|
||||
const rewardInsert = await tx
|
||||
.insert(ReferralRewardTable)
|
||||
.ignore()
|
||||
.values([
|
||||
{ workspaceID: input.inviterWorkspaceID, referralID, amount: REWARD_AMOUNT },
|
||||
{ workspaceID: input.inviteeWorkspaceID, referralID, amount: REWARD_AMOUNT },
|
||||
])
|
||||
|
||||
const rewards = await tx
|
||||
.select({ workspaceID: ReferralRewardTable.workspaceID, amount: ReferralRewardTable.amount })
|
||||
.from(ReferralRewardTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ReferralRewardTable.referralID, referralID),
|
||||
inArray(ReferralRewardTable.workspaceID, [input.inviterWorkspaceID, input.inviteeWorkspaceID]),
|
||||
isNull(ReferralRewardTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
if (rewards.length !== 2) throw new Error(`Referral rewards not created: ${referralID}`)
|
||||
if (rewards.some((reward) => reward.amount !== REWARD_AMOUNT)) {
|
||||
throw new Error(`Referral reward amount mismatch: ${referralID}`)
|
||||
}
|
||||
|
||||
return {
|
||||
referralID,
|
||||
createdReferral: !existingReferral,
|
||||
createdRewards: rewardInsert.rowsAffected,
|
||||
inviteeAccountID: invitee.accountID,
|
||||
inviteeUserID: invitee.userID,
|
||||
liteID: invitee.liteID,
|
||||
rewardWorkspaces: rewards.map((reward) => reward.workspaceID),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function completeFromLiteSubscription(input: { workspaceID: string; userID: string }) {
|
||||
return Database.transaction(async (tx) => {
|
||||
const invitee = await tx
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Key } from "./key"
|
||||
import { KeyTable } from "./schema/key.sql"
|
||||
import { WorkspaceTable } from "./schema/workspace.sql"
|
||||
import { AuthTable } from "./schema/auth.sql"
|
||||
import { AccountTable } from "./schema/account.sql"
|
||||
|
||||
export namespace User {
|
||||
const assertNotSelf = (id: string) => {
|
||||
@@ -162,13 +161,6 @@ export namespace User {
|
||||
export const joinInvitedWorkspaces = fn(z.void(), async () => {
|
||||
const account = Actor.assert("account")
|
||||
const invitations = await Database.use(async (tx) => {
|
||||
const active = await tx
|
||||
.select({ id: AccountTable.id })
|
||||
.from(AccountTable)
|
||||
.where(and(eq(AccountTable.id, account.properties.accountID), isNull(AccountTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!active) throw new Error("Account is not active")
|
||||
|
||||
const invitations = await tx
|
||||
.select({
|
||||
id: UserTable.id,
|
||||
|
||||
@@ -6,9 +6,8 @@ import { Identifier } from "./identifier"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
import { BillingTable } from "./schema/billing.sql"
|
||||
import { WorkspaceTable } from "./schema/workspace.sql"
|
||||
import { AccountTable } from "./schema/account.sql"
|
||||
import { Key } from "./key"
|
||||
import { and, eq, isNull, sql } from "drizzle-orm"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
|
||||
export namespace Workspace {
|
||||
export const create = fn(
|
||||
@@ -20,13 +19,6 @@ export namespace Workspace {
|
||||
const workspaceID = Identifier.create("workspace")
|
||||
const userID = Identifier.create("user")
|
||||
await Database.transaction(async (tx) => {
|
||||
const active = await tx
|
||||
.select({ id: AccountTable.id })
|
||||
.from(AccountTable)
|
||||
.where(and(eq(AccountTable.id, account.properties.accountID), isNull(AccountTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!active) throw new Error("Account is not active")
|
||||
|
||||
await tx.insert(WorkspaceTable).values({
|
||||
id: workspaceID,
|
||||
name,
|
||||
|
||||
@@ -1,17 +1,47 @@
|
||||
export * as AgentV2 from "./agent"
|
||||
|
||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||
import { Array, Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PermissionSchema } from "./permission/schema"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { PositiveInt } from "./schema"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Agent.ID
|
||||
export type ID = typeof ID.Type
|
||||
export const defaultID = ID.make("build")
|
||||
|
||||
export const Color = Agent.Color
|
||||
export const Color = Schema.Union([
|
||||
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
||||
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
||||
])
|
||||
|
||||
export const Info = Agent.Info
|
||||
export type Info = Agent.Info
|
||||
export class Info extends Schema.Class<Info>("AgentV2.Info")({
|
||||
id: ID,
|
||||
model: ModelV2.Ref.pipe(Schema.optional),
|
||||
request: ProviderV2.Request,
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]),
|
||||
hidden: Schema.Boolean,
|
||||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
permissions: PermissionSchema.Ruleset,
|
||||
}) {
|
||||
static empty(id: ID) {
|
||||
return new Info({
|
||||
id,
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
},
|
||||
mode: "all",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface Selection {
|
||||
readonly id: ID
|
||||
|
||||
@@ -73,7 +73,7 @@ export const layer = Layer.effect(
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (integration?.connections.length) return true
|
||||
return provider.integrationID === undefined && !integration
|
||||
return !integration
|
||||
}
|
||||
|
||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
@@ -89,7 +89,7 @@ export const layer = Layer.effect(
|
||||
...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request),
|
||||
variant: model.request.variant,
|
||||
}
|
||||
return ModelV2.Info.make({
|
||||
return new ModelV2.Info({
|
||||
...model,
|
||||
api,
|
||||
request,
|
||||
@@ -184,7 +184,7 @@ export const layer = Layer.effect(
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
|
||||
available(provider, active.get(Integration.ID.make(provider.id))),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
export * as CommandV2 from "./command"
|
||||
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { State } from "./state"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
export class Info extends Schema.Class<Info>("CommandV2.Info")({
|
||||
name: Schema.String,
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: ModelV2.Ref.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export type Data = {
|
||||
commands: Map<string, Types.DeepMutable<Info>>
|
||||
@@ -34,7 +40,7 @@ export const layer = Layer.effect(
|
||||
list: () => Array.from(draft.commands.values()) as Info[],
|
||||
get: (name) => draft.commands.get(name),
|
||||
update: (name, update) => {
|
||||
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
|
||||
const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>)
|
||||
if (!draft.commands.has(name)) draft.commands.set(name, current)
|
||||
update(current)
|
||||
current.name = name
|
||||
|
||||
@@ -3,10 +3,10 @@ export * as Config from "./config"
|
||||
import path from "path"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { PermissionSchema } from "./permission/schema"
|
||||
import { Policy } from "./policy"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
@@ -56,7 +56,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
|
||||
permissions: PermissionSchema.Ruleset.pipe(Schema.optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { PermissionSchema } from "../permission/schema"
|
||||
import { ConfigProvider } from "./provider"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
@@ -21,5 +21,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
|
||||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||
permissions: PermissionSchema.Ruleset.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const Plugin = define({
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
@@ -36,7 +36,7 @@ export const Plugin = define({
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
})
|
||||
: Reference.GitSource.make({
|
||||
: new Reference.GitSource({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
|
||||
@@ -22,23 +22,20 @@ export const Plugin = define({
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
)
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
|
||||
draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
|
||||
@@ -2,26 +2,41 @@ export * as Credential from "./credential"
|
||||
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Database } from "./database/database"
|
||||
import { IntegrationSchema } from "./integration/schema"
|
||||
import { NonNegativeInt, withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { CredentialTable } from "./credential/sql"
|
||||
|
||||
export const ID = Credential.ID
|
||||
export type ID = Credential.ID
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Credential.ID"),
|
||||
withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const OAuth = Credential.OAuth
|
||||
export type OAuth = Credential.OAuth
|
||||
export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
methodID: IntegrationSchema.MethodID,
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: NonNegativeInt,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export const Key = Credential.Key
|
||||
export type Key = Credential.Key
|
||||
export class Key extends Schema.Class<Key>("Credential.Key")({
|
||||
type: Schema.Literal("key"),
|
||||
key: Schema.String,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export const Value = Credential.Value
|
||||
export type Value = Credential.Value
|
||||
export const Value = Schema.Union([OAuth, Key])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Credential.Value" })
|
||||
export type Value = Schema.Schema.Type<typeof Value>
|
||||
|
||||
export class Info extends Schema.Class<Info>("Credential.Info")({
|
||||
id: ID,
|
||||
integrationID: Integration.ID,
|
||||
integrationID: IntegrationSchema.ID,
|
||||
label: Schema.String,
|
||||
value: Value,
|
||||
}) {}
|
||||
@@ -30,12 +45,12 @@ export interface Interface {
|
||||
/** Returns every stored credential. */
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
/** Returns stored credentials belonging to one integration. */
|
||||
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly integrationID: IntegrationSchema.ID
|
||||
readonly value: Value
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Info>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { IntegrationSchema } from "../integration/schema"
|
||||
import type { Credential } from "../credential"
|
||||
|
||||
export const CredentialTable = sqliteTable("credential", {
|
||||
id: text().$type<Credential.ID>().primaryKey(),
|
||||
integration_id: text().$type<Credential.Info["integrationID"]>(),
|
||||
integration_id: text().$type<IntegrationSchema.ID>(),
|
||||
label: text().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
|
||||
connector_id: text(),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
import { withStatics } from "./schema"
|
||||
import { externalID, type ExternalID, withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
@@ -14,6 +14,7 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
|
||||
Schema.brand("Event.ID"),
|
||||
withStatics((schema) => ({
|
||||
create: () => schema.make("evt_" + Identifier.ascending()),
|
||||
fromExternal: (input: ExternalID) => schema.make(externalID("evt", input)),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
import { PositiveInt, RelativePath } from "./schema"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
||||
import { Entry, Match } from "./filesystem/schema"
|
||||
export { Entry, Match, Submatch } from "./filesystem/schema"
|
||||
|
||||
export const ReadInput = Schema.Struct({
|
||||
path: RelativePath,
|
||||
@@ -108,9 +108,10 @@ const baseLayer = Layer.effect(
|
||||
const absolute = path.join(target.absolute, item.name)
|
||||
const relative = path.relative(target.directory, absolute)
|
||||
return [
|
||||
Entry.make({
|
||||
new Entry({
|
||||
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||
type: item.type,
|
||||
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
|
||||
path: RelativePath,
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
mime: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Submatch = Schema.Struct({
|
||||
text: Schema.String,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
})
|
||||
export type Submatch = typeof Submatch.Type
|
||||
|
||||
export class Match extends Schema.Class<Match>("FileSystem.Match")({
|
||||
entry: Entry,
|
||||
line: PositiveInt,
|
||||
offset: NonNegativeInt,
|
||||
text: Schema.String,
|
||||
submatches: Schema.Array(Submatch),
|
||||
}) {}
|
||||
@@ -59,11 +59,12 @@ export const ripgrepLayer = Layer.effect(
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
result.map((entry) =>
|
||||
FileSystem.Entry.make({
|
||||
...entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
||||
}),
|
||||
result.map(
|
||||
(entry) =>
|
||||
new FileSystem.Entry({
|
||||
...entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
@@ -84,14 +85,15 @@ export const ripgrepLayer = Layer.effect(
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
result.map((match) =>
|
||||
FileSystem.Match.make({
|
||||
...match,
|
||||
entry: FileSystem.Entry.make({
|
||||
...match.entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
|
||||
result.map(
|
||||
(match) =>
|
||||
new FileSystem.Match({
|
||||
...match,
|
||||
entry: new FileSystem.Entry({
|
||||
...match.entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
@@ -108,9 +110,12 @@ export const ripgrepLayer = Layer.effect(
|
||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
return FileSystem.Entry.make({
|
||||
const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative
|
||||
const absolute = path.resolve(location.directory, clean)
|
||||
return new FileSystem.Entry({
|
||||
path: RelativePath.make(relative),
|
||||
type,
|
||||
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
|
||||
})
|
||||
})
|
||||
}),
|
||||
@@ -143,12 +148,14 @@ export const fffLayer = Layer.effect(
|
||||
pageSize: input.limit,
|
||||
})
|
||||
if (!found.ok) throw found.error
|
||||
return found.value.items.map((item) =>
|
||||
FileSystem.Entry.make({
|
||||
return found.value.items.map((item) => {
|
||||
const absolute = path.resolve(location.directory, item.relativePath)
|
||||
return new FileSystem.Entry({
|
||||
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
|
||||
type: "file",
|
||||
}),
|
||||
)
|
||||
mime: FSUtil.mimeType(absolute),
|
||||
})
|
||||
})
|
||||
}),
|
||||
grep: (input) =>
|
||||
Effect.sync(() => {
|
||||
@@ -162,10 +169,11 @@ export const fffLayer = Layer.effect(
|
||||
if (!found.ok) throw found.error
|
||||
return found.value.items.map((match) => {
|
||||
const bytes = Buffer.from(match.lineContent)
|
||||
return FileSystem.Match.make({
|
||||
entry: FileSystem.Entry.make({
|
||||
return new FileSystem.Match({
|
||||
entry: new FileSystem.Entry({
|
||||
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
|
||||
type: "file",
|
||||
mime: FSUtil.mimeType(match.relativePath),
|
||||
}),
|
||||
line: match.lineNumber,
|
||||
offset: match.byteOffset,
|
||||
@@ -212,9 +220,11 @@ export const fffLayer = Layer.effect(
|
||||
.sort((a, b) => b.score - a.score || a.path.length - b.path.length)
|
||||
.map((item) => {
|
||||
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
|
||||
return FileSystem.Entry.make({
|
||||
const absolute = path.resolve(location.directory, relative)
|
||||
return new FileSystem.Entry({
|
||||
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||
type: item.type,
|
||||
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -14,12 +14,7 @@ export namespace FSUtil {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
method: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {
|
||||
override get message() {
|
||||
const detail = this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause)
|
||||
return `Filesystem operation failed: ${this.method}${detail ? `: ${detail}` : ""}`
|
||||
}
|
||||
}
|
||||
}) {}
|
||||
|
||||
export type Error = PlatformError | FileSystemError
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user