diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 29b5aa3047..037020c03a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -325,6 +325,7 @@ jobs: run: bun run build working-directory: packages/desktop env: + NODE_OPTIONS: --max-old-space-size=4096 OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13a76c0a1f..c69de1d93b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,6 +69,11 @@ jobs: env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + - name: Check generated client + if: runner.os == 'Linux' + working-directory: packages/client + run: bun run check:generated + - name: Run HttpApi exerciser gates if: runner.os == 'Linux' working-directory: packages/opencode diff --git a/.prettierignore b/.prettierignore index a2a2776596..7c72d70225 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,4 @@ sst-env.d.ts packages/desktop/src/bindings.ts +packages/client/src/generated/ +packages/client/src/generated-effect/ diff --git a/AGENTS.md b/AGENTS.md index 4c6be738db..2fe10ba44d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity - Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream - In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module. +- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`. Reduce total variable count by inlining when a value is only used once. diff --git a/CONTEXT.md b/CONTEXT.md index 4b5fabd93c..97919b63f6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -60,6 +60,21 @@ A temporary file created under OpenCode's shared tool-output directory to retain **PTY Environment**: The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. +**OpenCode Client**: +The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers. +_Avoid_: Remote client + +**SDK Contract IR**: +The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter. + +**Embedded OpenCode**: +A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly. +_Avoid_: Local implementation + +**Page**: +A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction. +_Avoid_: Response envelope + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -108,6 +123,51 @@ The host-supplied environment overlay applied by the server when creating a PTY, - Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. - A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. +- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs. +- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately. +- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers. +- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property. +- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names. +- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server. +- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently. +- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR. +- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR. +- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime. +- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface. +- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy. +- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors. +- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division. +- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred. +- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly. +- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction. +- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client. +- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides. +- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation. +- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently. +- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol. +- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client. +- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors. +- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names. +- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately. +- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes. +- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state. +- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior. +- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API. +- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed. +- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question. +- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented. +- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields. +- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor. +- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid. +- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. +- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op. +- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate. +- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata? +- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior. +- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics. +- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics. +- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session. +- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation. - A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. @@ -124,6 +184,23 @@ The host-supplied environment overlay applied by the server when creating a PTY, - **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority. - Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads. +## Client contract architecture + +Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server. + +Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases. + +Before stabilizing the client API: + +- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM. +- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API. +- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract. +- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier. +- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change. +- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests. +- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly. +- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client. + ## Example dialogue > **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?" diff --git a/bun.lock b/bun.lock index 411c09c8ff..7a5e89c7cb 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -87,7 +87,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.9", + "version": "1.17.11", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -110,9 +110,32 @@ "@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.9", + "version": "1.17.11", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -148,7 +171,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -175,7 +198,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -197,7 +220,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -221,7 +244,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -241,7 +264,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.9", + "version": "1.17.11", "bin": { "opencode": "./bin/opencode", }, @@ -334,7 +357,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -388,7 +411,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -402,7 +425,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "effect": "catalog:", }, @@ -414,7 +437,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -446,7 +469,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -462,7 +485,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -479,9 +502,21 @@ "effect": "4.0.0-beta.83", }, }, + "packages/httpapi-codegen": { + "name": "@opencode-ai/httpapi-codegen", + "dependencies": { + "effect": "catalog:", + "prettier": "3.6.2", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -500,7 +535,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.9", + "version": "1.17.11", "bin": { "opencode": "./bin/opencode", }, @@ -539,6 +574,8 @@ "@openauthjs/openauth": "catalog:", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", @@ -628,7 +665,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -645,9 +682,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.4.2", - "@opentui/keymap": ">=0.4.2", - "@opentui/solid": ">=0.4.2", + "@opentui/core": ">=0.3.4", + "@opentui/keymap": ">=0.3.4", + "@opentui/solid": ">=0.3.4", }, "optionalPeers": [ "@opentui/core", @@ -655,6 +692,18 @@ "@opentui/solid", ], }, + "packages/protocol": { + "name": "@opencode-ai/protocol", + "dependencies": { + "@opencode-ai/schema": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/schema": { "name": "@opencode-ai/schema", "dependencies": { @@ -676,9 +725,23 @@ "@types/semver": "^7.5.8", }, }, + "packages/sdk-next": { + "name": "@opencode-ai/sdk-next", + "dependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "cross-spawn": "catalog:", }, @@ -693,9 +756,10 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@opencode-ai/core": "workspace:*", + "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -707,7 +771,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -751,7 +815,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -764,7 +828,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -797,7 +861,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -816,7 +880,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -857,7 +921,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -884,7 +948,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.9", + "version": "1.17.11", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -894,8 +958,6 @@ "@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:", @@ -911,27 +973,32 @@ "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.9", + "version": "1.17.11", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1005,9 +1072,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.4.2", - "@opentui/keymap": "0.4.2", - "@opentui/solid": "0.4.2", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1832,6 +1899,8 @@ "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], + "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], + "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], @@ -1858,16 +1927,22 @@ "@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"], + "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], + "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], + "@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"], + "@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], + "@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"], + "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], @@ -1916,27 +1991,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.4.2", "", { "dependencies": { "bun-ffi-structs": "0.2.3", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.2", "@opentui/core-darwin-x64": "0.4.2", "@opentui/core-linux-arm64": "0.4.2", "@opentui/core-linux-arm64-musl": "0.4.2", "@opentui/core-linux-x64": "0.4.2", "@opentui/core-linux-x64-musl": "0.4.2", "@opentui/core-win32-arm64": "0.4.2", "@opentui/core-win32-x64": "0.4.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ulx6RMqftf2fm7Itf9e81GcCDMNY6NAhmnKYhllDOMYD+PxYXR+vomy2bxQNV5ow31RE7s8WQFnb7hWTRUbx2g=="], + "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0s/ooK+AhWP306By3gu+XhzcVEThC2sqKMPK1nQmGDujQhd+xOrtbtfCVcJSx62UzAovC2VNqypvP8vHByOg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cjv6Bv7l3p/KLNJr5RyqCS0FmRlAGJnkA2IK3S+HkHhCOv/O02S1G+DBUY6POnyjp1eNy95vauustApobhdbig=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], - "@opentui/keymap": ["@opentui/keymap@0.4.2", "", { "dependencies": { "@opentui/core": "0.4.2" }, "peerDependencies": { "@opentui/react": "0.4.2", "@opentui/solid": "0.4.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wxBEFfWgm3feqCRLckWg1JH4tbMJinpyK3yobkLTsWJ7PDsM+fPoFMyQ8ieKVdUL2eP6ELTmHvM1bHKShZ7SUQ=="], + "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], - "@opentui/solid": ["@opentui/solid@0.4.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zuYXsnrlsMtnXrS7QCYBdPzMtUSonG2LqnJikBR2NjEE2O4zEKvJd48n3eB1igcxjv96tiotTXRNCylYS0SNdQ=="], + "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -3114,7 +3189,7 @@ "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], - "bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], @@ -5472,6 +5547,8 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], diff --git a/nix/hashes.json b/nix/hashes.json index 17d8d49544..35b8d35ffe 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-cXEEfBfOY7umTADufUAgFdwwq8tfFWDOS1+6rsHeC3o=", - "aarch64-linux": "sha256-xX9IutXeIEdj0IuW5ohXQfcK7ovMZ7bY0mIAwJXc5pU=", - "aarch64-darwin": "sha256-9iXck/18RsR+ncVUL0e2miccZz1mEde9ddVvsg6L29A=", - "x86_64-darwin": "sha256-lFO/GpThzUw1uymb9EllJdg5ZTW/iaJf/6LaXzL/1y0=" + "x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=", + "aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=", + "aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=", + "x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE=" } } diff --git a/package.json b/package.json index 409b624caa..49507128d6 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.4.2", - "@opentui/keymap": "0.4.2", - "@opentui/solid": "0.4.2", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", "@tanstack/solid-virtual": "3.13.28", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts index 6353416d50..4dad1df37b 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts @@ -119,7 +119,6 @@ export async function setupTimelineBenchmark(page: Page, options: { historyTurns editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, - showSessionProgressBar: true, }, }), ) diff --git a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts index 1535dd2c70..401fb74496 100644 --- a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts +++ b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts @@ -13,7 +13,6 @@ export async function installTimelineSettings(page: Page) { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, - showSessionProgressBar: true, }, }), ) diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts new file mode 100644 index 0000000000..031440f166 --- /dev/null +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -0,0 +1,135 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const sessionA = session("ses_server_a", "C:/server-a", "Server A session") +const sessionB = session("ses_server_b", "/home/server-b", "Server B session") + +test("closing the active server's last tab opens the remaining server tab", async ({ page }) => { + const requests: string[] = [] + await mockServers(page, requests) + await page.addInitScript( + ({ serverB, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify([ + { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA }, + { type: "session", server: serverB, sessionId: sessionB }, + ]), + ) + }, + { serverB, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(hrefA) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + + const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`) + await tabA.locator('[data-slot="tab-close"] button').click() + + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) + expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) + expect( + requests.some((request) => { + const url = new URL(request) + return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory + }), + ).toBe(true) +}) + +test("legacy session routes preserve an existing tab's server", async ({ page }) => { + await mockServers(page, []) + await page.addInitScript( + ({ serverB, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]), + ) + }, + { serverB, sessionB: sessionB.id }, + ) + + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`) + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +async function mockServers(page: Page, requests: string[]) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + requests.push(url.toString()) + const current = url.origin === serverA ? sessionA : sessionB + const directory = url.searchParams.get("directory") + if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/session") return json(route, [current]) + if (url.pathname === `/session/${current.id}`) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (url.pathname === `/session/${current.id}/message`) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + return json(route, {}) + if (url.pathname === "/provider") + return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: current.projectID, + worktree: current.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts new file mode 100644 index 0000000000..e66cf81504 --- /dev/null +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -0,0 +1,132 @@ +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 } })) + }) +} diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 7935b10014..5b6e0b127b 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -261,7 +261,6 @@ async function configurePage(page: Page) { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, - showSessionProgressBar: true, }, }), ) diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index 745dee10e5..9ed5906bb0 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -47,7 +47,6 @@ async function configurePage(page: Page) { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, - showSessionProgressBar: true, }, }), ) diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts new file mode 100644 index 0000000000..fd0b00f71c --- /dev/null +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -0,0 +1,186 @@ +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 +} + +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 = { [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('[data-component="session-todo-dock"]') + const clip = dock?.parentElement?.parentElement + const label = dock?.querySelector('[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) +} diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 4df3df43ec..5bca533e6a 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -362,7 +362,6 @@ async function configureSmokePage(page: Page, directory: string) { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, - showSessionProgressBar: true, }, }), ) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index b722282e7b..875c3b7a96 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,15 +1,6 @@ import type { Page, Route } from "@playwright/test" -const emptyList = new Set([ - "/skill", - "/command", - "/lsp", - "/formatter", - "/permission", - "/question", - "/vcs/status", - "/vcs/diff", -]) +const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"]) export interface MockServerConfig { @@ -23,6 +14,9 @@ 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) { @@ -55,6 +49,10 @@ 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, []) @@ -66,7 +64,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, session ?? {}) } - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, []) + const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) + if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) + if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { diff --git a/packages/app/package.json b/packages/app/package.json index b3648e3a9c..53c01ea64e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.9", + "version": "1.17.11", "description": "", "type": "module", "exports": { @@ -17,9 +17,9 @@ "dev": "vite", "build": "vite build", "serve": "vite preview", - "test": "bun run test:unit && bun run test:virtualizer", + "test": "bun run test:unit && bun run test:browser", "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", - "test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts", + "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 9062b715cc..b618df3980 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -31,8 +31,8 @@ import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" -import { ServerSyncProvider } from "@/context/server-sync" -import { GlobalProvider, useGlobal } from "@/context/global" +import { ServerSyncProvider, useServerSync } from "@/context/server-sync" +import { GlobalProvider } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" @@ -51,7 +51,14 @@ import LegacyLayout from "@/pages/layout" import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route" +import { + legacySessionHref, + legacySessionServer, + requireServerKey, + selectSessionLineage, + sessionHref, +} from "./utils/session-route" +import { isSessionNotFoundError } from "./utils/server-errors" import Session from "@/pages/session" import { NewHome, LegacyHome } from "@/pages/home" @@ -67,7 +74,15 @@ const SessionRoute = () => { const tabs = useTabs() if (params.id && settings.general.newLayoutDesigns()) { - return + const sessionID = params.id + return ( + + {(_) => { + const persisted = tabs.store.filter((item) => item.type === "session") + return + }} + + ) } // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) @@ -95,7 +110,7 @@ const TargetSessionRoute = () => { }) return ( - + @@ -109,43 +124,36 @@ function ResolvedTargetSessionRoute() { const params = useParams<{ serverKey: string; id: string }>() const settings = useSettings() const tabs = useTabs() - const global = useGlobal() - const serverSDK = useServerSDK() + const sync = useServerSync() const serverKey = createMemo(() => requireServerKey(params.serverKey)) - const placement = createMemo(() => global.sessionPlacement.get(serverKey(), params.id)) + const cached = createMemo(() => sync().session.lineage.peek(params.id)) const [resolved] = createResource( () => { - if (placement()) return - return { id: params.id, sdk: serverSDK() } - }, - async ({ id, sdk }) => { - const session = (await sdk.client.session.get({ sessionID: id })).data! - const root = await rootSession(session, (sessionID) => - sdk.client.session.get({ sessionID }).then((result) => result.data!), - ) - return global.sessionPlacement.set({ - server: serverKey(), - leafID: session.id, - rootID: root.id, - directory: session.directory, - }) + if (cached()) return + return { id: params.id, server: serverKey(), sync: sync() } }, + ({ id, server, sync }) => + sync.session.lineage.resolve(id).catch((error) => { + if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id }) + throw error + }), ) - const directory = createMemo(() => placement()?.directory ?? resolved()?.directory) + const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved())) + const directory = createMemo(() => current()?.session.directory) const targetDirectory = () => directory()! createEffect(() => { - const current = placement() ?? resolved() - if (!current) return + const session = current() + if (!session) return tabs.addSessionTab({ server: serverKey(), - sessionId: current.rootID, + sessionId: session.root.id, }) }) return ( params.id}> - }> + }> - {props.children} + {props.children} @@ -334,7 +342,7 @@ function TargetServerScopedProviders(props: ServerScopedShellProps) { return ( - {props.children} + {props.children} ) diff --git a/packages/app/src/components/command-tooltip-keybind.test.ts b/packages/app/src/components/command-tooltip-keybind.test.ts new file mode 100644 index 0000000000..63c7f32468 --- /dev/null +++ b/packages/app/src/components/command-tooltip-keybind.test.ts @@ -0,0 +1,22 @@ +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"]) + }) +}) diff --git a/packages/app/src/components/command-tooltip-keybind.ts b/packages/app/src/components/command-tooltip-keybind.ts new file mode 100644 index 0000000000..d6685b95a8 --- /dev/null +++ b/packages/app/src/components/command-tooltip-keybind.ts @@ -0,0 +1,11 @@ +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") +} diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 76da7d5b42..0fb89d29f9 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" -import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" +import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" @@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" -export function DialogConnectProvider(props: { provider: string }) { +export function DialogConnectProvider(props: { provider: string; directory?: Accessor }) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() const language = useLanguage() - const providers = useProviders() + const providers = useProviders(props.directory) const all = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index f2b0432dde..647e5002a2 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { useMutation } from "@tanstack/solid-query" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" -import { batch, For } from "solid-js" +import { type Accessor, batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" @@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider" type Props = { back?: "providers" | "close" + directory?: Accessor } export function DialogCustomProvider(props: Props) { @@ -40,7 +41,7 @@ export function DialogCustomProvider(props: Props) { dialog.close() return } - dialog.show(() => ) + dialog.show(() => ) } const addModel = () => { diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index b46034bbad..a0821ef1a3 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -9,14 +9,16 @@ import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectProvider } from "./dialog-select-provider" +import { decode64 } from "@/utils/base64" export const DialogManageModels: Component = () => { const local = useLocal() const language = useLanguage() const dialog = useDialog() + const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - dialog.show(() => ) + dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index fae743bb81..bcb4a2fbcb 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -10,24 +10,27 @@ import { useLocal } from "@/context/local" import { popularProviders, useProviders } from "@/hooks/use-providers" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" type ModelState = ReturnType["model"] export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => { - const model = props.model ?? useLocal().model + const local = useLocal() + const model = props.model ?? local.model const dialog = useDialog() - const providers = useProviders() + const directory = () => decode64(local.slug()) + const providers = useProviders(directory) const language = useLanguage() const connect = (provider: string) => { void import("./dialog-connect-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } const all = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index ca2643c3b5..e16d9a6657 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -12,6 +12,7 @@ import { List } from "@opencode-ai/ui/list" import { Tooltip } from "@opencode-ai/ui/tooltip" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" const isFree = (provider: string, cost: { input: number } | undefined) => provider === "opencode" && (!cost || cost.input === 0) @@ -104,6 +105,8 @@ export function ModelSelectorPopover(props: { dismiss: null, }) const dialog = useDialog() + const local = useLocal() + const directory = () => decode64(local.slug()) const close = (dismiss: Dismiss) => { setStore("dismiss", dismiss) @@ -120,7 +123,7 @@ export function ModelSelectorPopover(props: { const handleConnectProvider = () => { close("provider") void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } const language = useLanguage() @@ -199,10 +202,12 @@ export function ModelSelectorPopover(props: { export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { const dialog = useDialog() const language = useLanguage() + const local = useLocal() + const directory = () => decode64(local.slug()) const provider = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index 89310286da..ff0ab5d2bf 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -1,4 +1,4 @@ -import { Component, Show } from "solid-js" +import { type Accessor, Component, Show } from "solid-js" import { useDialog } from "@opencode-ai/ui/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" @@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider" const CUSTOM_ID = "_custom" -export const DialogSelectProvider: Component = () => { +export const DialogSelectProvider: Component<{ directory?: Accessor }> = (props) => { const dialog = useDialog() - const providers = useProviders() + const providers = useProviders(props.directory) const language = useLanguage() const popularGroup = () => language.t("dialog.provider.group.popular") @@ -56,10 +56,10 @@ export const DialogSelectProvider: Component = () => { onSelect={(x) => { if (!x) return if (x.id === CUSTOM_ID) { - dialog.show(() => ) + dialog.show(() => ) return } - dialog.show(() => ) + dialog.show(() => ) }} > {(i) => ( diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index b56287a878..fcdd544ec0 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -1,11 +1,27 @@ // @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 PromptInputExample() { +function createPromptInputStoryRuntime() { const state = createPromptState() - const history = createPromptInputHistory() + return { + state, + history: createPromptInputHistory(), + submission: { + abort() {}, + handleSubmit(event: Event) { + event.preventDefault() + state.reset() + }, + }, + } +} + +function PromptInputExample() { + const input = createPromptInputStoryRuntime() const [controls, setControls] = createStore({ agent: "build", variant: undefined as string | undefined, @@ -22,13 +38,6 @@ 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" }], @@ -69,7 +78,7 @@ function PromptInputExample() { const addReviewComment = () => { const comment = controls.comments + 1 setControls("comments", comment) - state.context.add({ + input.state.context.add({ type: "file", path: "src/components/prompt-input.tsx", selection: { @@ -87,7 +96,7 @@ function PromptInputExample() { return (
- +
), } + +export const DockAlreadyOpen = { + render: () => ( +
+

Prompt Input with open Todo dock

+ +
+ ), +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 98e3931372..20bee2dbb1 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -67,6 +67,7 @@ import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" +import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { pathKey } from "@/utils/path-key" @@ -346,25 +347,10 @@ export const PromptInput: Component = (props) => { prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) - const [store, setStore] = createStore<{ - popover: "at" | "slash" | null - historyIndex: number - savedPrompt: PromptHistoryEntry | null - placeholder: number - draggingType: "image" | "@mention" | null - mode: "normal" | "shell" - applyingHistory: boolean - variantOpen: boolean - }>({ - popover: null, - historyIndex: -1, - savedPrompt: null as PromptHistoryEntry | null, - placeholder: Math.floor(Math.random() * EXAMPLES.length), - draggingType: null, - mode: "normal", - applyingHistory: false, - variantOpen: false, - }) + const [store, setStore] = createPromptInputTransientState( + () => prompt.capture(), + Math.floor(Math.random() * EXAMPLES.length), + ) const [picker, setPicker] = createStore({ projectOpen: false, projectSearch: "", @@ -2210,7 +2196,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) { )} {props.state.modelName} - + + + } @@ -2239,7 +2227,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) { )} {props.state.modelName} - + + + diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 3087c774b7..82b2d906a8 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -25,6 +25,19 @@ function dataUrl(file: File, mime: string) { }) } +type PromptTarget = Pick["capture"]>, "current" | "cursor" | "set"> +type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } + +type PromptAttachmentsCoreInput = { + capture: () => PromptTarget + editor: () => HTMLDivElement | undefined + focusEditor?: () => void + addPart?: (part: ContentPart) => boolean + warn?: () => void + readClipboardImage?: () => Promise + getPathForFile?: (file: File) => string +} + type PromptAttachmentsInput = { prompt: ReturnType editor: () => HTMLDivElement | undefined @@ -36,27 +49,22 @@ type PromptAttachmentsInput = { getPathForFile?: (file: File) => string } -export function createPromptAttachments(input: PromptAttachmentsInput) { - const prompt = input.prompt - const language = useLanguage() - - const warn = () => { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) +export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { + const capture = (): AttachmentTarget | undefined => { + const prompt = input.capture() + const editor = input.editor() + if (!editor) return + return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) } } - const add = async (file: File, toast = true) => { + const add = async (file: File, toast = true, target = capture()) => { + if (!target) return false const mime = await attachmentMime(file) if (!mime) { - if (toast) warn() + if (toast) input.warn?.() return false } - const editor = input.editor() - if (!editor) return false - const url = await dataUrl(file, mime) if (!url) return false @@ -68,34 +76,42 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { mime, dataUrl: url, } - const cursor = prompt.cursor() ?? getCursorPosition(editor) - prompt.set([...prompt.current(), attachment], cursor) + target.prompt.set([...target.prompt.current(), attachment], target.cursor) return true } const addAttachment = (file: File) => add(file) - const addAttachments = async (files: File[], toast = true) => { + const addAttachments = async (files: File[], toast = true, target = capture()) => { let found = false for (const file of files) { - const ok = await add(file, false) + const ok = await add(file, false, target) if (ok) found = true } - if (!found && files.length > 0 && toast) warn() + if (!found && files.length > 0 && toast) input.warn?.() return found } + const addClipboardAttachment = async (pending: Promise, target = capture()) => { + const file = await pending + if (!file) return false + return add(file, true, target) + } + const removeAttachment = (id: string) => { - const current = prompt.current() + const target = input.capture() + const current = target.current() const next = current.filter((part) => part.type !== "image" || part.id !== id) - prompt.set(next, prompt.cursor()) + target.set(next, target.cursor()) } const handlePaste = async (event: ClipboardEvent) => { const clipboardData = event.clipboardData if (!clipboardData) return + const target = capture() + if (!target) return event.preventDefault() event.stopPropagation() @@ -107,7 +123,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { }) if (files.length > 0) { - await addAttachments(files) + await addAttachments(files, true, target) return } @@ -115,11 +131,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images if (input.readClipboardImage && !plainText) { - const file = await input.readClipboardImage() - if (file) { - await addAttachment(file) - return - } + if (await addClipboardAttachment(input.readClipboardImage(), target)) return } if (!plainText) return @@ -127,9 +139,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const text = normalizePaste(plainText) const put = () => { - if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true - input.focusEditor() - return input.addPart({ type: "text", content: text, start: 0, end: 0 }) + if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true + input.focusEditor?.() + return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false } if (pasteMode(text) === "manual") { @@ -143,6 +155,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { put() } + return { + addAttachment, + addAttachments, + addClipboardAttachment, + removeAttachment, + handlePaste, + } +} + +export function createPromptAttachments(input: PromptAttachmentsInput) { + const language = useLanguage() + const attachments = createPromptAttachmentsCore({ + ...input, + capture: input.prompt.capture, + warn: () => { + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }) + }, + }) + const handleGlobalDragOver = (event: DragEvent) => { if (input.isDialogActive()) return @@ -181,7 +215,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const dropped = event.dataTransfer?.files if (!dropped) return - await addAttachments(Array.from(dropped)) + await attachments.addAttachments(Array.from(dropped)) } onMount(() => { @@ -190,10 +224,5 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { makeEventListener(document, "drop", handleGlobalDrop) }) - return { - addAttachment, - addAttachments, - removeAttachment, - handlePaste, - } + return attachments } diff --git a/packages/app/src/components/prompt-input/submission-state.ts b/packages/app/src/components/prompt-input/submission-state.ts new file mode 100644 index 0000000000..ebc4c50715 --- /dev/null +++ b/packages/app/src/components/prompt-input/submission-state.ts @@ -0,0 +1,31 @@ +import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt" + +type PromptTarget = ReturnType["capture"]> + +export function createPromptSubmissionState(input: { + target: PromptTarget + prompt: Prompt + context: (ContextItem & { key: string })[] +}) { + let target = input.target + let cleared: Prompt | undefined + + return { + prompt: input.prompt, + context: input.context, + target: () => target, + clear() { + target.reset() + cleared = target.current() + }, + retarget(next: PromptTarget) { + input.context.forEach(next.context.add) + target = next + }, + current: (value: PromptTarget) => target === value, + restore() { + if (cleared !== undefined && target.current() !== cleared) return + return { target, prompt: input.prompt, context: input.context } + }, + } +} diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 94a2488044..9a65fa101d 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -20,8 +20,10 @@ const storedSessions: Record> = {} 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 @@ -41,6 +43,7 @@ const prompt = { replaceComments: () => undefined, items: () => [], }, + capture: () => prompt, } const clientFor = (directory: string) => { @@ -78,7 +81,7 @@ beforeAll(async () => { useNavigate: () => () => undefined, useParams: () => params, useLocation: () => ({}), - useSearchParams: () => [{}, () => undefined], + useSearchParams: () => [search, () => undefined], })) mock.module("@opencode-ai/sdk/v2/client", () => ({ @@ -128,7 +131,10 @@ beforeAll(async () => { mock.module("@/context/tabs", () => ({ useTabs: () => ({ - promoteDraft: () => undefined, + draft: () => ({ server: "project-server" }), + promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => { + promotedDrafts.push({ draftID, ...session }) + }, }), })) @@ -185,6 +191,10 @@ beforeAll(async () => { mock.module("@/context/server-sync", () => ({ useServerSync: () => () => ({ + session: { + remember: () => undefined, + set: () => undefined, + }, child: (directory: string) => { syncedDirectories.push(directory) storedSessions[directory] ??= [] @@ -229,7 +239,9 @@ 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" @@ -304,6 +316,33 @@ 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" diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 621723ae11..05d978a947 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -4,8 +4,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { batch, type Accessor } from "solid-js" -import type { FileSelection } from "@/context/file" -import { useServer } from "@/context/server" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -21,6 +19,7 @@ import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" +import { createPromptSubmissionState } from "./submission-state" type PendingPrompt = { abort: AbortController @@ -56,16 +55,14 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac export async function sendFollowupDraft(input: FollowupSendInput) { const text = draftText(input.draft.prompt) const images = draftImages(input.draft.prompt) - const [, setStore] = input.serverSync.child(input.draft.sessionDirectory) - const setBusy = () => { if (!input.optimisticBusy) return - setStore("session_status", input.draft.sessionID, { type: "busy" }) + input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" }) } const setIdle = () => { if (!input.optimisticBusy) return - setStore("session_status", input.draft.sessionID, { type: "idle" }) + input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) } const wait = async () => { @@ -196,15 +193,6 @@ type PromptSubmitInput = { onSubmit?: () => void } -type CommentItem = { - path: string - selection?: FileSelection - comment?: string - commentID?: string - commentOrigin?: "review" | "file" - preview?: string -} - export function createPromptSubmit(input: PromptSubmitInput) { const navigate = useNavigate() const sdk = useSDK() @@ -217,7 +205,6 @@ 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) @@ -234,9 +221,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const sessionID = params.id if (!sessionID) return Promise.resolve() - serverSync().todo.set(sessionID, []) - const [, setStore] = serverSync().child(sdk().directory) - setStore("todo", sessionID, []) + serverSync().session.set("todo", sessionID, []) input.onAbort?.() @@ -255,9 +240,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { .catch(() => {}) } - const restoreCommentItems = (items: CommentItem[]) => { + const restoreCommentItems = ( + target: ReturnType["capture"]>, + items: (ContextItem & { key: string })[], + ) => { for (const item of items) { - prompt.context.add({ + target.context.add({ type: "file", path: item.path, selection: item.selection, @@ -269,19 +257,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const removeCommentItems = (items: { key: string }[]) => { - for (const item of items) { - prompt.context.remove(item.key) - } - } - - const clearContext = () => { - for (const item of prompt.context.items()) { - prompt.context.remove(item.key) + const clearContext = (target: ReturnType["capture"]>) => { + for (const item of target.context.items()) { + target.context.remove(item.key) } } const seed = (dir: string, info: Session) => { + serverSync().session.remember(info) const [, setStore] = serverSync().child(dir) setStore("session", (list: Session[]) => { const result = Binary.search(list, info.id, (item) => item.id) @@ -298,7 +281,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { const handleSubmit = async (event: Event) => { event.preventDefault() - const currentPrompt = prompt.current() + const target = prompt.capture() + const submission = createPromptSubmissionState({ + target, + prompt: target.current(), + context: target.context.items().slice(), + }) + const currentPrompt = submission.prompt + const context = submission.context const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const images = input.imageAttachments().slice() const mode = input.mode() @@ -388,8 +378,9 @@ 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: server.key, sessionId: session.id }) + if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) } } if (!session) { @@ -405,7 +396,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { providerID: currentModel.provider.id, } const agent = currentAgent.name - const context = prompt.context.items().slice() const draft: FollowupDraft = { sessionID: session.id, sessionDirectory, @@ -417,13 +407,16 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const clearInput = () => { - prompt.reset() + submission.clear() input.setMode("normal") input.setPopover(null) } const restoreInput = () => { - prompt.set(currentPrompt, input.promptLength(currentPrompt)) + const restored = submission.restore() + if (!restored) return false + restored.target.set(restored.prompt, input.promptLength(restored.prompt)) + if (!submission.current(prompt.capture())) return true input.setMode(mode) input.setPopover(null) requestAnimationFrame(() => { @@ -433,11 +426,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { setCursorPosition(editor, input.promptLength(currentPrompt)) input.queueScroll() }) + return true } if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { input.onQueue?.(draft) - clearContext() + clearContext(submission.target()) clearInput() return } @@ -507,7 +501,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) } - removeCommentItems(commentItems) + for (const item of commentItems) submission.target().context.remove(item.key) clearInput() const waitForWorktree = async () => { @@ -524,8 +518,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { sync().set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() - restoreCommentItems(commentItems) - restoreInput() + if (restoreInput()) restoreCommentItems(submission.target(), commentItems) } pending.set(pendingKey(session.id), { abort: controller, cleanup }) @@ -587,8 +580,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { description: errorMessage(err), }) removeOptimisticMessage() - restoreCommentItems(commentItems) - restoreInput() + if (restoreInput()) restoreCommentItems(submission.target(), commentItems) }) } diff --git a/packages/app/src/components/prompt-input/transient-state.ts b/packages/app/src/components/prompt-input/transient-state.ts new file mode 100644 index 0000000000..c8abf9a766 --- /dev/null +++ b/packages/app/src/components/prompt-input/transient-state.ts @@ -0,0 +1,43 @@ +import { createComputed, on, type Accessor } from "solid-js" +import { createStore, type SetStoreFunction } from "solid-js/store" +import type { PromptHistoryEntry } from "./history" + +export type PromptInputTransientState = { + popover: "at" | "slash" | null + historyIndex: number + savedPrompt: PromptHistoryEntry | null + placeholder: number + draggingType: "image" | "@mention" | null + mode: "normal" | "shell" + applyingHistory: boolean + variantOpen: boolean +} + +function resetPromptInputTransientState(setStore: SetStoreFunction) { + setStore({ + popover: null, + historyIndex: -1, + savedPrompt: null, + draggingType: null, + mode: "normal", + applyingHistory: false, + variantOpen: false, + }) +} + +export function createPromptInputTransientState(identity: Accessor, placeholder: number) { + const [store, setStore] = createStore({ + popover: null, + historyIndex: -1, + savedPrompt: null, + placeholder, + draggingType: null, + mode: "normal", + applyingHistory: false, + variantOpen: false, + }) + + createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true })) + + return [store, setStore] as const +} diff --git a/packages/app/src/components/server/server-row-menu.tsx b/packages/app/src/components/server/server-row-menu.tsx index ece38ab49b..9d5f5e5a32 100644 --- a/packages/app/src/components/server/server-row-menu.tsx +++ b/packages/app/src/components/server/server-row-menu.tsx @@ -19,7 +19,7 @@ export const ServerRowMenu: Component<{ const isDefault = () => props.controller.defaultKey() === key return ( - + sdk().directory) const { params, tabs, view } = useSessionLayout() const variant = createMemo(() => props.variant ?? "button") diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index a44bfa2ec7..e5fa48a914 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -13,6 +13,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" +import { useSDK } from "@/context/sdk" import { useSessionLayout } from "@/pages/session/session-layout" import { getSessionContextMetrics } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" @@ -93,7 +94,8 @@ const emptyUserMessages: UserMessage[] = [] export function SessionContextTab() { const sync = useSync() const language = useLanguage() - const providers = useProviders() + const sdk = useSDK() + const providers = useProviders(() => sdk().directory) const { params, view } = useSessionLayout() const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index a65bc6491f..ef93bbe081 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -28,6 +28,9 @@ 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", @@ -237,7 +240,7 @@ export function SessionHeader() { statusVisible: status(), statusLabel: language.t("status.popover.trigger"), reviewLabel: language.t("command.review.toggle"), - reviewKeybind: command.keybind("review.toggle"), + reviewKeybind: reviewTooltipKeybind(command), reviewVisible: isDesktop(), reviewOpened: view().reviewPanel.opened(), onReviewToggle: () => view().reviewPanel.toggle(), @@ -520,13 +523,15 @@ 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 (
@@ -535,7 +540,17 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { - + + {props.state.reviewLabel} + 0}> + + + + } + > } /> - +
) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 1be3ffc513..cbbf33d2b1 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -335,18 +335,6 @@ export const SettingsGeneral: Component = () => {
- -
- settings.general.setShowSessionProgressBar(checked)} - /> -
-
- { - -
- settings.general.setShowSessionProgressBar(checked)} - /> -
-
- { - + { test("reads valid removed session tab details", () => { expect( readSessionTabsRemovedDetail( new CustomEvent(SESSION_TABS_REMOVED_EVENT, { - detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] }, + detail: { server: "remote", directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] }, }), ), ).toEqual({ + server: remote, directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2"], }) diff --git a/packages/app/src/components/titlebar-session-events.ts b/packages/app/src/components/titlebar-session-events.ts index aa6b5e1d11..7d914d7c71 100644 --- a/packages/app/src/components/titlebar-session-events.ts +++ b/packages/app/src/components/titlebar-session-events.ts @@ -1,6 +1,9 @@ +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[] } @@ -18,11 +21,14 @@ 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, } diff --git a/packages/app/src/components/titlebar-tab-drag.test.ts b/packages/app/src/components/titlebar-tab-drag.test.ts new file mode 100644 index 0000000000..075c035988 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-drag.test.ts @@ -0,0 +1,141 @@ +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 = 'Session' + const preview = createTabDragPreview(tab) + expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull() + expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session") + }) + + test("captures the grab offset before navigation scrolls the tab", () => { + const tab = document.createElement("div") + tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect + + expect(captureTabPointerDown(tab, 100, 20)).toEqual({ + startX: 100, + startY: 20, + grabOffsetX: 20, + grabOffsetY: 10, + width: 120, + element: tab, + }) + }) + + test("detects when the primary pointer button was released outside the window", () => { + expect(isPrimaryPointerPressed(1)).toBe(true) + expect(isPrimaryPointerPressed(3)).toBe(true) + expect(isPrimaryPointerPressed(0)).toBe(false) + expect(isPrimaryPointerPressed(2)).toBe(false) + }) + + test("preserves native panning for touch pointers", () => { + expect(canStartTabDrag("mouse")).toBe(true) + expect(canStartTabDrag("pen")).toBe(true) + expect(canStartTabDrag("touch")).toBe(false) + }) +}) diff --git a/packages/app/src/components/titlebar-tab-drag.ts b/packages/app/src/components/titlebar-tab-drag.ts new file mode 100644 index 0000000000..b0d75750e0 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-drag.ts @@ -0,0 +1,137 @@ +export type TabDragLayout = { + tabWidthById: Map + dividerWidth: number + listLeft: number +} + +export const ACTIVATION_DISTANCE = 4 +export const HYSTERESIS_DEADBAND = 8 +export const AUTOSCROLL_EDGE = 24 +export const AUTOSCROLL_MAX_SPEED = 8 +export const FLOATER_OVERSHOOT_MAX = 8 + +export function pointerDistance(x1: number, y1: number, x2: number, y2: number) { + const dx = x2 - x1 + const dy = y2 - y1 + return Math.sqrt(dx * dx + dy * dy) +} + +export function captureTabDragLayout(list: HTMLElement, order: string[]) { + const tabWidthById = new Map() + const slots = list.querySelectorAll("[data-titlebar-tab-slot]") + for (const slot of slots) { + const id = slot.dataset.tabKey + if (!id) continue + const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector("[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("[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 +} diff --git a/packages/app/src/components/titlebar-tab-gesture.ts b/packages/app/src/components/titlebar-tab-gesture.ts new file mode 100644 index 0000000000..02866d4111 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-gesture.ts @@ -0,0 +1,37 @@ +import type { Ref } from "solid-js" + +export function isTabCloseTarget(target: EventTarget | null) { + return target instanceof Element && !!target.closest('[data-slot="tab-close"]') +} + +export function canStartTabDrag(pointerType: string) { + return pointerType !== "touch" +} + +export function isPrimaryPointerPressed(buttons: number) { + return (buttons & 1) !== 0 +} + +export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) { + const rect = element.getBoundingClientRect() + return { + startX: clientX, + startY: clientY, + grabOffsetX: clientX - rect.left, + grabOffsetY: clientY - rect.top, + width: rect.width, + element, + } +} + +export function forwardTabRef(ref: Ref | undefined, element: HTMLDivElement) { + if (typeof ref === "function") ref(element) +} + +export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) { + return !dragging && !editing && !committing +} + +export function createTabDragPreview(element: HTMLDivElement) { + return element.cloneNode(true) as HTMLDivElement +} diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css new file mode 100644 index 0000000000..07a6d1d052 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -0,0 +1,92 @@ +[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%); + } +} diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx new file mode 100644 index 0000000000..03196a4006 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -0,0 +1,375 @@ +import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { projectForSession } from "@/pages/layout/helpers" +import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" +import { showToast } from "@/utils/toast" +import type { Session } from "@opencode-ai/sdk/v2" +import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" +import "./titlebar-tab-nav.css" + +export function TabNavItem(props: { + ref?: Ref + href: string + server: ServerConnection.Key + session: () => Session | undefined + onTitleChange?: (title: string) => void + onTitleChangeFailed?: (title: string) => void + onClose: () => void + onNavigate: () => void + active?: boolean + activeServer: boolean + forceTruncate?: boolean + suppressNavigation?: () => boolean + dragging?: boolean + pressed?: boolean + hidden?: boolean + 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 ( +
+
{ + 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) + }} + > + + {(session) => { + return ( + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.preventDefault() + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + + + { + titleEl = el + titleEl.textContent = session().title + }} + data-slot="tab-title" + 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() + }} + /> + + ) + }} + + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={closeTab} + icon={} + /> +
+
+
+ ) +} + +export function DraftTabItem(props: { + ref?: Ref + href: string + title: string + active?: boolean + onNavigate: () => void + onClose: () => void + suppressNavigation?: () => boolean + dragging?: boolean + pressed?: boolean + hidden?: boolean + tabKey: string + dragActive: boolean + onPointerDown: (event: PointerEvent) => void +}) { + const closeTab = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + props.onClose() + } + return ( +
+
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) + }} + > + { + 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]" + > + + + + + {props.title} + + +
+ { + 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={} + aria-label="Close tab" + /> +
+
+
+ ) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx new file mode 100644 index 0000000000..e6188fe8b5 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -0,0 +1,564 @@ +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 ( + { + 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() + 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("[data-titlebar-tab]") + if (!tabEl) return + if (!tabEl.querySelector('[data-slot="tab-link"]')) return + const tab = props.tabs.find((item) => tabKey(item) === id) + if (!tab) return + const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY) + setSuppressNavigation(true) + props.onNavigate(tab, tabEl) + setPressedId(id) + setGesture("pending", { + id, + pointerId: event.pointerId, + ...pointer, + }) + } + + function onPointerMove(event: PointerEvent) { + const pending = gesture.pending + if (pending && event.pointerId !== pending.pointerId) return + if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return + if (!isPrimaryPointerPressed(event.buttons)) { + if (drag.active) endDrag(true) + if (pending) { + setGesture("pending", undefined) + setPressedId(undefined) + requestAnimationFrame(() => setSuppressNavigation(false)) + } + return + } + + if (pending && !drag.active) { + if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return + startDrag(pending.id) + } + + if (!drag.active) return + + setDrag("pointerX", event.clientX) + syncScroll() + } + + function onPointerUp(event: PointerEvent) { + if (drag.active) { + if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return + setDrag("pointerX", event.clientX) + syncScroll() + endDrag(true) + return + } + + const pending = gesture.pending + if (pending && event.pointerId !== pending.pointerId) return + + setGesture("pending", undefined) + setPressedId(undefined) + requestAnimationFrame(() => setSuppressNavigation(false)) + } + + function onPointerCancel(event: PointerEvent) { + if (drag.active) { + if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return + endDrag(false) + return + } + + if (!gesture.pending) return + if (gesture.pending.pointerId !== event.pointerId) return + setGesture("pending", undefined) + setPressedId(undefined) + requestAnimationFrame(() => setSuppressNavigation(false)) + } + + onMount(() => { + const cleanups = [ + makeEventListener(window, "pointermove", onPointerMove), + makeEventListener(window, "pointerup", onPointerUp), + makeEventListener(window, "pointercancel", onPointerCancel), + ] + refreshOverflow() + onCleanup(() => cleanups.forEach((cleanup) => cleanup())) + }) + + onCleanup(stopAutoscroll) + onCleanup(() => { + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + }) + + createEffect(() => { + props.tabs.length + tabIds() + refreshOverflow() + }) + + createEffect(() => { + if (!drag.active || !scrollRef) return + onCleanup(makeEventListener(scrollRef, "scroll", syncScroll)) + }) + + const floaterStyle = () => { + stripScrollLeft() + const strip = scrollRef?.getBoundingClientRect() + const left = strip + ? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right) + : drag.pointerX - drag.grabOffsetX + + return { + position: "fixed" as const, + top: `${drag.floaterTop}px`, + left: `${left}px`, + width: `${drag.draggedWidth}px`, + "z-index": "10000", + "pointer-events": "none" as const, + } + } + + const draggedTab = createMemo(() => { + const id = drag.draggedId + if (!id) return + return props.tabs.find((tab) => tabKey(tab) === id) + }) + + return ( + <> +
+
+
+ + {(tab, index) => { + const id = tabKey(tab) + let ref!: HTMLDivElement + useTabShortcut(index, () => props.onNavigate(tab, ref)) + + const dragged = () => drag.active && drag.draggedId === id + const serverCtx = createMemo(() => { + if (tab.type !== "session") return + const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) + if (conn) return global.ensureServerCtx(conn) + }) + + if (tab.type === "session") { + return ( + props.currentTab() === tab} + activeServerKey={props.activeServerKey} + forceTruncate={props.forceTruncate} + dragActive={drag.active} + dragged={dragged} + pressed={() => pressedId() === id} + serverCtx={serverCtx} + suppressNavigation={() => suppressNavigation()} + onPointerDown={(event) => { + if (dragged()) return + onPointerDown(id, event) + }} + onNavigate={(element) => props.onNavigate(tab, element)} + onClose={() => props.onClose(tab)} + /> + ) + } + + return ( + { + if (dragged()) return + onPointerDown(id, event) + }} + 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()} + /> + ) + }} + +
+
+