mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8e5b12f41 |
@@ -69,11 +69,6 @@ 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
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
sst-env.d.ts
|
||||
packages/desktop/src/bindings.ts
|
||||
packages/client/src/generated/
|
||||
packages/client/src/generated-effect/
|
||||
|
||||
-77
@@ -60,21 +60,6 @@ A temporary file created under OpenCode's shared tool-output directory to retain
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
**OpenCode Client**:
|
||||
The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers.
|
||||
_Avoid_: Remote client
|
||||
|
||||
**SDK Contract IR**:
|
||||
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
|
||||
|
||||
**Embedded OpenCode**:
|
||||
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
|
||||
_Avoid_: Local implementation
|
||||
|
||||
**Page**:
|
||||
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
|
||||
_Avoid_: Response envelope
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
@@ -123,51 +108,6 @@ _Avoid_: Response envelope
|
||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
|
||||
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
|
||||
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
|
||||
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
|
||||
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
|
||||
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
|
||||
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
|
||||
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
|
||||
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
|
||||
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
|
||||
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
|
||||
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
|
||||
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
|
||||
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
|
||||
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
|
||||
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
|
||||
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
|
||||
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
|
||||
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
|
||||
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
|
||||
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
|
||||
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
|
||||
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
|
||||
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
||||
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
||||
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
||||
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
||||
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
||||
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
||||
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
||||
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
||||
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
||||
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
|
||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
||||
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
||||
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
@@ -184,23 +124,6 @@ _Avoid_: Response envelope
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
|
||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||
|
||||
## Client contract architecture
|
||||
|
||||
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
|
||||
|
||||
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
|
||||
|
||||
Before stabilizing the client API:
|
||||
|
||||
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
|
||||
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
|
||||
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
|
||||
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
|
||||
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
|
||||
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
|
||||
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
|
||||
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -87,7 +87,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -110,32 +110,9 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode-ai/client",
|
||||
"dependencies": {
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.83",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"effect",
|
||||
],
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -171,7 +148,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -198,7 +175,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -220,7 +197,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -244,7 +221,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -264,7 +241,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -357,7 +334,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -411,7 +388,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -425,7 +402,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -437,7 +414,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -469,7 +446,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -485,7 +462,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -502,21 +479,9 @@
|
||||
"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.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -535,7 +500,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -665,7 +630,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -682,9 +647,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.3.4",
|
||||
"@opentui/keymap": ">=0.3.4",
|
||||
"@opentui/solid": ">=0.3.4",
|
||||
"@opentui/core": ">=0.4.2",
|
||||
"@opentui/keymap": ">=0.4.2",
|
||||
"@opentui/solid": ">=0.4.2",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -725,23 +690,9 @@
|
||||
"@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.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -756,7 +707,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -771,7 +722,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -815,7 +766,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -828,7 +779,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
@@ -861,7 +812,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -880,7 +831,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -921,7 +872,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -948,7 +899,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -958,6 +909,8 @@
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
@@ -973,32 +926,27 @@
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/katex": "0.16.7",
|
||||
"@types/luxon": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@solidjs/meta": "^0.29.0",
|
||||
"solid-js": "^1.9.0",
|
||||
},
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1072,9 +1020,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1899,8 +1847,6 @@
|
||||
|
||||
"@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"],
|
||||
@@ -1927,8 +1873,6 @@
|
||||
|
||||
"@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"],
|
||||
@@ -1941,8 +1885,6 @@
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
|
||||
|
||||
"@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
|
||||
|
||||
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
|
||||
|
||||
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
|
||||
@@ -1991,27 +1933,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="],
|
||||
|
||||
"@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": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="],
|
||||
|
||||
"@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-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
|
||||
"@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-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
|
||||
"@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-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="],
|
||||
|
||||
"@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/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/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=="],
|
||||
"@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=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -3189,7 +3131,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.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -5547,8 +5489,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
|
||||
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
|
||||
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
|
||||
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
|
||||
"x86_64-linux": "sha256-Q0CW2wMhVtRM3uILV1kvKAzpQJqBxTp4MLsGlsSoT1s=",
|
||||
"aarch64-linux": "sha256-UHpoDqqV+umG47LENB7DLxbCqvwjTCDjHWIFiv6Vmk8=",
|
||||
"aarch64-darwin": "sha256-rqlFositEiB1wzx6Jcp5yVQlp8LLYBW+79KXmRpXh2Q=",
|
||||
"x86_64-darwin": "sha256-BKr4YQio3eP0VLZ0z+oyaM93H8dAbSwmlzxT8PH6W8M="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -39,9 +39,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
|
||||
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
await mockServers(page, requests)
|
||||
await page.addInitScript(
|
||||
({ serverB, sessionA, sessionB }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
||||
{ type: "session", server: serverB, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(hrefA)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
|
||||
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
||||
await tabA.locator('[data-slot="tab-close"] button').click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||
expect(
|
||||
requests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
|
||||
await mockServers(page, [])
|
||||
await page.addInitScript(
|
||||
({ serverB, sessionB }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
|
||||
)
|
||||
},
|
||||
{ serverB, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
function session(id: string, directory: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: `project-${id}`,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServers(page: Page, requests: string[]) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
requests.push(url.toString())
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session") return json(route, [current])
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
worktree: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/RequestDocks"
|
||||
const projectID = "proj_request_docks"
|
||||
const sessionID = "ses_request_docks"
|
||||
const title = "Request dock regression"
|
||||
|
||||
test("shows a pending question dock", async ({ page }) => {
|
||||
await mockServer(page, {
|
||||
questions: [
|
||||
{
|
||||
id: "question-request",
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
header: "Implementation",
|
||||
question: "Which implementation should be used?",
|
||||
options: [
|
||||
{ label: "Minimal", description: "Use the smallest correct change" },
|
||||
{ label: "Extended", description: "Include additional behavior" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
await expect(question).toBeVisible()
|
||||
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
||||
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
|
||||
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
||||
|
||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||
const reply = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||
)
|
||||
await question.getByRole("button", { name: "Submit" }).click()
|
||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||
})
|
||||
|
||||
test("shows a pending permission dock", async ({ page }) => {
|
||||
await mockServer(page, {
|
||||
permissions: [
|
||||
{
|
||||
id: "permission-request",
|
||||
sessionID,
|
||||
permission: "bash",
|
||||
patterns: ["git status", "git diff"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
|
||||
await expect(permission).toBeVisible()
|
||||
await expect(permission.getByText("git status")).toBeVisible()
|
||||
await expect(permission.getByText("git diff")).toBeVisible()
|
||||
await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
|
||||
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
||||
|
||||
const reply = page.waitForRequest((request) => request.method() === "POST")
|
||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||
const request = await reply
|
||||
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
|
||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||
})
|
||||
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
requests: {
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
},
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "request-docks",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "request-docks",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
permissions: requests.permissions,
|
||||
questions: requests.questions,
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||
const projectID = "proj_todo_dock_navigation"
|
||||
const sourceID = "ses_todo_dock_source"
|
||||
const otherID = "ses_todo_dock_other"
|
||||
const sourceTitle = "Todo dock animation"
|
||||
const otherTitle = "Separate session"
|
||||
|
||||
const activeTodos = [
|
||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||
]
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||
|
||||
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
const events: EventPayload[] = []
|
||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "todo-dock-navigation",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
todos: (sessionID) => todos[sessionID] ?? [],
|
||||
})
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(sessionHref(sourceID))
|
||||
await expectSessionTitle(page, sourceTitle)
|
||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
events.push(statusEvent(sourceID, "busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
|
||||
await page.waitForTimeout(700)
|
||||
const opening = sampleDock(page, 1_000)
|
||||
todos[sourceID] = activeTodos
|
||||
events.push(todoEvent(sourceID, activeTodos))
|
||||
await expect(dock).toBeVisible()
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
const returningOpen = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
||||
expect(openSamples.length).toBeGreaterThan(0)
|
||||
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
||||
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
|
||||
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
||||
const closing = sampleDock(page, 1_000)
|
||||
todos[sourceID] = completedTodos
|
||||
events.push(todoEvent(sourceID, completedTodos))
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
todos[sourceID] = []
|
||||
events.push(todoEvent(sourceID, []))
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
const returningEmpty = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
||||
})
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||
}
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, dirBase64, server, sessionIDs }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = sessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
function sampleDock(page: Page, duration: number) {
|
||||
return page.evaluate(async (duration) => {
|
||||
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||
const start = performance.now()
|
||||
while (performance.now() - start < duration) {
|
||||
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||
const clip = dock?.parentElement?.parentElement
|
||||
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||
samples.push({
|
||||
present: !!dock,
|
||||
height: clip?.getBoundingClientRect().height ?? 0,
|
||||
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||
})
|
||||
await new Promise(requestAnimationFrame)
|
||||
}
|
||||
return samples
|
||||
}, duration)
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyList = new Set([
|
||||
"/skill",
|
||||
"/command",
|
||||
"/lsp",
|
||||
"/formatter",
|
||||
"/permission",
|
||||
"/question",
|
||||
"/vcs/status",
|
||||
"/vcs/diff",
|
||||
])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
@@ -14,9 +23,6 @@ export interface MockServerConfig {
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
events?: () => unknown[]
|
||||
eventRetry?: number
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
@@ -49,10 +55,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
@@ -64,9 +66,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, session ?? {})
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -17,9 +17,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "bun run test:unit && bun run test:browser",
|
||||
"test": "bun run test:unit && bun run test:virtualizer",
|
||||
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||
"test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts",
|
||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
|
||||
@@ -51,14 +51,7 @@ import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import {
|
||||
legacySessionHref,
|
||||
legacySessionServer,
|
||||
requireServerKey,
|
||||
selectSessionLineage,
|
||||
sessionHref,
|
||||
} from "./utils/session-route"
|
||||
import { isSessionNotFoundError } from "./utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
|
||||
import Session from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
@@ -74,15 +67,7 @@ const SessionRoute = () => {
|
||||
const tabs = useTabs()
|
||||
|
||||
if (params.id && settings.general.newLayoutDesigns()) {
|
||||
const sessionID = params.id
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
{(_) => {
|
||||
const persisted = tabs.store.filter((item) => item.type === "session")
|
||||
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
return <Navigate href={sessionHref(server.key, params.id)} />
|
||||
}
|
||||
|
||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||
@@ -110,7 +95,7 @@ const TargetSessionRoute = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<Show when={`${params.serverKey}\0${params.id}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ResolvedTargetSessionRoute />
|
||||
@@ -130,15 +115,11 @@ function ResolvedTargetSessionRoute() {
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (cached()) return
|
||||
return { id: params.id, server: serverKey(), sync: sync() }
|
||||
return { id: params.id, sync: sync() }
|
||||
},
|
||||
({ id, server, sync }) =>
|
||||
sync.session.lineage.resolve(id).catch((error) => {
|
||||
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
|
||||
throw error
|
||||
}),
|
||||
({ id, sync }) => sync.session.lineage.resolve(id),
|
||||
)
|
||||
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||
const current = createMemo(() => cached() ?? resolved())
|
||||
const directory = createMemo(() => current()?.session.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
@@ -153,7 +134,7 @@ function ResolvedTargetSessionRoute() {
|
||||
|
||||
return (
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
@@ -313,7 +294,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
@@ -342,7 +323,7 @@ function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind"
|
||||
|
||||
describe("command tooltip keybinds", () => {
|
||||
test("keeps localized review shortcut modifiers", () => {
|
||||
const command = {
|
||||
keybind: () => "Ctrl+Maj+R",
|
||||
keybindParts: () => ["Ctrl", "Maj", "R"],
|
||||
}
|
||||
|
||||
expect(reviewTooltipKeybind(command, (key) => key)).toEqual(["Ctrl", "Maj", "R"])
|
||||
})
|
||||
|
||||
test("uses the configured new-tab shortcut", () => {
|
||||
const command = {
|
||||
keybind: () => "Alt+N",
|
||||
keybindParts: () => ["Alt", "N"],
|
||||
}
|
||||
|
||||
expect(newTabTooltipKeybind(command, (key) => key)).toEqual(["Alt", "N"])
|
||||
})
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
type CommandKeybind = {
|
||||
keybindParts: (id: string) => string[]
|
||||
}
|
||||
|
||||
export function reviewTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
|
||||
return command.keybindParts("review.toggle")
|
||||
}
|
||||
|
||||
export function newTabTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
|
||||
return command.keybindParts("tab.new")
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
@@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
|
||||
export function DialogConnectProvider(props: { provider: string }) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders(props.directory)
|
||||
const providers = useProviders()
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, batch, For } from "solid-js"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
@@ -17,7 +17,6 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
type Props = {
|
||||
back?: "providers" | "close"
|
||||
directory?: Accessor<string | undefined>
|
||||
}
|
||||
|
||||
export function DialogCustomProvider(props: Props) {
|
||||
@@ -41,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
|
||||
const addModel = () => {
|
||||
|
||||
@@ -9,16 +9,14 @@ import { popularProviders } from "@/hooks/use-providers"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
export const DialogManageModels: Component = () => {
|
||||
const local = useLocal()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
|
||||
@@ -10,27 +10,24 @@ import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||
const local = useLocal()
|
||||
const model = props.model ?? local.model
|
||||
const model = props.model ?? useLocal().model
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
const providers = useProviders(directory)
|
||||
const providers = useProviders()
|
||||
const language = useLanguage()
|
||||
|
||||
const connect = (provider: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
|
||||
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
|
||||
})
|
||||
}
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
@@ -105,8 +104,6 @@ export function ModelSelectorPopover(props: {
|
||||
dismiss: null,
|
||||
})
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const close = (dismiss: Dismiss) => {
|
||||
setStore("dismiss", dismiss)
|
||||
@@ -123,7 +120,7 @@ export function ModelSelectorPopover(props: {
|
||||
const handleConnectProvider = () => {
|
||||
close("provider")
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
const language = useLanguage()
|
||||
@@ -202,12 +199,10 @@ export function ModelSelectorPopover(props: {
|
||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const provider = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Accessor, Component, Show } from "solid-js"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
|
||||
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
|
||||
export const DialogSelectProvider: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders(props.directory)
|
||||
const providers = useProviders()
|
||||
const language = useLanguage()
|
||||
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
@@ -56,10 +56,10 @@ export const DialogSelectProvider: Component<{ directory?: Accessor<string | und
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
if (x.id === CUSTOM_ID) {
|
||||
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
|
||||
dialog.show(() => <DialogCustomProvider back="providers" />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function createPromptInputStoryRuntime() {
|
||||
const state = createPromptState()
|
||||
return {
|
||||
state,
|
||||
history: createPromptInputHistory(),
|
||||
submission: {
|
||||
abort() {},
|
||||
handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
state.reset()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function PromptInputExample() {
|
||||
const input = createPromptInputStoryRuntime()
|
||||
const state = createPromptState()
|
||||
const history = createPromptInputHistory()
|
||||
const [controls, setControls] = createStore({
|
||||
agent: "build",
|
||||
variant: undefined as string | undefined,
|
||||
@@ -38,6 +22,13 @@ function PromptInputExample() {
|
||||
set: (variant?: string) => setControls("variant", variant),
|
||||
},
|
||||
}
|
||||
const submission = {
|
||||
abort() {},
|
||||
handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
state.reset()
|
||||
},
|
||||
}
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [{ name: "review", hidden: false, mode: "subagent" }],
|
||||
@@ -78,7 +69,7 @@ function PromptInputExample() {
|
||||
const addReviewComment = () => {
|
||||
const comment = controls.comments + 1
|
||||
setControls("comments", comment)
|
||||
input.state.context.add({
|
||||
state.context.add({
|
||||
type: "file",
|
||||
path: "src/components/prompt-input.tsx",
|
||||
selection: {
|
||||
@@ -96,7 +87,7 @@ function PromptInputExample() {
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInput controls={inputControls} {...input} />
|
||||
<PromptInput controls={inputControls} state={state} history={history} submission={submission} />
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -110,82 +101,6 @@ function PromptInputExample() {
|
||||
)
|
||||
}
|
||||
|
||||
const todos: Todo[] = [
|
||||
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||
]
|
||||
|
||||
function PromptInputWithOpenDock() {
|
||||
const input = createPromptInputStoryRuntime()
|
||||
const [controls, setControls] = createStore({
|
||||
agent: "build",
|
||||
activeTab: undefined as string | undefined,
|
||||
todoCollapsed: false,
|
||||
})
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [],
|
||||
options: ["build"],
|
||||
get current() {
|
||||
return controls.agent
|
||||
},
|
||||
loading: false,
|
||||
visible: true,
|
||||
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||
},
|
||||
model: {
|
||||
selection: {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||
},
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: {
|
||||
active: () => controls.activeTab,
|
||||
all: () => [],
|
||||
open: () => {},
|
||||
setActive: (tab: string) => setControls("activeTab", tab),
|
||||
},
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
const state = {
|
||||
blocked: () => false,
|
||||
questionRequest: () => undefined,
|
||||
permissionRequest: () => undefined,
|
||||
permissionResponding: () => false,
|
||||
decide: () => {},
|
||||
todos: () => todos,
|
||||
dock: () => true,
|
||||
closing: () => false,
|
||||
opening: () => false,
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
state={state}
|
||||
sessionKey="story-session"
|
||||
sessionID="story-session"
|
||||
controls={inputControls}
|
||||
promptInput={{ ...input, ref: () => {}, newSessionWorktree: "", onNewSessionWorktreeReset: () => {} }}
|
||||
todo={{
|
||||
collapsed: controls.todoCollapsed,
|
||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||
}}
|
||||
ready
|
||||
centered={false}
|
||||
onResponseSubmit={() => {}}
|
||||
setPromptDockRef={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/PromptInput",
|
||||
id: "app-prompt-input",
|
||||
@@ -200,12 +115,3 @@ export const Basic = {
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DockAlreadyOpen = {
|
||||
render: () => (
|
||||
<div class="pt-10">
|
||||
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||
<PromptInputWithOpenDock />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@@ -347,10 +346,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
|
||||
const [store, setStore] = createPromptInputTransientState(
|
||||
() => prompt.capture(),
|
||||
Math.floor(Math.random() * EXAMPLES.length),
|
||||
)
|
||||
const [store, setStore] = createStore<{
|
||||
popover: "at" | "slash" | null
|
||||
historyIndex: number
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
placeholder: number
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
variantOpen: boolean
|
||||
}>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null as PromptHistoryEntry | null,
|
||||
placeholder: Math.floor(Math.random() * EXAMPLES.length),
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
const [picker, setPicker] = createStore({
|
||||
projectOpen: false,
|
||||
projectSearch: "",
|
||||
@@ -2196,9 +2210,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
@@ -2227,9 +2239,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
|
||||
@@ -25,19 +25,6 @@ function dataUrl(file: File, mime: string) {
|
||||
})
|
||||
}
|
||||
|
||||
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
||||
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
||||
|
||||
type PromptAttachmentsCoreInput = {
|
||||
capture: () => PromptTarget
|
||||
editor: () => HTMLDivElement | undefined
|
||||
focusEditor?: () => void
|
||||
addPart?: (part: ContentPart) => boolean
|
||||
warn?: () => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
@@ -49,22 +36,27 @@ type PromptAttachmentsInput = {
|
||||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
const capture = (): AttachmentTarget | undefined => {
|
||||
const prompt = input.capture()
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const prompt = input.prompt
|
||||
const language = useLanguage()
|
||||
|
||||
const warn = () => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
})
|
||||
}
|
||||
|
||||
const add = async (file: File, toast = true, target = capture()) => {
|
||||
if (!target) return false
|
||||
const add = async (file: File, toast = true) => {
|
||||
const mime = await attachmentMime(file)
|
||||
if (!mime) {
|
||||
if (toast) input.warn?.()
|
||||
if (toast) warn()
|
||||
return false
|
||||
}
|
||||
|
||||
const editor = input.editor()
|
||||
if (!editor) return false
|
||||
|
||||
const url = await dataUrl(file, mime)
|
||||
if (!url) return false
|
||||
|
||||
@@ -76,42 +68,34 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
mime,
|
||||
dataUrl: url,
|
||||
}
|
||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||
const cursor = prompt.cursor() ?? getCursorPosition(editor)
|
||||
prompt.set([...prompt.current(), attachment], cursor)
|
||||
return true
|
||||
}
|
||||
|
||||
const addAttachment = (file: File) => add(file)
|
||||
|
||||
const addAttachments = async (files: File[], toast = true, target = capture()) => {
|
||||
const addAttachments = async (files: File[], toast = true) => {
|
||||
let found = false
|
||||
|
||||
for (const file of files) {
|
||||
const ok = await add(file, false, target)
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
|
||||
if (!found && files.length > 0 && toast) input.warn?.()
|
||||
if (!found && files.length > 0 && toast) warn()
|
||||
return found
|
||||
}
|
||||
|
||||
const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
|
||||
const file = await pending
|
||||
if (!file) return false
|
||||
return add(file, true, target)
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const target = input.capture()
|
||||
const current = target.current()
|
||||
const current = prompt.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
target.set(next, target.cursor())
|
||||
prompt.set(next, prompt.cursor())
|
||||
}
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return
|
||||
const target = capture()
|
||||
if (!target) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -123,7 +107,7 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files, true, target)
|
||||
await addAttachments(files)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,7 +115,11 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
|
||||
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
|
||||
if (input.readClipboardImage && !plainText) {
|
||||
if (await addClipboardAttachment(input.readClipboardImage(), target)) return
|
||||
const file = await input.readClipboardImage()
|
||||
if (file) {
|
||||
await addAttachment(file)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!plainText) return
|
||||
@@ -139,9 +127,9 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
const text = normalizePaste(plainText)
|
||||
|
||||
const put = () => {
|
||||
if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||
input.focusEditor?.()
|
||||
return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
|
||||
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||
input.focusEditor()
|
||||
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
if (pasteMode(text) === "manual") {
|
||||
@@ -155,28 +143,6 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
put()
|
||||
}
|
||||
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
addClipboardAttachment,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const language = useLanguage()
|
||||
const attachments = createPromptAttachmentsCore({
|
||||
...input,
|
||||
capture: input.prompt.capture,
|
||||
warn: () => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleGlobalDragOver = (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
|
||||
@@ -215,7 +181,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
await attachments.addAttachments(Array.from(dropped))
|
||||
await addAttachments(Array.from(dropped))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -224,5 +190,10 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
makeEventListener(document, "drop", handleGlobalDrop)
|
||||
})
|
||||
|
||||
return attachments
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
|
||||
type PromptTarget = ReturnType<ReturnType<typeof usePrompt>["capture"]>
|
||||
|
||||
export function createPromptSubmissionState(input: {
|
||||
target: PromptTarget
|
||||
prompt: Prompt
|
||||
context: (ContextItem & { key: string })[]
|
||||
}) {
|
||||
let target = input.target
|
||||
let cleared: Prompt | undefined
|
||||
|
||||
return {
|
||||
prompt: input.prompt,
|
||||
context: input.context,
|
||||
target: () => target,
|
||||
clear() {
|
||||
target.reset()
|
||||
cleared = target.current()
|
||||
},
|
||||
retarget(next: PromptTarget) {
|
||||
input.context.forEach(next.context.add)
|
||||
target = next
|
||||
},
|
||||
current: (value: PromptTarget) => target === value,
|
||||
restore() {
|
||||
if (cleared !== undefined && target.current() !== cleared) return
|
||||
return { target, prompt: input.prompt, context: input.context }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,8 @@ const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
|
||||
@@ -43,7 +41,6 @@ const prompt = {
|
||||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
capture: () => prompt,
|
||||
}
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
@@ -81,7 +78,7 @@ beforeAll(async () => {
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => params,
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [search, () => undefined],
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
@@ -131,10 +128,7 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
draft: () => ({ server: "project-server" }),
|
||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||
promotedDrafts.push({ draftID, ...session })
|
||||
},
|
||||
promoteDraft: () => undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -239,9 +233,7 @@ beforeEach(() => {
|
||||
optimistic.length = 0
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
promotedDrafts.length = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
@@ -316,33 +308,6 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("promotes drafts using the selected project's server", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
||||
})
|
||||
|
||||
test("includes the selected variant on optimistic prompts", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
||||
@@ -4,6 +4,8 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -19,7 +21,6 @@ import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -193,6 +194,15 @@ type PromptSubmitInput = {
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
type CommentItem = {
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
@@ -205,6 +215,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
|
||||
@@ -240,12 +251,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const restoreCommentItems = (
|
||||
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
|
||||
items: (ContextItem & { key: string })[],
|
||||
) => {
|
||||
const restoreCommentItems = (items: CommentItem[]) => {
|
||||
for (const item of items) {
|
||||
target.context.add({
|
||||
prompt.context.add({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
@@ -257,9 +265,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
|
||||
for (const item of target.context.items()) {
|
||||
target.context.remove(item.key)
|
||||
const removeCommentItems = (items: { key: string }[]) => {
|
||||
for (const item of items) {
|
||||
prompt.context.remove(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = () => {
|
||||
for (const item of prompt.context.items()) {
|
||||
prompt.context.remove(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,14 +295,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
const target = prompt.capture()
|
||||
const submission = createPromptSubmissionState({
|
||||
target,
|
||||
prompt: target.current(),
|
||||
context: target.context.items().slice(),
|
||||
})
|
||||
const currentPrompt = submission.prompt
|
||||
const context = submission.context
|
||||
const currentPrompt = prompt.current()
|
||||
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
const images = input.imageAttachments().slice()
|
||||
const mode = input.mode()
|
||||
@@ -378,9 +385,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
@@ -396,6 +402,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const context = prompt.context.items().slice()
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
@@ -407,16 +414,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
prompt.reset()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
prompt.set(currentPrompt, input.promptLength(currentPrompt))
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
@@ -426,12 +430,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearContext()
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
@@ -501,7 +504,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
@@ -518,7 +521,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
restoreCommentItems(commentItems)
|
||||
restoreInput()
|
||||
}
|
||||
|
||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||
@@ -580,7 +584,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
restoreCommentItems(commentItems)
|
||||
restoreInput()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createComputed, on, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { PromptHistoryEntry } from "./history"
|
||||
|
||||
export type PromptInputTransientState = {
|
||||
popover: "at" | "slash" | null
|
||||
historyIndex: number
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
placeholder: number
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
variantOpen: boolean
|
||||
}
|
||||
|
||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||
setStore({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
|
||||
const [store, setStore] = createStore<PromptInputTransientState>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
placeholder,
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
|
||||
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
||||
|
||||
return [store, setStore] as const
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export const ServerRowMenu: Component<{
|
||||
const isDefault = () => props.controller.defaultKey() === key
|
||||
|
||||
return (
|
||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
variant="ghost-muted"
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ServerHealthIndicator(props: { health?: ServerHealth }) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": props.health?.healthy === true,
|
||||
"bg-icon-critical-base": props.health?.healthy === false,
|
||||
"bg-border-weak-base": props.health === undefined,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
@@ -34,8 +33,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const providers = useProviders()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
@@ -94,8 +93,7 @@ const emptyUserMessages: UserMessage[] = []
|
||||
export function SessionContextTab() {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const providers = useProviders()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
|
||||
@@ -28,9 +28,6 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
@@ -240,7 +237,7 @@ export function SessionHeader() {
|
||||
statusVisible: status(),
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewKeybind: command.keybind("review.toggle"),
|
||||
reviewVisible: isDesktop(),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
@@ -523,15 +520,13 @@ type SessionHeaderV2ActionsState = {
|
||||
statusVisible: boolean
|
||||
statusLabel: string
|
||||
reviewLabel: string
|
||||
reviewKeybind: string[]
|
||||
reviewKeybind: string
|
||||
reviewVisible: boolean
|
||||
reviewOpened: boolean
|
||||
onReviewToggle: () => void
|
||||
}
|
||||
|
||||
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={props.state.statusVisible}>
|
||||
@@ -540,17 +535,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{props.state.reviewLabel}
|
||||
<Show when={props.state.reviewKeybind.length > 0}>
|
||||
<KeybindV2 keys={props.state.reviewKeybind} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
@@ -563,7 +548,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
aria-controls="review-panel"
|
||||
icon={<IconV2 name="sidebar-right" />}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -336,7 +336,7 @@ export const SettingsGeneralV2: Component = () => {
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<Show when={mobile()}>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
|
||||
|
||||
const remote = "remote" as ServerConnection.Key
|
||||
|
||||
describe("titlebar session events", () => {
|
||||
test("reads valid removed session tab details", () => {
|
||||
expect(
|
||||
readSessionTabsRemovedDetail(
|
||||
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
|
||||
detail: { server: "remote", directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
|
||||
detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
server: remote,
|
||||
directory: "/tmp/project",
|
||||
sessionIDs: ["ses_1", "ses_2"],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
|
||||
|
||||
export type SessionTabsRemovedDetail = {
|
||||
server?: ServerConnection.Key
|
||||
directory: string
|
||||
sessionIDs: string[]
|
||||
}
|
||||
@@ -21,14 +18,11 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
|
||||
if (!("sessionIDs" in detail)) return undefined
|
||||
if (typeof detail.directory !== "string") return undefined
|
||||
if (!Array.isArray(detail.sessionIDs)) return undefined
|
||||
if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
|
||||
|
||||
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
|
||||
if (sessionIDs.length === 0) return undefined
|
||||
|
||||
return {
|
||||
server:
|
||||
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
|
||||
directory: detail.directory,
|
||||
sessionIDs,
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
|
||||
import {
|
||||
canOpenTabRename,
|
||||
captureTabPointerDown,
|
||||
canStartTabDrag,
|
||||
createTabDragPreview,
|
||||
forwardTabRef,
|
||||
isPrimaryPointerPressed,
|
||||
isTabCloseTarget,
|
||||
} from "./titlebar-tab-gesture"
|
||||
|
||||
describe("titlebar tab drag", () => {
|
||||
const layout = {
|
||||
listLeft: 100,
|
||||
dividerWidth: 13,
|
||||
tabWidthById: new Map([
|
||||
["a", 40],
|
||||
["b", 40],
|
||||
["c", 40],
|
||||
["d", 40],
|
||||
]),
|
||||
}
|
||||
|
||||
test("moves across multiple tabs from one pointer update", () => {
|
||||
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
|
||||
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
|
||||
})
|
||||
|
||||
test("keeps the current index inside the left hysteresis deadband", () => {
|
||||
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
|
||||
})
|
||||
|
||||
test("includes slot margins in captured divider width", () => {
|
||||
const list = document.createElement("div")
|
||||
const first = document.createElement("div")
|
||||
const second = document.createElement("div")
|
||||
const firstTab = document.createElement("div")
|
||||
const secondTab = document.createElement("div")
|
||||
first.dataset.titlebarTabSlot = ""
|
||||
first.dataset.tabKey = "a"
|
||||
second.dataset.titlebarTabSlot = ""
|
||||
second.dataset.tabKey = "b"
|
||||
second.style.marginLeft = "6px"
|
||||
firstTab.dataset.titlebarTab = ""
|
||||
secondTab.dataset.titlebarTab = ""
|
||||
first.append(firstTab)
|
||||
second.append(secondTab)
|
||||
list.append(first, second)
|
||||
document.body.append(list)
|
||||
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
||||
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
||||
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
|
||||
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
|
||||
|
||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
|
||||
list.remove()
|
||||
})
|
||||
|
||||
test("uses the list gap as the divider width", () => {
|
||||
const list = document.createElement("div")
|
||||
const first = document.createElement("div")
|
||||
const second = document.createElement("div")
|
||||
const firstTab = document.createElement("div")
|
||||
const secondTab = document.createElement("div")
|
||||
first.dataset.titlebarTabSlot = ""
|
||||
first.dataset.tabKey = "a"
|
||||
second.dataset.titlebarTabSlot = ""
|
||||
second.dataset.tabKey = "b"
|
||||
firstTab.dataset.titlebarTab = ""
|
||||
secondTab.dataset.titlebarTab = ""
|
||||
first.append(firstTab)
|
||||
second.append(secondTab)
|
||||
list.append(first, second)
|
||||
list.style.columnGap = "13.5px"
|
||||
document.body.append(list)
|
||||
|
||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
|
||||
list.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe("titlebar tab gestures", () => {
|
||||
test("excludes close controls from tab gestures", () => {
|
||||
const close = document.createElement("div")
|
||||
const button = document.createElement("button")
|
||||
const link = document.createElement("a")
|
||||
close.dataset.slot = "tab-close"
|
||||
close.append(button)
|
||||
expect(isTabCloseTarget(close)).toBe(true)
|
||||
expect(isTabCloseTarget(button)).toBe(true)
|
||||
expect(isTabCloseTarget(link)).toBe(false)
|
||||
})
|
||||
|
||||
test("forwards component refs", () => {
|
||||
const element = document.createElement("div")
|
||||
let received: HTMLDivElement | undefined
|
||||
forwardTabRef((value) => (received = value), element)
|
||||
expect(received).toBe(element)
|
||||
})
|
||||
|
||||
test("does not reopen rename while a save is pending", () => {
|
||||
expect(canOpenTabRename(false, false, false)).toBe(true)
|
||||
expect(canOpenTabRename(false, false, true)).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps the rendered tab content in the drag preview", () => {
|
||||
const tab = document.createElement("div")
|
||||
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
|
||||
const preview = createTabDragPreview(tab)
|
||||
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
|
||||
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
|
||||
})
|
||||
|
||||
test("captures the grab offset before navigation scrolls the tab", () => {
|
||||
const tab = document.createElement("div")
|
||||
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
|
||||
|
||||
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
|
||||
startX: 100,
|
||||
startY: 20,
|
||||
grabOffsetX: 20,
|
||||
grabOffsetY: 10,
|
||||
width: 120,
|
||||
element: tab,
|
||||
})
|
||||
})
|
||||
|
||||
test("detects when the primary pointer button was released outside the window", () => {
|
||||
expect(isPrimaryPointerPressed(1)).toBe(true)
|
||||
expect(isPrimaryPointerPressed(3)).toBe(true)
|
||||
expect(isPrimaryPointerPressed(0)).toBe(false)
|
||||
expect(isPrimaryPointerPressed(2)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves native panning for touch pointers", () => {
|
||||
expect(canStartTabDrag("mouse")).toBe(true)
|
||||
expect(canStartTabDrag("pen")).toBe(true)
|
||||
expect(canStartTabDrag("touch")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
export type TabDragLayout = {
|
||||
tabWidthById: Map<string, number>
|
||||
dividerWidth: number
|
||||
listLeft: number
|
||||
}
|
||||
|
||||
export const ACTIVATION_DISTANCE = 4
|
||||
export const HYSTERESIS_DEADBAND = 8
|
||||
export const AUTOSCROLL_EDGE = 24
|
||||
export const AUTOSCROLL_MAX_SPEED = 8
|
||||
export const FLOATER_OVERSHOOT_MAX = 8
|
||||
|
||||
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
|
||||
const tabWidthById = new Map<string, number>()
|
||||
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
|
||||
for (const slot of slots) {
|
||||
const id = slot.dataset.tabKey
|
||||
if (!id) continue
|
||||
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||
if (!tab) continue
|
||||
tabWidthById.set(id, tab.getBoundingClientRect().width)
|
||||
}
|
||||
|
||||
let dividerWidth = 0
|
||||
if (order.length >= 2) {
|
||||
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
|
||||
const secondId = order[1]
|
||||
for (const slot of slots) {
|
||||
if (slot.dataset.tabKey !== secondId) continue
|
||||
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||
if (!tab) break
|
||||
const style = getComputedStyle(slot)
|
||||
dividerWidth =
|
||||
gap ||
|
||||
slot.getBoundingClientRect().width -
|
||||
tab.getBoundingClientRect().width +
|
||||
(Number.parseFloat(style.marginLeft) || 0) +
|
||||
(Number.parseFloat(style.marginRight) || 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabWidthById,
|
||||
dividerWidth,
|
||||
listLeft: list.getBoundingClientRect().left,
|
||||
}
|
||||
}
|
||||
|
||||
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
|
||||
layout.listLeft = list.getBoundingClientRect().left
|
||||
}
|
||||
|
||||
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
|
||||
const id = order[index]
|
||||
if (!id) return 0
|
||||
const tabWidth = layout.tabWidthById.get(id) ?? 0
|
||||
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
|
||||
}
|
||||
|
||||
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
|
||||
let left = layout.listLeft
|
||||
for (let i = 0; i < index; i++) {
|
||||
left += slotWidthAt(order, i, layout)
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
export function insertIndexFromVirtualLayout(
|
||||
pointerX: number,
|
||||
order: readonly string[],
|
||||
draggedId: string,
|
||||
currentIndex: number,
|
||||
layout: TabDragLayout,
|
||||
deadband = HYSTERESIS_DEADBAND,
|
||||
) {
|
||||
if (order.length === 0) return 0
|
||||
|
||||
const others = order.filter((id) => id !== draggedId)
|
||||
let target = currentIndex
|
||||
|
||||
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
|
||||
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
|
||||
const fromIndex = order.indexOf(draggedId)
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
|
||||
const next = [...order]
|
||||
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
|
||||
return next
|
||||
}
|
||||
|
||||
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
|
||||
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
|
||||
return final.some((key, index) => key !== initial[index])
|
||||
}
|
||||
|
||||
function easeOvershoot(overshoot: number) {
|
||||
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
|
||||
}
|
||||
|
||||
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
|
||||
const stripWidth = stripRight - stripLeft
|
||||
if (width >= stripWidth) return stripLeft
|
||||
|
||||
const maxLeft = stripRight - width
|
||||
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
|
||||
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
|
||||
|
||||
return left
|
||||
}
|
||||
|
||||
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
|
||||
const leftEdge = containerLeft + AUTOSCROLL_EDGE
|
||||
const rightEdge = containerRight - AUTOSCROLL_EDGE
|
||||
|
||||
if (pointerX < leftEdge) {
|
||||
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
|
||||
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
||||
}
|
||||
|
||||
if (pointerX > rightEdge) {
|
||||
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
|
||||
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Ref } from "solid-js"
|
||||
|
||||
export function isTabCloseTarget(target: EventTarget | null) {
|
||||
return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
|
||||
}
|
||||
|
||||
export function canStartTabDrag(pointerType: string) {
|
||||
return pointerType !== "touch"
|
||||
}
|
||||
|
||||
export function isPrimaryPointerPressed(buttons: number) {
|
||||
return (buttons & 1) !== 0
|
||||
}
|
||||
|
||||
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return {
|
||||
startX: clientX,
|
||||
startY: clientY,
|
||||
grabOffsetX: clientX - rect.left,
|
||||
grabOffsetY: clientY - rect.top,
|
||||
width: rect.width,
|
||||
element,
|
||||
}
|
||||
}
|
||||
|
||||
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
||||
if (typeof ref === "function") ref(element)
|
||||
}
|
||||
|
||||
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
||||
return !dragging && !editing && !committing
|
||||
}
|
||||
|
||||
export function createTabDragPreview(element: HTMLDivElement) {
|
||||
return element.cloneNode(true) as HTMLDivElement
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
[data-titlebar-tab] [data-slot="tab-close"] {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-list] {
|
||||
gap: 13.5px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot]:not(:first-child)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: -6.75px;
|
||||
width: 1.5px;
|
||||
height: 12px;
|
||||
border-radius: 9999px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 28px;
|
||||
width: 16px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, transparent, var(--tab-bg));
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
|
||||
display: block;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
|
||||
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
|
||||
[data-slot="tab-close"]::before {
|
||||
right: 100%;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
||||
[data-slot="tab-link"] {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-title"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container (max-width: 64px) {
|
||||
[data-titlebar-tab-link] {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-title] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="tab-close"] {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import "./titlebar-tab-nav.css"
|
||||
|
||||
export function TabNavItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => Session | undefined
|
||||
onTitleChange?: (title: string) => void
|
||||
onTitleChangeFailed?: (title: string) => void
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
activeServer: boolean
|
||||
forceTruncate?: boolean
|
||||
suppressNavigation?: () => boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
tabKey: string
|
||||
dragActive: boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [editing, setEditing] = createSignal(false)
|
||||
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
||||
let tabRoot!: HTMLDivElement
|
||||
let titleEl!: HTMLSpanElement
|
||||
let committing = false
|
||||
let measureFrame: number | undefined
|
||||
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
const project = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
})
|
||||
|
||||
const measureTitleOverflow = () => {
|
||||
if (!titleEl || editing()) {
|
||||
setTitleOverflowing(false)
|
||||
return
|
||||
}
|
||||
setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth)
|
||||
}
|
||||
|
||||
const scheduleTitleOverflow = () => {
|
||||
if (measureFrame !== undefined) return
|
||||
measureFrame = requestAnimationFrame(() => {
|
||||
measureFrame = undefined
|
||||
measureTitleOverflow()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.session()?.title
|
||||
props.forceTruncate
|
||||
editing()
|
||||
scheduleTitleOverflow()
|
||||
})
|
||||
|
||||
createResizeObserver(() => tabRoot, scheduleTitleOverflow)
|
||||
onCleanup(() => {
|
||||
if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
|
||||
})
|
||||
|
||||
const selectTitle = () => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(titleEl)
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
const rename = async (title: string) => {
|
||||
const ctx = serverCtx()
|
||||
const session = props.session()
|
||||
if (!ctx || !session) return
|
||||
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
|
||||
await client.session.update({ sessionID: session.id, title })
|
||||
}
|
||||
|
||||
const closeRename = async (save: boolean) => {
|
||||
if (committing || !editing()) return
|
||||
committing = true
|
||||
|
||||
const original = props.session()?.title ?? ""
|
||||
const next = (titleEl.textContent ?? "").trim()
|
||||
|
||||
titleEl.scrollLeft = 0
|
||||
if (save && next && next !== original) props.onTitleChange?.(next)
|
||||
setEditing(false)
|
||||
|
||||
if (!save || !next || next === original) {
|
||||
committing = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await rename(next)
|
||||
} catch (err) {
|
||||
props.onTitleChangeFailed?.(original)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
committing = false
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (editing()) return
|
||||
if (!titleEl) return
|
||||
const title = props.session()?.title
|
||||
if (title === undefined) return
|
||||
titleEl.textContent = title
|
||||
})
|
||||
|
||||
const openRename = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canOpenTabRename(props.dragging, editing(), committing)) return
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
titleEl.textContent = session.title
|
||||
setEditing(true)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
titleEl.focus()
|
||||
selectTitle()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!editing()) return
|
||||
|
||||
const cleanup = makeEventListener(
|
||||
document,
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (tabRoot.contains(target)) return
|
||||
void closeRename(true)
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.tabKey}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
||||
onPointerDown={props.onPointerDown}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
forwardTabRef(props.ref, el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-title-overflow={titleOverflowing()}
|
||||
data-editing={editing()}
|
||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DraftTabItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
title: string
|
||||
active?: boolean
|
||||
onNavigate: () => void
|
||||
onClose: () => void
|
||||
suppressNavigation?: () => boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
tabKey: string
|
||||
dragActive: boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.tabKey}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
||||
onPointerDown={props.onPointerDown}
|
||||
>
|
||||
<div
|
||||
ref={(el) => forwardTabRef(props.ref, el)}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
</a>
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
||||
import { useGlobal, type ServerCtx } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { createTabPromptState } from "@/context/prompt"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
captureTabPointerDown,
|
||||
canStartTabDrag,
|
||||
createTabDragPreview,
|
||||
isPrimaryPointerPressed,
|
||||
isTabCloseTarget,
|
||||
} from "./titlebar-tab-gesture"
|
||||
import {
|
||||
ACTIVATION_DISTANCE,
|
||||
autoscrollSpeed,
|
||||
captureTabDragLayout,
|
||||
clampFloaterLeft,
|
||||
draftOrderChanged,
|
||||
insertIndexFromVirtualLayout,
|
||||
movePlaceholder,
|
||||
pointerDistance,
|
||||
syncLayoutScroll,
|
||||
type TabDragLayout,
|
||||
} from "@/components/titlebar-tab-drag"
|
||||
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
active: () => boolean
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
dragActive: boolean
|
||||
dragged: () => boolean
|
||||
pressed: () => boolean
|
||||
serverCtx: () => ServerCtx | undefined
|
||||
suppressNavigation: () => boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
let ref!: HTMLDivElement
|
||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
const ctx = props.serverCtx()
|
||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||
},
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
let prefetched = false
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = props.serverCtx()
|
||||
const value = session()
|
||||
if (!ctx || !value || prefetched) return
|
||||
prefetched = true
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync
|
||||
.ensureDirSyncContext(value.directory)
|
||||
.session.sync(value.id)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const value = session()
|
||||
const current = sdk()
|
||||
if (!value || !current) return
|
||||
createTabPromptState(tabs, props.tab, current.scope, {
|
||||
dir: base64Encode(value.directory),
|
||||
id: value.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<TabNavItem
|
||||
tabKey={props.id}
|
||||
dragActive={props.dragActive}
|
||||
onPointerDown={props.onPointerDown}
|
||||
ref={ref}
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onTitleChangeFailed={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
activeServer={props.tab.server === props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
suppressNavigation={props.suppressNavigation}
|
||||
pressed={props.pressed()}
|
||||
hidden={props.dragged() || !session()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitlebarTabStrip(props: {
|
||||
tabs: Tab[]
|
||||
currentTab: () => Tab | undefined
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||
onClose: (tab: Tab) => void
|
||||
onReorder: (keys: string[]) => void
|
||||
onOverflowChange: (overflowing: boolean) => void
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const [drag, setDrag] = createStore({
|
||||
active: false,
|
||||
draggedId: undefined as string | undefined,
|
||||
placeholderIndex: 0,
|
||||
draftOrder: [] as string[],
|
||||
initialOrder: [] as string[],
|
||||
draggedWidth: 0,
|
||||
pointerX: 0,
|
||||
grabOffsetX: 0,
|
||||
floaterTop: 0,
|
||||
})
|
||||
|
||||
const [gesture, setGesture] = createStore({
|
||||
pending: undefined as
|
||||
| {
|
||||
id: string
|
||||
startX: number
|
||||
startY: number
|
||||
grabOffsetX: number
|
||||
grabOffsetY: number
|
||||
pointerId: number
|
||||
width: number
|
||||
element: HTMLDivElement
|
||||
}
|
||||
| undefined,
|
||||
})
|
||||
|
||||
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
|
||||
const [pressedId, setPressedId] = createSignal<string | undefined>()
|
||||
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
|
||||
let scrollRef!: HTMLDivElement
|
||||
let listRef!: HTMLDivElement
|
||||
let dragLayout: TabDragLayout | undefined
|
||||
let dragPointerId: number | undefined
|
||||
let autoscrollFrame: number | undefined
|
||||
let resizeFrame: number | undefined
|
||||
let dragPreview: HTMLDivElement | undefined
|
||||
|
||||
const tabIds = () => props.tabs.map(tabKey)
|
||||
|
||||
const displayTabs = createMemo(() => {
|
||||
if (!drag.active || drag.draftOrder.length === 0) return props.tabs
|
||||
const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
|
||||
return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
|
||||
})
|
||||
|
||||
function refreshOverflow() {
|
||||
if (!scrollRef) return
|
||||
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
|
||||
}
|
||||
|
||||
createResizeObserver(
|
||||
() => [scrollRef, listRef],
|
||||
() => {
|
||||
if (resizeFrame !== undefined) return
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = undefined
|
||||
refreshOverflow()
|
||||
if (!drag.active || !listRef) return
|
||||
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
|
||||
updateInsertIndex()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
function syncScroll() {
|
||||
if (!scrollRef || !listRef || !dragLayout) return
|
||||
syncLayoutScroll(listRef, dragLayout)
|
||||
setStripScrollLeft(scrollRef.scrollLeft)
|
||||
updateInsertIndex()
|
||||
}
|
||||
|
||||
function stopAutoscroll() {
|
||||
if (autoscrollFrame === undefined) return
|
||||
cancelAnimationFrame(autoscrollFrame)
|
||||
autoscrollFrame = undefined
|
||||
}
|
||||
|
||||
function tickAutoscroll() {
|
||||
if (!drag.active || !scrollRef) return
|
||||
|
||||
const strip = scrollRef.getBoundingClientRect()
|
||||
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
|
||||
|
||||
if (speed !== 0) {
|
||||
scrollRef.scrollLeft += speed
|
||||
syncScroll()
|
||||
}
|
||||
|
||||
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
||||
}
|
||||
|
||||
function startAutoscroll() {
|
||||
stopAutoscroll()
|
||||
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
||||
}
|
||||
|
||||
function applyPlaceholderIndex(nextIndex: number) {
|
||||
const id = drag.draggedId
|
||||
if (!id) return
|
||||
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
|
||||
setDrag({
|
||||
draftOrder: next,
|
||||
placeholderIndex: nextIndex,
|
||||
})
|
||||
}
|
||||
|
||||
function updateInsertIndex() {
|
||||
if (!drag.active || !dragLayout) return
|
||||
const draggedId = drag.draggedId
|
||||
if (!draggedId) return
|
||||
const nextIndex = insertIndexFromVirtualLayout(
|
||||
drag.pointerX,
|
||||
drag.draftOrder,
|
||||
draggedId,
|
||||
drag.placeholderIndex,
|
||||
dragLayout,
|
||||
)
|
||||
if (nextIndex === drag.placeholderIndex) return
|
||||
applyPlaceholderIndex(nextIndex)
|
||||
}
|
||||
|
||||
function startDrag(id: string) {
|
||||
const order = tabIds()
|
||||
const index = order.indexOf(id)
|
||||
const pending = gesture.pending
|
||||
if (index === -1 || !pending || !listRef || !scrollRef) return
|
||||
|
||||
dragLayout = captureTabDragLayout(listRef, order)
|
||||
dragPreview = createTabDragPreview(pending.element)
|
||||
dragPointerId = pending.pointerId
|
||||
setGesture("pending", undefined)
|
||||
|
||||
setDrag({
|
||||
active: true,
|
||||
draggedId: id,
|
||||
placeholderIndex: index,
|
||||
draftOrder: order,
|
||||
initialOrder: order,
|
||||
draggedWidth: pending.width,
|
||||
pointerX: pending.startX,
|
||||
grabOffsetX: pending.grabOffsetX,
|
||||
floaterTop: pending.startY - pending.grabOffsetY,
|
||||
})
|
||||
setPressedId(undefined)
|
||||
setStripScrollLeft(scrollRef.scrollLeft)
|
||||
startAutoscroll()
|
||||
}
|
||||
|
||||
function endDrag(commit: boolean) {
|
||||
const initial = drag.initialOrder
|
||||
const final = drag.draftOrder
|
||||
const moved = drag.active
|
||||
|
||||
if (commit && moved && draftOrderChanged(initial, final)) {
|
||||
props.onReorder(final)
|
||||
}
|
||||
|
||||
if (moved) setSuppressNavigation(true)
|
||||
|
||||
setDrag({
|
||||
active: false,
|
||||
draggedId: undefined,
|
||||
placeholderIndex: 0,
|
||||
draftOrder: [],
|
||||
initialOrder: [],
|
||||
draggedWidth: 0,
|
||||
pointerX: 0,
|
||||
grabOffsetX: 0,
|
||||
floaterTop: 0,
|
||||
})
|
||||
|
||||
dragLayout = undefined
|
||||
dragPreview = undefined
|
||||
dragPointerId = undefined
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
stopAutoscroll()
|
||||
refreshOverflow()
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
|
||||
function onPointerDown(id: string, event: PointerEvent) {
|
||||
if (event.button !== 0 || drag.active) return
|
||||
if (!canStartTabDrag(event.pointerType)) return
|
||||
if (isTabCloseTarget(event.target)) return
|
||||
const target = event.currentTarget as HTMLDivElement
|
||||
const tabEl = target.matches("[data-titlebar-tab]")
|
||||
? target
|
||||
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||
if (!tabEl) return
|
||||
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
|
||||
const tab = props.tabs.find((item) => tabKey(item) === id)
|
||||
if (!tab) return
|
||||
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
|
||||
setSuppressNavigation(true)
|
||||
props.onNavigate(tab, tabEl)
|
||||
setPressedId(id)
|
||||
setGesture("pending", {
|
||||
id,
|
||||
pointerId: event.pointerId,
|
||||
...pointer,
|
||||
})
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
const pending = gesture.pending
|
||||
if (pending && event.pointerId !== pending.pointerId) return
|
||||
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||
if (!isPrimaryPointerPressed(event.buttons)) {
|
||||
if (drag.active) endDrag(true)
|
||||
if (pending) {
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pending && !drag.active) {
|
||||
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
|
||||
startDrag(pending.id)
|
||||
}
|
||||
|
||||
if (!drag.active) return
|
||||
|
||||
setDrag("pointerX", event.clientX)
|
||||
syncScroll()
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
if (drag.active) {
|
||||
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||
setDrag("pointerX", event.clientX)
|
||||
syncScroll()
|
||||
endDrag(true)
|
||||
return
|
||||
}
|
||||
|
||||
const pending = gesture.pending
|
||||
if (pending && event.pointerId !== pending.pointerId) return
|
||||
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
|
||||
function onPointerCancel(event: PointerEvent) {
|
||||
if (drag.active) {
|
||||
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||
endDrag(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!gesture.pending) return
|
||||
if (gesture.pending.pointerId !== event.pointerId) return
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const cleanups = [
|
||||
makeEventListener(window, "pointermove", onPointerMove),
|
||||
makeEventListener(window, "pointerup", onPointerUp),
|
||||
makeEventListener(window, "pointercancel", onPointerCancel),
|
||||
]
|
||||
refreshOverflow()
|
||||
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
|
||||
})
|
||||
|
||||
onCleanup(stopAutoscroll)
|
||||
onCleanup(() => {
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.tabs.length
|
||||
tabIds()
|
||||
refreshOverflow()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!drag.active || !scrollRef) return
|
||||
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
|
||||
})
|
||||
|
||||
const floaterStyle = () => {
|
||||
stripScrollLeft()
|
||||
const strip = scrollRef?.getBoundingClientRect()
|
||||
const left = strip
|
||||
? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
|
||||
: drag.pointerX - drag.grabOffsetX
|
||||
|
||||
return {
|
||||
position: "fixed" as const,
|
||||
top: `${drag.floaterTop}px`,
|
||||
left: `${left}px`,
|
||||
width: `${drag.draggedWidth}px`,
|
||||
"z-index": "10000",
|
||||
"pointer-events": "none" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const draggedTab = createMemo(() => {
|
||||
const id = drag.draggedId
|
||||
if (!id) return
|
||||
return props.tabs.find((tab) => tabKey(tab) === id)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||
<div
|
||||
data-slot="titlebar-tabs-scroll"
|
||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
|
||||
<For each={displayTabs()}>
|
||||
{(tab, index) => {
|
||||
const id = tabKey(tab)
|
||||
let ref!: HTMLDivElement
|
||||
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
||||
|
||||
const dragged = () => drag.active && drag.draggedId === id
|
||||
const serverCtx = createMemo(() => {
|
||||
if (tab.type !== "session") return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
|
||||
if (tab.type === "session") {
|
||||
return (
|
||||
<SessionTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
active={() => props.currentTab() === tab}
|
||||
activeServerKey={props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragActive={drag.active}
|
||||
dragged={dragged}
|
||||
pressed={() => pressedId() === id}
|
||||
serverCtx={serverCtx}
|
||||
suppressNavigation={() => suppressNavigation()}
|
||||
onPointerDown={(event) => {
|
||||
if (dragged()) return
|
||||
onPointerDown(id, event)
|
||||
}}
|
||||
onNavigate={(element) => props.onNavigate(tab, element)}
|
||||
onClose={() => props.onClose(tab)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DraftTabItem
|
||||
tabKey={id}
|
||||
dragActive={drag.active}
|
||||
onPointerDown={(event) => {
|
||||
if (dragged()) return
|
||||
onPointerDown(id, event)
|
||||
}}
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
title={language.t("command.session.new")}
|
||||
onNavigate={() => props.onNavigate(tab, ref)}
|
||||
onClose={() => props.onClose(tab)}
|
||||
suppressNavigation={() => suppressNavigation()}
|
||||
active={props.currentTab() === tab}
|
||||
pressed={pressedId() === id}
|
||||
hidden={dragged()}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-left"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-right"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
</div>
|
||||
<Show when={drag.active && draggedTab() && dragPreview}>
|
||||
{(_) => (
|
||||
<Portal>
|
||||
<div data-titlebar-tab-preview style={floaterStyle()}>
|
||||
{dragPreview}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function useTabShortcut(index: () => number, onSelect: () => void) {
|
||||
const command = useCommand()
|
||||
|
||||
command.register(() => {
|
||||
const number = index() + 1
|
||||
if (number > 9) return []
|
||||
return [
|
||||
{
|
||||
id: `tab.${number}`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+${number}`,
|
||||
hidden: true,
|
||||
onSelect,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -1,34 +1,3 @@
|
||||
[data-slot="titlebar-tab-item"] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"] a {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-v2"]
|
||||
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:focus-visible, [data-state="focus"]):not(
|
||||
:disabled
|
||||
) {
|
||||
outline: none;
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"]
|
||||
[data-component="icon-button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"]:has([data-component="icon-button-v2"]:focus-visible) [data-slot="titlebar-tab-close"] {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"]:has([data-component="icon-button-v2"]:focus-visible)
|
||||
[data-slot="titlebar-tab-close-fade"] {
|
||||
background-image: var(--active-bg);
|
||||
}
|
||||
|
||||
@keyframes titlebar-tab-fade-left {
|
||||
from {
|
||||
visibility: hidden;
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -18,15 +30,19 @@ import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { WindowsAppMenu } from "./windows-app-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
|
||||
import { projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabKey, useTabs } from "@/context/tabs"
|
||||
import { tabHref, useTabs } from "@/context/tabs"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||
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>
|
||||
@@ -218,7 +234,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
return (
|
||||
<header
|
||||
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
|
||||
classList={{
|
||||
"shrink-0 relative flex flex-row": true,
|
||||
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
|
||||
@@ -241,6 +256,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<Switch>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const global = useGlobal()
|
||||
|
||||
@@ -372,7 +388,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
id: `tab.prev`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
||||
keybind: `mod+option+ArrowLeft`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
@@ -389,7 +405,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
id: `tab.next`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
||||
keybind: `mod+option+ArrowRight`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
@@ -406,6 +422,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
})
|
||||
|
||||
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
|
||||
let tabScrollRef!: HTMLDivElement
|
||||
|
||||
function refreshTabsAreOverflowing() {
|
||||
setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -444,42 +465,171 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
/>
|
||||
</TooltipV2>
|
||||
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab}
|
||||
activeServerKey={server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.removeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Show when={!(creating() && params.dir)}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("command.session.new")}
|
||||
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||
<div
|
||||
data-slot="titlebar-tabs-scroll"
|
||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||
ref={(el) => {
|
||||
tabScrollRef = el
|
||||
createResizeObserver(el, refreshTabsAreOverflowing)
|
||||
}}
|
||||
>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
<div
|
||||
class="flex min-w-0 flex-row items-center gap-1.5"
|
||||
ref={(el) => createResizeObserver(el, refreshTabsAreOverflowing)}
|
||||
>
|
||||
<For each={tabsStore}>
|
||||
{(tab, i) => {
|
||||
let ref!: HTMLDivElement
|
||||
useTabShortcut(i, () => tabs.select(tab))
|
||||
|
||||
const divider = () =>
|
||||
i() !== 0 && (
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
)
|
||||
|
||||
if (tab.type === "draft") {
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<DraftTabItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
title={language.t("command.session.new")}
|
||||
active={currentTab() === tab}
|
||||
onNavigate={() => {
|
||||
tabs.select(tab)
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
|
||||
return conn ? global.ensureServerCtx(conn) : undefined
|
||||
})
|
||||
const sdk = createMemo(() => serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => serverCtx()?.sync.session.peek(tab.sessionId))
|
||||
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
const id = tab.sessionId
|
||||
const ctx = serverCtx()
|
||||
return ctx ? { id, ctx } : null
|
||||
},
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
let prefetched = false
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = serverCtx()
|
||||
const sess = session()
|
||||
if (!ctx || !sess || prefetched) return
|
||||
prefetched = true
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync
|
||||
.ensureDirSyncContext(sess.directory)
|
||||
.session.sync(sess.id)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (tab.type !== "session") return
|
||||
const _sdk = sdk()
|
||||
if (!_sdk) return
|
||||
const sess = session()
|
||||
if (!sess) return
|
||||
createTabPromptState(tabs, tab, _sdk.scope, {
|
||||
dir: base64Encode(sess.directory),
|
||||
id: sess.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<Show when={session()}>
|
||||
{(session) => (
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
server={tab.server}
|
||||
sessionId={tab.sessionId}
|
||||
session={session()}
|
||||
onNavigate={() => {
|
||||
tabs.select(tab)
|
||||
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
active={currentTab() === tab}
|
||||
activeServer={tab.server === server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={creating() && params.dir}>
|
||||
{(_) => {
|
||||
let ref!: HTMLDivElement
|
||||
|
||||
onMount(() => {
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
<NewSessionTabItem
|
||||
ref={ref}
|
||||
href={`/${params.dir}/session`}
|
||||
title={language.t("command.session.new")}
|
||||
onClose={() => {
|
||||
const tab = tabsStore.at(-1)
|
||||
if (tab) tabs.select(tab)
|
||||
else navigate("/")
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-left"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-right"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
</div>
|
||||
<Show when={!(creating() && params.dir)}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</Show>
|
||||
<div class="flex-1" />
|
||||
<TitlebarV2Right state={v2RightState()} />
|
||||
@@ -567,6 +717,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
disabled={layout.sidebar.opened()}
|
||||
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||
@@ -576,9 +727,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
>
|
||||
<IconV2 name="edit" size="small" />
|
||||
</Button>
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
@@ -676,16 +825,16 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
|
||||
|
||||
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
return (
|
||||
<div class="group relative mr-3 h-5 w-5 shrink-0 rounded-full bg-v2-background-bg-deep transition-[width] duration-150 ease-out hover:z-30 hover:w-[68px] focus-within:z-30 focus-within:w-[68px] motion-reduce:transition-none">
|
||||
<div class="relative isolate mr-3 size-5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out group-hover:w-[68px] group-hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] group-focus-within:w-[68px] group-focus-within:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
|
||||
class="group absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out hover:z-30 hover:w-[68px] hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:z-30 focus-visible:w-[68px] focus-visible:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
|
||||
onClick={props.state.onInstall}
|
||||
disabled={props.state.installing}
|
||||
aria-busy={props.state.installing}
|
||||
aria-label={props.state.ariaLabel}
|
||||
>
|
||||
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
|
||||
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-visible:opacity-100 group-focus-visible:translate-x-0 motion-reduce:translate-x-0">
|
||||
Update
|
||||
</span>
|
||||
<span class="flex size-5 shrink-0 items-center justify-center">
|
||||
@@ -703,6 +852,206 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function useTabShortcut(index: () => number, onSelect: () => void) {
|
||||
const command = useCommand()
|
||||
|
||||
command.register(() => {
|
||||
const number = index() + 1
|
||||
if (number > 9) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: `tab.${number}`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+${number}`,
|
||||
hidden: true,
|
||||
onSelect,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function TabNavItem(props: {
|
||||
ref?: HTMLDivElement
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
sessionId?: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
activeServer: boolean
|
||||
forceTruncate?: boolean
|
||||
session: Session
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={props.active}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={props.session}>
|
||||
{(session) => {
|
||||
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
||||
|
||||
return (
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">{session().title}</span>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="absolute not-group-hover:not-group-data-[active=true]:not-data-[truncate=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 data-[truncate=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"
|
||||
data-truncate={props.forceTruncate}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||
style={{
|
||||
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
|
||||
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
|
||||
}}
|
||||
/>
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftTabItem(props: {
|
||||
ref?: HTMLDivElement
|
||||
href: string
|
||||
title: string
|
||||
active?: boolean
|
||||
onNavigate: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
data-active={props.active}
|
||||
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate leading-5">{props.title}</span>
|
||||
</a>
|
||||
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
aria-current="page"
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate leading-5">{props.title}</span>
|
||||
</a>
|
||||
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator() {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -227,11 +227,6 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
// KeybindV2 takes an array instead of a string
|
||||
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
|
||||
return formatKeybindParts(config, t)
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { createMemo } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
@@ -73,10 +73,6 @@ export const createDirSyncContext = (
|
||||
if (match.found) return serverSync.data.project[match.index]
|
||||
},
|
||||
session: {
|
||||
remember(session: Session) {
|
||||
serverSync.session.remember(session)
|
||||
index(session.id)
|
||||
},
|
||||
get(sessionID: string) {
|
||||
const session = serverSync.session.get(sessionID)
|
||||
if (session?.directory === directory) return session
|
||||
|
||||
@@ -60,7 +60,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const providers = useProviders()
|
||||
const models = useModels()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Accessor, createMemo, createResource } from "solid-js"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
@@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
|
||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||
name: "Models",
|
||||
gate: false,
|
||||
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
|
||||
const providers = useProviders(props.directory)
|
||||
init: () => {
|
||||
const providers = useProviders()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model", ["model.v1"]),
|
||||
@@ -145,15 +145,6 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
|
||||
const [recentModels] = createResource(
|
||||
async () => {
|
||||
const recent = store.recent
|
||||
await ready.promise
|
||||
return recent
|
||||
},
|
||||
(p) => p,
|
||||
{ initialValue: [] },
|
||||
)
|
||||
return {
|
||||
ready,
|
||||
list,
|
||||
@@ -161,7 +152,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
visible,
|
||||
setVisibility,
|
||||
recent: {
|
||||
list: () => recentModels()!,
|
||||
list: createMemo(() => store.recent),
|
||||
push,
|
||||
},
|
||||
variant: {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { useServer } from "./server"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { useSettings } from "./settings"
|
||||
|
||||
@@ -169,15 +169,6 @@ type PromptStore = {
|
||||
|
||||
type Scope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
|
||||
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
|
||||
if (!scope.id) return
|
||||
return (
|
||||
tabs.find((tab) => tab.type === "session" && tab.server === server && tab.sessionId === scope.id) ??
|
||||
({ type: "session", server, sessionId: scope.id } satisfies Tab)
|
||||
)
|
||||
}
|
||||
|
||||
function scopeKey(scope: Scope) {
|
||||
if ("draftID" in scope) return `draft:${scope.draftID}`
|
||||
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
||||
@@ -222,7 +213,7 @@ function promptStore(): PromptStore {
|
||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
|
||||
const value = {
|
||||
return {
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
@@ -259,9 +250,7 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
|
||||
},
|
||||
set: actions.set,
|
||||
reset: actions.reset,
|
||||
capture: () => value,
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function createPromptState() {
|
||||
@@ -312,11 +301,21 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const owner = getOwner()
|
||||
const serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key)
|
||||
const scope = () =>
|
||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||
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 = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||
const current = tab()
|
||||
if (current) {
|
||||
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
}
|
||||
@@ -342,13 +341,14 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
return entry.value
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(scope()))
|
||||
const session = createMemo(() =>
|
||||
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
|
||||
)
|
||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: Scope) => pick(scope).capture(),
|
||||
current: () => session().current(),
|
||||
cursor: () => session().cursor(),
|
||||
dirty: () => session().dirty(),
|
||||
|
||||
@@ -471,18 +471,14 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "permission.asked": {
|
||||
const permission = event.properties as PermissionRequest
|
||||
const permissions = data.permission[permission.sessionID]
|
||||
if (!permissions) {
|
||||
setData("permission", permission.sessionID, [permission])
|
||||
return
|
||||
}
|
||||
const permissions = data.permission[permission.sessionID] ?? []
|
||||
const result = Binary.search(permissions, permission.id, (item) => item.id)
|
||||
if (result.found) setData("permission", permission.sessionID, result.index, reconcile(permission))
|
||||
if (!result.found)
|
||||
setData(
|
||||
"permission",
|
||||
permission.sessionID,
|
||||
produce((draft) => void draft.splice(result.index, 0, permission)),
|
||||
produce((draft = []) => void draft.splice(result.index, 0, permission)),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -501,18 +497,14 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const questions = data.question[question.sessionID]
|
||||
if (!questions) {
|
||||
setData("question", question.sessionID, [question])
|
||||
return
|
||||
}
|
||||
const questions = data.question[question.sessionID] ?? []
|
||||
const result = Binary.search(questions, question.id, (item) => item.id)
|
||||
if (result.found) setData("question", question.sessionID, result.index, reconcile(question))
|
||||
if (!result.found)
|
||||
setData(
|
||||
"question",
|
||||
question.sessionID,
|
||||
produce((draft) => void draft.splice(result.index, 0, question)),
|
||||
produce((draft = []) => void draft.splice(result.index, 0, question)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,27 +112,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = store[index + 1] ?? store[index - 1]
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
}
|
||||
|
||||
const actions = {
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
const next = { type: "session" as const, ...tab }
|
||||
@@ -146,16 +125,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
)
|
||||
return next
|
||||
},
|
||||
reorder(keys: string[]) {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const byKey = new Map(tabs.map((tab) => [tabKey(tab), tab]))
|
||||
const next = keys.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
|
||||
if (next.length !== tabs.length) return
|
||||
tabs.splice(0, tabs.length, ...next)
|
||||
}),
|
||||
)
|
||||
},
|
||||
draft(draftID: string) {
|
||||
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||
@@ -196,12 +165,25 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
memory.remove(`draft:${draftID}`)
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
removeTab,
|
||||
removeSessionTab(input: Omit<SessionTab, "type">) {
|
||||
const index = store.findIndex(
|
||||
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
|
||||
)
|
||||
if (index !== -1) removeTab(index)
|
||||
removeTab: (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = store[index + 1] ?? store[index - 1]
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
|
||||
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] : []))
|
||||
@@ -213,10 +195,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (server.key === key) navigate("/")
|
||||
},
|
||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
const targetServer = input.server ?? server.key
|
||||
const removed = store
|
||||
.filter(
|
||||
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
|
||||
(tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
|
||||
)
|
||||
.map(tabKey)
|
||||
void startTransition(() => {
|
||||
@@ -224,28 +205,28 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
produce((tabs) => {
|
||||
const sessionIDs = new Set(input.sessionIDs)
|
||||
const currentHref =
|
||||
targetServer === server.key && params.dir && params.id
|
||||
params.dir && params.id
|
||||
? tabHref({
|
||||
type: "session",
|
||||
server: targetServer,
|
||||
server: server.key,
|
||||
sessionId: params.id,
|
||||
})
|
||||
: undefined
|
||||
const currentIndex = currentHref
|
||||
? tabs.findIndex(
|
||||
(tab) => tab.type === "session" && tab.server === targetServer && tabHref(tab) === currentHref,
|
||||
(tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref,
|
||||
)
|
||||
: -1
|
||||
const currentTab = tabs[currentIndex]
|
||||
const removedCurrent =
|
||||
currentTab?.type === "session" &&
|
||||
currentTab.server === targetServer &&
|
||||
currentTab.server === server.key &&
|
||||
sessionIDs.has(currentTab.sessionId)
|
||||
|
||||
for (let i = tabs.length - 1; i >= 0; i--) {
|
||||
const tab = tabs[i]
|
||||
if (!tab || tab.type !== "session") continue
|
||||
if (tab.server !== targetServer) continue
|
||||
if (tab.server !== server.key) continue
|
||||
if (!sessionIDs.has(tab.sessionId)) continue
|
||||
tabs.splice(i, 1)
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { selectProviderCatalog } from "./provider-catalog"
|
||||
|
||||
const catalog = (id: string): NormalizedProviderListResponse => ({
|
||||
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
|
||||
connected: [id],
|
||||
default: { [id]: `${id}-model` },
|
||||
})
|
||||
|
||||
test("selects the ready catalog for an explicit directory", () => {
|
||||
const directory = catalog("directory")
|
||||
|
||||
expect(
|
||||
selectProviderCatalog({
|
||||
explicit: true,
|
||||
directory: "/repo",
|
||||
catalog: { ready: true, providers: directory },
|
||||
}),
|
||||
).toBe(directory)
|
||||
})
|
||||
|
||||
test("returns an empty catalog while an explicit directory is unresolved", () => {
|
||||
expect(selectProviderCatalog({ explicit: true })).toEqual({ all: new Map(), connected: [], default: {} })
|
||||
expect(
|
||||
selectProviderCatalog({
|
||||
explicit: true,
|
||||
directory: "/repo",
|
||||
catalog: { ready: false, providers: catalog("directory") },
|
||||
}),
|
||||
).toEqual({ all: new Map(), connected: [], default: {} })
|
||||
})
|
||||
|
||||
test("uses the route catalog when it is ready", () => {
|
||||
const directory = catalog("directory")
|
||||
|
||||
expect(
|
||||
selectProviderCatalog({
|
||||
explicit: false,
|
||||
directory: "/repo",
|
||||
catalog: { ready: true, providers: directory },
|
||||
global: catalog("global"),
|
||||
}),
|
||||
).toBe(directory)
|
||||
})
|
||||
|
||||
test("falls back to the global catalog for route consumers", () => {
|
||||
const global = catalog("global")
|
||||
|
||||
expect(selectProviderCatalog({ explicit: false, global })).toBe(global)
|
||||
expect(
|
||||
selectProviderCatalog({
|
||||
explicit: false,
|
||||
directory: "/repo",
|
||||
catalog: { ready: false, providers: catalog("directory") },
|
||||
global,
|
||||
}),
|
||||
).toBe(global)
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
|
||||
const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
|
||||
|
||||
type DirectoryCatalog = {
|
||||
ready: boolean
|
||||
providers: NormalizedProviderListResponse
|
||||
}
|
||||
|
||||
type ProviderCatalogInput =
|
||||
| {
|
||||
explicit: true
|
||||
directory?: string
|
||||
catalog?: DirectoryCatalog
|
||||
}
|
||||
| {
|
||||
explicit: false
|
||||
directory?: string
|
||||
catalog?: DirectoryCatalog
|
||||
global: NormalizedProviderListResponse
|
||||
}
|
||||
|
||||
export function selectProviderCatalog(input: ProviderCatalogInput) {
|
||||
if (input.directory && input.catalog?.ready) return input.catalog.providers
|
||||
if (input.explicit) return emptyProviderCatalog
|
||||
return input.global
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { useServerSync } from "@/context/server-sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Iterable, pipe } from "effect"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { selectProviderCatalog } from "./provider-catalog"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
export const popularProviders = [
|
||||
"opencode",
|
||||
@@ -17,25 +16,16 @@ export const popularProviders = [
|
||||
]
|
||||
const popularProviderSet = new Set(popularProviders)
|
||||
|
||||
export function useProviders(directory?: Accessor<string | undefined>) {
|
||||
export function useProviders() {
|
||||
const serverSync = useServerSync()
|
||||
const params = useParams()
|
||||
const dir = () => (directory ? directory() : decode64(params.dir))
|
||||
const dir = createMemo(() => decode64(params.dir) ?? "")
|
||||
const providers = () => {
|
||||
const value = dir()
|
||||
const projectStore = value ? serverSync().child(value)[0] : undefined
|
||||
if (directory)
|
||||
return selectProviderCatalog({
|
||||
explicit: true,
|
||||
directory: value,
|
||||
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
|
||||
})
|
||||
return selectProviderCatalog({
|
||||
explicit: false,
|
||||
directory: value,
|
||||
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
|
||||
global: serverSync().data.provider,
|
||||
})
|
||||
if (dir()) {
|
||||
const [projectStore] = serverSync().child(dir())
|
||||
if (projectStore.provider_ready) return projectStore.provider
|
||||
}
|
||||
return serverSync().data.provider
|
||||
}
|
||||
return {
|
||||
all: () => providers().all,
|
||||
|
||||
@@ -592,11 +592,10 @@ export const dict = {
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
"home.server.expand": "Expand server projects",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
"home.sessions.search.placeholder.scoped": "Search sessions in {{scope}}",
|
||||
"home.sessions.search.sessions": "Sessions",
|
||||
"home.sessions.search.noResults": "No sessions found for {{query}}",
|
||||
"home.sessions.empty": "Nothing here yet",
|
||||
"home.sessions.empty.description": "Create a session to get started",
|
||||
"home.sessions.empty": "No sessions found",
|
||||
"home.sessions.empty.description": "Start a new session for this project",
|
||||
"home.sessions.group.today": "Today",
|
||||
"home.sessions.group.yesterday": "Yesterday",
|
||||
"home.sessions.group.older": "Older",
|
||||
|
||||
@@ -507,11 +507,9 @@ export const dict = {
|
||||
"home.projects": "项目",
|
||||
"home.project.add": "添加项目",
|
||||
"home.sessions.search.placeholder": "搜索会话",
|
||||
"home.sessions.search.placeholder.scoped": "在 {{scope}} 中搜索会话",
|
||||
"home.sessions.search.sessions": "会话",
|
||||
"home.sessions.search.noResults": "未找到与 {{query}} 相关的会话",
|
||||
"home.sessions.empty": "这里还没有内容",
|
||||
"home.sessions.empty.description": "创建一个会话以开始。",
|
||||
"home.sessions.empty": "未找到会话",
|
||||
"home.sessions.group.today": "今天",
|
||||
"home.sessions.group.yesterday": "昨天",
|
||||
"home.sessions.group.older": "更早",
|
||||
|
||||
@@ -58,18 +58,14 @@ export function DirectoryDataProvider(
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={directory()} keyed>
|
||||
{(directory) => (
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={directory}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
)}
|
||||
</Show>
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={directory()}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { archiveHomeSession } from "./home-session-archive"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
const remote = "remote" as ServerConnection.Key
|
||||
|
||||
test("archiving a Home session removes its open titlebar tab", async () => {
|
||||
let detail: ReturnType<typeof readSessionTabsRemovedDetail>
|
||||
let removed = false
|
||||
window.addEventListener(
|
||||
SESSION_TABS_REMOVED_EVENT,
|
||||
(event) => {
|
||||
detail = readSessionTabsRemovedDetail(event)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", directory: "/workspace" },
|
||||
update: async () => undefined,
|
||||
remove: () => {
|
||||
removed = true
|
||||
},
|
||||
})
|
||||
|
||||
expect(removed).toBe(true)
|
||||
expect(detail).toEqual({ server: remote, directory: "/workspace", sessionIDs: ["ses_1"] })
|
||||
})
|
||||
|
||||
test("reports archive failures without removing the session", async () => {
|
||||
const failure = new Error("offline")
|
||||
let error: unknown
|
||||
let removed = false
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", directory: "/workspace" },
|
||||
update: async () => Promise.reject(failure),
|
||||
remove: () => {
|
||||
removed = true
|
||||
},
|
||||
onError: (value) => {
|
||||
error = value
|
||||
},
|
||||
})
|
||||
|
||||
expect(error).toBe(failure)
|
||||
expect(removed).toBe(false)
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
type HomeSession = {
|
||||
id: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
type SessionUpdate = {
|
||||
directory: string
|
||||
sessionID: string
|
||||
time: { archived: number }
|
||||
}
|
||||
|
||||
export async function archiveHomeSession(input: {
|
||||
server: ServerConnection.Key
|
||||
session: HomeSession
|
||||
update: (value: SessionUpdate) => Promise<unknown>
|
||||
remove: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}) {
|
||||
await input
|
||||
.update({
|
||||
directory: input.session.directory,
|
||||
sessionID: input.session.id,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
.then(() => {
|
||||
input.remove()
|
||||
notifySessionTabsRemoved({
|
||||
server: input.server,
|
||||
directory: input.session.directory,
|
||||
sessionIDs: [input.session.id],
|
||||
})
|
||||
})
|
||||
.catch((error) => input.onError?.(error))
|
||||
}
|
||||
+102
-208
@@ -14,7 +14,7 @@ import {
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
@@ -25,7 +25,6 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
@@ -36,7 +35,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -44,7 +43,6 @@ import { useNotification } from "@/context/notification"
|
||||
import {
|
||||
closeHomeProject,
|
||||
displayName,
|
||||
errorMessage,
|
||||
getProjectAvatarSource,
|
||||
homeProjectDirectories,
|
||||
type HomeProjectSelection,
|
||||
@@ -57,15 +55,12 @@ import { sessionTitle } from "@/utils/session-title"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
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/session-ui/markdown-cache"
|
||||
import { archiveHomeSession } from "./home-session-archive"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_ROW_LAYOUT =
|
||||
@@ -90,7 +85,7 @@ type HomeSessionGroup = {
|
||||
|
||||
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
|
||||
const HOME_SEARCH_RESULT_ROW =
|
||||
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-4 pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
const HOME_SEARCH_RESULT_TITLE =
|
||||
"min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]"
|
||||
const HOME_SEARCH_RESULT_META =
|
||||
@@ -177,19 +172,6 @@ export function NewHome() {
|
||||
return directories(project)
|
||||
})
|
||||
const search = createMemo(() => state.search.trim())
|
||||
const searchPlaceholder = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (project) {
|
||||
return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
|
||||
}
|
||||
if (global.servers.list().length > 1) {
|
||||
const conn = focusedServer()
|
||||
if (conn) {
|
||||
return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
|
||||
}
|
||||
}
|
||||
return language.t("home.sessions.search.placeholder")
|
||||
})
|
||||
const sessionLoad = useQuery(() => ({
|
||||
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
|
||||
queryFn: async () => {
|
||||
@@ -276,7 +258,7 @@ export function NewHome() {
|
||||
command.register("home", () => [
|
||||
{
|
||||
id: "home.sessions.search.focus",
|
||||
title: searchPlaceholder(),
|
||||
title: language.t("home.sessions.search.placeholder"),
|
||||
keybind: "mod+f",
|
||||
hidden: true,
|
||||
onSelect: () => focusSessionSearch?.(),
|
||||
@@ -368,33 +350,7 @@ export function NewHome() {
|
||||
})
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const conn = focusedServer()
|
||||
const ctx = focusedServerCtx()
|
||||
if (!conn || !ctx) return
|
||||
const [, setStore] = ctx.sync.child(session.directory)
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
session,
|
||||
update: (value) => ctx.sdk.client.session.update(value),
|
||||
remove: () =>
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
),
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(error, language.t("common.requestFailed")),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||
|
||||
function resolve(result: string | string[] | null) {
|
||||
addProjects(conn, homeProjectDirectories(result))
|
||||
}
|
||||
@@ -446,11 +402,10 @@ export function NewHome() {
|
||||
>
|
||||
<HomeSessionSearch
|
||||
value={state.search}
|
||||
placeholder={searchPlaceholder()}
|
||||
placeholder={language.t("home.sessions.search.placeholder")}
|
||||
open={searchOpen()}
|
||||
loading={sessionLoad.isLoading}
|
||||
results={searchResults()}
|
||||
showProjectName={!selectedProject()}
|
||||
server={state.selection.server}
|
||||
activeServer={state.selection.server === server.key}
|
||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||
@@ -463,19 +418,22 @@ export function NewHome() {
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}
|
||||
>
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<HomeSessionGroupHeader
|
||||
title={language.t("home.sessions.empty")}
|
||||
onNewSession={newSessionProject() ? openNewSession : undefined}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
@@ -488,11 +446,9 @@ export function NewHome() {
|
||||
{(record) => (
|
||||
<HomeSessionRow
|
||||
record={record}
|
||||
showProjectName={!selectedProject()}
|
||||
server={state.selection.server}
|
||||
activeServer={state.selection.server === server.key}
|
||||
openSession={openSession}
|
||||
archiveSession={archiveSession}
|
||||
onClick={() => openSession(record.session)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -500,9 +456,9 @@ export function NewHome() {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</section>
|
||||
<HomeUtilityNav
|
||||
@@ -546,18 +502,15 @@ function HomeProjectColumn(props: {
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
|
||||
onClick={() => props.chooseProject(global.servers.list()[0]!)}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
onClick={() => props.chooseProject(global.servers.list()[0]!)}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
@@ -693,7 +646,7 @@ function HomeServerRow(props: {
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={state.menuOpen}
|
||||
>
|
||||
<ServerRowMenu
|
||||
@@ -703,17 +656,14 @@ function HomeServerRow(props: {
|
||||
open={state.menuOpen}
|
||||
onOpenChange={(open) => setState("menuOpen", open)}
|
||||
/>
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
disabled={props.health?.healthy === false}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
/>
|
||||
</TooltipV2>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -783,11 +733,19 @@ function HomeProjectRow(props: {
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</button>
|
||||
<div
|
||||
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={state.menuOpen}
|
||||
>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="edit" />}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={() => props.openNewSession(props.server, props.project.worktree)}
|
||||
/>
|
||||
<MenuV2
|
||||
gutter={6}
|
||||
gutter={4}
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
open={state.menuOpen}
|
||||
@@ -807,7 +765,7 @@ function HomeProjectRow(props: {
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
|
||||
{props.language.t("dialog.project.edit.title")}
|
||||
{props.language.t("common.edit")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item
|
||||
disabled={props.unseenCount === 0}
|
||||
@@ -822,14 +780,6 @@ function HomeProjectRow(props: {
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="edit" />}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={() => props.openNewSession(props.server, props.project.worktree)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -860,7 +810,7 @@ function HomeSessionLeading(props: {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||
style={{ right: "calc(100% + 5px)" }}
|
||||
style={{ right: "calc(100% + 12px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTabAvatar
|
||||
@@ -879,7 +829,6 @@ function HomeSessionSearch(props: {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
results: HomeSessionRecord[]
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
noResultsLabel: string
|
||||
@@ -957,12 +906,12 @@ function HomeSessionSearch(props: {
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="mr-2 w-[calc(100%_-_8px)]">
|
||||
<div class="ml-4 mr-2 w-[calc(100%_-_24px)]">
|
||||
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-component="home-session-search-panel"
|
||||
class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
|
||||
class="absolute flex flex-col rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
|
||||
style={{
|
||||
top: "-6px",
|
||||
left: "-6px",
|
||||
@@ -970,7 +919,7 @@ function HomeSessionSearch(props: {
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4 pb-2">
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
@@ -982,32 +931,29 @@ function HomeSessionSearch(props: {
|
||||
<Show
|
||||
when={props.results.length > 0}
|
||||
fallback={
|
||||
<p class="my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
<p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{props.noResultsLabel}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<p class="my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
<p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{language.t("home.sessions.search.sessions")}
|
||||
</p>
|
||||
<ScrollView class="max-h-80" viewportRef={(el) => (listRef = el)}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.results}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
record={record}
|
||||
showProjectName={props.showProjectName}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
selected={store.active === homeSessionSearchKey(record)}
|
||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||
onSelect={(session) => props.onSelect(session)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ScrollView>
|
||||
<div ref={listRef} class="flex max-h-80 flex-col gap-px overflow-y-auto">
|
||||
<For each={props.results}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
record={record}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
selected={store.active === homeSessionSearchKey(record)}
|
||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||
onSelect={(session) => props.onSelect(session)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -1016,10 +962,11 @@ function HomeSessionSearch(props: {
|
||||
</div>
|
||||
</Show>
|
||||
<label
|
||||
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
|
||||
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
|
||||
classList={{
|
||||
"focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]": !props.open,
|
||||
"shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
|
||||
"bg-v2-background-bg-layer-03 focus-within:bg-v2-background-bg-layer-03 focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]":
|
||||
!props.open,
|
||||
"bg-transparent shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
|
||||
}}
|
||||
>
|
||||
<IconV2 name="magnifying-glass" />
|
||||
@@ -1084,7 +1031,6 @@ function HomeSessionSearch(props: {
|
||||
|
||||
function HomeSessionSearchResultRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
selected: boolean
|
||||
@@ -1092,7 +1038,6 @@ function HomeSessionSearchResultRow(props: {
|
||||
onSelect: (session: Session) => void
|
||||
}) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
@@ -1119,11 +1064,11 @@ function HomeSessionSearchResultRow(props: {
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span
|
||||
class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
class={`${HOME_SEARCH_RESULT_TITLE} ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={showProjectName()}>
|
||||
<Show when={props.record.projectName}>
|
||||
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -1134,7 +1079,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px] pr-2">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-4 pr-2">
|
||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
@@ -1156,79 +1101,36 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
|
||||
|
||||
function HomeSessionRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
openSession: (session: Session) => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
onClick: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
return (
|
||||
<div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
|
||||
onClick={() => props.openSession(props.record.session)}
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
<span
|
||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
<span
|
||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
{title()}
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={props.record.projectName}>
|
||||
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
|
||||
{props.record.projectName}
|
||||
</span>
|
||||
<Show when={showProjectName()}>
|
||||
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
|
||||
{props.record.projectName}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionsEmpty(props: { onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
|
||||
<div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
|
||||
{language.t("home.sessions.empty")}
|
||||
</div>
|
||||
<p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{language.t("home.sessions.empty.description")}
|
||||
</p>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1280,7 +1182,6 @@ export function LegacyHome() {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync().data.path.home)
|
||||
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
||||
const recent = createMemo(() => {
|
||||
return sync()
|
||||
.data.project.slice()
|
||||
@@ -1303,7 +1204,6 @@ export function LegacyHome() {
|
||||
}
|
||||
|
||||
function chooseProject() {
|
||||
if (serverUnreachable()) return
|
||||
const s = server.current
|
||||
if (!s) return
|
||||
|
||||
@@ -1347,13 +1247,7 @@ export function LegacyHome() {
|
||||
<div class="mt-20 w-full flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center justify-between pl-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
||||
<Button
|
||||
icon="folder-add-left"
|
||||
size="normal"
|
||||
class="pl-2 pr-3"
|
||||
disabled={serverUnreachable()}
|
||||
onClick={chooseProject}
|
||||
>
|
||||
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1379,7 +1273,7 @@ export function LegacyHome() {
|
||||
<Match when={!sync().ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1391,7 +1285,7 @@ export function LegacyHome() {
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
||||
</div>
|
||||
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
<Button class="px-3 mt-1" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
@@ -2116,6 +2115,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
<div class="shrink-0 py-4">
|
||||
<Button
|
||||
size="large"
|
||||
icon="new-session"
|
||||
class="w-full"
|
||||
onClick={() => {
|
||||
const dir = worktree()
|
||||
@@ -2123,7 +2123,6 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
|
||||
}}
|
||||
>
|
||||
<IconV2 name="edit" size="small" />
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -27,17 +27,7 @@ export function SessionTabAvatar(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span class="relative block size-4 shrink-0">
|
||||
<SessionProgressIndicatorV2 class="absolute inset-0 group-hover:invisible" />
|
||||
<span class="invisible absolute inset-0 group-hover:visible">
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<SessionProgressIndicatorV2 class="shrink-0" />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
@@ -301,7 +300,7 @@ export const NewSessionItem = (props: {
|
||||
}}
|
||||
>
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<IconV2 name="edit" size="small" class="text-icon-weak" />
|
||||
<Icon name="new-session" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
|
||||
</A>
|
||||
|
||||
@@ -10,8 +10,6 @@ import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -216,10 +214,9 @@ const WorkspaceActions = (props: {
|
||||
</DropdownMenu>
|
||||
<Show when={!props.touch()}>
|
||||
<Tooltip value={props.language.t("command.session.new")} placement="top">
|
||||
<IconButtonV2
|
||||
icon={<IconV2 name="edit" size="small" />}
|
||||
<IconButton
|
||||
icon="new-session"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
|
||||
data-action="workspace-new-session"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
|
||||
@@ -6,13 +6,7 @@ import { useComments } from "@/context/comments"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import {
|
||||
createSessionComposerControls,
|
||||
createSessionComposerState,
|
||||
SessionComposerRegion,
|
||||
} from "@/pages/session/composer"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
|
||||
/**
|
||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||
@@ -23,19 +17,12 @@ export default function NewSessionPage() {
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
|
||||
let inputRef: HTMLDivElement | undefined
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
const composerControls = createSessionComposerControls({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
queryOptions: serverSync().queryOptions,
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
worktree: "main",
|
||||
@@ -70,21 +57,15 @@ export default function NewSessionPage() {
|
||||
<NewSessionDesignView>
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
sessionKey={route.sessionKey()}
|
||||
sessionID={route.params.id}
|
||||
controls={composerControls()}
|
||||
promptInput={{
|
||||
ref: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
newSessionWorktree: newSessionWorktree(),
|
||||
onNewSessionWorktreeReset: () => setStore("worktree", "main"),
|
||||
onSubmit: () => comments.clear(),
|
||||
}}
|
||||
todo={{ collapsed: false, onToggle: () => {} }}
|
||||
ready
|
||||
centered={false}
|
||||
placement="inline"
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
||||
onSubmit={() => comments.clear()}
|
||||
onResponseSubmit={() => {}}
|
||||
setPromptDockRef={() => {}}
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { useLocation, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
@@ -43,11 +43,7 @@ import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
import {
|
||||
createSessionComposerControls,
|
||||
createSessionComposerState,
|
||||
SessionComposerRegion,
|
||||
} from "@/pages/session/composer"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import {
|
||||
createOpenReviewFile,
|
||||
createSessionTabs,
|
||||
@@ -70,9 +66,7 @@ import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionOwnership } from "./session/session-ownership"
|
||||
|
||||
type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
@@ -81,35 +75,6 @@ const emptyFollowups: FollowupItem[] = []
|
||||
type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
const sessionViewState = () => ({
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "git" as ChangeMode,
|
||||
})
|
||||
|
||||
async function runPromptRollbackMutation<T, R>(input: {
|
||||
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
|
||||
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
|
||||
request: () => Promise<R>
|
||||
complete: (result: R) => void
|
||||
rollback: () => void
|
||||
fail: (error: unknown) => void
|
||||
}) {
|
||||
const prompt = input.capturePrompt()
|
||||
const previous = prompt.current().slice()
|
||||
batch(() => input.optimistic(prompt))
|
||||
await input
|
||||
.request()
|
||||
.then(input.complete)
|
||||
.catch((error) => {
|
||||
batch(() => {
|
||||
input.rollback()
|
||||
prompt.set(previous)
|
||||
})
|
||||
input.fail(error)
|
||||
})
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const serverSync = useServerSync()
|
||||
const layout = useLayout()
|
||||
@@ -128,9 +93,7 @@ export default function Page() {
|
||||
const terminal = useTerminal()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
|
||||
createEffect(() => {
|
||||
@@ -156,11 +119,6 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
const composerControls = createSessionComposerControls({
|
||||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
queryOptions: serverSync().queryOptions,
|
||||
})
|
||||
|
||||
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
||||
|
||||
@@ -298,7 +256,9 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
...sessionViewState(),
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "git" as ChangeMode,
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
@@ -322,9 +282,8 @@ export default function Page() {
|
||||
const key = sessionKey()
|
||||
if (key !== prev) {
|
||||
setStore("deferRender", true)
|
||||
const owner = sessionOwnership.capture()
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => owner.run(() => setStore("deferRender", false)), 0)
|
||||
setTimeout(() => setStore("deferRender", false), 0)
|
||||
})
|
||||
}
|
||||
return key
|
||||
@@ -590,7 +549,8 @@ export default function Page() {
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore(sessionViewState())
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "git")
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
@@ -1167,37 +1127,27 @@ export default function Page() {
|
||||
|
||||
let captureHistoryAnchor = () => {}
|
||||
let restoreHistoryAnchor = (_done: boolean) => {}
|
||||
const historyRequests = new Set<string>()
|
||||
let historyRequest = false
|
||||
let historyContinuationFrame: number | undefined
|
||||
const loadOlder = async () => {
|
||||
const owner = sessionOwnership.capture()
|
||||
if (historyLoading() || historyRequests.has(owner.key)) return
|
||||
historyRequests.add(owner.key)
|
||||
if (historyRequest || historyLoading()) return
|
||||
historyRequest = true
|
||||
const before = timeline.messages().length
|
||||
try {
|
||||
await timeline.history.loadOlder({
|
||||
before: () => owner.run(captureHistoryAnchor),
|
||||
after: (done) => owner.run(() => restoreHistoryAnchor(done)),
|
||||
})
|
||||
await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
|
||||
} finally {
|
||||
historyRequests.delete(owner.key)
|
||||
historyRequest = false
|
||||
}
|
||||
if (!owner.current() || timeline.messages().length <= before) return
|
||||
if (timeline.messages().length <= before) return
|
||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
|
||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||
historyContinuationFrame = requestAnimationFrame(() => {
|
||||
historyContinuationFrame = undefined
|
||||
owner.run(onHistoryScroll)
|
||||
onHistoryScroll()
|
||||
})
|
||||
}
|
||||
const onHistoryScroll = () => {
|
||||
if (
|
||||
historyRequests.has(sessionOwnership.key()) ||
|
||||
historyLoading() ||
|
||||
!autoScroll.userScrolled() ||
|
||||
!scroller ||
|
||||
scroller.scrollTop >= 200
|
||||
)
|
||||
if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200)
|
||||
return
|
||||
void loadOlder()
|
||||
}
|
||||
@@ -1268,13 +1218,23 @@ export default function Page() {
|
||||
})
|
||||
}
|
||||
|
||||
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
|
||||
const merge = (next: NonNullable<ReturnType<typeof info>>) =>
|
||||
sync().set("session", (list) => {
|
||||
const idx = list.findIndex((item) => item.id === next.id)
|
||||
if (idx < 0) return list
|
||||
const out = list.slice()
|
||||
out[idx] = next
|
||||
return out
|
||||
})
|
||||
|
||||
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
|
||||
const session = target.session.get(sessionID)
|
||||
if (!session) return
|
||||
target.session.remember({ ...session, revert: next })
|
||||
}
|
||||
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
|
||||
sync().set("session", (list) => {
|
||||
const idx = list.findIndex((item) => item.id === sessionID)
|
||||
if (idx < 0) return list
|
||||
const out = list.slice()
|
||||
out[idx] = { ...out[idx], revert: next }
|
||||
return out
|
||||
})
|
||||
|
||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||
|
||||
@@ -1292,7 +1252,6 @@ export default function Page() {
|
||||
|
||||
const followupMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||
const owner = sessionOwnership.capture()
|
||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||
if (!item) return
|
||||
|
||||
@@ -1313,7 +1272,7 @@ export default function Page() {
|
||||
if (!ok) return
|
||||
|
||||
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
||||
if (input.manual) owner.run(resumeScroll)
|
||||
if (input.manual) resumeScroll()
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1402,23 +1361,25 @@ export default function Page() {
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
const client = sdk().client
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
const value = draft(input.messageID)
|
||||
await runPromptRollbackMutation({
|
||||
capturePrompt: prompt.capture,
|
||||
optimistic: (prompt) => {
|
||||
roll(input.sessionID, { messageID: input.messageID }, target)
|
||||
prompt.set(value)
|
||||
},
|
||||
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
|
||||
complete: (result) => {
|
||||
if (result.data) merge(result.data, target)
|
||||
},
|
||||
rollback: () => roll(input.sessionID, last, target),
|
||||
fail,
|
||||
batch(() => {
|
||||
roll(input.sessionID, { messageID: input.messageID })
|
||||
prompt.set(value)
|
||||
})
|
||||
await halt(input.sessionID)
|
||||
.then(() => sdk().client.session.revert(input))
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(input.sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1427,31 +1388,39 @@ export default function Page() {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const client = sdk().client
|
||||
const target = sync()
|
||||
const next = userMessages().find((item) => item.id > id)
|
||||
const last = target.session.get(sessionID)?.revert
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
|
||||
await runPromptRollbackMutation({
|
||||
capturePrompt: prompt.capture,
|
||||
optimistic: (promptSession) => {
|
||||
roll(sessionID, next ? { messageID: next.id } : undefined, target)
|
||||
if (next) {
|
||||
promptSession.set(draft(next.id))
|
||||
return
|
||||
}
|
||||
promptSession.reset()
|
||||
},
|
||||
request: () =>
|
||||
!next
|
||||
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
|
||||
complete: (result) => {
|
||||
if (result.data) merge(result.data, target)
|
||||
},
|
||||
rollback: () => roll(sessionID, last, target),
|
||||
fail,
|
||||
batch(() => {
|
||||
roll(sessionID, next ? { messageID: next.id } : undefined)
|
||||
if (next) {
|
||||
prompt.set(draft(next.id))
|
||||
return
|
||||
}
|
||||
prompt.reset()
|
||||
})
|
||||
|
||||
const task = !next
|
||||
? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() =>
|
||||
sdk().client.session.revert({
|
||||
sessionID,
|
||||
messageID: next.id,
|
||||
}),
|
||||
)
|
||||
|
||||
await task
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1570,35 +1539,17 @@ export default function Page() {
|
||||
const composerRegion = (placement: "dock" | "inline") => (
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
sessionKey={sessionKey()}
|
||||
sessionID={params.id}
|
||||
controls={composerControls()}
|
||||
promptInput={{
|
||||
ref: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
newSessionWorktree: newSessionWorktree(),
|
||||
onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
|
||||
onSubmit: () => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
},
|
||||
}}
|
||||
todo={{
|
||||
collapsed: view().todoCollapsed.get(),
|
||||
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
|
||||
}}
|
||||
ready={!store.deferRender && messagesReady()}
|
||||
centered={placement === "dock" && centered()}
|
||||
placement={placement}
|
||||
openParent={() => {
|
||||
const id = info()?.parentID
|
||||
if (!id) return
|
||||
navigate(
|
||||
params.serverKey
|
||||
? sessionHref(requireServerKey(params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||
onSubmit={() => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
}}
|
||||
onResponseSubmit={resumeScroll}
|
||||
followup={
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { SessionComposerRegion } from "./session-composer-region"
|
||||
export { createSessionComposerControls } from "./session-composer-controls"
|
||||
export { createSessionComposerState } from "./session-composer-state"
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import type { PromptInputControls } from "@/components/prompt-input"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import type { QueryOptionsApi } from "@/context/server-sync"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { type DraftTab, useTabs } from "@/context/tabs"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export function createSessionComposerControls(input: {
|
||||
sessionKey: Accessor<string>
|
||||
sessionID: Accessor<string | undefined>
|
||||
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const providers = useProviders()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const view = layout.view(input.sessionKey)
|
||||
|
||||
const draft = createMemo(() => {
|
||||
if (!search.draftId) return
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
})
|
||||
const projectServer = createMemo(() => {
|
||||
if (!search.draftId) return server.current
|
||||
const target = draft()?.server
|
||||
if (!target) return
|
||||
return server.list.find((conn) => ServerConnection.key(conn) === target)
|
||||
})
|
||||
const projectServerCtx = createMemo(() => {
|
||||
const conn = projectServer()
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
const projects = createMemo(() =>
|
||||
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
|
||||
)
|
||||
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
|
||||
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
|
||||
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
|
||||
|
||||
const selectProject = (worktree: string) => {
|
||||
const conn = projectServer()
|
||||
const target = projectServerCtx()
|
||||
if (search.draftId) {
|
||||
if (!conn || !target) return
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||
return
|
||||
}
|
||||
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
|
||||
const addProject = (title: string) => {
|
||||
const conn = projectServer()
|
||||
if (!conn) return
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) selectProject(directory)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return createMemo<PromptInputControls>(() => ({
|
||||
agents: {
|
||||
available: sync().data.agent,
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
loading: agentsQuery.isLoading,
|
||||
visible: settings.visibility.customAgents(),
|
||||
select: local.agent.set,
|
||||
},
|
||||
model: {
|
||||
selection: local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
},
|
||||
projects: {
|
||||
available: projects(),
|
||||
directory: sdk().directory,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
},
|
||||
session: {
|
||||
id: input.sessionID(),
|
||||
tabs: layout.tabs(input.sessionKey),
|
||||
reviewPanel: view.reviewPanel,
|
||||
},
|
||||
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
||||
}))
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||
@@ -15,21 +18,29 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { type DraftTab, useTabs } from "@/context/tabs"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useGlobal } from "@/context/global"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
sessionKey: string
|
||||
sessionID?: string
|
||||
controls: PromptInputControls
|
||||
promptInput: Omit<PromptInputProps, "controls" | "variant">
|
||||
todo: {
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
ready: boolean
|
||||
centered: boolean
|
||||
placement?: "dock" | "inline"
|
||||
openParent?: () => void
|
||||
inputRef: (el: HTMLDivElement) => void
|
||||
newSessionWorktree: string
|
||||
onNewSessionWorktreeReset: () => void
|
||||
onSubmit: () => void
|
||||
onResponseSubmit: () => void
|
||||
followup?: {
|
||||
queue: () => boolean
|
||||
@@ -50,12 +61,102 @@ export function SessionComposerRegion(props: {
|
||||
}
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const prompt = props.promptInput.state ?? usePrompt()
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const prompt = usePrompt()
|
||||
const language = useLanguage()
|
||||
const route = useSessionKey()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const queryOptions = useQueryOptions()
|
||||
const local = useLocal()
|
||||
const providers = useProviders()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const view = layout.view(route.sessionKey)
|
||||
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(props.sessionKey)?.prompt)
|
||||
const info = createMemo(() => (props.sessionID ? sync().session.get(props.sessionID) : undefined))
|
||||
const draft = createMemo(() => {
|
||||
if (!search.draftId) return
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
})
|
||||
const projectServer = createMemo(() => {
|
||||
if (!search.draftId) return server.current
|
||||
const target = draft()?.server
|
||||
if (!target) return
|
||||
return server.list.find((conn) => ServerConnection.key(conn) === target)
|
||||
})
|
||||
const projectServerCtx = createMemo(() => {
|
||||
const conn = projectServer()
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
const projects = createMemo(() =>
|
||||
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
|
||||
)
|
||||
|
||||
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
|
||||
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
|
||||
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
|
||||
const selectProject = (worktree: string) => {
|
||||
const conn = projectServer()
|
||||
const target = projectServerCtx()
|
||||
if (search.draftId) {
|
||||
if (!conn || !target) return
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||
return
|
||||
}
|
||||
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
const addProject = (title: string) => {
|
||||
const conn = projectServer()
|
||||
if (!conn) return
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) selectProject(directory)
|
||||
},
|
||||
})
|
||||
}
|
||||
const controls = createMemo(() => ({
|
||||
agents: {
|
||||
available: sync().data.agent,
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
loading: agentsQuery.isLoading,
|
||||
visible: settings.visibility.customAgents(),
|
||||
select: local.agent.set,
|
||||
},
|
||||
model: {
|
||||
selection: local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
},
|
||||
projects: {
|
||||
available: projects(),
|
||||
directory: sdk().directory,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
},
|
||||
session: {
|
||||
id: route.params.id,
|
||||
tabs: layout.tabs(route.sessionKey),
|
||||
reviewPanel: view.reviewPanel,
|
||||
},
|
||||
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
||||
}))
|
||||
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
||||
const info = createMemo(() => (route.params.id ? sync().session.get(route.params.id) : undefined))
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const child = createMemo(() => !!parentID())
|
||||
const showComposer = createMemo(() => !props.state.blocked() || child())
|
||||
@@ -74,11 +175,11 @@ export function SessionComposerRegion(props: {
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
setSessionHandoff(props.sessionKey, { prompt: previewPrompt() })
|
||||
setSessionHandoff(route.sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
ready: props.ready || props.state.dock(),
|
||||
ready: false,
|
||||
height: 320,
|
||||
body: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
@@ -97,17 +198,13 @@ export function SessionComposerRegion(props: {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.sessionKey
|
||||
route.sessionKey()
|
||||
const ready = props.ready
|
||||
const dock = props.state.dock()
|
||||
const delay = 140
|
||||
|
||||
clear()
|
||||
if (store.ready || (!ready && !dock)) return
|
||||
if (dock) {
|
||||
setStore("ready", true)
|
||||
return
|
||||
}
|
||||
setStore("ready", false)
|
||||
if (!ready) return
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
@@ -121,17 +218,23 @@ export function SessionComposerRegion(props: {
|
||||
onCleanup(clear)
|
||||
|
||||
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
{ visualDuration: 0.3, bounce: 0 },
|
||||
() => `${props.sessionKey}\0${store.ready}`,
|
||||
)
|
||||
const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
|
||||
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
|
||||
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
|
||||
const full = createMemo(() => Math.max(78, store.height))
|
||||
|
||||
const openParent = () => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
navigate(
|
||||
route.params.serverKey
|
||||
? sessionHref(requireServerKey(route.params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const el = store.body
|
||||
if (!el) return
|
||||
@@ -187,30 +290,8 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
|
||||
<Show when={showComposer()}>
|
||||
<Show when={dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": value() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${full() * value()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={(el) => setStore("body", el)}>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
collapsed={props.todo.collapsed}
|
||||
onToggle={props.todo.onToggle}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={value()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={prompt.ready() || promptReadyResource()}
|
||||
when={promptReadyResource()}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={rolled()} keyed>
|
||||
@@ -225,15 +306,35 @@ export function SessionComposerRegion(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
|
||||
style={{ "margin-top": `${-36 * value()}px` }}
|
||||
>
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
{handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show when={dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": value() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${full() * value()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={(el) => setStore("body", el)}>
|
||||
<SessionTodoDock
|
||||
sessionID={route.params.id}
|
||||
todos={props.state.todos()}
|
||||
collapsed={view.todoCollapsed.get()}
|
||||
onToggle={() => view.todoCollapsed.set(!view.todoCollapsed.get())}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={value()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={rolled()} keyed>
|
||||
{(revert) => (
|
||||
<div
|
||||
@@ -252,7 +353,7 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-30": true,
|
||||
"relative z-10": true,
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-lift()}px`,
|
||||
@@ -271,28 +372,31 @@ export function SessionComposerRegion(props: {
|
||||
fallback={
|
||||
<Show when={!props.state.blocked()}>
|
||||
<PromptInput
|
||||
{...props.promptInput}
|
||||
controls={props.controls}
|
||||
controls={controls()}
|
||||
variant={props.placement === "inline" ? "new-session" : undefined}
|
||||
ref={props.inputRef}
|
||||
newSessionWorktree={props.newSessionWorktree}
|
||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||
edit={props.followup?.edit}
|
||||
onEditLoaded={props.followup?.onEditLoaded}
|
||||
shouldQueue={props.followup?.queue}
|
||||
onQueue={props.followup?.onQueue}
|
||||
onAbort={props.followup?.onAbort}
|
||||
onSubmit={props.onSubmit}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={props.promptInput.ref}
|
||||
ref={props.inputRef}
|
||||
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||
>
|
||||
<span>{language.t("session.child.promptDisabled")} </span>
|
||||
<Show when={parentID() && props.openParent}>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-base transition-colors hover:text-text-strong"
|
||||
onClick={props.openParent}
|
||||
onClick={openParent}
|
||||
>
|
||||
{language.t("session.child.backToParent")}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { todoDockAtBoundary, todoState } from "./session-composer-state"
|
||||
import { todoState } from "./session-composer-state"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
@@ -126,13 +126,3 @@ describe("todoState", () => {
|
||||
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todoDockAtBoundary", () => {
|
||||
test("shows active todos when entering a session", () => {
|
||||
expect(todoDockAtBoundary("open")).toBe(true)
|
||||
})
|
||||
|
||||
test("hides completed todos when entering a session", () => {
|
||||
expect(todoDockAtBoundary("close")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,8 +21,6 @@ export const todoState = (input: {
|
||||
return "close"
|
||||
}
|
||||
|
||||
export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
||||
@@ -62,9 +60,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
sessionID: params.id,
|
||||
responding: undefined as string | undefined,
|
||||
dock: todos().length > 0 && !done() && live(),
|
||||
dock: todos().length > 0 && live(),
|
||||
closing: false,
|
||||
opening: false,
|
||||
})
|
||||
@@ -119,8 +116,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.id, todos().length, done(), live()] as const,
|
||||
([id, count, complete, active], previous) => {
|
||||
() => [todos().length, done(), live()] as const,
|
||||
([count, complete, active]) => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = undefined
|
||||
|
||||
@@ -130,14 +127,6 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
live: active,
|
||||
})
|
||||
|
||||
if (!previous || previous[0] !== id) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
|
||||
if (next === "clear") clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "hide") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
@@ -192,12 +181,9 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
permissionResponding,
|
||||
decide,
|
||||
todos,
|
||||
dock: () =>
|
||||
store.sessionID === params.id
|
||||
? store.dock
|
||||
: todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
|
||||
closing: () => store.sessionID === params.id && store.closing,
|
||||
opening: () => store.sessionID === params.id && store.opening,
|
||||
dock: () => store.dock,
|
||||
closing: () => store.closing,
|
||||
opening: () => store.opening,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ function dot(status: Todo["status"]) {
|
||||
}
|
||||
|
||||
export function SessionTodoDock(props: {
|
||||
sessionID?: string
|
||||
todos: Todo[]
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
@@ -49,7 +50,7 @@ export function SessionTodoDock(props: {
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
height: 78,
|
||||
height: 320,
|
||||
})
|
||||
|
||||
const total = createMemo(() => props.todos.length)
|
||||
@@ -84,7 +85,7 @@ export function SessionTodoDock(props: {
|
||||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setStore("height", (height) => Math.max(height, el.scrollHeight))
|
||||
setStore("height", el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
createResizeObserver(el, update)
|
||||
|
||||
@@ -50,25 +50,6 @@ const btn = (accent?: boolean) =>
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const controls = {
|
||||
agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} },
|
||||
model: {
|
||||
selection: {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||
},
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
|
||||
const css = `
|
||||
[data-component="todo-stage"] {
|
||||
display: grid;
|
||||
@@ -151,7 +132,6 @@ export const Playground = {
|
||||
const global = useServerSync()
|
||||
const [cfg, setCfg] = createStore({
|
||||
open: true,
|
||||
collapsed: false,
|
||||
step: 1,
|
||||
dockOpenDuration: 0.3,
|
||||
dockOpenBounce: 0,
|
||||
@@ -190,6 +170,7 @@ export const Playground = {
|
||||
const countWidthDuration = () => cfg.countWidthDuration
|
||||
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||
let frame
|
||||
let composerRef
|
||||
let scrollRef
|
||||
|
||||
const todos = createMemo<Todo[]>(() => {
|
||||
@@ -215,8 +196,15 @@ export const Playground = {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
|
||||
const collapsed = () => cfg.collapsed
|
||||
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
|
||||
const collapsed = () =>
|
||||
!!composerRef?.querySelector('[data-action="session-todo-toggle-button"][data-collapsed="true"]')
|
||||
|
||||
const setCollapsed = (value: boolean) => {
|
||||
const button = composerRef?.querySelector('[data-action="session-todo-toggle-button"]')
|
||||
if (!(button instanceof HTMLButtonElement)) return
|
||||
if (collapsed() === value) return
|
||||
button.click()
|
||||
}
|
||||
|
||||
const openDock = () => {
|
||||
clear()
|
||||
@@ -279,21 +267,14 @@ export const Playground = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div ref={composerRef}>
|
||||
<SessionComposerRegion
|
||||
state={state}
|
||||
sessionKey="story-session"
|
||||
sessionID="story-session"
|
||||
controls={controls}
|
||||
promptInput={{
|
||||
submission: { abort: () => {}, handleSubmit: (event) => event.preventDefault() },
|
||||
ref: () => {},
|
||||
newSessionWorktree: "",
|
||||
onNewSessionWorktreeReset: () => {},
|
||||
}}
|
||||
todo={{ collapsed: collapsed(), onToggle: () => setCollapsed(!collapsed()) }}
|
||||
ready
|
||||
centered={false}
|
||||
inputRef={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
onSubmit={() => {}}
|
||||
onResponseSubmit={pin}
|
||||
setPromptDockRef={() => {}}
|
||||
dockOpenVisualDuration={dockOpenDuration()}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { createComputed, onCleanup } from "solid-js"
|
||||
|
||||
export function createSessionOwnership(sessionKey: () => string) {
|
||||
let current = sessionKey()
|
||||
let generation = 0
|
||||
const transition = () => {
|
||||
const next = sessionKey()
|
||||
if (next === current) return
|
||||
current = next
|
||||
generation++
|
||||
}
|
||||
createComputed(transition)
|
||||
onCleanup(() => generation++)
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
transition()
|
||||
return `${generation}:${current}`
|
||||
},
|
||||
capture() {
|
||||
transition()
|
||||
const captured = generation
|
||||
return {
|
||||
key: `${captured}:${current}`,
|
||||
current: () => {
|
||||
transition()
|
||||
return generation === captured
|
||||
},
|
||||
run<T>(action: () => T) {
|
||||
transition()
|
||||
if (generation !== captured) return
|
||||
return action()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
@@ -65,7 +66,9 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { makeTimer } from "@solid-primitives/timer"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
@@ -274,7 +277,25 @@ export function MessageTimeline(props: {
|
||||
if (!id) return idle
|
||||
return sync().data.session_status[id] ?? idle
|
||||
})
|
||||
const working = createMemo(() => sessionStatus().type !== "idle")
|
||||
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent))
|
||||
|
||||
const [timeoutDone, setTimeoutDone] = createSignal(true)
|
||||
|
||||
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
|
||||
if (working()) return "showing"
|
||||
if (prev === "showing" || !timeoutDone()) return "hiding"
|
||||
return "hidden"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (workingStatus() !== "hiding") return
|
||||
|
||||
setTimeoutDone(false)
|
||||
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
|
||||
})
|
||||
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
@@ -1218,56 +1239,26 @@ export function MessageTimeline(props: {
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"bottom-8": settings.general.newLayoutDesigns(),
|
||||
"bottom-6": !settings.general.newLayoutDesigns(),
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
|
||||
"opacity-0 translate-y-2 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
|
||||
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && settings.general.newLayoutDesigns(),
|
||||
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !settings.general.newLayoutDesigns(),
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("session.messages.jumpToLatest")}
|
||||
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("session.messages.jumpToLatest")}
|
||||
class="pointer-events-auto flex items-center justify-center w-8 h-7 px-2 py-1.5 rounded-lg border-none cursor-pointer text-v2-text-text-base backdrop-blur-[2px]"
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--v2-background-bg-base) 92%, transparent)",
|
||||
"box-shadow": "var(--v2-elevation-raised), 0px 2px 8px var(--v2-background-bg-base)",
|
||||
"box-shadow":
|
||||
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||
}}
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12.3333 8.66665L8 13L3.66667 8.66665M8 12.6667V2.83332"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<ScrollView
|
||||
viewportRef={bindListRoot}
|
||||
@@ -1317,6 +1308,23 @@ export function MessageTimeline(props: {
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: working() ? "16px" : "0px",
|
||||
"margin-right": working() ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show when={workingStatus() !== "hidden"}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
@@ -51,24 +50,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionTabs = useTabs()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||
const owner = sessionOwnership.capture()
|
||||
const value = await load()
|
||||
owner.run(() => show(value))
|
||||
}
|
||||
const runCommand = async <T,>(input: {
|
||||
owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>
|
||||
prompt: T
|
||||
request: () => Promise<unknown>
|
||||
updatePrompt: (prompt: T) => void
|
||||
updateViewport: () => void
|
||||
}) => {
|
||||
await input.request()
|
||||
input.updatePrompt(input.prompt)
|
||||
input.owner.run(input.updateViewport)
|
||||
}
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const info = () => {
|
||||
const id = params.id
|
||||
@@ -235,10 +217,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const openFile = () => {
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-file"),
|
||||
(x) => dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />),
|
||||
)
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
@@ -272,17 +253,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const chooseModel = () => {
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-model"),
|
||||
(x) => dialog.show(() => <x.DialogSelectModel model={local.model} />),
|
||||
)
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
}
|
||||
|
||||
const chooseMcp = () => {
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-mcp"),
|
||||
(x) => dialog.show(() => <x.DialogSelectMcp />),
|
||||
)
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAutoAccept = () => {
|
||||
@@ -306,61 +285,47 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const undo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const client = sdk().client
|
||||
const directory = sdk().directory
|
||||
const promptSession = prompt.capture()
|
||||
const revert = info()?.revert?.messageID
|
||||
const messages = userMessages()
|
||||
const message = findLast(messages, (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
const parts = sync().data.part[message.id]
|
||||
|
||||
if (sync().data.session_working(sessionID)) {
|
||||
await client.session.abort({ sessionID }).catch(() => {})
|
||||
if (sync().data.session_working(params.id ?? "")) {
|
||||
await sdk()
|
||||
.client.session.abort({ sessionID })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.revert({ sessionID, messageID: message.id }),
|
||||
updatePrompt: (promptSession) => {
|
||||
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
||||
},
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)),
|
||||
})
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
|
||||
await sdk().client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync().data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk().directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
|
||||
const prev = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(prev)
|
||||
}
|
||||
|
||||
const redo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const client = sdk().client
|
||||
const messages = userMessages()
|
||||
const promptSession = prompt.capture()
|
||||
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
|
||||
const next = messages.find((x) => x.id > revertMessageID)
|
||||
const next = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!next) {
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.unrevert({ sessionID }),
|
||||
updatePrompt: (promptSession) => promptSession.reset(),
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
|
||||
})
|
||||
await sdk().client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(last)
|
||||
return
|
||||
}
|
||||
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.revert({ sessionID, messageID: next.id }),
|
||||
updatePrompt: () => undefined,
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
|
||||
})
|
||||
await sdk().client.session.revert({ sessionID, messageID: next.id })
|
||||
const prev = findLast(userMessages(), (x) => x.id < next.id)
|
||||
setActiveMessage(prev)
|
||||
}
|
||||
|
||||
const compact = async () => {
|
||||
@@ -384,10 +349,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const fork = () => {
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-fork"),
|
||||
(x) => dialog.show(() => <x.DialogFork />),
|
||||
)
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
}
|
||||
|
||||
const shareCmds = () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotFoundError } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
|
||||
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors"
|
||||
import { formatServerError, parseReadableConfigInvalidError } from "./server-errors"
|
||||
|
||||
function fill(text: string, vars?: Record<string, string | number>) {
|
||||
if (!vars) return text
|
||||
@@ -143,33 +142,3 @@ describe("formatServerError", () => {
|
||||
expect(formatServerError(wrapped, language.t)).toBe("Arquivo de config em config invalido: Missing host")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSessionNotFoundError", () => {
|
||||
test("matches an SDK-wrapped error for the requested session", () => {
|
||||
const body = {
|
||||
_tag: "SessionNotFoundError",
|
||||
sessionID: "ses_missing",
|
||||
message: "Session not found",
|
||||
} satisfies SessionNotFoundError
|
||||
|
||||
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), body.sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects errors for other sessions and other 404 responses", () => {
|
||||
const body = {
|
||||
_tag: "SessionNotFoundError",
|
||||
sessionID: "ses_parent",
|
||||
message: "Session not found",
|
||||
} satisfies SessionNotFoundError
|
||||
|
||||
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), "ses_tab")).toBe(false)
|
||||
expect(
|
||||
isSessionNotFoundError(
|
||||
new Error("Provider not found", {
|
||||
cause: { body: { _tag: "ProviderNotFoundError", providerID: "missing" }, status: 404 },
|
||||
}),
|
||||
"ses_tab",
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,13 +42,6 @@ function unwrapNamedError(error: unknown): unknown {
|
||||
return error
|
||||
}
|
||||
|
||||
export function isSessionNotFoundError(error: unknown, sessionID: string) {
|
||||
const unwrapped = unwrapNamedError(error)
|
||||
if (typeof unwrapped !== "object" || unwrapped === null) return false
|
||||
const value = unwrapped as Record<string, unknown>
|
||||
return value._tag === "SessionNotFoundError" && value.sessionID === sessionID
|
||||
}
|
||||
|
||||
function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
const o = error as Record<string, unknown>
|
||||
|
||||
@@ -1,38 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import {
|
||||
legacySessionHref,
|
||||
legacySessionServer,
|
||||
requireServerKey,
|
||||
rootSession,
|
||||
selectSessionLineage,
|
||||
sessionHref,
|
||||
} from "./session-route"
|
||||
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
|
||||
|
||||
describe("session routes", () => {
|
||||
test("uses the unique persisted server for a legacy session route", () => {
|
||||
expect(
|
||||
legacySessionServer(
|
||||
[{ type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" }],
|
||||
"session-1",
|
||||
ServerConnection.Key.make("server-a"),
|
||||
),
|
||||
).toBe(ServerConnection.Key.make("server-b"))
|
||||
})
|
||||
|
||||
test("prefers the active server when a legacy session ID is ambiguous", () => {
|
||||
expect(
|
||||
legacySessionServer(
|
||||
[
|
||||
{ type: "session", server: ServerConnection.Key.make("server-a"), sessionId: "session-1" },
|
||||
{ type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" },
|
||||
],
|
||||
"session-1",
|
||||
ServerConnection.Key.make("server-b"),
|
||||
),
|
||||
).toBe(ServerConnection.Key.make("server-b"))
|
||||
})
|
||||
|
||||
test("builds and decodes a server-keyed session route", () => {
|
||||
const server = ServerConnection.Key.make("https://example.com:4096")
|
||||
const href = sessionHref(server, "session-1")
|
||||
@@ -75,10 +45,4 @@ describe("session routes", () => {
|
||||
|
||||
expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child")
|
||||
})
|
||||
|
||||
test("ignores a resolved lineage retained from the previous route", () => {
|
||||
const previous = { session: { id: "A" }, root: { id: "A" } }
|
||||
|
||||
expect(selectSessionLineage("B", undefined, previous)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,26 +16,8 @@ export function requireServerKey(segment: string | undefined) {
|
||||
return ServerConnection.Key.make(key)
|
||||
}
|
||||
|
||||
export function legacySessionServer(
|
||||
tabs: readonly { type: "session"; server: ServerConnection.Key; sessionId: string }[],
|
||||
sessionID: string,
|
||||
active: ServerConnection.Key,
|
||||
) {
|
||||
const matches = tabs.filter((tab) => tab.sessionId === sessionID)
|
||||
return matches.find((tab) => tab.server === active)?.server ?? (matches.length === 1 ? matches[0]?.server : active)
|
||||
}
|
||||
|
||||
type SessionParent = { id: string; parentID?: string }
|
||||
|
||||
export function selectSessionLineage<T extends { session: { id: string } }>(
|
||||
sessionID: string,
|
||||
cached: T | undefined,
|
||||
resolved: T | undefined,
|
||||
) {
|
||||
if (cached?.session.id === sessionID) return cached
|
||||
if (resolved?.session.id === sessionID) return resolved
|
||||
}
|
||||
|
||||
export async function rootSession<T extends SessionParent>(session: T, get: (sessionID: string) => Promise<T>) {
|
||||
const seen = new Set([session.id])
|
||||
let current = session
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
|
||||
test("snaps spring progress when the session changes", async () => {
|
||||
const state = createRoot((dispose) => {
|
||||
const [target, setTarget] = createSignal(0)
|
||||
const [session, setSession] = createSignal("session-a")
|
||||
const progress = useSpring(target, { visualDuration: 0.3, bounce: 0 }, session)
|
||||
return { dispose, progress, setTarget, setSession }
|
||||
})
|
||||
|
||||
await new Promise<void>(queueMicrotask)
|
||||
state.setTarget(1)
|
||||
expect(state.progress()).toBe(0)
|
||||
|
||||
state.setSession("session-b")
|
||||
expect(state.progress()).toBe(1)
|
||||
state.dispose()
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
|
||||
describe("prompt attachment session ownership", () => {
|
||||
test("adds an asynchronously read image to the session where the read started", async () => {
|
||||
await createRoot(async (dispose) => {
|
||||
const sessions = { A: createPromptState(), B: createPromptState() }
|
||||
let active: "A" | "B" = "A"
|
||||
const attachments = createPromptAttachmentsCore({
|
||||
capture: () => sessions[active].capture(),
|
||||
editor: () => document.createElement("div"),
|
||||
})
|
||||
const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" }))
|
||||
|
||||
active = "B"
|
||||
await pending
|
||||
|
||||
expect(images(sessions.A)).toHaveLength(1)
|
||||
expect(images(sessions.B)).toHaveLength(0)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("finishes the captured attachment after the active editor is removed", async () => {
|
||||
await createRoot(async (dispose) => {
|
||||
const prompt = createPromptState()
|
||||
let editor: HTMLDivElement | undefined = document.createElement("div")
|
||||
const attachments = createPromptAttachmentsCore({
|
||||
capture: prompt.capture,
|
||||
editor: () => editor,
|
||||
})
|
||||
const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" }))
|
||||
|
||||
editor = undefined
|
||||
await pending
|
||||
|
||||
expect(images(prompt)).toHaveLength(1)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps every file in a batch on the session where the batch started", async () => {
|
||||
await createRoot(async (dispose) => {
|
||||
const sessions = { A: createPromptState(), B: createPromptState() }
|
||||
let active: "A" | "B" = "A"
|
||||
const attachments = createPromptAttachmentsCore({
|
||||
capture: () => sessions[active].capture(),
|
||||
editor: () => document.createElement("div"),
|
||||
})
|
||||
const pending = attachments.addAttachments([
|
||||
new File([new Uint8Array(1024 * 1024)], "first.png", { type: "image/png" }),
|
||||
new File([new Uint8Array(1024 * 1024)], "second.png", { type: "image/png" }),
|
||||
])
|
||||
|
||||
active = "B"
|
||||
await pending
|
||||
|
||||
expect(images(sessions.A)).toHaveLength(2)
|
||||
expect(images(sessions.B)).toHaveLength(0)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps a delayed native clipboard image on the session where paste started", async () => {
|
||||
await createRoot(async (dispose) => {
|
||||
const sessions = { A: createPromptState(), B: createPromptState() }
|
||||
const read = Promise.withResolvers<File | null>()
|
||||
let active: "A" | "B" = "A"
|
||||
const attachments = createPromptAttachmentsCore({
|
||||
capture: () => sessions[active].capture(),
|
||||
editor: () => document.createElement("div"),
|
||||
})
|
||||
const pending = attachments.addClipboardAttachment(read.promise)
|
||||
|
||||
active = "B"
|
||||
read.resolve(new File([new Uint8Array(1024 * 1024)], "clipboard.png", { type: "image/png" }))
|
||||
await pending
|
||||
|
||||
expect(images(sessions.A)).toHaveLength(1)
|
||||
expect(images(sessions.B)).toHaveLength(0)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function images(prompt: ReturnType<typeof createPromptState>) {
|
||||
return prompt.current().filter((part) => part.type === "image")
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { selectPromptTab } from "@/context/prompt"
|
||||
import type { Tab } from "@/context/tabs"
|
||||
|
||||
test("selects the explicitly scoped session tab instead of the active tab", () => {
|
||||
const server = ServerConnection.Key.make("local")
|
||||
const tabs: Tab[] = [
|
||||
{ type: "session", server, sessionId: "A" },
|
||||
{ type: "session", server, sessionId: "B" },
|
||||
]
|
||||
|
||||
expect(selectPromptTab(tabs, { dir: "repo", id: "B" }, server)).toBe(tabs[1])
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { createPromptSubmissionState } from "@/components/prompt-input/submission-state"
|
||||
|
||||
describe("prompt submission state", () => {
|
||||
test("keeps failed submission restoration with the prompt where it started", () => {
|
||||
const target = createPromptState()
|
||||
const submission = createPromptSubmissionState({
|
||||
target,
|
||||
prompt: [{ type: "text", content: "prompt-A", start: 0, end: 8 }],
|
||||
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
|
||||
})
|
||||
|
||||
expect(submission.restore()).toEqual({
|
||||
target,
|
||||
prompt: [{ type: "text", content: "prompt-A", start: 0, end: 8 }],
|
||||
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("moves first-submit restoration and context to the promoted session", () => {
|
||||
const draft = createPromptState()
|
||||
const session = createPromptState()
|
||||
const submission = createPromptSubmissionState({
|
||||
target: draft,
|
||||
prompt: [{ type: "text", content: "first prompt", start: 0, end: 12 }],
|
||||
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
|
||||
})
|
||||
|
||||
submission.retarget(session)
|
||||
|
||||
expect(submission.restore()).toEqual({
|
||||
target: session,
|
||||
prompt: [{ type: "text", content: "first prompt", start: 0, end: 12 }],
|
||||
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
|
||||
})
|
||||
expect(session.context.items()).toHaveLength(1)
|
||||
expect(session.context.items()[0]).toMatchObject({ type: "file", path: "src/index.ts" })
|
||||
})
|
||||
|
||||
test("does not restore over a prompt edited after submission", () => {
|
||||
const target = createPromptState()
|
||||
target.set([{ type: "text", content: "submitted", start: 0, end: 9 }])
|
||||
const submission = createPromptSubmissionState({
|
||||
target,
|
||||
prompt: target.current(),
|
||||
context: [],
|
||||
})
|
||||
|
||||
submission.clear()
|
||||
target.set([{ type: "text", content: "new draft", start: 0, end: 9 }])
|
||||
|
||||
expect(submission.restore()).toBeUndefined()
|
||||
expect(target.current()[0]).toMatchObject({ type: "text", content: "new draft" })
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createPromptInputTransientState } from "@/components/prompt-input/transient-state"
|
||||
|
||||
test("resets transient prompt input state when the prompt session changes", () => {
|
||||
createRoot((dispose) => {
|
||||
const [identity, setIdentity] = createSignal("A")
|
||||
const [state, setState] = createPromptInputTransientState(identity, 3)
|
||||
setState({
|
||||
popover: "slash",
|
||||
historyIndex: 2,
|
||||
savedPrompt: {
|
||||
prompt: [{ type: "text", content: "draft-A", start: 0, end: 7 }],
|
||||
comments: [],
|
||||
},
|
||||
draggingType: "image",
|
||||
mode: "shell",
|
||||
applyingHistory: true,
|
||||
variantOpen: true,
|
||||
})
|
||||
|
||||
setIdentity("B")
|
||||
|
||||
expect(state).toMatchObject({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
placeholder: 3,
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createSessionOwnership } from "@/pages/session/session-ownership"
|
||||
|
||||
describe("createSessionOwnership", () => {
|
||||
test("invalidates captured work when its Solid owner is disposed", () => {
|
||||
let current = true
|
||||
createRoot((dispose) => {
|
||||
const owner = createSessionOwnership(() => "A").capture()
|
||||
dispose()
|
||||
current = owner.current()
|
||||
})
|
||||
|
||||
expect(current).toBe(false)
|
||||
})
|
||||
|
||||
test("does not run a continuation after navigation", () => {
|
||||
createRoot((dispose) => {
|
||||
const [session, setSession] = createSignal("A")
|
||||
const owner = createSessionOwnership(session).capture()
|
||||
let ran = false
|
||||
|
||||
setSession("B")
|
||||
owner.run(() => {
|
||||
ran = true
|
||||
})
|
||||
|
||||
expect(ran).toBe(false)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("does not revive a continuation after A to B to A navigation", () => {
|
||||
createRoot((dispose) => {
|
||||
const [session, setSession] = createSignal("A")
|
||||
const owner = createSessionOwnership(session).capture()
|
||||
|
||||
setSession("B")
|
||||
setSession("A")
|
||||
|
||||
expect(owner.current()).toBe(false)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# @opencode-ai/client
|
||||
|
||||
Private generation target for clients derived directly from OpenCode's authoritative Effect `HttpApi`.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
|
||||
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
|
||||
|
||||
The generated surface starts with the Session group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
|
||||
|
||||
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
|
||||
|
||||
The Promise root remains structural and has no Core or Effect runtime dependency. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce both import graphs.
|
||||
|
||||
Effect consumers construct canonical decoded inputs:
|
||||
|
||||
```ts
|
||||
import { AbsolutePath, Location, OpenCode, Prompt } from "@opencode-ai/client/effect"
|
||||
|
||||
const client = yield * OpenCode.make({ baseUrl: "https://opencode.example" })
|
||||
yield *
|
||||
client.sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }),
|
||||
})
|
||||
yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) })
|
||||
```
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/client",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effect": "./src/effect.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "bun run script/build.ts",
|
||||
"check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect",
|
||||
"test": "bun test --timeout 5000",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.83"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApi } from "effect/unstable/httpapi"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
|
||||
groupNames: { "server.session": "sessions" },
|
||||
})
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
[
|
||||
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
|
||||
write(
|
||||
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
|
||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||
),
|
||||
],
|
||||
{ concurrency: 2, discard: true },
|
||||
).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
|
||||
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
||||
"@opencode-ai/client/LocationMiddleware",
|
||||
) {}
|
||||
|
||||
class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
|
||||
"@opencode-ai/client/SessionLocationMiddleware",
|
||||
{ error: [InvalidRequestError, SessionNotFoundError] },
|
||||
) {}
|
||||
|
||||
const Api = makeDefaultApi({
|
||||
locationMiddleware: LocationMiddleware,
|
||||
sessionLocationMiddleware: SessionLocationMiddleware,
|
||||
})
|
||||
|
||||
export const SessionGroup = Api.groups["server.session"]
|
||||
@@ -1,12 +0,0 @@
|
||||
// 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"
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
"client-error.ts",
|
||||
"client.ts",
|
||||
"index.ts"
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
@@ -1,164 +0,0 @@
|
||||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { SessionGroup } from "../contract"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
const Api = HttpApi.make("generated").add(SessionGroup)
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof Api>
|
||||
|
||||
const mapClientError = <E>(error: E) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: error
|
||||
|
||||
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
|
||||
type Endpoint0_0Input = {
|
||||
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
|
||||
readonly limit?: Endpoint0_0Request["query"]["limit"]
|
||||
readonly order?: Endpoint0_0Request["query"]["order"]
|
||||
readonly search?: Endpoint0_0Request["query"]["search"]
|
||||
readonly directory?: Endpoint0_0Request["query"]["directory"]
|
||||
readonly project?: Endpoint0_0Request["query"]["project"]
|
||||
readonly subpath?: Endpoint0_0Request["query"]["subpath"]
|
||||
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
|
||||
}
|
||||
const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) =>
|
||||
raw["session.list"]({
|
||||
query: {
|
||||
workspace: input?.workspace,
|
||||
limit: input?.limit,
|
||||
order: input?.order,
|
||||
search: input?.search,
|
||||
directory: input?.directory,
|
||||
project: input?.project,
|
||||
subpath: input?.subpath,
|
||||
cursor: input?.cursor,
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
|
||||
type Endpoint0_1Input = {
|
||||
readonly id?: Endpoint0_1Request["payload"]["id"]
|
||||
readonly agent?: Endpoint0_1Request["payload"]["agent"]
|
||||
readonly model?: Endpoint0_1Request["payload"]["model"]
|
||||
readonly location?: Endpoint0_1Request["payload"]["location"]
|
||||
}
|
||||
const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) =>
|
||||
raw["session.create"]({
|
||||
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
|
||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
|
||||
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint0_3Input = {
|
||||
readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint0_3Request["payload"]["agent"]
|
||||
}
|
||||
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
||||
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint0_4Input = {
|
||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||
readonly model: Endpoint0_4Request["payload"]["model"]
|
||||
}
|
||||
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
||||
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint0_5Input = {
|
||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint0_5Request["payload"]["id"]
|
||||
readonly prompt: Endpoint0_5Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint0_5Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
|
||||
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
||||
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint0_8Input = {
|
||||
readonly sessionID: Endpoint0_8Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_8Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint0_8Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { messageID: input.messageID, files: input.files },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
|
||||
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
|
||||
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
|
||||
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint0_0(raw),
|
||||
create: Endpoint0_1(raw),
|
||||
get: Endpoint0_2(raw),
|
||||
switchAgent: Endpoint0_3(raw),
|
||||
switchModel: Endpoint0_4(raw),
|
||||
prompt: Endpoint0_5(raw),
|
||||
compact: Endpoint0_6(raw),
|
||||
wait: Endpoint0_7(raw),
|
||||
stage: Endpoint0_8(raw),
|
||||
clear: Endpoint0_9(raw),
|
||||
commit: Endpoint0_10(raw),
|
||||
context: Endpoint0_11(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ClientError } from "./client-error"
|
||||
export * as OpenCode from "./client"
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
"client-error.ts",
|
||||
"client.ts",
|
||||
"index.ts",
|
||||
"types.ts"
|
||||
]
|
||||
@@ -1,11 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import type {
|
||||
SessionsListInput,
|
||||
SessionsListOutput,
|
||||
SessionsCreateInput,
|
||||
SessionsCreateOutput,
|
||||
SessionsGetInput,
|
||||
SessionsGetOutput,
|
||||
SessionsSwitchAgentInput,
|
||||
SessionsSwitchAgentOutput,
|
||||
SessionsSwitchModelInput,
|
||||
SessionsSwitchModelOutput,
|
||||
SessionsPromptInput,
|
||||
SessionsPromptOutput,
|
||||
SessionsCompactInput,
|
||||
SessionsCompactOutput,
|
||||
SessionsWaitInput,
|
||||
SessionsWaitOutput,
|
||||
SessionsStageInput,
|
||||
SessionsStageOutput,
|
||||
SessionsClearInput,
|
||||
SessionsClearOutput,
|
||||
SessionsCommitInput,
|
||||
SessionsCommitOutput,
|
||||
SessionsContextInput,
|
||||
SessionsContextOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
export interface ClientOptions {
|
||||
readonly baseUrl: string
|
||||
readonly fetch?: typeof globalThis.fetch
|
||||
readonly headers?: HeadersInit
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
readonly signal?: AbortSignal
|
||||
readonly headers?: HeadersInit
|
||||
}
|
||||
|
||||
interface RequestDescriptor {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly query?: Record<string, unknown>
|
||||
readonly headers?: Record<string, unknown>
|
||||
readonly body?: unknown
|
||||
readonly successStatus: number
|
||||
readonly declaredStatuses: ReadonlyArray<number>
|
||||
readonly empty: boolean
|
||||
}
|
||||
|
||||
export function make(options: ClientOptions) {
|
||||
const fetch = options.fetch ?? globalThis.fetch
|
||||
|
||||
const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
|
||||
const url = new URL(descriptor.path, options.baseUrl)
|
||||
for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)
|
||||
const headers = new Headers(options.headers)
|
||||
for (const [key, value] of Object.entries(descriptor.headers ?? {})) {
|
||||
if (value !== undefined && value !== null) headers.set(key, String(value))
|
||||
}
|
||||
for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)
|
||||
if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
|
||||
return {
|
||||
url,
|
||||
init: {
|
||||
method: descriptor.method,
|
||||
signal: requestOptions?.signal,
|
||||
headers,
|
||||
body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),
|
||||
} satisfies RequestInit,
|
||||
}
|
||||
}
|
||||
|
||||
const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
|
||||
try {
|
||||
const prepared = prepare(descriptor, requestOptions)
|
||||
return await fetch(prepared.url, prepared.init)
|
||||
} catch (cause) {
|
||||
throw new ClientError("Transport", { cause })
|
||||
}
|
||||
}
|
||||
|
||||
const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
|
||||
if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
|
||||
try {
|
||||
await response.body?.cancel()
|
||||
} catch {}
|
||||
throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })
|
||||
}
|
||||
|
||||
const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
|
||||
const response = await execute(descriptor, requestOptions)
|
||||
if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
|
||||
if (descriptor.empty) {
|
||||
try {
|
||||
await response.body?.cancel()
|
||||
} catch {}
|
||||
return undefined as A
|
||||
}
|
||||
return (await json(response)) as A
|
||||
}
|
||||
|
||||
const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const response = await execute(descriptor, requestOptions)
|
||||
if (response.status !== descriptor.successStatus) await responseError(response, descriptor)
|
||||
if (!isContentType(response, "text/event-stream")) {
|
||||
try {
|
||||
await response.body?.cancel()
|
||||
} catch {}
|
||||
throw new ClientError("UnsupportedContentType")
|
||||
}
|
||||
if (response.body === null) throw new ClientError("MalformedResponse")
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
try {
|
||||
while (true) {
|
||||
let next
|
||||
try {
|
||||
next = await reader.read()
|
||||
} catch (cause) {
|
||||
throw new ClientError("Transport", { cause })
|
||||
}
|
||||
buffer += decoder.decode(next.value, { stream: !next.done })
|
||||
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
|
||||
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
|
||||
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
|
||||
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
||||
if (trailingCarriageReturn) buffer += "\r"
|
||||
if (next.done && buffer !== "") buffer += "\n\n"
|
||||
let boundary = buffer.indexOf("\n\n")
|
||||
while (boundary >= 0) {
|
||||
const block = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const data = block
|
||||
.split("\n")
|
||||
.flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : []))
|
||||
.join("\n")
|
||||
if (data !== "") {
|
||||
try {
|
||||
yield JSON.parse(data) as A
|
||||
} catch (cause) {
|
||||
throw new ClientError("MalformedResponse", { cause })
|
||||
}
|
||||
}
|
||||
boundary = buffer.indexOf("\n\n")
|
||||
}
|
||||
if (next.done) return
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch {}
|
||||
reader.releaseLock()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session`,
|
||||
query: {
|
||||
workspace: input?.workspace,
|
||||
limit: input?.limit,
|
||||
order: input?.order,
|
||||
search: input?.search,
|
||||
directory: input?.directory,
|
||||
project: input?.project,
|
||||
subpath: input?.subpath,
|
||||
cursor: input?.cursor,
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsCreateOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session`,
|
||||
body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsGetOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsSwitchAgentOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
|
||||
body: { agent: input.agent },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsSwitchModelOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
|
||||
body: { model: input.model },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsPromptOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
|
||||
body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsCompactOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 503, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsWaitOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 503, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
stage: (input: SessionsStageInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsStageOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
|
||||
body: { messageID: input.messageID, files: input.files },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
clear: (input: SessionsClearInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsClearOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsCommitOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
context: (input: SessionsContextInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsContextOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
|
||||
if (value === undefined || value === null) return
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) appendQuery(params, key, item)
|
||||
return
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item)
|
||||
return
|
||||
}
|
||||
params.append(key, String(value))
|
||||
}
|
||||
|
||||
async function json(response: Response): Promise<unknown> {
|
||||
if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {
|
||||
try {
|
||||
await response.body?.cancel()
|
||||
} catch {}
|
||||
throw new ClientError("UnsupportedContentType")
|
||||
}
|
||||
let text: string
|
||||
try {
|
||||
text = await response.text()
|
||||
} catch (cause) {
|
||||
throw new ClientError("Transport", { cause })
|
||||
}
|
||||
if (text === "") throw new ClientError("MalformedResponse")
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch (cause) {
|
||||
throw new ClientError("MalformedResponse", { cause })
|
||||
}
|
||||
}
|
||||
|
||||
function isContentType(response: Response, expected: string) {
|
||||
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { ClientError, type ClientErrorReason } from "./client-error"
|
||||
export * as OpenCode from "./client"
|
||||
export * from "./types"
|
||||
@@ -1,631 +0,0 @@
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| ReadonlyArray<JsonValue>
|
||||
| { readonly [key: string]: JsonValue }
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidCursorError"
|
||||
|
||||
export type InvalidRequestError = {
|
||||
readonly _tag: "InvalidRequestError"
|
||||
readonly message: string
|
||||
readonly kind?: string | undefined
|
||||
readonly field?: string | undefined
|
||||
}
|
||||
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidRequestError"
|
||||
|
||||
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnauthorizedError"
|
||||
|
||||
export type SessionNotFoundError = {
|
||||
readonly _tag: "SessionNotFoundError"
|
||||
readonly sessionID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionNotFoundError"
|
||||
|
||||
export type ConflictError = {
|
||||
readonly _tag: "ConflictError"
|
||||
readonly message: string
|
||||
readonly resource?: string | undefined
|
||||
}
|
||||
export const isConflictError = (value: unknown): value is ConflictError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ServiceUnavailableError"
|
||||
|
||||
export type MessageNotFoundError = {
|
||||
readonly _tag: "MessageNotFoundError"
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "MessageNotFoundError"
|
||||
|
||||
export type UnknownError = {
|
||||
readonly _tag: "UnknownError"
|
||||
readonly message: string
|
||||
readonly ref?: string | undefined
|
||||
}
|
||||
export const isUnknownError = (value: unknown): value is UnknownError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnknownError"
|
||||
|
||||
export type SessionsListInput = {
|
||||
readonly workspace?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["workspace"]
|
||||
readonly limit?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["limit"]
|
||||
readonly order?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["order"]
|
||||
readonly search?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["search"]
|
||||
readonly directory?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["directory"]
|
||||
readonly project?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["project"]
|
||||
readonly subpath?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["subpath"]
|
||||
readonly cursor?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["cursor"]
|
||||
}
|
||||
|
||||
export type SessionsListOutput = {
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly parentID?: string
|
||||
readonly projectID: string
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
|
||||
readonly cost: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
|
||||
readonly title: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string | null | null }
|
||||
readonly subpath?: string | null
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string | null
|
||||
readonly snapshot?: string | null
|
||||
readonly diff?: string | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly path: string
|
||||
readonly status: "added" | "modified" | "deleted"
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly patch: string
|
||||
}> | null
|
||||
} | null
|
||||
}>
|
||||
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
|
||||
}
|
||||
|
||||
export type SessionsCreateInput = {
|
||||
readonly id?: {
|
||||
readonly id?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
|
||||
}["id"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly id?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
|
||||
}["model"]
|
||||
readonly location?: {
|
||||
readonly id?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type SessionsCreateOutput = {
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly parentID?: string
|
||||
readonly projectID: string
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
|
||||
readonly cost: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
|
||||
readonly title: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string | null | null }
|
||||
readonly subpath?: string | null
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string | null
|
||||
readonly snapshot?: string | null
|
||||
readonly diff?: string | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly path: string
|
||||
readonly status: "added" | "modified" | "deleted"
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly patch: string
|
||||
}> | null
|
||||
} | null
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionsGetOutput = {
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly parentID?: string
|
||||
readonly projectID: string
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
|
||||
readonly cost: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
|
||||
readonly title: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string | null | null }
|
||||
readonly subpath?: string | null
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string | null
|
||||
readonly snapshot?: string | null
|
||||
readonly diff?: string | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly path: string
|
||||
readonly status: "added" | "modified" | "deleted"
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly patch: string
|
||||
}> | null
|
||||
} | null
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type SessionsSwitchAgentInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly agent: { readonly agent: string }["agent"]
|
||||
}
|
||||
|
||||
export type SessionsSwitchAgentOutput = void
|
||||
|
||||
export type SessionsSwitchModelInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly model: {
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
|
||||
}["model"]
|
||||
}
|
||||
|
||||
export type SessionsSwitchModelOutput = void
|
||||
|
||||
export type SessionsPromptInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly id?: {
|
||||
readonly id?: string | undefined
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?:
|
||||
| ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | undefined
|
||||
readonly description?: string | undefined
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
|
||||
}>
|
||||
| undefined
|
||||
readonly agents?:
|
||||
| ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
|
||||
}>
|
||||
| undefined
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}["id"]
|
||||
readonly prompt: {
|
||||
readonly id?: string | undefined
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?:
|
||||
| ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | undefined
|
||||
readonly description?: string | undefined
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
|
||||
}>
|
||||
| undefined
|
||||
readonly agents?:
|
||||
| ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
|
||||
}>
|
||||
| undefined
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}["prompt"]
|
||||
readonly delivery?: {
|
||||
readonly id?: string | undefined
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?:
|
||||
| ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | undefined
|
||||
readonly description?: string | undefined
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
|
||||
}>
|
||||
| undefined
|
||||
readonly agents?:
|
||||
| ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
|
||||
}>
|
||||
| undefined
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}["delivery"]
|
||||
readonly resume?: {
|
||||
readonly id?: string | undefined
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?:
|
||||
| ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | undefined
|
||||
readonly description?: string | undefined
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
|
||||
}>
|
||||
| undefined
|
||||
readonly agents?:
|
||||
| ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
|
||||
}>
|
||||
| undefined
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}["resume"]
|
||||
}
|
||||
|
||||
export type SessionsPromptOutput = {
|
||||
readonly data: {
|
||||
readonly admittedSeq: number
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
readonly description?: string | null
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
|
||||
}> | null
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
|
||||
}> | null
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
readonly timeCreated: number
|
||||
readonly promotedSeq?: number | null
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionsCompactOutput = void
|
||||
|
||||
export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionsWaitOutput = void
|
||||
|
||||
export type SessionsStageInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"]
|
||||
readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"]
|
||||
}
|
||||
|
||||
export type SessionsStageOutput = {
|
||||
readonly data: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string | undefined
|
||||
readonly snapshot?: string | undefined
|
||||
readonly diff?: string | undefined
|
||||
readonly files?:
|
||||
| ReadonlyArray<{
|
||||
readonly path: string
|
||||
readonly status: "added" | "modified" | "deleted"
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly patch: string
|
||||
}>
|
||||
| undefined
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type SessionsClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionsClearOutput = void
|
||||
|
||||
export type SessionsCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionsCommitOutput = void
|
||||
|
||||
export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionsContextOutput = {
|
||||
readonly data: ReadonlyArray<
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "model-switched"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
readonly description?: string | null
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
|
||||
}> | null
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
|
||||
}> | null
|
||||
readonly type: "user"
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
readonly sessionID: string
|
||||
readonly text: string
|
||||
readonly type: "synthetic"
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "system"
|
||||
readonly text: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number; readonly completed?: number | null }
|
||||
readonly type: "shell"
|
||||
readonly callID: string
|
||||
readonly command: string
|
||||
readonly output: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number; readonly completed?: number | null }
|
||||
readonly type: "assistant"
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly id: string; readonly text: string }
|
||||
| {
|
||||
readonly type: "reasoning"
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly provider?: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
|
||||
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
|
||||
} | null
|
||||
readonly state:
|
||||
| { readonly status: "pending"; readonly input: string }
|
||||
| {
|
||||
readonly status: "running"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly structured: { readonly [x: string]: any }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
>
|
||||
}
|
||||
| {
|
||||
readonly status: "completed"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly attachments?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
readonly description?: string | null
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
|
||||
}> | null
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
>
|
||||
readonly outputPaths?: ReadonlyArray<string> | null
|
||||
readonly structured: { readonly [x: string]: any }
|
||||
readonly result?: JsonValue | null
|
||||
}
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
>
|
||||
readonly structured: { readonly [x: string]: any }
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly result?: JsonValue | null
|
||||
}
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly ran?: number | null
|
||||
readonly completed?: number | null
|
||||
readonly pruned?: number | null
|
||||
}
|
||||
}
|
||||
>
|
||||
readonly snapshot?: {
|
||||
readonly start?: string | null
|
||||
readonly end?: string | null
|
||||
readonly files?: ReadonlyArray<string> | null
|
||||
} | null
|
||||
readonly finish?: string | null
|
||||
readonly cost?: number | null
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
} | null
|
||||
readonly error?: { readonly type: "unknown"; readonly message: string } | null
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly time: { readonly created: number }
|
||||
}
|
||||
>
|
||||
}["data"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user