mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4742b5aca2 | |||
| 9c38b91e91 | |||
| 6e8e772582 | |||
| 971518c6d9 | |||
| ae853561cd | |||
| 753d312c28 | |||
| eeb5b1d8bc | |||
| f8ceb30b43 | |||
| 78a5a030ce | |||
| 219ba24d90 | |||
| e8fea9e63a | |||
| 11537260aa | |||
| 929c4aaf29 | |||
| 19e510f5d2 | |||
| 8a1608ed1d | |||
| 077deb9d82 | |||
| 41f2705e72 | |||
| ef5c9f4931 | |||
| 40ecfceec2 | |||
| a1f093a748 | |||
| 9859b19b93 | |||
| 45badf7163 | |||
| 28a00ad6af | |||
| 0f272d931d | |||
| ded29f03f0 | |||
| f428755851 | |||
| 17166b271f | |||
| 5f61d21487 | |||
| 0568855cc6 | |||
| 3fbc005f26 | |||
| 21c0e03a70 | |||
| a2b847e29e | |||
| fae100fe72 | |||
| b60c0a5e8c | |||
| 0870a838a8 | |||
| 0e731c2a1a | |||
| 21b78c49fc | |||
| f44423609b | |||
| 44806777ca | |||
| a267222080 | |||
| ecabab49a7 | |||
| 9e9d405d7e | |||
| 5682371d0a | |||
| 10e4b87d25 | |||
| e7c59b17a8 | |||
| 69f75dff1a | |||
| fec087bf37 | |||
| 4f4aa68db9 |
@@ -1,4 +1,6 @@
|
|||||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||||
|
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||||
|
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||||
- The default branch in this repo is `dev`.
|
- The default branch in this repo is `dev`.
|
||||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -57,11 +57,21 @@ The bounded projection of a Core-executed tool result persisted in Session histo
|
|||||||
**Managed Tool Output File**:
|
**Managed Tool Output File**:
|
||||||
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
||||||
|
|
||||||
|
**Model Request Options**:
|
||||||
|
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
||||||
|
_Avoid_: Request body, wire options
|
||||||
|
|
||||||
|
**Generation Controls**:
|
||||||
|
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||||
|
|
||||||
|
**Native Continuation Metadata**:
|
||||||
|
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
|
||||||
|
|
||||||
**PTY Environment**:
|
**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.
|
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**:
|
**OpenCode Client**:
|
||||||
The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers.
|
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
|
||||||
_Avoid_: Remote client
|
_Avoid_: Remote client
|
||||||
|
|
||||||
**SDK Contract IR**:
|
**SDK Contract IR**:
|
||||||
@@ -122,6 +132,9 @@ _Avoid_: Response envelope
|
|||||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||||
- 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.
|
- 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.
|
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||||
|
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||||
|
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||||
|
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -141,7 +154,6 @@ _Avoid_: Response envelope
|
|||||||
- 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 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.
|
- 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.
|
- 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.
|
||||||
- Event definition durability is authoritative for published payloads. Durable definitions publish and decode only with commit metadata (`aggregateID`, `seq`, and `version`); live definitions forbid that metadata. Core's pre-commit payload without an assigned sequence is a separate internal type and never reaches subscribers or projectors.
|
|
||||||
- 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.
|
- 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.
|
- 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 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.
|
||||||
@@ -160,8 +172,9 @@ _Avoid_: Response envelope
|
|||||||
- 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.
|
- 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.
|
- 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.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.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 `MessageNotFoundError` 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.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.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||||
- `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.
|
- `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?
|
- **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.
|
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.11",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
|
"@dnd-kit/dom": "0.5.0",
|
||||||
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -59,7 +63,7 @@
|
|||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
@@ -1451,6 +1455,20 @@
|
|||||||
|
|
||||||
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
||||||
|
|
||||||
|
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="],
|
||||||
|
|
||||||
|
"@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="],
|
||||||
|
|
||||||
|
"@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="],
|
||||||
|
|
||||||
|
"@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="],
|
||||||
|
|
||||||
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
|
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
|
||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||||
@@ -2287,6 +2305,8 @@
|
|||||||
|
|
||||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||||
|
|
||||||
|
"@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="],
|
||||||
|
|
||||||
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
|
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
|
||||||
|
|
||||||
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
|
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
|
||||||
@@ -3781,7 +3801,7 @@
|
|||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||||
|
|
||||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="],
|
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
|
||||||
|
|
||||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
|
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
||||||
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
|
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
||||||
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
|
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
||||||
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
|
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/HiddenTerminalRegression"
|
||||||
|
const projectID = "proj_hidden_terminal_regression"
|
||||||
|
const sessionID = "ses_hidden_terminal_regression"
|
||||||
|
const title = "Hidden terminal regression"
|
||||||
|
|
||||||
|
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "hidden-terminal-regression",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: "hidden-terminal-regression",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.route("**/pty", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route("**/pty/pty_hidden_terminal", (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||||
|
)
|
||||||
|
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
const panel = page.locator("#terminal-panel")
|
||||||
|
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||||
|
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(panel).toHaveAttribute("aria-hidden", "true")
|
||||||
|
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1200, height: 700 })
|
||||||
|
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
function base64Encode(value: string) {
|
||||||
|
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@
|
|||||||
"vite-plugin-solid": "catalog:"
|
"vite-plugin-solid": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
|
"@dnd-kit/dom": "0.5.0",
|
||||||
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -72,7 +76,7 @@
|
|||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|||||||
+32
-15
@@ -16,6 +16,7 @@ import {
|
|||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
|
createRenderEffect,
|
||||||
createResource,
|
createResource,
|
||||||
createSignal,
|
createSignal,
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
@@ -32,7 +33,7 @@ import { CommentsProvider } from "@/context/comments"
|
|||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||||
import { GlobalProvider } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
@@ -103,10 +104,10 @@ const SessionRoute = () => {
|
|||||||
|
|
||||||
const TargetSessionRoute = () => {
|
const TargetSessionRoute = () => {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
const server = useServer()
|
const global = useGlobal()
|
||||||
const conn = createMemo(() => {
|
const conn = createMemo(() => {
|
||||||
const key = requireServerKey(params.serverKey)
|
const key = requireServerKey(params.serverKey)
|
||||||
return server.list.find((item) => ServerConnection.key(item) === key)
|
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -221,12 +222,13 @@ function DraftRoute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||||
const server = useServer()
|
const global = useGlobal()
|
||||||
const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
|
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
||||||
const directory = () => props.draft.directory
|
const directory = () => props.draft.directory
|
||||||
const serverKey = () => props.draft.server
|
const serverKey = () => props.draft.server
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
<TargetServerScopedProviders directory={directory}>
|
<TargetServerScopedProviders directory={directory}>
|
||||||
@@ -240,6 +242,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
</TargetServerScopedProviders>
|
</TargetServerScopedProviders>
|
||||||
</ServerSyncProvider>
|
</ServerSyncProvider>
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,10 +279,11 @@ function QueryProvider(props: ParentProps) {
|
|||||||
function BodyDesignClass() {
|
function BodyDesignClass() {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
createEffect(() => {
|
createRenderEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
|
|
||||||
const enabled = settings.general.newLayoutDesigns()
|
const enabled = settings.general.newLayoutDesigns()
|
||||||
|
document.body.toggleAttribute("data-new-layout", enabled)
|
||||||
document.body.classList.toggle("text-12-regular", !enabled)
|
document.body.classList.toggle("text-12-regular", !enabled)
|
||||||
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
||||||
document.body.classList.toggle("text-[13px]", enabled)
|
document.body.classList.toggle("text-[13px]", enabled)
|
||||||
@@ -588,18 +592,31 @@ function Routes() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Show when={settings.general.newLayoutDesigns()}>
|
<Show when={settings.general.newLayoutDesigns()}>
|
||||||
<Route path="/" component={NewHome} />
|
<Route path="/" component={NewHome} />
|
||||||
<Route
|
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
|
||||||
path="/:dir/session/:id"
|
|
||||||
component={() => {
|
|
||||||
const server = useServer()
|
|
||||||
const { id } = useParams()
|
|
||||||
|
|
||||||
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LegacyTargetSessionRoute() {
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={tabs.ready()}>
|
||||||
|
<Navigate
|
||||||
|
href={sessionHref(
|
||||||
|
legacySessionServer(
|
||||||
|
tabs.store.filter((item) => item.type === "session"),
|
||||||
|
params.id,
|
||||||
|
server.key,
|
||||||
|
),
|
||||||
|
params.id,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "@pierre/trees/web-components"
|
import "@pierre/trees/web-components"
|
||||||
import { FileTree } from "@pierre/trees"
|
import { FileTree } from "@pierre/trees"
|
||||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
pickerRoot,
|
pickerRoot,
|
||||||
} from "./directory-picker-domain"
|
} from "./directory-picker-domain"
|
||||||
import "./dialog-select-directory-v2.css"
|
import "./dialog-select-directory-v2.css"
|
||||||
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
|
|
||||||
interface DialogSelectDirectoryV2Props {
|
interface DialogSelectDirectoryV2Props {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -266,8 +267,12 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
onCleanup(() => tree?.cleanUp())
|
onCleanup(() => tree?.cleanUp())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
|
<Dialog size="large" class="directory-picker-v2">
|
||||||
<div class="directory-picker-v2-body">
|
<DialogHeader>
|
||||||
|
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DividerV2 />
|
||||||
|
<DialogBody class="directory-picker-v2-body pt-4!">
|
||||||
<div class="directory-picker-v2-path" ref={pathArea}>
|
<div class="directory-picker-v2-path" ref={pathArea}>
|
||||||
<TextInputV2
|
<TextInputV2
|
||||||
value={input()}
|
value={input()}
|
||||||
@@ -349,7 +354,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
|
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
|
||||||
</div>
|
</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
@@ -272,7 +271,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const server = useServer()
|
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
@@ -393,10 +391,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
state.cleanup?.()
|
state.cleanup?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) {
|
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
||||||
return (
|
return (
|
||||||
<DialogSelectFileV2
|
<DialogSelectFileV2
|
||||||
server={server.current}
|
server={serverSDK().server}
|
||||||
mode="file"
|
mode="file"
|
||||||
start={projectDirectory()}
|
start={projectDirectory()}
|
||||||
title={language.t("session.header.searchFiles")}
|
title={language.t("session.header.searchFiles")}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
import { createPromptState } from "@/context/prompt"
|
import { createPromptState } from "@/context/prompt"
|
||||||
import { SessionComposerRegion } from "@/pages/session/composer"
|
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||||
|
|
||||||
function createPromptInputStoryRuntime() {
|
function createPromptInputStoryRuntime() {
|
||||||
@@ -54,12 +54,6 @@ function PromptInputExample() {
|
|||||||
paid: true,
|
paid: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
},
|
},
|
||||||
projects: {
|
|
||||||
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
|
|
||||||
directory: "/tmp/story",
|
|
||||||
select() {},
|
|
||||||
add() {},
|
|
||||||
},
|
|
||||||
session: {
|
session: {
|
||||||
id: "story-session",
|
id: "story-session",
|
||||||
tabs: {
|
tabs: {
|
||||||
@@ -142,7 +136,6 @@ function PromptInputWithOpenDock() {
|
|||||||
paid: true,
|
paid: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
},
|
},
|
||||||
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
|
|
||||||
session: {
|
session: {
|
||||||
id: "story-session",
|
id: "story-session",
|
||||||
tabs: {
|
tabs: {
|
||||||
@@ -166,22 +159,35 @@ function PromptInputWithOpenDock() {
|
|||||||
closing: () => false,
|
closing: () => false,
|
||||||
opening: () => false,
|
opening: () => false,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionComposerRegion
|
<SessionComposerRegion
|
||||||
state={state}
|
controller={createSessionComposerRegionController({
|
||||||
sessionKey="story-session"
|
state,
|
||||||
sessionID="story-session"
|
sessionKey: () => "story-session",
|
||||||
controls={inputControls}
|
sessionID: () => "story-session",
|
||||||
promptInput={{ ...input, ref: () => {}, newSessionWorktree: "", onNewSessionWorktreeReset: () => {} }}
|
prompt: input.state,
|
||||||
todo={{
|
ready: () => true,
|
||||||
collapsed: controls.todoCollapsed,
|
centered: () => false,
|
||||||
|
todo: {
|
||||||
|
collapsed: () => controls.todoCollapsed,
|
||||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||||
}}
|
},
|
||||||
ready
|
followup: () => undefined,
|
||||||
centered={false}
|
revert: () => undefined,
|
||||||
onResponseSubmit={() => {}}
|
onResponseSubmit: () => {},
|
||||||
setPromptDockRef={() => {}}
|
openParent: () => {},
|
||||||
|
setPromptRef: () => {},
|
||||||
|
setDockRef: () => {},
|
||||||
|
})}
|
||||||
|
promptInput={
|
||||||
|
<PromptInput
|
||||||
|
controls={inputControls}
|
||||||
|
{...input}
|
||||||
|
ref={() => {}}
|
||||||
|
newSessionWorktree=""
|
||||||
|
onNewSessionWorktreeReset={() => {}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
on,
|
on,
|
||||||
Component,
|
Component,
|
||||||
splitProps,
|
|
||||||
For,
|
|
||||||
Show,
|
Show,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
createMemo,
|
createMemo,
|
||||||
@@ -13,10 +11,8 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
Switch,
|
Switch,
|
||||||
Match,
|
Match,
|
||||||
type ComponentProps,
|
|
||||||
type JSX,
|
type JSX,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { useLocal } from "@/context/local"
|
import type { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
@@ -36,7 +32,7 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||||
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -70,8 +66,6 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
|
|||||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
import { displayName } from "@/pages/layout/helpers"
|
|
||||||
|
|
||||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||||
|
|
||||||
@@ -99,12 +93,6 @@ export type PromptInputControls = {
|
|||||||
paid: boolean
|
paid: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
}
|
}
|
||||||
projects: {
|
|
||||||
available: { name?: string; worktree: string; sandboxes?: string[] }[]
|
|
||||||
directory: string
|
|
||||||
select: (worktree: string) => void
|
|
||||||
add: (title: string) => void
|
|
||||||
}
|
|
||||||
session: {
|
session: {
|
||||||
id?: string
|
id?: string
|
||||||
tabs: {
|
tabs: {
|
||||||
@@ -175,6 +163,7 @@ export interface PromptInputProps {
|
|||||||
onQueue?: (draft: FollowupDraft) => void
|
onQueue?: (draft: FollowupDraft) => void
|
||||||
onAbort?: () => void
|
onAbort?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
|
toolbar?: JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXAMPLES = [
|
const EXAMPLES = [
|
||||||
@@ -223,7 +212,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
let fileInputRef: HTMLInputElement | undefined
|
let fileInputRef: HTMLInputElement | undefined
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let slashPopoverRef!: HTMLDivElement
|
let slashPopoverRef!: HTMLDivElement
|
||||||
let projectSearchRef: HTMLInputElement | undefined
|
|
||||||
|
|
||||||
const mirror = { input: false }
|
const mirror = { input: false }
|
||||||
const inset = 56
|
const inset = 56
|
||||||
@@ -351,11 +339,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
() => prompt.capture(),
|
() => prompt.capture(),
|
||||||
Math.floor(Math.random() * EXAMPLES.length),
|
Math.floor(Math.random() * EXAMPLES.length),
|
||||||
)
|
)
|
||||||
const [picker, setPicker] = createStore({
|
|
||||||
projectOpen: false,
|
|
||||||
projectSearch: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||||
const motion = (value: number) => ({
|
const motion = (value: number) => ({
|
||||||
opacity: value,
|
opacity: value,
|
||||||
@@ -1392,72 +1375,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const newSession = () => props.variant === "new-session"
|
const newSession = () => props.variant === "new-session"
|
||||||
const projects = createMemo(() => props.controls.projects.available)
|
|
||||||
const projectForDirectory = (directory: string | undefined) => {
|
|
||||||
if (!directory) return
|
|
||||||
const key = pathKey(directory)
|
|
||||||
return projects().find(
|
|
||||||
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
|
|
||||||
const projectResults = createMemo(() => {
|
|
||||||
const search = picker.projectSearch.trim().toLowerCase()
|
|
||||||
if (!search) return projects()
|
|
||||||
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
|
|
||||||
})
|
|
||||||
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
||||||
const selectProject = (worktree: string) => {
|
|
||||||
setPicker({
|
|
||||||
projectOpen: false,
|
|
||||||
projectSearch: "",
|
|
||||||
})
|
|
||||||
if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) {
|
|
||||||
restoreFocus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
props.controls.projects.select(worktree)
|
|
||||||
restoreFocus()
|
|
||||||
}
|
|
||||||
const addProject = () => {
|
|
||||||
props.controls.projects.add(language.t("command.project.open"))
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectPickerState = createMemo<ComposerPickerState>(() => ({
|
|
||||||
open: picker.projectOpen,
|
|
||||||
trigger: {
|
|
||||||
action: "prompt-project",
|
|
||||||
icon: "folder",
|
|
||||||
label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"),
|
|
||||||
class: "max-w-[203px]",
|
|
||||||
style: control(),
|
|
||||||
onPress: () => setPicker("projectOpen", true),
|
|
||||||
},
|
|
||||||
search: picker.projectSearch,
|
|
||||||
searchPlaceholder: language.t("session.new.project.search"),
|
|
||||||
clearLabel: language.t("common.clear"),
|
|
||||||
items: projectResults().map((project) => ({
|
|
||||||
icon: "folder",
|
|
||||||
label: displayName(project),
|
|
||||||
selected: selectedProject()?.worktree === project.worktree,
|
|
||||||
onSelect: () => selectProject(project.worktree),
|
|
||||||
})),
|
|
||||||
action: {
|
|
||||||
icon: "plus",
|
|
||||||
label: language.t("session.new.project.add"),
|
|
||||||
onSelect: () => {
|
|
||||||
setPicker("projectOpen", false)
|
|
||||||
void addProject()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onOpenChange: (open) => {
|
|
||||||
setPicker("projectOpen", open)
|
|
||||||
if (open) requestAnimationFrame(() => projectSearchRef?.focus())
|
|
||||||
},
|
|
||||||
onSearchInput: (value) => setPicker("projectSearch", value),
|
|
||||||
onSearchClear: () => setPicker("projectSearch", ""),
|
|
||||||
searchRef: (el) => (projectSearchRef = el),
|
|
||||||
}))
|
|
||||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||||
title: language.t("command.agent.cycle"),
|
title: language.t("command.agent.cycle"),
|
||||||
keybind: command.keybind("agent.cycle"),
|
keybind: command.keybind("agent.cycle"),
|
||||||
@@ -1469,15 +1387,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
restoreFocus()
|
restoreFocus()
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
const newProjectTriggerState = createMemo<ComposerPickerTriggerState>(() => ({
|
|
||||||
action: "prompt-project",
|
|
||||||
icon: "folder-add-left",
|
|
||||||
label: language.t("session.new.project.new"),
|
|
||||||
class: "max-w-[160px]",
|
|
||||||
style: control(),
|
|
||||||
onPress: () => void addProject(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full flex flex-col gap-0">
|
<div class="relative size-full flex flex-col gap-0">
|
||||||
{(promptReady(), null)}
|
{(promptReady(), null)}
|
||||||
@@ -1608,9 +1517,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={showAgentControl()}>
|
<Show when={showAgentControl()}>
|
||||||
<ComposerAgentControl state={agentControlState()} />
|
<ComposerAgentControl state={agentControlState()} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={newSession() && !selectedProject()}>
|
{props.toolbar}
|
||||||
<ComposerPickerTrigger state={newProjectTriggerState()} />
|
|
||||||
</Show>
|
|
||||||
<ComposerModelControl state={modelControlState()} />
|
<ComposerModelControl state={modelControlState()} />
|
||||||
<Show when={store.mode !== "shell" && showVariantControl()}>
|
<Show when={store.mode !== "shell" && showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
@@ -1664,11 +1571,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</DockShellForm>
|
</DockShellForm>
|
||||||
<Show when={newSession() && selectedProject()}>
|
|
||||||
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
|
|
||||||
<ComposerPicker state={projectPickerState()} />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when>
|
<Match when>
|
||||||
@@ -2010,37 +1912,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ComposerPickerItemState = {
|
|
||||||
icon: IconProps["name"]
|
|
||||||
label: string
|
|
||||||
selected?: boolean
|
|
||||||
onSelect: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type ComposerPickerTriggerState = {
|
|
||||||
action: string
|
|
||||||
icon?: IconProps["name"]
|
|
||||||
label: string
|
|
||||||
class?: string
|
|
||||||
style: JSX.CSSProperties | undefined
|
|
||||||
onPress: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type ComposerPickerState = {
|
|
||||||
open: boolean
|
|
||||||
trigger: ComposerPickerTriggerState
|
|
||||||
search: string
|
|
||||||
searchPlaceholder: string
|
|
||||||
clearLabel: string
|
|
||||||
items: ComposerPickerItemState[]
|
|
||||||
action: ComposerPickerItemState
|
|
||||||
listClass?: string
|
|
||||||
searchRef: (el: HTMLInputElement) => void
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSearchInput: (value: string) => void
|
|
||||||
onSearchClear: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type ComposerAgentControlState = {
|
type ComposerAgentControlState = {
|
||||||
title: string
|
title: string
|
||||||
keybind: string
|
keybind: string
|
||||||
@@ -2063,90 +1934,6 @@ type ComposerModelControlState = {
|
|||||||
onUnpaidClick: () => void
|
onUnpaidClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) {
|
|
||||||
const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"])
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...rest}
|
|
||||||
data-action={local.state.action}
|
|
||||||
type="button"
|
|
||||||
class={`flex h-7 min-w-0 items-center gap-1.5 rounded px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`}
|
|
||||||
style={local.state.style}
|
|
||||||
onClick={() => local.state.onPress()}
|
|
||||||
>
|
|
||||||
<Show when={local.state.icon}>
|
|
||||||
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
|
|
||||||
</Show>
|
|
||||||
<span class="min-w-0 truncate leading-5">{local.state.label}</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex h-7 w-full items-center gap-2 rounded px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
|
||||||
onClick={props.state.onSelect}
|
|
||||||
>
|
|
||||||
<Icon name={props.state.icon} size="small" class="shrink-0 text-v2-icon-icon-base" />
|
|
||||||
<span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span>
|
|
||||||
<Show when={props.state.selected}>
|
|
||||||
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposerPicker(props: { state: ComposerPickerState }) {
|
|
||||||
return (
|
|
||||||
<KobaltePopover
|
|
||||||
open={props.state.open}
|
|
||||||
placement="bottom-start"
|
|
||||||
gutter={4}
|
|
||||||
modal={false}
|
|
||||||
onOpenChange={props.state.onOpenChange}
|
|
||||||
>
|
|
||||||
<KobaltePopover.Trigger as={ComposerPickerTrigger} state={props.state.trigger} />
|
|
||||||
<KobaltePopover.Portal>
|
|
||||||
<KobaltePopover.Content
|
|
||||||
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
<div class={`flex flex-col p-0.5 ${props.state.listClass ?? ""}`}>
|
|
||||||
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
|
|
||||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
|
||||||
<input
|
|
||||||
ref={props.state.searchRef}
|
|
||||||
value={props.state.search}
|
|
||||||
placeholder={props.state.searchPlaceholder}
|
|
||||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
|
||||||
onInput={(event) => props.state.onSearchInput(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Show when={props.state.search.trim()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
|
||||||
onClick={props.state.onSearchClear}
|
|
||||||
aria-label={props.state.clearLabel}
|
|
||||||
>
|
|
||||||
<Icon name="close-small" size="small" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>
|
|
||||||
</div>
|
|
||||||
<div class="h-px bg-v2-border-border-muted" />
|
|
||||||
<div class="flex flex-col p-0.5">
|
|
||||||
<ComposerPickerMenuItem state={props.state.action} />
|
|
||||||
</div>
|
|
||||||
</KobaltePopover.Content>
|
|
||||||
</KobaltePopover.Portal>
|
|
||||||
</KobaltePopover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||||
return (
|
return (
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
|
import { getProjectAvatarVariant } from "@/context/layout"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
|
export type PromptProject = {
|
||||||
|
name?: string
|
||||||
|
id?: string
|
||||||
|
worktree: string
|
||||||
|
sandboxes?: string[]
|
||||||
|
icon?: { color?: string; url?: string; override?: string }
|
||||||
|
server?: { key: string; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptProjectControls = {
|
||||||
|
available: PromptProject[]
|
||||||
|
directory: string
|
||||||
|
server?: string
|
||||||
|
select: (worktree: string, server?: string) => void
|
||||||
|
add: (title: string, server?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionPrefix = "action:"
|
||||||
|
const projectPrefix = "project:"
|
||||||
|
|
||||||
|
function projectKey(project: PromptProject) {
|
||||||
|
return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionKey(server?: string) {
|
||||||
|
return `${actionPrefix}${encodeURIComponent(server ?? "")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptProjectController(input: {
|
||||||
|
controls: Accessor<PromptProjectControls>
|
||||||
|
onDone: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
const selected = () => {
|
||||||
|
const key = pathKey(input.controls().directory)
|
||||||
|
return input
|
||||||
|
.controls()
|
||||||
|
.available.find(
|
||||||
|
(project) =>
|
||||||
|
(!project.server || project.server.key === input.controls().server) &&
|
||||||
|
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const projects = () => {
|
||||||
|
const search = store.search.trim().toLowerCase()
|
||||||
|
if (!search) return input.controls().available
|
||||||
|
return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search))
|
||||||
|
}
|
||||||
|
const servers = () =>
|
||||||
|
input
|
||||||
|
.controls()
|
||||||
|
.available.map((project) => project.server)
|
||||||
|
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
|
||||||
|
const keys = () => {
|
||||||
|
if (servers().length <= 1) {
|
||||||
|
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
|
||||||
|
}
|
||||||
|
return servers().flatMap((server) => [
|
||||||
|
...projects()
|
||||||
|
.filter((project) => project.server?.key === server!.key)
|
||||||
|
.map(projectKey),
|
||||||
|
actionKey(server!.key),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const initialActive = () => {
|
||||||
|
const selectedKey = selected() ? projectKey(selected()!) : undefined
|
||||||
|
const options = keys()
|
||||||
|
if (selectedKey && options.includes(selectedKey)) return selectedKey
|
||||||
|
return options[0] ?? ""
|
||||||
|
}
|
||||||
|
const close = () => {
|
||||||
|
setStore({ open: false, search: "", active: "" })
|
||||||
|
input.onDone()
|
||||||
|
}
|
||||||
|
const select = (project: PromptProject) => {
|
||||||
|
if (
|
||||||
|
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
|
||||||
|
project.server?.key !== selected()?.server?.key
|
||||||
|
) {
|
||||||
|
input.controls().select(project.worktree, project.server?.key)
|
||||||
|
}
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
const add = (server?: string) => {
|
||||||
|
setStore({ open: false, search: "", active: "" })
|
||||||
|
input.controls().add(language.t("command.project.open"), server)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selected,
|
||||||
|
projects,
|
||||||
|
servers,
|
||||||
|
projectKey,
|
||||||
|
actionKey,
|
||||||
|
open: () => store.open,
|
||||||
|
search: () => store.search,
|
||||||
|
active: () => store.active,
|
||||||
|
labels: {
|
||||||
|
add: () => language.t("session.new.project.add"),
|
||||||
|
clear: () => language.t("common.clear"),
|
||||||
|
new: () => language.t("session.new.project.new"),
|
||||||
|
search: () => language.t("session.new.project.search"),
|
||||||
|
},
|
||||||
|
add,
|
||||||
|
select,
|
||||||
|
setOpen(open: boolean) {
|
||||||
|
if (open) {
|
||||||
|
setStore({ open: true, active: initialActive() })
|
||||||
|
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore({ open: false, search: "", active: "" })
|
||||||
|
},
|
||||||
|
setSearch(value: string) {
|
||||||
|
const search = value.trim().toLowerCase()
|
||||||
|
const first = input
|
||||||
|
.controls()
|
||||||
|
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
||||||
|
setStore({ search: value, active: first ? projectKey(first) : actionKey(servers()[0]?.key) })
|
||||||
|
},
|
||||||
|
clearSearch() {
|
||||||
|
setStore({ search: "", active: initialActive() })
|
||||||
|
setTimeout(() => searchRef?.focus())
|
||||||
|
},
|
||||||
|
setActive(key: string) {
|
||||||
|
setStore("active", key)
|
||||||
|
},
|
||||||
|
moveActive(delta: number) {
|
||||||
|
const options = keys()
|
||||||
|
if (options.length === 0) return
|
||||||
|
const index = options.indexOf(store.active)
|
||||||
|
const start = index === -1 ? 0 : index
|
||||||
|
setStore("active", options[(start + delta + options.length) % options.length])
|
||||||
|
},
|
||||||
|
activeProject() {
|
||||||
|
return store.active.startsWith(projectPrefix)
|
||||||
|
? projects().find((project) => projectKey(project) === store.active)
|
||||||
|
: undefined
|
||||||
|
},
|
||||||
|
activeServer() {
|
||||||
|
return store.active.startsWith(actionPrefix)
|
||||||
|
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
|
||||||
|
: undefined
|
||||||
|
},
|
||||||
|
setSearchRef(el: HTMLInputElement) {
|
||||||
|
searchRef = el
|
||||||
|
},
|
||||||
|
focusSearch() {
|
||||||
|
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
||||||
|
|
||||||
|
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
|
||||||
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
let restoreTrigger = true
|
||||||
|
|
||||||
|
const activeItem = () =>
|
||||||
|
props.controller.active()
|
||||||
|
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||||
|
: undefined
|
||||||
|
const afterClose = (callback: () => void) => {
|
||||||
|
const complete = () => {
|
||||||
|
if (contentRef?.isConnected) {
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||||
|
}
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
}
|
||||||
|
const selectProject = (project: PromptProject) => {
|
||||||
|
restoreTrigger = false
|
||||||
|
props.controller.setOpen(false)
|
||||||
|
afterClose(() => props.controller.select(project))
|
||||||
|
}
|
||||||
|
const selectAction = (server?: string) => {
|
||||||
|
restoreTrigger = false
|
||||||
|
props.controller.setOpen(false)
|
||||||
|
afterClose(() => props.controller.add(server))
|
||||||
|
}
|
||||||
|
const selectActive = () => {
|
||||||
|
const project = props.controller.activeProject()
|
||||||
|
if (project) {
|
||||||
|
selectProject(project)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectAction(props.controller.activeServer())
|
||||||
|
}
|
||||||
|
const moveActive = (delta: number) => {
|
||||||
|
props.controller.moveActive(delta)
|
||||||
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||||
|
}
|
||||||
|
const focusPreviousControl = () => {
|
||||||
|
const target = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
||||||
|
.findLast((element) => element.offsetParent !== null)
|
||||||
|
restoreTrigger = false
|
||||||
|
target?.focus()
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (props.controller.open()) props.controller.setOpen(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const selectedValue = () => {
|
||||||
|
const project = props.controller.selected()
|
||||||
|
return project ? props.controller.projectKey(project) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
open={props.controller.open()}
|
||||||
|
placement="bottom-start"
|
||||||
|
gutter={4}
|
||||||
|
shift={-6}
|
||||||
|
modal={false}
|
||||||
|
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
ref={contentRef}
|
||||||
|
id="prompt-project-menu"
|
||||||
|
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||||
|
onFocusOutside={() => (restoreTrigger = false)}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!restoreTrigger) event.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col p-0.5">
|
||||||
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
|
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={(el) => props.controller.setSearchRef(el)}
|
||||||
|
value={props.controller.search()}
|
||||||
|
placeholder={props.controller.labels.search()}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="prompt-project-menu"
|
||||||
|
aria-activedescendant={props.controller.active() || undefined}
|
||||||
|
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||||
|
onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.shiftKey) {
|
||||||
|
focusPreviousControl()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeItem()?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
props.controller.setOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.altKey || event.metaKey) return
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault()
|
||||||
|
moveActive(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault()
|
||||||
|
moveActive(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && !event.isComposing) {
|
||||||
|
event.preventDefault()
|
||||||
|
selectActive()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Show when={props.controller.search().trim()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||||
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => props.controller.clearSearch()}
|
||||||
|
aria-label={props.controller.labels.clear()}
|
||||||
|
>
|
||||||
|
<Icon name="close-small" size="small" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={props.controller.servers().length > 1}
|
||||||
|
fallback={
|
||||||
|
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||||
|
<For each={props.controller.projects()}>
|
||||||
|
{(project) => (
|
||||||
|
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={props.controller.servers()}>
|
||||||
|
{(server) => (
|
||||||
|
<div>
|
||||||
|
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||||
|
{server!.name}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||||
|
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
||||||
|
{(project) => (
|
||||||
|
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
|
<ProjectAction server={server!.key} controller={props.controller} onSelect={selectAction} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={props.controller.servers().length <= 1}>
|
||||||
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
|
<div class="flex flex-col p-0.5">
|
||||||
|
<ProjectAction
|
||||||
|
server={props.controller.servers()[0]?.key}
|
||||||
|
controller={props.controller}
|
||||||
|
onSelect={selectAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptProjectAddButton(props: { controller: PromptProjectController }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-action="prompt-project"
|
||||||
|
type="button"
|
||||||
|
class="flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
|
onClick={() => props.controller.add()}
|
||||||
|
>
|
||||||
|
<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate leading-5">{props.controller.labels.new()}</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
|
||||||
|
const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
|
||||||
|
const project = () => local.controller.selected()
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...rest}
|
||||||
|
data-action="prompt-project"
|
||||||
|
type="button"
|
||||||
|
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
|
classList={{
|
||||||
|
...local.classList,
|
||||||
|
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
|
||||||
|
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
|
||||||
|
}}
|
||||||
|
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof local.onKeyDown === "function") local.onKeyDown(event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={project()}
|
||||||
|
fallback={<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />}
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={displayName(item())}
|
||||||
|
src={getProjectAvatarSource(item().id, item().icon)}
|
||||||
|
variant={getProjectAvatarVariant(item().icon?.color)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<span class="min-w-0 truncate leading-5">
|
||||||
|
{project() ? displayName(project()!) : local.controller.labels.new()}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectItem(props: {
|
||||||
|
project: PromptProject
|
||||||
|
controller: PromptProjectController
|
||||||
|
onSelect: (project: PromptProject) => void
|
||||||
|
}) {
|
||||||
|
const key = () => props.controller.projectKey(props.project)
|
||||||
|
return (
|
||||||
|
<DropdownMenu.RadioItem
|
||||||
|
id={key()}
|
||||||
|
value={key()}
|
||||||
|
data-option-key={key()}
|
||||||
|
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||||
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
|
||||||
|
style={{
|
||||||
|
"font-family": "var(--v2-font-family-sans)",
|
||||||
|
"font-size": "13px",
|
||||||
|
"font-weight": 440,
|
||||||
|
"line-height": "20px",
|
||||||
|
"letter-spacing": "-0.04px",
|
||||||
|
color: "var(--v2-text-text-base)",
|
||||||
|
padding: "0 12px",
|
||||||
|
}}
|
||||||
|
closeOnSelect
|
||||||
|
onMouseEnter={() => {
|
||||||
|
props.controller.setActive(key())
|
||||||
|
props.controller.focusSearch()
|
||||||
|
}}
|
||||||
|
onSelect={() => props.onSelect(props.project)}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={displayName(props.project)}
|
||||||
|
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||||
|
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
|
||||||
|
<DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
|
||||||
|
<IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||||
|
</DropdownMenu.ItemIndicator>
|
||||||
|
</DropdownMenu.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectAction(props: {
|
||||||
|
server?: string
|
||||||
|
controller: PromptProjectController
|
||||||
|
onSelect: (server?: string) => void
|
||||||
|
}) {
|
||||||
|
const key = () => props.controller.actionKey(props.server)
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
id={key()}
|
||||||
|
data-option-key={key()}
|
||||||
|
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||||
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
|
||||||
|
style={{
|
||||||
|
"font-family": "var(--v2-font-family-sans)",
|
||||||
|
"font-size": "13px",
|
||||||
|
"font-weight": 440,
|
||||||
|
"line-height": "20px",
|
||||||
|
"letter-spacing": "-0.04px",
|
||||||
|
color: "var(--v2-text-text-base)",
|
||||||
|
padding: "0 12px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
props.controller.setActive(key())
|
||||||
|
props.controller.focusSearch()
|
||||||
|
}}
|
||||||
|
onSelect={() => props.onSelect(props.server)}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size="small" />
|
||||||
|
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
|
||||||
|
{props.controller.labels.add()}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { onCleanup } from "solid-js"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
|
||||||
|
export function useSettingsDialog() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const params = useParams<{ id?: string }>()
|
||||||
|
let run = 0
|
||||||
|
let dead = false
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
dead = true
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const current = ++run
|
||||||
|
const sessionID = params.id
|
||||||
|
void import("@/components/settings-v2").then((module) => {
|
||||||
|
if (dead || run !== current) return
|
||||||
|
void dialog.show(() => <module.DialogSettings sessionID={sessionID} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsCommand() {
|
||||||
|
const command = useCommand()
|
||||||
|
const language = useLanguage()
|
||||||
|
const show = useSettingsDialog()
|
||||||
|
|
||||||
|
command.register("settings", () => [
|
||||||
|
{
|
||||||
|
id: "settings.open",
|
||||||
|
title: language.t("command.settings.open"),
|
||||||
|
category: language.t("command.category.settings"),
|
||||||
|
keybind: "mod+comma",
|
||||||
|
onSelect: show,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
return show
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
@@ -52,8 +53,12 @@ export const DialogServerV2: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={title()} fit class="settings-v2-server-dialog">
|
<Dialog fit class="settings-v2-server-dialog">
|
||||||
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
|
<DialogHeader hideClose={true}>
|
||||||
|
<DialogTitle>{title()}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DividerV2 />
|
||||||
|
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||||
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||||
@@ -115,7 +120,7 @@ export const DialogServerV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { SettingsModelsV2 } from "./models"
|
|||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
|
|
||||||
export const DialogSettings: Component = () => {
|
export const DialogSettings: Component<{
|
||||||
|
sessionID?: string
|
||||||
|
}> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ export const DialogSettings: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</TabsV2.List>
|
</TabsV2.List>
|
||||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||||
<SettingsGeneralV2 />
|
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||||
<SettingsKeybinds v2 />
|
<SettingsKeybinds v2 />
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
|||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -25,7 +24,6 @@ import {
|
|||||||
terminalInput,
|
terminalInput,
|
||||||
useSettings,
|
useSettings,
|
||||||
} from "@/context/settings"
|
} from "@/context/settings"
|
||||||
import { decode64 } from "@/utils/base64"
|
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { Link } from "../link"
|
import { Link } from "../link"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
@@ -82,50 +80,46 @@ const playDemoSound = (id: string | undefined) => {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsGeneralV2: Component = () => {
|
export const SettingsGeneralV2: Component<{
|
||||||
|
sessionID?: string
|
||||||
|
}> = (props) => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const params = useParams()
|
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const serverSdk = useServerSDK()
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
|
|
||||||
const dir = createMemo(() => decode64(params.dir))
|
const dir = createMemo(() => {
|
||||||
|
if (!props.sessionID) return undefined
|
||||||
|
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
|
||||||
|
})
|
||||||
const accepting = createMemo(() => {
|
const accepting = createMemo(() => {
|
||||||
const value = dir()
|
const value = dir()
|
||||||
if (!value) return false
|
if (!value || !props.sessionID) return false
|
||||||
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
return permission.isAutoAccepting(props.sessionID, value)
|
||||||
return permission.isAutoAccepting(params.id, value)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleAccept = (checked: boolean) => {
|
const toggleAccept = (checked: boolean) => {
|
||||||
const value = dir()
|
const value = dir()
|
||||||
if (!value) return
|
if (!value || !props.sessionID) return
|
||||||
|
|
||||||
if (!params.id) {
|
|
||||||
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
|
||||||
permission.toggleAutoAcceptDirectory(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
permission.enableAutoAccept(params.id, value)
|
permission.enableAutoAccept(props.sessionID, value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
permission.disableAutoAccept(params.id, value)
|
permission.disableAutoAccept(props.sessionID, value)
|
||||||
}
|
}
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() =>
|
||||||
serverSdk()
|
serverSdk()
|
||||||
|
|||||||
@@ -633,7 +633,7 @@
|
|||||||
|
|
||||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 24px 24px 0;
|
padding: 24px 24px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { Popover } from "@opencode-ai/ui/popover"
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
|
|
||||||
@@ -81,11 +82,11 @@ export function StatusPopoverV2(props: { scope?: "server" }) {
|
|||||||
|
|
||||||
function DirectoryStatusPopover() {
|
function DirectoryStatusPopover() {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServerSDK()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
|
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
|
||||||
const mcpIssue = createMemo(() => {
|
const mcpIssue = createMemo(() => {
|
||||||
const mcp = Object.values(sync().data.mcp ?? {})
|
const mcp = Object.values(sync().data.mcp ?? {})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { matchKeybind, parseKeybind } from "@/context/command"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServer } from "@/context/server"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||||
import type { LocalPTY } from "@/context/terminal"
|
import type { LocalPTY } from "@/context/terminal"
|
||||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||||
@@ -160,13 +160,15 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
||||||
|
const connection = useServerSDK()().server
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const client = sdk().client
|
const client = sdk().client
|
||||||
const url = sdk().url
|
const url = sdk().url
|
||||||
const auth = server.current?.http
|
const auth = connection.http
|
||||||
const username = auth?.username ?? "opencode"
|
const username = auth?.username ?? "opencode"
|
||||||
const password = auth?.password ?? ""
|
const password = auth?.password ?? ""
|
||||||
|
const authToken = connection.type === "http" ? connection.authToken : false
|
||||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||||
@@ -540,7 +542,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
sameOrigin,
|
sameOrigin,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
authToken: server.current?.type === "http" ? server.current.authToken : false,
|
authToken,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
|
|
||||||
import {
|
|
||||||
canOpenTabRename,
|
|
||||||
captureTabPointerDown,
|
|
||||||
canStartTabDrag,
|
|
||||||
createTabDragPreview,
|
|
||||||
forwardTabRef,
|
|
||||||
isPrimaryPointerPressed,
|
|
||||||
isTabCloseTarget,
|
|
||||||
} from "./titlebar-tab-gesture"
|
|
||||||
|
|
||||||
describe("titlebar tab drag", () => {
|
|
||||||
const layout = {
|
|
||||||
listLeft: 100,
|
|
||||||
dividerWidth: 13,
|
|
||||||
tabWidthById: new Map([
|
|
||||||
["a", 40],
|
|
||||||
["b", 40],
|
|
||||||
["c", 40],
|
|
||||||
["d", 40],
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
|
|
||||||
test("moves across multiple tabs from one pointer update", () => {
|
|
||||||
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
|
|
||||||
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps the current index inside the left hysteresis deadband", () => {
|
|
||||||
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes slot margins in captured divider width", () => {
|
|
||||||
const list = document.createElement("div")
|
|
||||||
const first = document.createElement("div")
|
|
||||||
const second = document.createElement("div")
|
|
||||||
const firstTab = document.createElement("div")
|
|
||||||
const secondTab = document.createElement("div")
|
|
||||||
first.dataset.titlebarTabSlot = ""
|
|
||||||
first.dataset.tabKey = "a"
|
|
||||||
second.dataset.titlebarTabSlot = ""
|
|
||||||
second.dataset.tabKey = "b"
|
|
||||||
second.style.marginLeft = "6px"
|
|
||||||
firstTab.dataset.titlebarTab = ""
|
|
||||||
secondTab.dataset.titlebarTab = ""
|
|
||||||
first.append(firstTab)
|
|
||||||
second.append(secondTab)
|
|
||||||
list.append(first, second)
|
|
||||||
document.body.append(list)
|
|
||||||
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
|
||||||
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
|
||||||
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
|
|
||||||
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
|
|
||||||
|
|
||||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
|
|
||||||
list.remove()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses the list gap as the divider width", () => {
|
|
||||||
const list = document.createElement("div")
|
|
||||||
const first = document.createElement("div")
|
|
||||||
const second = document.createElement("div")
|
|
||||||
const firstTab = document.createElement("div")
|
|
||||||
const secondTab = document.createElement("div")
|
|
||||||
first.dataset.titlebarTabSlot = ""
|
|
||||||
first.dataset.tabKey = "a"
|
|
||||||
second.dataset.titlebarTabSlot = ""
|
|
||||||
second.dataset.tabKey = "b"
|
|
||||||
firstTab.dataset.titlebarTab = ""
|
|
||||||
secondTab.dataset.titlebarTab = ""
|
|
||||||
first.append(firstTab)
|
|
||||||
second.append(secondTab)
|
|
||||||
list.append(first, second)
|
|
||||||
list.style.columnGap = "13.5px"
|
|
||||||
document.body.append(list)
|
|
||||||
|
|
||||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
|
|
||||||
list.remove()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("titlebar tab gestures", () => {
|
|
||||||
test("excludes close controls from tab gestures", () => {
|
|
||||||
const close = document.createElement("div")
|
|
||||||
const button = document.createElement("button")
|
|
||||||
const link = document.createElement("a")
|
|
||||||
close.dataset.slot = "tab-close"
|
|
||||||
close.append(button)
|
|
||||||
expect(isTabCloseTarget(close)).toBe(true)
|
|
||||||
expect(isTabCloseTarget(button)).toBe(true)
|
|
||||||
expect(isTabCloseTarget(link)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("forwards component refs", () => {
|
|
||||||
const element = document.createElement("div")
|
|
||||||
let received: HTMLDivElement | undefined
|
|
||||||
forwardTabRef((value) => (received = value), element)
|
|
||||||
expect(received).toBe(element)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not reopen rename while a save is pending", () => {
|
|
||||||
expect(canOpenTabRename(false, false, false)).toBe(true)
|
|
||||||
expect(canOpenTabRename(false, false, true)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps the rendered tab content in the drag preview", () => {
|
|
||||||
const tab = document.createElement("div")
|
|
||||||
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
|
|
||||||
const preview = createTabDragPreview(tab)
|
|
||||||
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
|
|
||||||
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("captures the grab offset before navigation scrolls the tab", () => {
|
|
||||||
const tab = document.createElement("div")
|
|
||||||
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
|
|
||||||
|
|
||||||
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
|
|
||||||
startX: 100,
|
|
||||||
startY: 20,
|
|
||||||
grabOffsetX: 20,
|
|
||||||
grabOffsetY: 10,
|
|
||||||
width: 120,
|
|
||||||
element: tab,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("detects when the primary pointer button was released outside the window", () => {
|
|
||||||
expect(isPrimaryPointerPressed(1)).toBe(true)
|
|
||||||
expect(isPrimaryPointerPressed(3)).toBe(true)
|
|
||||||
expect(isPrimaryPointerPressed(0)).toBe(false)
|
|
||||||
expect(isPrimaryPointerPressed(2)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves native panning for touch pointers", () => {
|
|
||||||
expect(canStartTabDrag("mouse")).toBe(true)
|
|
||||||
expect(canStartTabDrag("pen")).toBe(true)
|
|
||||||
expect(canStartTabDrag("touch")).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
export type TabDragLayout = {
|
|
||||||
tabWidthById: Map<string, number>
|
|
||||||
dividerWidth: number
|
|
||||||
listLeft: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ACTIVATION_DISTANCE = 4
|
|
||||||
export const HYSTERESIS_DEADBAND = 8
|
|
||||||
export const AUTOSCROLL_EDGE = 24
|
|
||||||
export const AUTOSCROLL_MAX_SPEED = 8
|
|
||||||
export const FLOATER_OVERSHOOT_MAX = 8
|
|
||||||
|
|
||||||
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
|
|
||||||
const dx = x2 - x1
|
|
||||||
const dy = y2 - y1
|
|
||||||
return Math.sqrt(dx * dx + dy * dy)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
|
|
||||||
const tabWidthById = new Map<string, number>()
|
|
||||||
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
|
|
||||||
for (const slot of slots) {
|
|
||||||
const id = slot.dataset.tabKey
|
|
||||||
if (!id) continue
|
|
||||||
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
|
||||||
if (!tab) continue
|
|
||||||
tabWidthById.set(id, tab.getBoundingClientRect().width)
|
|
||||||
}
|
|
||||||
|
|
||||||
let dividerWidth = 0
|
|
||||||
if (order.length >= 2) {
|
|
||||||
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
|
|
||||||
const secondId = order[1]
|
|
||||||
for (const slot of slots) {
|
|
||||||
if (slot.dataset.tabKey !== secondId) continue
|
|
||||||
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
|
||||||
if (!tab) break
|
|
||||||
const style = getComputedStyle(slot)
|
|
||||||
dividerWidth =
|
|
||||||
gap ||
|
|
||||||
slot.getBoundingClientRect().width -
|
|
||||||
tab.getBoundingClientRect().width +
|
|
||||||
(Number.parseFloat(style.marginLeft) || 0) +
|
|
||||||
(Number.parseFloat(style.marginRight) || 0)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tabWidthById,
|
|
||||||
dividerWidth,
|
|
||||||
listLeft: list.getBoundingClientRect().left,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
|
|
||||||
layout.listLeft = list.getBoundingClientRect().left
|
|
||||||
}
|
|
||||||
|
|
||||||
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
|
|
||||||
const id = order[index]
|
|
||||||
if (!id) return 0
|
|
||||||
const tabWidth = layout.tabWidthById.get(id) ?? 0
|
|
||||||
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
|
|
||||||
let left = layout.listLeft
|
|
||||||
for (let i = 0; i < index; i++) {
|
|
||||||
left += slotWidthAt(order, i, layout)
|
|
||||||
}
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
|
|
||||||
export function insertIndexFromVirtualLayout(
|
|
||||||
pointerX: number,
|
|
||||||
order: readonly string[],
|
|
||||||
draggedId: string,
|
|
||||||
currentIndex: number,
|
|
||||||
layout: TabDragLayout,
|
|
||||||
deadband = HYSTERESIS_DEADBAND,
|
|
||||||
) {
|
|
||||||
if (order.length === 0) return 0
|
|
||||||
|
|
||||||
const others = order.filter((id) => id !== draggedId)
|
|
||||||
let target = currentIndex
|
|
||||||
|
|
||||||
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
|
|
||||||
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
|
|
||||||
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
|
|
||||||
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
|
|
||||||
const fromIndex = order.indexOf(draggedId)
|
|
||||||
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
|
|
||||||
const next = [...order]
|
|
||||||
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
|
|
||||||
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
|
|
||||||
return final.some((key, index) => key !== initial[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
function easeOvershoot(overshoot: number) {
|
|
||||||
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
|
|
||||||
const stripWidth = stripRight - stripLeft
|
|
||||||
if (width >= stripWidth) return stripLeft
|
|
||||||
|
|
||||||
const maxLeft = stripRight - width
|
|
||||||
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
|
|
||||||
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
|
|
||||||
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
|
|
||||||
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
|
|
||||||
const leftEdge = containerLeft + AUTOSCROLL_EDGE
|
|
||||||
const rightEdge = containerRight - AUTOSCROLL_EDGE
|
|
||||||
|
|
||||||
if (pointerX < leftEdge) {
|
|
||||||
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
|
|
||||||
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pointerX > rightEdge) {
|
|
||||||
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
|
|
||||||
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||||
|
|
||||||
|
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("preserves native panning for touch pointers", () => {
|
||||||
|
expect(canStartTabDrag("mouse")).toBe(true)
|
||||||
|
expect(canStartTabDrag("pen")).toBe(true)
|
||||||
|
expect(canStartTabDrag("touch")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,22 +8,6 @@ export function canStartTabDrag(pointerType: string) {
|
|||||||
return pointerType !== "touch"
|
return pointerType !== "touch"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPrimaryPointerPressed(buttons: number) {
|
|
||||||
return (buttons & 1) !== 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return {
|
|
||||||
startX: clientX,
|
|
||||||
startY: clientY,
|
|
||||||
grabOffsetX: clientX - rect.left,
|
|
||||||
grabOffsetY: clientY - rect.top,
|
|
||||||
width: rect.width,
|
|
||||||
element,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
||||||
if (typeof ref === "function") ref(element)
|
if (typeof ref === "function") ref(element)
|
||||||
}
|
}
|
||||||
@@ -31,7 +15,3 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
|
|||||||
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
||||||
return !dragging && !editing && !committing
|
return !dragging && !editing && !committing
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTabDragPreview(element: HTMLDivElement) {
|
|
||||||
return element.cloneNode(true) as HTMLDivElement
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-list] {
|
[data-titlebar-tab-list] {
|
||||||
gap: 13.5px;
|
gap: 13.5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ export function TabNavItem(props: {
|
|||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
tabKey: string
|
|
||||||
dragActive: boolean
|
|
||||||
onPointerDown: (event: PointerEvent) => void
|
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [editing, setEditing] = createSignal(false)
|
const [editing, setEditing] = createSignal(false)
|
||||||
@@ -172,13 +169,6 @@ export function TabNavItem(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
data-titlebar-tab-slot
|
|
||||||
data-tab-key={props.tabKey}
|
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
|
||||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
|
||||||
onPointerDown={props.onPointerDown}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
tabRoot = el
|
tabRoot = el
|
||||||
@@ -189,6 +179,7 @@ export function TabNavItem(props: {
|
|||||||
data-title-overflow={titleOverflowing()}
|
data-title-overflow={titleOverflowing()}
|
||||||
data-editing={editing()}
|
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)]"
|
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)]"
|
||||||
|
classList={{ invisible: props.hidden }}
|
||||||
data-active={props.active}
|
data-active={props.active}
|
||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
data-pressed={props.pressed}
|
data-pressed={props.pressed}
|
||||||
@@ -280,7 +271,6 @@ export function TabNavItem(props: {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,9 +285,6 @@ export function DraftTabItem(props: {
|
|||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
tabKey: string
|
|
||||||
dragActive: boolean
|
|
||||||
onPointerDown: (event: PointerEvent) => void
|
|
||||||
}) {
|
}) {
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -305,13 +292,6 @@ export function DraftTabItem(props: {
|
|||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
data-titlebar-tab-slot
|
|
||||||
data-tab-key={props.tabKey}
|
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
|
||||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
|
||||||
onPointerDown={props.onPointerDown}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={(el) => forwardTabRef(props.ref, el)}
|
ref={(el) => forwardTabRef(props.ref, el)}
|
||||||
data-titlebar-tab
|
data-titlebar-tab
|
||||||
@@ -319,7 +299,8 @@ export function DraftTabItem(props: {
|
|||||||
data-active={props.active}
|
data-active={props.active}
|
||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
data-pressed={props.pressed}
|
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)]"
|
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--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)]"
|
||||||
|
classList={{ invisible: props.hidden }}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== 1) return
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
@@ -370,6 +351,5 @@ export function DraftTabItem(props: {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import {
|
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
|
||||||
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 { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||||
|
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||||
|
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||||
|
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
|
||||||
|
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||||
|
import { arrayMove } from "@dnd-kit/helpers"
|
||||||
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
||||||
@@ -22,42 +15,30 @@ import { useCommand } from "@/context/command"
|
|||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { createTabPromptState } from "@/context/prompt"
|
import { createTabPromptState } from "@/context/prompt"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import {
|
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||||
captureTabPointerDown,
|
|
||||||
canStartTabDrag,
|
const sortableTransition = { duration: 0 }
|
||||||
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: {
|
function SessionTabSlot(props: {
|
||||||
tab: SessionTab
|
tab: SessionTab
|
||||||
id: string
|
id: string
|
||||||
|
index: () => number
|
||||||
active: () => boolean
|
active: () => boolean
|
||||||
activeServerKey: ServerConnection.Key
|
activeServerKey: ServerConnection.Key
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
dragActive: boolean
|
|
||||||
dragged: () => boolean
|
|
||||||
pressed: () => boolean
|
|
||||||
serverCtx: () => ServerCtx | undefined
|
serverCtx: () => ServerCtx | undefined
|
||||||
suppressNavigation: () => boolean
|
|
||||||
onPointerDown: (event: PointerEvent) => void
|
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
const sortable = useSortable({
|
||||||
|
get id() {
|
||||||
|
return props.id
|
||||||
|
},
|
||||||
|
get index() {
|
||||||
|
return props.index()
|
||||||
|
},
|
||||||
|
})
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||||
@@ -100,11 +81,17 @@ function SessionTabSlot(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
ref={sortable.ref}
|
||||||
|
data-titlebar-tab-slot
|
||||||
|
data-tab-key={props.id}
|
||||||
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
|
classList={{ hidden: !session() }}
|
||||||
|
>
|
||||||
<TabNavItem
|
<TabNavItem
|
||||||
tabKey={props.id}
|
ref={(el) => {
|
||||||
dragActive={props.dragActive}
|
ref = el
|
||||||
onPointerDown={props.onPointerDown}
|
}}
|
||||||
ref={ref}
|
|
||||||
href={tabHref(props.tab)}
|
href={tabHref(props.tab)}
|
||||||
server={props.tab.server}
|
server={props.tab.server}
|
||||||
session={session}
|
session={session}
|
||||||
@@ -123,10 +110,50 @@ function SessionTabSlot(props: {
|
|||||||
active={props.active()}
|
active={props.active()}
|
||||||
activeServer={props.tab.server === props.activeServerKey}
|
activeServer={props.tab.server === props.activeServerKey}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
suppressNavigation={props.suppressNavigation}
|
dragging={sortable.isDragSource()}
|
||||||
pressed={props.pressed()}
|
|
||||||
hidden={props.dragged() || !session()}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraftTabSlot(props: {
|
||||||
|
tab: Extract<Tab, { type: "draft" }>
|
||||||
|
id: string
|
||||||
|
index: () => number
|
||||||
|
active: () => boolean
|
||||||
|
title: string
|
||||||
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const sortable = useSortable({
|
||||||
|
get id() {
|
||||||
|
return props.id
|
||||||
|
},
|
||||||
|
get index() {
|
||||||
|
return props.index()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let ref!: HTMLDivElement
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sortable.ref}
|
||||||
|
data-titlebar-tab-slot
|
||||||
|
data-tab-key={props.id}
|
||||||
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
|
>
|
||||||
|
<DraftTabItem
|
||||||
|
ref={(el) => {
|
||||||
|
ref = el
|
||||||
|
}}
|
||||||
|
href={tabHref(props.tab)}
|
||||||
|
title={props.title}
|
||||||
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
|
onClose={props.onClose}
|
||||||
|
active={props.active()}
|
||||||
|
dragging={sortable.isDragSource()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,52 +169,12 @@ export function TitlebarTabStrip(props: {
|
|||||||
}) {
|
}) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [drag, setDrag] = createStore({
|
|
||||||
active: false,
|
|
||||||
draggedId: undefined as string | undefined,
|
|
||||||
placeholderIndex: 0,
|
|
||||||
draftOrder: [] as string[],
|
|
||||||
initialOrder: [] as string[],
|
|
||||||
draggedWidth: 0,
|
|
||||||
pointerX: 0,
|
|
||||||
grabOffsetX: 0,
|
|
||||||
floaterTop: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [gesture, setGesture] = createStore({
|
|
||||||
pending: undefined as
|
|
||||||
| {
|
|
||||||
id: string
|
|
||||||
startX: number
|
|
||||||
startY: number
|
|
||||||
grabOffsetX: number
|
|
||||||
grabOffsetY: number
|
|
||||||
pointerId: number
|
|
||||||
width: number
|
|
||||||
element: HTMLDivElement
|
|
||||||
}
|
|
||||||
| undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
|
|
||||||
const [pressedId, setPressedId] = createSignal<string | undefined>()
|
|
||||||
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
|
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let listRef!: HTMLDivElement
|
let listRef!: HTMLDivElement
|
||||||
let dragLayout: TabDragLayout | undefined
|
|
||||||
let dragPointerId: number | undefined
|
|
||||||
let autoscrollFrame: number | undefined
|
|
||||||
let resizeFrame: number | undefined
|
let resizeFrame: number | undefined
|
||||||
let dragPreview: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
const tabIds = () => props.tabs.map(tabKey)
|
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() {
|
function refreshOverflow() {
|
||||||
if (!scrollRef) return
|
if (!scrollRef) return
|
||||||
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
|
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
|
||||||
@@ -200,220 +187,14 @@ export function TitlebarTabStrip(props: {
|
|||||||
resizeFrame = requestAnimationFrame(() => {
|
resizeFrame = requestAnimationFrame(() => {
|
||||||
resizeFrame = undefined
|
resizeFrame = undefined
|
||||||
refreshOverflow()
|
refreshOverflow()
|
||||||
if (!drag.active || !listRef) return
|
|
||||||
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
|
|
||||||
updateInsertIndex()
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
function syncScroll() {
|
|
||||||
if (!scrollRef || !listRef || !dragLayout) return
|
|
||||||
syncLayoutScroll(listRef, dragLayout)
|
|
||||||
setStripScrollLeft(scrollRef.scrollLeft)
|
|
||||||
updateInsertIndex()
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAutoscroll() {
|
|
||||||
if (autoscrollFrame === undefined) return
|
|
||||||
cancelAnimationFrame(autoscrollFrame)
|
|
||||||
autoscrollFrame = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function tickAutoscroll() {
|
|
||||||
if (!drag.active || !scrollRef) return
|
|
||||||
|
|
||||||
const strip = scrollRef.getBoundingClientRect()
|
|
||||||
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
|
|
||||||
|
|
||||||
if (speed !== 0) {
|
|
||||||
scrollRef.scrollLeft += speed
|
|
||||||
syncScroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAutoscroll() {
|
|
||||||
stopAutoscroll()
|
|
||||||
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPlaceholderIndex(nextIndex: number) {
|
|
||||||
const id = drag.draggedId
|
|
||||||
if (!id) return
|
|
||||||
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
|
|
||||||
setDrag({
|
|
||||||
draftOrder: next,
|
|
||||||
placeholderIndex: nextIndex,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateInsertIndex() {
|
|
||||||
if (!drag.active || !dragLayout) return
|
|
||||||
const draggedId = drag.draggedId
|
|
||||||
if (!draggedId) return
|
|
||||||
const nextIndex = insertIndexFromVirtualLayout(
|
|
||||||
drag.pointerX,
|
|
||||||
drag.draftOrder,
|
|
||||||
draggedId,
|
|
||||||
drag.placeholderIndex,
|
|
||||||
dragLayout,
|
|
||||||
)
|
|
||||||
if (nextIndex === drag.placeholderIndex) return
|
|
||||||
applyPlaceholderIndex(nextIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startDrag(id: string) {
|
|
||||||
const order = tabIds()
|
|
||||||
const index = order.indexOf(id)
|
|
||||||
const pending = gesture.pending
|
|
||||||
if (index === -1 || !pending || !listRef || !scrollRef) return
|
|
||||||
|
|
||||||
dragLayout = captureTabDragLayout(listRef, order)
|
|
||||||
dragPreview = createTabDragPreview(pending.element)
|
|
||||||
dragPointerId = pending.pointerId
|
|
||||||
setGesture("pending", undefined)
|
|
||||||
|
|
||||||
setDrag({
|
|
||||||
active: true,
|
|
||||||
draggedId: id,
|
|
||||||
placeholderIndex: index,
|
|
||||||
draftOrder: order,
|
|
||||||
initialOrder: order,
|
|
||||||
draggedWidth: pending.width,
|
|
||||||
pointerX: pending.startX,
|
|
||||||
grabOffsetX: pending.grabOffsetX,
|
|
||||||
floaterTop: pending.startY - pending.grabOffsetY,
|
|
||||||
})
|
|
||||||
setPressedId(undefined)
|
|
||||||
setStripScrollLeft(scrollRef.scrollLeft)
|
|
||||||
startAutoscroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
function endDrag(commit: boolean) {
|
|
||||||
const initial = drag.initialOrder
|
|
||||||
const final = drag.draftOrder
|
|
||||||
const moved = drag.active
|
|
||||||
|
|
||||||
if (commit && moved && draftOrderChanged(initial, final)) {
|
|
||||||
props.onReorder(final)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (moved) setSuppressNavigation(true)
|
|
||||||
|
|
||||||
setDrag({
|
|
||||||
active: false,
|
|
||||||
draggedId: undefined,
|
|
||||||
placeholderIndex: 0,
|
|
||||||
draftOrder: [],
|
|
||||||
initialOrder: [],
|
|
||||||
draggedWidth: 0,
|
|
||||||
pointerX: 0,
|
|
||||||
grabOffsetX: 0,
|
|
||||||
floaterTop: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
dragLayout = undefined
|
|
||||||
dragPreview = undefined
|
|
||||||
dragPointerId = undefined
|
|
||||||
setGesture("pending", undefined)
|
|
||||||
setPressedId(undefined)
|
|
||||||
stopAutoscroll()
|
|
||||||
refreshOverflow()
|
|
||||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerDown(id: string, event: PointerEvent) {
|
|
||||||
if (event.button !== 0 || drag.active) return
|
|
||||||
if (!canStartTabDrag(event.pointerType)) return
|
|
||||||
if (isTabCloseTarget(event.target)) return
|
|
||||||
const target = event.currentTarget as HTMLDivElement
|
|
||||||
const tabEl = target.matches("[data-titlebar-tab]")
|
|
||||||
? target
|
|
||||||
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
|
||||||
if (!tabEl) return
|
|
||||||
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
|
|
||||||
const tab = props.tabs.find((item) => tabKey(item) === id)
|
|
||||||
if (!tab) return
|
|
||||||
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
|
|
||||||
setSuppressNavigation(true)
|
|
||||||
props.onNavigate(tab, tabEl)
|
|
||||||
setPressedId(id)
|
|
||||||
setGesture("pending", {
|
|
||||||
id,
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
...pointer,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerMove(event: PointerEvent) {
|
|
||||||
const pending = gesture.pending
|
|
||||||
if (pending && event.pointerId !== pending.pointerId) return
|
|
||||||
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
|
||||||
if (!isPrimaryPointerPressed(event.buttons)) {
|
|
||||||
if (drag.active) endDrag(true)
|
|
||||||
if (pending) {
|
|
||||||
setGesture("pending", undefined)
|
|
||||||
setPressedId(undefined)
|
|
||||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pending && !drag.active) {
|
|
||||||
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
|
|
||||||
startDrag(pending.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!drag.active) return
|
|
||||||
|
|
||||||
setDrag("pointerX", event.clientX)
|
|
||||||
syncScroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerUp(event: PointerEvent) {
|
|
||||||
if (drag.active) {
|
|
||||||
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
|
||||||
setDrag("pointerX", event.clientX)
|
|
||||||
syncScroll()
|
|
||||||
endDrag(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = gesture.pending
|
|
||||||
if (pending && event.pointerId !== pending.pointerId) return
|
|
||||||
|
|
||||||
setGesture("pending", undefined)
|
|
||||||
setPressedId(undefined)
|
|
||||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerCancel(event: PointerEvent) {
|
|
||||||
if (drag.active) {
|
|
||||||
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
|
||||||
endDrag(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!gesture.pending) return
|
|
||||||
if (gesture.pending.pointerId !== event.pointerId) return
|
|
||||||
setGesture("pending", undefined)
|
|
||||||
setPressedId(undefined)
|
|
||||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const cleanups = [
|
|
||||||
makeEventListener(window, "pointermove", onPointerMove),
|
|
||||||
makeEventListener(window, "pointerup", onPointerUp),
|
|
||||||
makeEventListener(window, "pointercancel", onPointerCancel),
|
|
||||||
]
|
|
||||||
refreshOverflow()
|
refreshOverflow()
|
||||||
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(stopAutoscroll)
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||||
})
|
})
|
||||||
@@ -424,50 +205,54 @@ export function TitlebarTabStrip(props: {
|
|||||||
refreshOverflow()
|
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 (
|
return (
|
||||||
<>
|
|
||||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||||
<div
|
<div
|
||||||
data-slot="titlebar-tabs-scroll"
|
data-slot="titlebar-tabs-scroll"
|
||||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
>
|
>
|
||||||
<div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
|
<DragDropProvider
|
||||||
<For each={displayTabs()}>
|
sensors={[
|
||||||
|
PointerSensor.configure({
|
||||||
|
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||||
|
preventActivation: (event) =>
|
||||||
|
!canStartTabDrag(event.pointerType) ||
|
||||||
|
isTabCloseTarget(event.target) ||
|
||||||
|
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]}
|
||||||
|
plugins={(defaults) => [
|
||||||
|
...defaults.filter((plugin) => plugin !== Accessibility),
|
||||||
|
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
|
||||||
|
Feedback.configure({ dropAnimation: null }),
|
||||||
|
]}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
const source = event.operation.source
|
||||||
|
if (!source) return
|
||||||
|
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
|
||||||
|
if (!tab) return
|
||||||
|
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||||
|
props.onNavigate(tab, tabEl ?? undefined)
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
const current = tabIds()
|
||||||
|
const source = event.operation.source
|
||||||
|
if (event.canceled || !isSortable(source)) return
|
||||||
|
|
||||||
|
const { initialIndex, index } = source
|
||||||
|
if (initialIndex !== index) {
|
||||||
|
props.onReorder(arrayMove(current, source.initialIndex, source.index))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
||||||
|
<For each={props.tabs}>
|
||||||
{(tab, index) => {
|
{(tab, index) => {
|
||||||
const id = tabKey(tab)
|
const id = tabKey(tab)
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
||||||
|
|
||||||
const dragged = () => drag.active && drag.draggedId === id
|
|
||||||
const serverCtx = createMemo(() => {
|
const serverCtx = createMemo(() => {
|
||||||
if (tab.type !== "session") return
|
if (tab.type !== "session") return
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||||
@@ -479,46 +264,38 @@ export function TitlebarTabStrip(props: {
|
|||||||
<SessionTabSlot
|
<SessionTabSlot
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
|
index={index}
|
||||||
active={() => props.currentTab() === tab}
|
active={() => props.currentTab() === tab}
|
||||||
activeServerKey={props.activeServerKey}
|
activeServerKey={props.activeServerKey}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
dragActive={drag.active}
|
|
||||||
dragged={dragged}
|
|
||||||
pressed={() => pressedId() === id}
|
|
||||||
serverCtx={serverCtx}
|
serverCtx={serverCtx}
|
||||||
suppressNavigation={() => suppressNavigation()}
|
onNavigate={(element) => {
|
||||||
onPointerDown={(event) => {
|
ref = element
|
||||||
if (dragged()) return
|
props.onNavigate(tab, element)
|
||||||
onPointerDown(id, event)
|
|
||||||
}}
|
}}
|
||||||
onNavigate={(element) => props.onNavigate(tab, element)}
|
|
||||||
onClose={() => props.onClose(tab)}
|
onClose={() => props.onClose(tab)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DraftTabItem
|
<DraftTabSlot
|
||||||
tabKey={id}
|
tab={tab}
|
||||||
dragActive={drag.active}
|
id={id}
|
||||||
onPointerDown={(event) => {
|
index={index}
|
||||||
if (dragged()) return
|
active={() => props.currentTab() === tab}
|
||||||
onPointerDown(id, event)
|
|
||||||
}}
|
|
||||||
ref={ref}
|
|
||||||
href={tabHref(tab)}
|
|
||||||
title={language.t("command.session.new")}
|
title={language.t("command.session.new")}
|
||||||
onNavigate={() => props.onNavigate(tab, ref)}
|
onNavigate={(element) => {
|
||||||
|
ref = element
|
||||||
|
props.onNavigate(tab, element)
|
||||||
|
}}
|
||||||
onClose={() => props.onClose(tab)}
|
onClose={() => props.onClose(tab)}
|
||||||
suppressNavigation={() => suppressNavigation()}
|
|
||||||
active={props.currentTab() === tab}
|
|
||||||
pressed={pressedId() === id}
|
|
||||||
hidden={dragged()}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
</DragDropProvider>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
data-slot="titlebar-tabs-fade-left"
|
data-slot="titlebar-tabs-fade-left"
|
||||||
@@ -531,16 +308,6 @@ export function TitlebarTabStrip(props: {
|
|||||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Show when={drag.active && draggedTab() && dragPreview}>
|
|
||||||
{(_) => (
|
|
||||||
<Portal>
|
|
||||||
<div data-titlebar-tab-preview style={floaterStyle()}>
|
|
||||||
{dragPreview}
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -319,6 +319,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeTab = currentTab()
|
||||||
|
if (activeTab?.type === "draft") {
|
||||||
|
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const current = layout.projects.list()[0]
|
const current = layout.projects.list()[0]
|
||||||
if (current) {
|
if (current) {
|
||||||
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
|
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
import { useLocation } from "@solidjs/router"
|
import { useLocation } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
@@ -17,6 +17,7 @@ import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
|||||||
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
||||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
|
import { type DraftTab, useTabs } from "./tabs"
|
||||||
|
|
||||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ type TabHandoff = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||||
|
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||||
|
|
||||||
export type ReviewDiffStyle = "unified" | "split"
|
export type ReviewDiffStyle = "unified" | "split"
|
||||||
|
|
||||||
@@ -158,12 +160,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const route = createMemo(() => {
|
const route = createMemo(() => {
|
||||||
const value = currentRoute(location.pathname, location.search)
|
const value = currentRoute(location.pathname, location.search)
|
||||||
if (value.type === "home") return value
|
if (value.type === "home") return value
|
||||||
if (value.server) return value
|
if (value.server) return value
|
||||||
|
if (value.type === "draft") {
|
||||||
|
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
|
||||||
|
if (draft) return { ...value, server: draft.server }
|
||||||
|
}
|
||||||
return { ...value, server: server.key }
|
return { ...value, server: server.key }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -290,6 +297,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
handoff: {
|
handoff: {
|
||||||
tabs: undefined as TabHandoff | undefined,
|
tabs: undefined as TabHandoff | undefined,
|
||||||
},
|
},
|
||||||
|
home: {
|
||||||
|
selection: { server: server.key } as HomeProjectSelection,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -579,6 +589,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
return {
|
return {
|
||||||
route,
|
route,
|
||||||
ready,
|
ready,
|
||||||
|
home: {
|
||||||
|
selection: createMemo(() => store.home.selection),
|
||||||
|
setSelection(selection: HomeProjectSelection) {
|
||||||
|
setStore("home", "selection", reconcile(selection))
|
||||||
|
},
|
||||||
|
},
|
||||||
handoff: {
|
handoff: {
|
||||||
tabs: createMemo(() => store.handoff?.tabs),
|
tabs: createMemo(() => store.handoff?.tabs),
|
||||||
setTabs(dir: string, id: string) {
|
setTabs(dir: string, id: string) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk"
|
|||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useTabs, type Tab } from "./tabs"
|
import { useTabs, type Tab } from "./tabs"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection } from "./server"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
|
|
||||||
@@ -287,7 +287,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const cache = new Map<string, PromptCacheEntry>()
|
const cache = new Map<string, PromptCacheEntry>()
|
||||||
@@ -312,7 +311,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
const serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key)
|
const serverKey = () =>
|
||||||
|
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||||
const scope = () =>
|
const scope = () =>
|
||||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||||
const load = (scope: Scope) => {
|
const load = (scope: Scope) => {
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
server,
|
||||||
scope,
|
scope,
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
client: sdk,
|
client: sdk,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
batch,
|
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createRoot,
|
createRoot,
|
||||||
@@ -26,7 +25,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
|
import { getProjectAvatarVariant, useLayout, type HomeProjectSelection, type LocalProject } from "@/context/layout"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -34,6 +33,7 @@ import { usePlatform } from "@/context/platform"
|
|||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||||
@@ -47,7 +47,6 @@ import {
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
getProjectAvatarSource,
|
getProjectAvatarSource,
|
||||||
homeProjectDirectories,
|
homeProjectDirectories,
|
||||||
type HomeProjectSelection,
|
|
||||||
projectForSession,
|
projectForSession,
|
||||||
sortedRootSessions,
|
sortedRootSessions,
|
||||||
toggleHomeProjectSelection,
|
toggleHomeProjectSelection,
|
||||||
@@ -146,15 +145,16 @@ export function NewHome() {
|
|||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const marked = useMarked()
|
const marked = useMarked()
|
||||||
|
const openSettings = useSettingsCommand()
|
||||||
let focusSessionSearch: (() => void) | undefined
|
let focusSessionSearch: (() => void) | undefined
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
search: "",
|
search: "",
|
||||||
selection: { server: server.key } as HomeProjectSelection,
|
|
||||||
searchFocused: false,
|
searchFocused: false,
|
||||||
})
|
})
|
||||||
|
const selection = layout.home.selection
|
||||||
|
|
||||||
const focusedServer = createMemo(
|
const focusedServer = createMemo(
|
||||||
() => global.servers.list().find((conn) => ServerConnection.key(conn) === state.selection.server) ?? server.current,
|
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
|
||||||
)
|
)
|
||||||
const focusedServerCtx = createMemo(() => {
|
const focusedServerCtx = createMemo(() => {
|
||||||
const conn = focusedServer()
|
const conn = focusedServer()
|
||||||
@@ -163,7 +163,7 @@ export function NewHome() {
|
|||||||
})
|
})
|
||||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory))
|
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
|
||||||
const newSessionProject = createMemo(
|
const newSessionProject = createMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedProject() ??
|
selectedProject() ??
|
||||||
@@ -191,7 +191,7 @@ export function NewHome() {
|
|||||||
return language.t("home.sessions.search.placeholder")
|
return language.t("home.sessions.search.placeholder")
|
||||||
})
|
})
|
||||||
const sessionLoad = useQuery(() => ({
|
const sessionLoad = useQuery(() => ({
|
||||||
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
|
queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
projectDirectories().map((directory) =>
|
projectDirectories().map((directory) =>
|
||||||
@@ -257,10 +257,7 @@ export function NewHome() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function setSelection(next: HomeProjectSelection) {
|
function setSelection(next: HomeProjectSelection) {
|
||||||
batch(() => {
|
layout.home.setSelection(next)
|
||||||
if (state.selection.server !== next.server) setState("selection", "server", next.server)
|
|
||||||
if (state.selection.directory !== next.directory) setState("selection", "directory", next.directory)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSearch() {
|
function closeSearch() {
|
||||||
@@ -285,7 +282,7 @@ export function NewHome() {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const list = global.servers.list()
|
const list = global.servers.list()
|
||||||
if (list.some((conn) => ServerConnection.key(conn) === state.selection.server)) return
|
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
|
||||||
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
|
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
|
||||||
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
||||||
})
|
})
|
||||||
@@ -310,7 +307,7 @@ export function NewHome() {
|
|||||||
.some((project) => project.worktree === directory)
|
.some((project) => project.worktree === directory)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
setSelection(toggleHomeProjectSelection(state.selection, key, directory))
|
setSelection(toggleHomeProjectSelection(selection(), key, directory))
|
||||||
}
|
}
|
||||||
|
|
||||||
function addProjects(conn: ServerConnection.Any, directories: string[]) {
|
function addProjects(conn: ServerConnection.Any, directories: string[]) {
|
||||||
@@ -407,18 +404,12 @@ export function NewHome() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings() {
|
|
||||||
void import("@/components/settings-v2").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogSettings />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
|
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
|
||||||
<HomeProjectColumn
|
<HomeProjectColumn
|
||||||
projects={projects()}
|
projects={projects()}
|
||||||
selected={state.selection}
|
selected={selection()}
|
||||||
focusServer={focusServer}
|
focusServer={focusServer}
|
||||||
selectProject={selectProject}
|
selectProject={selectProject}
|
||||||
openNewSession={openProjectNewSession}
|
openNewSession={openProjectNewSession}
|
||||||
@@ -426,7 +417,7 @@ export function NewHome() {
|
|||||||
editProject={editProject}
|
editProject={editProject}
|
||||||
closeProject={(conn, directory) => {
|
closeProject={(conn, directory) => {
|
||||||
const next = closeHomeProject(
|
const next = closeHomeProject(
|
||||||
state.selection,
|
selection(),
|
||||||
ServerConnection.key(conn),
|
ServerConnection.key(conn),
|
||||||
global.ensureServerCtx(conn).projects,
|
global.ensureServerCtx(conn).projects,
|
||||||
directory,
|
directory,
|
||||||
@@ -451,8 +442,8 @@ export function NewHome() {
|
|||||||
loading={sessionLoad.isLoading}
|
loading={sessionLoad.isLoading}
|
||||||
results={searchResults()}
|
results={searchResults()}
|
||||||
showProjectName={!selectedProject()}
|
showProjectName={!selectedProject()}
|
||||||
server={state.selection.server}
|
server={selection().server}
|
||||||
activeServer={state.selection.server === server.key}
|
activeServer={selection().server === server.key}
|
||||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||||
bindFocus={(focus) => {
|
bindFocus={(focus) => {
|
||||||
focusSessionSearch = focus
|
focusSessionSearch = focus
|
||||||
@@ -489,8 +480,8 @@ export function NewHome() {
|
|||||||
<HomeSessionRow
|
<HomeSessionRow
|
||||||
record={record}
|
record={record}
|
||||||
showProjectName={!selectedProject()}
|
showProjectName={!selectedProject()}
|
||||||
server={state.selection.server}
|
server={selection().server}
|
||||||
activeServer={state.selection.server === server.key}
|
activeServer={selection().server === server.key}
|
||||||
openSession={openSession}
|
openSession={openSession}
|
||||||
archiveSession={archiveSession}
|
archiveSession={archiveSession}
|
||||||
/>
|
/>
|
||||||
@@ -957,7 +948,7 @@ function HomeSessionSearch(props: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="mr-2 w-[calc(100%_-_8px)]">
|
<div class="w-full">
|
||||||
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
|
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
|
||||||
<Show when={props.open}>
|
<Show when={props.open}>
|
||||||
<div
|
<div
|
||||||
@@ -1134,7 +1125,7 @@ function HomeSessionSearchResultRow(props: {
|
|||||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return (
|
return (
|
||||||
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px] pr-2">
|
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px]">
|
||||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
||||||
<Show when={props.onNewSession}>
|
<Show when={props.onNewSession}>
|
||||||
{(onNewSession) => (
|
{(onNewSession) => (
|
||||||
|
|||||||
@@ -3,18 +3,12 @@ import { useNavigate, useParams } from "@solidjs/router"
|
|||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { HelpButton } from "@/components/help-button"
|
import { HelpButton } from "@/components/help-button"
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
import { useCommand } from "@/context/command"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { setNavigate } from "@/utils/notification-click"
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||||
|
|
||||||
export default function NewLayout(props: ParentProps) {
|
export default function NewLayout(props: ParentProps) {
|
||||||
const command = useCommand()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -28,20 +22,6 @@ export default function NewLayout(props: ParentProps) {
|
|||||||
notification.session.markViewed(params.id)
|
notification.session.markViewed(params.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
command.register("layout", () => [
|
|
||||||
{
|
|
||||||
id: "settings.open",
|
|
||||||
title: language.t("command.settings.open"),
|
|
||||||
category: language.t("command.category.settings"),
|
|
||||||
keybind: "mod+comma",
|
|
||||||
onSelect: () => {
|
|
||||||
void import("@/components/settings-v2").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogSettings />)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const update: TitlebarUpdate = {
|
const update: TitlebarUpdate = {
|
||||||
version: () => {
|
version: () => {
|
||||||
const state = platform.updater?.state()
|
const state = platform.updater?.state()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
|||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/server"
|
||||||
|
import type { HomeProjectSelection } from "@/context/layout"
|
||||||
|
|
||||||
type SessionStore = {
|
type SessionStore = {
|
||||||
session?: Session[]
|
session?: Session[]
|
||||||
@@ -56,8 +57,6 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
|
|||||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||||
project.name || getFilename(project.worktree) || project.worktree
|
project.name || getFilename(project.worktree) || project.worktree
|
||||||
|
|
||||||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
|
||||||
|
|
||||||
export function toggleHomeProjectSelection(
|
export function toggleHomeProjectSelection(
|
||||||
current: HomeProjectSelection | undefined,
|
current: HomeProjectSelection | undefined,
|
||||||
server: ServerConnection.Key,
|
server: ServerConnection.Key,
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { createEffect, createMemo, onMount, untrack } from "solid-js"
|
import { Show, createEffect, createMemo, createResource, untrack } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useSearchParams } from "@solidjs/router"
|
import { useSearchParams } from "@solidjs/router"
|
||||||
import { NewSessionDesignView } from "@/components/session"
|
import { NewSessionDesignView } from "@/components/session"
|
||||||
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
|
import {
|
||||||
|
PromptProjectAddButton,
|
||||||
|
PromptProjectSelector,
|
||||||
|
createPromptProjectController,
|
||||||
|
} from "@/components/prompt-project-selector"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import {
|
import { useLanguage } from "@/context/language"
|
||||||
createSessionComposerControls,
|
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||||
createSessionComposerState,
|
|
||||||
SessionComposerRegion,
|
|
||||||
} from "@/pages/session/composer"
|
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
|
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||||
|
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||||
@@ -25,17 +31,25 @@ export default function NewSessionPage() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
|
const language = useLanguage()
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
|
|
||||||
|
useComposerCommands()
|
||||||
|
useSettingsCommand()
|
||||||
|
|
||||||
let inputRef: HTMLDivElement | undefined
|
let inputRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
const composer = createSessionComposerState()
|
const inputController = createPromptInputController({
|
||||||
const composerControls = createSessionComposerControls({
|
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
sessionID: () => route.params.id,
|
sessionID: () => route.params.id,
|
||||||
queryOptions: serverSync().queryOptions,
|
queryOptions: serverSync().queryOptions,
|
||||||
})
|
})
|
||||||
|
const projectControls = createPromptProjectControls()
|
||||||
|
const projectController = createPromptProjectController({
|
||||||
|
controls: projectControls,
|
||||||
|
onDone: () => inputRef?.focus(),
|
||||||
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
worktree: "main",
|
worktree: "main",
|
||||||
@@ -58,9 +72,15 @@ export default function NewSessionPage() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
createEffect(() => {
|
||||||
|
if (!prompt.ready()) return
|
||||||
requestAnimationFrame(() => inputRef?.focus())
|
requestAnimationFrame(() => inputRef?.focus())
|
||||||
})
|
})
|
||||||
|
const ready = Promise.resolve()
|
||||||
|
const [promptReady] = createResource(
|
||||||
|
() => prompt.ready.promise ?? ready,
|
||||||
|
(promise) => promise.then(() => true),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full overflow-hidden flex flex-col">
|
<div class="relative size-full overflow-hidden flex flex-col">
|
||||||
@@ -68,26 +88,39 @@ export default function NewSessionPage() {
|
|||||||
<div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1">
|
<div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1">
|
||||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||||
<NewSessionDesignView>
|
<NewSessionDesignView>
|
||||||
<SessionComposerRegion
|
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||||
state={composer}
|
<Show
|
||||||
sessionKey={route.sessionKey()}
|
when={prompt.ready() || promptReady()}
|
||||||
sessionID={route.params.id}
|
fallback={
|
||||||
controls={composerControls()}
|
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak pointer-events-none">
|
||||||
promptInput={{
|
{language.t("prompt.loading")}
|
||||||
ref: (el) => {
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<PromptInput
|
||||||
|
controls={inputController()}
|
||||||
|
variant="new-session"
|
||||||
|
ref={(el) => {
|
||||||
inputRef = el
|
inputRef = el
|
||||||
},
|
|
||||||
newSessionWorktree: newSessionWorktree(),
|
|
||||||
onNewSessionWorktreeReset: () => setStore("worktree", "main"),
|
|
||||||
onSubmit: () => comments.clear(),
|
|
||||||
}}
|
}}
|
||||||
todo={{ collapsed: false, onToggle: () => {} }}
|
newSessionWorktree={newSessionWorktree()}
|
||||||
ready
|
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
||||||
centered={false}
|
onSubmit={() => comments.clear()}
|
||||||
placement="inline"
|
toolbar={
|
||||||
onResponseSubmit={() => {}}
|
<Show when={!projectController.selected()}>
|
||||||
setPromptDockRef={() => {}}
|
<PromptProjectAddButton controller={projectController} />
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
<Show when={projectController.selected()}>
|
||||||
|
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
|
||||||
|
<PromptProjectSelector controller={projectController} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</NewSessionDesignView>
|
</NewSessionDesignView>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,10 +42,13 @@ import { useServerSDK } from "@/context/server-sdk"
|
|||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||||
import {
|
import {
|
||||||
createSessionComposerControls,
|
createPromptInputController,
|
||||||
createSessionComposerState,
|
createSessionComposerController,
|
||||||
|
createSessionComposerRegionController,
|
||||||
SessionComposerRegion,
|
SessionComposerRegion,
|
||||||
} from "@/pages/session/composer"
|
} from "@/pages/session/composer"
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +66,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
|
|||||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||||
|
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
@@ -155,8 +159,8 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const composer = createSessionComposerState()
|
const composer = createSessionComposerController()
|
||||||
const composerControls = createSessionComposerControls({
|
const inputController = createPromptInputController({
|
||||||
sessionKey,
|
sessionKey,
|
||||||
sessionID: () => params.id,
|
sessionID: () => params.id,
|
||||||
queryOptions: serverSync().queryOptions,
|
queryOptions: serverSync().queryOptions,
|
||||||
@@ -783,6 +787,8 @@ export default function Page() {
|
|||||||
inputRef?.focus()
|
inputRef?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useComposerCommands()
|
||||||
|
useSettingsCommand()
|
||||||
useSessionCommands({
|
useSessionCommands({
|
||||||
navigateMessageByOffset,
|
navigateMessageByOffset,
|
||||||
setActiveMessage,
|
setActiveMessage,
|
||||||
@@ -902,7 +908,13 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const reviewPanel = () => (
|
const reviewPanel = () => (
|
||||||
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex flex-col h-full overflow-hidden contain-strict": true,
|
||||||
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
|
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
{reviewContent({
|
{reviewContent({
|
||||||
diffStyle: layout.review.diffStyle(),
|
diffStyle: layout.review.diffStyle(),
|
||||||
@@ -1567,62 +1579,28 @@ export default function Page() {
|
|||||||
|
|
||||||
useUsageExceededDialogs()
|
useUsageExceededDialogs()
|
||||||
|
|
||||||
const composerRegion = (placement: "dock" | "inline") => (
|
const composerRegion = () => {
|
||||||
<SessionComposerRegion
|
const controller = createSessionComposerRegionController({
|
||||||
state={composer}
|
state: composer,
|
||||||
sessionKey={sessionKey()}
|
sessionKey,
|
||||||
sessionID={params.id}
|
sessionID: () => params.id,
|
||||||
controls={composerControls()}
|
prompt,
|
||||||
promptInput={{
|
ready: () => !store.deferRender && messagesReady(),
|
||||||
ref: (el) => {
|
centered,
|
||||||
inputRef = el
|
todo: {
|
||||||
},
|
collapsed: () => view().todoCollapsed.get(),
|
||||||
newSessionWorktree: newSessionWorktree(),
|
|
||||||
onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
|
|
||||||
onSubmit: () => {
|
|
||||||
comments.clear()
|
|
||||||
resumeScroll()
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
todo={{
|
|
||||||
collapsed: view().todoCollapsed.get(),
|
|
||||||
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
|
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
|
||||||
}}
|
},
|
||||||
ready={!store.deferRender && messagesReady()}
|
followup: () =>
|
||||||
centered={placement === "dock" && centered()}
|
|
||||||
placement={placement}
|
|
||||||
openParent={() => {
|
|
||||||
const id = info()?.parentID
|
|
||||||
if (!id) return
|
|
||||||
navigate(
|
|
||||||
params.serverKey
|
|
||||||
? sessionHref(requireServerKey(params.serverKey), id)
|
|
||||||
: legacySessionHref(sdk().directory, id),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
onResponseSubmit={resumeScroll}
|
|
||||||
followup={
|
|
||||||
params.id && !isChildSession()
|
params.id && !isChildSession()
|
||||||
? {
|
? {
|
||||||
queue: queueEnabled,
|
|
||||||
items: followupDock(),
|
items: followupDock(),
|
||||||
sending: sendingFollowup(),
|
sending: sendingFollowup(),
|
||||||
edit: editingFollowup(),
|
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
|
||||||
onQueue: queueFollowup,
|
|
||||||
onAbort: () => {
|
|
||||||
const id = params.id
|
|
||||||
if (!id) return
|
|
||||||
setFollowup("paused", id, true)
|
|
||||||
},
|
|
||||||
onSend: (id) => {
|
|
||||||
void sendFollowup(params.id!, id, { manual: true })
|
|
||||||
},
|
|
||||||
onEdit: editFollowup,
|
onEdit: editFollowup,
|
||||||
onEditLoaded: clearFollowupEdit,
|
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined,
|
||||||
}
|
revert: () =>
|
||||||
revert={
|
|
||||||
rolled().length > 0
|
rolled().length > 0
|
||||||
? {
|
? {
|
||||||
items: rolled(),
|
items: rolled(),
|
||||||
@@ -1630,13 +1608,53 @@ export default function Page() {
|
|||||||
disabled: reverting(),
|
disabled: reverting(),
|
||||||
onRestore: restore,
|
onRestore: restore,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined,
|
||||||
}
|
onResponseSubmit: resumeScroll,
|
||||||
setPromptDockRef={(el) => {
|
openParent: () => {
|
||||||
|
const id = info()?.parentID
|
||||||
|
if (!id) return
|
||||||
|
navigate(
|
||||||
|
params.serverKey
|
||||||
|
? sessionHref(requireServerKey(params.serverKey), id)
|
||||||
|
: legacySessionHref(sdk().directory, id),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
setPromptRef: (el) => {
|
||||||
|
inputRef = el
|
||||||
|
},
|
||||||
|
setDockRef: (el) => {
|
||||||
promptDock = el
|
promptDock = el
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<SessionComposerRegion
|
||||||
|
controller={controller}
|
||||||
|
promptInput={
|
||||||
|
<PromptInput
|
||||||
|
controls={inputController()}
|
||||||
|
ref={(el) => {
|
||||||
|
inputRef = el
|
||||||
|
}}
|
||||||
|
newSessionWorktree={newSessionWorktree()}
|
||||||
|
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||||
|
onSubmit={() => {
|
||||||
|
comments.clear()
|
||||||
|
resumeScroll()
|
||||||
|
}}
|
||||||
|
edit={editingFollowup()}
|
||||||
|
onEditLoaded={clearFollowupEdit}
|
||||||
|
shouldQueue={queueEnabled}
|
||||||
|
onQueue={queueFollowup}
|
||||||
|
onAbort={() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
setFollowup("paused", id, true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const mobileTabs = (compact = false, bottom = false) => (
|
const mobileTabs = (compact = false, bottom = false) => (
|
||||||
<Tabs value={store.mobileTab} class="h-auto">
|
<Tabs value={store.mobileTab} class="h-auto">
|
||||||
@@ -1701,7 +1719,9 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex-1 min-h-0 flex flex-col bg-background-stronger": true,
|
"flex-1 min-h-0 flex flex-col": true,
|
||||||
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
|
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||||
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
|
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
|
||||||
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
||||||
}}
|
}}
|
||||||
@@ -1773,7 +1793,7 @@ export default function Page() {
|
|||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
|
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { SessionComposerRegion } from "./session-composer-region"
|
export { SessionComposerRegion } from "./session-composer-region"
|
||||||
export { createSessionComposerControls } from "./session-composer-controls"
|
export { createPromptInputController, createPromptProjectControls } from "./session-composer-controls"
|
||||||
export { createSessionComposerState } from "./session-composer-state"
|
export { createSessionComposerController } from "./session-composer-state"
|
||||||
|
export { createSessionComposerRegionController } from "./session-composer-region-controller"
|
||||||
|
|||||||
@@ -3,88 +3,37 @@ import { createQuery } from "@tanstack/solid-query"
|
|||||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo } from "solid-js"
|
import { type Accessor, createMemo } from "solid-js"
|
||||||
import type { PromptInputControls } from "@/components/prompt-input"
|
import type { PromptInputControls } from "@/components/prompt-input"
|
||||||
|
import type { PromptProjectControls } from "@/components/prompt-project-selector"
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import type { QueryOptionsApi } from "@/context/server-sync"
|
import type { QueryOptionsApi } from "@/context/server-sync"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { serverName, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { type DraftTab, useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
export function createSessionComposerControls(input: {
|
export function createPromptInputController(input: {
|
||||||
sessionKey: Accessor<string>
|
sessionKey: Accessor<string>
|
||||||
sessionID: Accessor<string | undefined>
|
sessionID: Accessor<string | undefined>
|
||||||
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
|
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const server = useServer()
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const tabs = useTabs()
|
|
||||||
const global = useGlobal()
|
|
||||||
const pickDirectory = useDirectoryPicker()
|
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
|
||||||
const view = layout.view(input.sessionKey)
|
const view = layout.view(input.sessionKey)
|
||||||
|
|
||||||
const draft = createMemo(() => {
|
|
||||||
if (!search.draftId) return
|
|
||||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
|
||||||
})
|
|
||||||
const projectServer = createMemo(() => {
|
|
||||||
if (!search.draftId) return server.current
|
|
||||||
const target = draft()?.server
|
|
||||||
if (!target) return
|
|
||||||
return server.list.find((conn) => ServerConnection.key(conn) === target)
|
|
||||||
})
|
|
||||||
const projectServerCtx = createMemo(() => {
|
|
||||||
const conn = projectServer()
|
|
||||||
if (conn) return global.ensureServerCtx(conn)
|
|
||||||
})
|
|
||||||
const projects = createMemo(() =>
|
|
||||||
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
|
|
||||||
)
|
|
||||||
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
|
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
|
||||||
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
|
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
|
||||||
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
|
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
|
||||||
|
|
||||||
const selectProject = (worktree: string) => {
|
|
||||||
const conn = projectServer()
|
|
||||||
const target = projectServerCtx()
|
|
||||||
if (search.draftId) {
|
|
||||||
if (!conn || !target) return
|
|
||||||
target.projects.open(worktree)
|
|
||||||
target.projects.touch(worktree)
|
|
||||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
layout.projects.open(worktree)
|
|
||||||
server.projects.touch(worktree)
|
|
||||||
navigate(`/${base64Encode(worktree)}/session`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addProject = (title: string) => {
|
|
||||||
const conn = projectServer()
|
|
||||||
if (!conn) return
|
|
||||||
pickDirectory({
|
|
||||||
server: conn,
|
|
||||||
title,
|
|
||||||
onSelect: (result) => {
|
|
||||||
const directory = Array.isArray(result) ? result[0] : result
|
|
||||||
if (directory) selectProject(directory)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return createMemo<PromptInputControls>(() => ({
|
return createMemo<PromptInputControls>(() => ({
|
||||||
agents: {
|
agents: {
|
||||||
available: sync().data.agent,
|
available: sync().data.agent,
|
||||||
@@ -99,12 +48,6 @@ export function createSessionComposerControls(input: {
|
|||||||
paid: providers.paid().length > 0,
|
paid: providers.paid().length > 0,
|
||||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||||
},
|
},
|
||||||
projects: {
|
|
||||||
available: projects(),
|
|
||||||
directory: sdk().directory,
|
|
||||||
select: selectProject,
|
|
||||||
add: addProject,
|
|
||||||
},
|
|
||||||
session: {
|
session: {
|
||||||
id: input.sessionID(),
|
id: input.sessionID(),
|
||||||
tabs: layout.tabs(input.sessionKey),
|
tabs: layout.tabs(input.sessionKey),
|
||||||
@@ -113,3 +56,75 @@ export function createSessionComposerControls(input: {
|
|||||||
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createPromptProjectControls() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const layout = useLayout()
|
||||||
|
const server = useServer()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const global = useGlobal()
|
||||||
|
const pickDirectory = useDirectoryPicker()
|
||||||
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
|
const projectServer = () => serverSDK().server
|
||||||
|
const projectServerCtx = createMemo(() => global.ensureServerCtx(projectServer()))
|
||||||
|
const projects = createMemo(() => {
|
||||||
|
if (server.list.length <= 1) {
|
||||||
|
return search.draftId ? projectServerCtx().projects.list() : layout.projects.list()
|
||||||
|
}
|
||||||
|
return server.list.flatMap((conn) => {
|
||||||
|
const item = { key: ServerConnection.key(conn), name: serverName(conn) }
|
||||||
|
return global
|
||||||
|
.ensureServerCtx(conn)
|
||||||
|
.projects.list()
|
||||||
|
.map((project) => ({ ...project, server: item }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const selectProject = (worktree: string, serverKey?: string) => {
|
||||||
|
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
|
||||||
|
if (search.draftId) {
|
||||||
|
if (!conn) return
|
||||||
|
const target = global.ensureServerCtx(conn)
|
||||||
|
target.projects.open(worktree)
|
||||||
|
target.projects.touch(worktree)
|
||||||
|
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverKey) {
|
||||||
|
layout.projects.open(worktree)
|
||||||
|
server.projects.touch(worktree)
|
||||||
|
navigate(`/${base64Encode(worktree)}/session`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conn) return
|
||||||
|
const target = global.ensureServerCtx(conn)
|
||||||
|
target.projects.open(worktree)
|
||||||
|
target.projects.touch(worktree)
|
||||||
|
server.setActive(ServerConnection.key(conn))
|
||||||
|
navigate(`/${base64Encode(worktree)}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addProject = (title: string, serverKey?: string) => {
|
||||||
|
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
|
||||||
|
if (!conn) return
|
||||||
|
pickDirectory({
|
||||||
|
server: conn,
|
||||||
|
title,
|
||||||
|
onSelect: (result) => {
|
||||||
|
const directory = Array.isArray(result) ? result[0] : result
|
||||||
|
if (directory) selectProject(directory, serverKey)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMemo<PromptProjectControls>(() => ({
|
||||||
|
available: projects(),
|
||||||
|
directory: sdk().directory,
|
||||||
|
server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
|
||||||
|
select: selectProject,
|
||||||
|
add: addProject,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
|
import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { PromptInputState } from "@/components/prompt-input"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||||
|
import type { SessionComposerController } from "./session-composer-state"
|
||||||
|
|
||||||
|
export type SessionComposerFollowupDock = {
|
||||||
|
items: { id: string; text: string }[]
|
||||||
|
sending?: string
|
||||||
|
onSend: (id: string) => void
|
||||||
|
onEdit: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionComposerRevertDock = {
|
||||||
|
items: { id: string; text: string }[]
|
||||||
|
restoring?: string
|
||||||
|
disabled?: boolean
|
||||||
|
onRestore: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionComposerRegionController(input: {
|
||||||
|
state: SessionComposerController
|
||||||
|
sessionKey: Accessor<string>
|
||||||
|
sessionID: Accessor<string | undefined>
|
||||||
|
prompt: PromptInputState
|
||||||
|
ready: Accessor<boolean>
|
||||||
|
centered: Accessor<boolean>
|
||||||
|
todo: {
|
||||||
|
collapsed: Accessor<boolean>
|
||||||
|
onToggle: () => void
|
||||||
|
}
|
||||||
|
followup: Accessor<SessionComposerFollowupDock | undefined>
|
||||||
|
revert: Accessor<SessionComposerRevertDock | undefined>
|
||||||
|
onResponseSubmit: () => void
|
||||||
|
openParent: () => void
|
||||||
|
setPromptRef: (el: HTMLDivElement) => void
|
||||||
|
setDockRef: (el: HTMLDivElement) => void
|
||||||
|
}) {
|
||||||
|
const sync = useSync()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
ready: input.ready() || input.state.dock(),
|
||||||
|
height: 320,
|
||||||
|
body: undefined as HTMLDivElement | undefined,
|
||||||
|
})
|
||||||
|
let timer: number | undefined
|
||||||
|
let frame: number | undefined
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer)
|
||||||
|
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||||
|
timer = undefined
|
||||||
|
frame = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
input.sessionKey()
|
||||||
|
const ready = input.ready()
|
||||||
|
const dock = input.state.dock()
|
||||||
|
|
||||||
|
clear()
|
||||||
|
if (store.ready || (!ready && !dock)) return
|
||||||
|
if (dock) {
|
||||||
|
setStore("ready", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = undefined
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
setStore("ready", true)
|
||||||
|
timer = undefined
|
||||||
|
}, 140)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!input.prompt.ready()) return
|
||||||
|
setSessionHandoff(input.sessionKey(), {
|
||||||
|
prompt: input.prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === "file") return `[file:${part.path}]`
|
||||||
|
if (part.type === "agent") return `@${part.name}`
|
||||||
|
if (part.type === "image") return `[image:${part.filename}]`
|
||||||
|
return part.content
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
.trim(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const el = store.body
|
||||||
|
if (!el) return
|
||||||
|
const update = () => setStore("height", el.getBoundingClientRect().height)
|
||||||
|
createResizeObserver(el, update)
|
||||||
|
update()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(clear)
|
||||||
|
|
||||||
|
const parentID = createMemo(() => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
return id ? sync().session.get(id)?.parentID : undefined
|
||||||
|
})
|
||||||
|
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
|
||||||
|
const progress = useSpring(
|
||||||
|
() => (open() ? 1 : 0),
|
||||||
|
{ visualDuration: 0.3, bounce: 0 },
|
||||||
|
() => `${input.sessionKey()}\0${store.ready}`,
|
||||||
|
)
|
||||||
|
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||||
|
const ready = Promise.resolve()
|
||||||
|
const [promptReady] = createResource(
|
||||||
|
() => input.prompt.ready.promise ?? ready,
|
||||||
|
(promise) => promise.then(() => true),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: input.state,
|
||||||
|
centered: input.centered,
|
||||||
|
todo: input.todo,
|
||||||
|
followup: input.followup,
|
||||||
|
revert: input.revert,
|
||||||
|
onResponseSubmit: input.onResponseSubmit,
|
||||||
|
openParent: input.openParent,
|
||||||
|
setPromptRef: input.setPromptRef,
|
||||||
|
setDockRef: input.setDockRef,
|
||||||
|
parentID,
|
||||||
|
child: () => !!parentID(),
|
||||||
|
showComposer: () => !input.state.blocked() || !!parentID(),
|
||||||
|
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
|
||||||
|
promptReady: () => input.prompt.ready() || promptReady(),
|
||||||
|
dock: () => (store.ready && input.state.dock()) || value() > 0.001,
|
||||||
|
dockProgress: value,
|
||||||
|
dockHeight: () => Math.max(78, store.height),
|
||||||
|
lift: () => (input.revert()?.items.length ? 18 : 36 * value()),
|
||||||
|
setDockBodyRef: (el: HTMLDivElement) => setStore("body", el),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionComposerRegionController = ReturnType<typeof createSessionComposerRegionController>
|
||||||
@@ -1,216 +1,89 @@
|
|||||||
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
import { Show, type JSX } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
|
||||||
import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
|
||||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||||
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
|
||||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
import type { SessionComposerRegionController } from "./session-composer-region-controller"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
|
||||||
|
|
||||||
export function SessionComposerRegion(props: {
|
export function SessionComposerRegion(props: {
|
||||||
state: SessionComposerState
|
controller: SessionComposerRegionController
|
||||||
sessionKey: string
|
promptInput: JSX.Element
|
||||||
sessionID?: string
|
|
||||||
controls: PromptInputControls
|
|
||||||
promptInput: Omit<PromptInputProps, "controls" | "variant">
|
|
||||||
todo: {
|
|
||||||
collapsed: boolean
|
|
||||||
onToggle: () => void
|
|
||||||
}
|
|
||||||
ready: boolean
|
|
||||||
centered: boolean
|
|
||||||
placement?: "dock" | "inline"
|
|
||||||
openParent?: () => void
|
|
||||||
onResponseSubmit: () => void
|
|
||||||
followup?: {
|
|
||||||
queue: () => boolean
|
|
||||||
items: { id: string; text: string }[]
|
|
||||||
sending?: string
|
|
||||||
edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] }
|
|
||||||
onQueue: (draft: FollowupDraft) => void
|
|
||||||
onAbort: () => void
|
|
||||||
onSend: (id: string) => void
|
|
||||||
onEdit: (id: string) => void
|
|
||||||
onEditLoaded: () => void
|
|
||||||
}
|
|
||||||
revert?: {
|
|
||||||
items: { id: string; text: string }[]
|
|
||||||
restoring?: string
|
|
||||||
disabled?: boolean
|
|
||||||
onRestore: (id: string) => void
|
|
||||||
}
|
|
||||||
setPromptDockRef: (el: HTMLDivElement) => void
|
|
||||||
}) {
|
}) {
|
||||||
const prompt = props.promptInput.state ?? usePrompt()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sync = useSync()
|
const controller = props.controller
|
||||||
|
const settings = useSettings()
|
||||||
const handoffPrompt = createMemo(() => getSessionHandoff(props.sessionKey)?.prompt)
|
const rolled = () => {
|
||||||
const info = createMemo(() => (props.sessionID ? sync().session.get(props.sessionID) : undefined))
|
const revert = controller.revert()
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
return revert?.items.length ? revert : undefined
|
||||||
const child = createMemo(() => !!parentID())
|
|
||||||
const showComposer = createMemo(() => !props.state.blocked() || child())
|
|
||||||
|
|
||||||
const previewPrompt = () =>
|
|
||||||
prompt
|
|
||||||
.current()
|
|
||||||
.map((part) => {
|
|
||||||
if (part.type === "file") return `[file:${part.path}]`
|
|
||||||
if (part.type === "agent") return `@${part.name}`
|
|
||||||
if (part.type === "image") return `[image:${part.filename}]`
|
|
||||||
return part.content
|
|
||||||
})
|
|
||||||
.join("")
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!prompt.ready()) return
|
|
||||||
setSessionHandoff(props.sessionKey, { prompt: previewPrompt() })
|
|
||||||
})
|
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
ready: props.ready || props.state.dock(),
|
|
||||||
height: 320,
|
|
||||||
body: undefined as HTMLDivElement | undefined,
|
|
||||||
})
|
|
||||||
let timer: number | undefined
|
|
||||||
let frame: number | undefined
|
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
if (timer !== undefined) {
|
|
||||||
window.clearTimeout(timer)
|
|
||||||
timer = undefined
|
|
||||||
}
|
}
|
||||||
if (frame !== undefined) {
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
frame = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
props.sessionKey
|
|
||||||
const ready = props.ready
|
|
||||||
const dock = props.state.dock()
|
|
||||||
const delay = 140
|
|
||||||
|
|
||||||
clear()
|
|
||||||
if (store.ready || (!ready && !dock)) return
|
|
||||||
if (dock) {
|
|
||||||
setStore("ready", true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
frame = requestAnimationFrame(() => {
|
|
||||||
frame = undefined
|
|
||||||
timer = window.setTimeout(() => {
|
|
||||||
setStore("ready", true)
|
|
||||||
timer = undefined
|
|
||||||
}, delay)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(clear)
|
|
||||||
|
|
||||||
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
|
|
||||||
const progress = useSpring(
|
|
||||||
() => (open() ? 1 : 0),
|
|
||||||
{ visualDuration: 0.3, bounce: 0 },
|
|
||||||
() => `${props.sessionKey}\0${store.ready}`,
|
|
||||||
)
|
|
||||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
|
||||||
const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
|
|
||||||
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
|
|
||||||
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
|
|
||||||
const full = createMemo(() => Math.max(78, store.height))
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const el = store.body
|
|
||||||
if (!el) return
|
|
||||||
const update = () => setStore("height", el.getBoundingClientRect().height)
|
|
||||||
createResizeObserver(store.body, update)
|
|
||||||
update()
|
|
||||||
})
|
|
||||||
|
|
||||||
const ready = Promise.resolve()
|
|
||||||
const [promptReadyResource] = createResource(
|
|
||||||
() => prompt.ready.promise ?? ready,
|
|
||||||
(promise) => promise.then(() => true),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={props.setPromptDockRef}
|
ref={controller.setDockRef}
|
||||||
data-component="session-prompt-dock"
|
data-component="session-prompt-dock"
|
||||||
classList={{
|
classList={{
|
||||||
"w-full flex flex-col justify-center items-center pointer-events-none": true,
|
"w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none": true,
|
||||||
"shrink-0 pb-3 bg-background-stronger": props.placement !== "inline",
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
|
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"w-full pointer-events-auto": true,
|
"w-full px-3 pointer-events-auto": true,
|
||||||
"px-3": props.placement !== "inline",
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": controller.centered(),
|
||||||
[NEW_SESSION_CONTENT_WIDTH]: props.placement === "inline",
|
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.state.questionRequest()} keyed>
|
<Show when={controller.state.questionRequest()} keyed>
|
||||||
{(request) => (
|
{(request) => (
|
||||||
<div>
|
<div>
|
||||||
<SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
|
<SessionQuestionDock request={request} onSubmit={controller.onResponseSubmit} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.state.permissionRequest()} keyed>
|
<Show when={controller.state.permissionRequest()} keyed>
|
||||||
{(request) => (
|
{(request) => (
|
||||||
<div>
|
<div>
|
||||||
<SessionPermissionDock
|
<SessionPermissionDock
|
||||||
request={request}
|
request={request}
|
||||||
responding={props.state.permissionResponding()}
|
responding={controller.state.permissionResponding()}
|
||||||
onDecide={(response) => {
|
onDecide={(response) => {
|
||||||
props.onResponseSubmit()
|
controller.onResponseSubmit()
|
||||||
props.state.decide(response)
|
controller.state.decide(response)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={showComposer()}>
|
<Show when={controller.showComposer()}>
|
||||||
<Show when={dock()}>
|
<Show when={controller.dock()}>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"overflow-hidden": true,
|
"overflow-hidden": true,
|
||||||
"pointer-events-none": value() < 0.98,
|
"pointer-events-none": controller.dockProgress() < 0.98,
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
"max-height": `${full() * value()}px`,
|
"max-height": `${controller.dockHeight() * controller.dockProgress()}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={(el) => setStore("body", el)}>
|
<div ref={controller.setDockBodyRef}>
|
||||||
<SessionTodoDock
|
<SessionTodoDock
|
||||||
todos={props.state.todos()}
|
todos={controller.state.todos()}
|
||||||
collapsed={props.todo.collapsed}
|
collapsed={controller.todo.collapsed()}
|
||||||
onToggle={props.todo.onToggle}
|
onToggle={controller.todo.onToggle}
|
||||||
collapseLabel={language.t("session.todo.collapse")}
|
collapseLabel={language.t("session.todo.collapse")}
|
||||||
expandLabel={language.t("session.todo.expand")}
|
expandLabel={language.t("session.todo.expand")}
|
||||||
dockProgress={value()}
|
dockProgress={controller.dockProgress()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={prompt.ready() || promptReadyResource()}
|
when={controller.promptReady()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Show when={rolled()} keyed>
|
<Show when={rolled()} keyed>
|
||||||
@@ -227,9 +100,9 @@ export function SessionComposerRegion(props: {
|
|||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
|
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
|
||||||
style={{ "margin-top": `${-36 * value()}px` }}
|
style={{ "margin-top": `${-36 * controller.dockProgress()}px` }}
|
||||||
>
|
>
|
||||||
{handoffPrompt() || language.t("prompt.loading")}
|
{controller.handoffPrompt() || language.t("prompt.loading")}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -238,7 +111,7 @@ export function SessionComposerRegion(props: {
|
|||||||
{(revert) => (
|
{(revert) => (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
"margin-top": `${-36 * value()}px`,
|
"margin-top": `${-36 * controller.dockProgress()}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionRevertDock
|
<SessionRevertDock
|
||||||
@@ -255,44 +128,31 @@ export function SessionComposerRegion(props: {
|
|||||||
"relative z-30": true,
|
"relative z-30": true,
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
"margin-top": `${-lift()}px`,
|
"margin-top": `${-controller.lift()}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.followup?.items.length}>
|
<Show when={controller.followup()?.items.length}>
|
||||||
<SessionFollowupDock
|
<SessionFollowupDock
|
||||||
items={props.followup!.items}
|
items={controller.followup()!.items}
|
||||||
sending={props.followup!.sending}
|
sending={controller.followup()!.sending}
|
||||||
onSend={props.followup!.onSend}
|
onSend={controller.followup()!.onSend}
|
||||||
onEdit={props.followup!.onEdit}
|
onEdit={controller.followup()!.onEdit}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={child()}
|
when={controller.child()}
|
||||||
fallback={
|
fallback={<Show when={!controller.state.blocked()}>{props.promptInput}</Show>}
|
||||||
<Show when={!props.state.blocked()}>
|
|
||||||
<PromptInput
|
|
||||||
{...props.promptInput}
|
|
||||||
controls={props.controls}
|
|
||||||
variant={props.placement === "inline" ? "new-session" : undefined}
|
|
||||||
edit={props.followup?.edit}
|
|
||||||
onEditLoaded={props.followup?.onEditLoaded}
|
|
||||||
shouldQueue={props.followup?.queue}
|
|
||||||
onQueue={props.followup?.onQueue}
|
|
||||||
onAbort={props.followup?.onAbort}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={props.promptInput.ref}
|
ref={controller.setPromptRef}
|
||||||
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||||
>
|
>
|
||||||
<span>{language.t("session.child.promptDisabled")} </span>
|
<span>{language.t("session.child.promptDisabled")} </span>
|
||||||
<Show when={parentID() && props.openParent}>
|
<Show when={controller.parentID()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-text-base transition-colors hover:text-text-strong"
|
class="text-text-base transition-colors hover:text-text-strong"
|
||||||
onClick={props.openParent}
|
onClick={controller.openParent}
|
||||||
>
|
>
|
||||||
{language.t("session.child.backToParent")}
|
{language.t("session.child.backToParent")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state
|
|||||||
|
|
||||||
const idle = { type: "idle" as const }
|
const idle = { type: "idle" as const }
|
||||||
|
|
||||||
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -201,4 +201,4 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionComposerState = ReturnType<typeof createSessionComposerState>
|
export type SessionComposerController = ReturnType<typeof createSessionComposerController>
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<div data-slot="question-header-title">{summary()}</div>
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
|
<Show when={total() > 1}>
|
||||||
<div data-slot="question-progress">
|
<div data-slot="question-progress">
|
||||||
<For each={questions()}>
|
<For each={questions()}>
|
||||||
{(_, i) => (
|
{(_, i) => (
|
||||||
@@ -447,6 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { createEffect, createMemo, onCleanup } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
import { useServerSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/global-sync"
|
||||||
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import {
|
||||||
|
SessionComposerRegion,
|
||||||
|
createSessionComposerController,
|
||||||
|
createSessionComposerRegionController,
|
||||||
|
} from "@/pages/session/composer"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "UI/Todo Panel Motion",
|
title: "UI/Todo Panel Motion",
|
||||||
@@ -60,7 +66,6 @@ const controls = {
|
|||||||
paid: true,
|
paid: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
},
|
},
|
||||||
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
|
|
||||||
session: {
|
session: {
|
||||||
id: "story-session",
|
id: "story-session",
|
||||||
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
|
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
|
||||||
@@ -149,6 +154,7 @@ const css = `
|
|||||||
export const Playground = {
|
export const Playground = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const global = useServerSync()
|
const global = useServerSync()
|
||||||
|
const prompt = usePrompt()
|
||||||
const [cfg, setCfg] = createStore({
|
const [cfg, setCfg] = createStore({
|
||||||
open: true,
|
open: true,
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
@@ -188,7 +194,7 @@ export const Playground = {
|
|||||||
const countMask = () => cfg.countMask
|
const countMask = () => cfg.countMask
|
||||||
const countMaskHeight = () => cfg.countMaskHeight
|
const countMaskHeight = () => cfg.countMaskHeight
|
||||||
const countWidthDuration = () => cfg.countWidthDuration
|
const countWidthDuration = () => cfg.countWidthDuration
|
||||||
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||||
let frame
|
let frame
|
||||||
let scrollRef
|
let scrollRef
|
||||||
|
|
||||||
@@ -217,7 +223,6 @@ export const Playground = {
|
|||||||
|
|
||||||
const collapsed = () => cfg.collapsed
|
const collapsed = () => cfg.collapsed
|
||||||
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
|
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
|
||||||
|
|
||||||
const openDock = () => {
|
const openDock = () => {
|
||||||
clear()
|
clear()
|
||||||
setCfg("open", true)
|
setCfg("open", true)
|
||||||
@@ -281,36 +286,30 @@ export const Playground = {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SessionComposerRegion
|
<SessionComposerRegion
|
||||||
state={state}
|
controller={createSessionComposerRegionController({
|
||||||
sessionKey="story-session"
|
state,
|
||||||
sessionID="story-session"
|
sessionKey: () => "story-session",
|
||||||
|
sessionID: () => "story-session",
|
||||||
|
prompt,
|
||||||
|
ready: () => true,
|
||||||
|
centered: () => false,
|
||||||
|
todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) },
|
||||||
|
followup: () => undefined,
|
||||||
|
revert: () => undefined,
|
||||||
|
onResponseSubmit: pin,
|
||||||
|
openParent: () => {},
|
||||||
|
setPromptRef: () => {},
|
||||||
|
setDockRef: () => {},
|
||||||
|
})}
|
||||||
|
promptInput={
|
||||||
|
<PromptInput
|
||||||
controls={controls}
|
controls={controls}
|
||||||
promptInput={{
|
submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }}
|
||||||
submission: { abort: () => {}, handleSubmit: (event) => event.preventDefault() },
|
ref={() => {}}
|
||||||
ref: () => {},
|
newSessionWorktree=""
|
||||||
newSessionWorktree: "",
|
onNewSessionWorktreeReset={() => {}}
|
||||||
onNewSessionWorktreeReset: () => {},
|
/>
|
||||||
}}
|
}
|
||||||
todo={{ collapsed: collapsed(), onToggle: () => setCollapsed(!collapsed()) }}
|
|
||||||
ready
|
|
||||||
centered={false}
|
|
||||||
onResponseSubmit={pin}
|
|
||||||
setPromptDockRef={() => {}}
|
|
||||||
dockOpenVisualDuration={dockOpenDuration()}
|
|
||||||
dockOpenBounce={dockOpenBounce()}
|
|
||||||
dockCloseVisualDuration={dockCloseDuration()}
|
|
||||||
dockCloseBounce={dockCloseBounce()}
|
|
||||||
drawerExpandVisualDuration={drawerExpandDuration()}
|
|
||||||
drawerExpandBounce={drawerExpandBounce()}
|
|
||||||
drawerCollapseVisualDuration={drawerCollapseDuration()}
|
|
||||||
drawerCollapseBounce={drawerCollapseBounce()}
|
|
||||||
subtitleDuration={subtitleDuration()}
|
|
||||||
subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()}
|
|
||||||
subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()}
|
|
||||||
countDuration={countDuration()}
|
|
||||||
countMask={countMask()}
|
|
||||||
countMaskHeight={countMaskHeight()}
|
|
||||||
countWidthDuration={countWidthDuration()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ export function TerminalPanel() {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div class="flex-1 min-h-0 relative">
|
<div class="flex-1 min-h-0 relative">
|
||||||
<Show when={terminal.active()} keyed>
|
<Show when={opened() && terminal.active()} keyed>
|
||||||
{(id) => {
|
{(id) => {
|
||||||
const ops = terminal.bind()
|
const ops = terminal.bind()
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1019,7 +1019,13 @@ export function MessageTimeline(props: {
|
|||||||
<div class="flex w-max min-w-full justify-end gap-2">
|
<div class="flex w-max min-w-full justify-end gap-2">
|
||||||
<Index each={comments()}>
|
<Index each={comments()}>
|
||||||
{(comment) => (
|
{(comment) => (
|
||||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
<div
|
||||||
|
classList={{
|
||||||
|
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
|
||||||
|
"border-[0.5px]": settings.general.newLayoutDesigns(),
|
||||||
|
border: !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||||
<FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
|
<FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
|
||||||
<span class="truncate">{getFilename(comment().path)}</span>
|
<span class="truncate">{getFilename(comment().path)}</span>
|
||||||
@@ -1288,7 +1294,11 @@ export function MessageTimeline(props: {
|
|||||||
<div
|
<div
|
||||||
data-session-title
|
data-session-title
|
||||||
classList={{
|
classList={{
|
||||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
"sticky top-0 z-30": true,
|
||||||
|
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
|
||||||
|
settings.general.newLayoutDesigns(),
|
||||||
|
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
|
||||||
|
!settings.general.newLayoutDesigns(),
|
||||||
"w-full": true,
|
"w-full": true,
|
||||||
"pb-4": true,
|
"pb-4": true,
|
||||||
"pr-3": true,
|
"pr-3": true,
|
||||||
@@ -1509,7 +1519,11 @@ export function MessageTimeline(props: {
|
|||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="w-full shadow-none border border-border-weak-base"
|
class={
|
||||||
|
settings.general.newLayoutDesigns()
|
||||||
|
? "w-full shadow-none border-[0.5px] border-border-weak-base"
|
||||||
|
: "w-full shadow-none border border-border-weak-base"
|
||||||
|
}
|
||||||
onClick={unshareSession}
|
onClick={unshareSession}
|
||||||
disabled={unshareMutation.isPending}
|
disabled={unshareMutation.isPending}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useSessionLayout } from "./session-layout"
|
||||||
|
import { createSessionOwnership } from "./session-ownership"
|
||||||
|
|
||||||
|
const withCategory = (category: string) => {
|
||||||
|
return (option: Omit<CommandOption, "category">): CommandOption => ({
|
||||||
|
...option,
|
||||||
|
category,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useComposerCommands = () => {
|
||||||
|
const command = useCommand()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const local = useLocal()
|
||||||
|
const settings = useSettings()
|
||||||
|
const { sessionKey } = useSessionLayout()
|
||||||
|
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||||
|
const modelCommand = withCategory(language.t("command.category.model"))
|
||||||
|
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||||
|
|
||||||
|
const chooseModel = async () => {
|
||||||
|
const owner = sessionOwnership.capture()
|
||||||
|
const { DialogSelectModel } = await import("@/components/dialog-select-model")
|
||||||
|
owner.run(() => {
|
||||||
|
void dialog.show(() => <DialogSelectModel model={local.model} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
command.register("composer", () => [
|
||||||
|
modelCommand({
|
||||||
|
id: "model.choose",
|
||||||
|
title: language.t("command.model.choose"),
|
||||||
|
description: language.t("command.model.choose.description"),
|
||||||
|
keybind: "mod+'",
|
||||||
|
slash: "model",
|
||||||
|
onSelect: chooseModel,
|
||||||
|
}),
|
||||||
|
modelCommand({
|
||||||
|
id: "model.variant.cycle",
|
||||||
|
title: language.t("command.model.variant.cycle"),
|
||||||
|
description: language.t("command.model.variant.cycle.description"),
|
||||||
|
keybind: "shift+mod+d",
|
||||||
|
onSelect: () => local.model.variant.cycle(),
|
||||||
|
}),
|
||||||
|
agentCommand({
|
||||||
|
id: "agent.cycle",
|
||||||
|
title: language.t("command.agent.cycle"),
|
||||||
|
description: language.t("command.agent.cycle.description"),
|
||||||
|
keybind: "mod+.",
|
||||||
|
slash: "agent",
|
||||||
|
disabled: !settings.visibility.customAgents(),
|
||||||
|
onSelect: () => local.agent.move(1),
|
||||||
|
}),
|
||||||
|
agentCommand({
|
||||||
|
id: "agent.cycle.reverse",
|
||||||
|
title: language.t("command.agent.cycle.reverse"),
|
||||||
|
description: language.t("command.agent.cycle.reverse.description"),
|
||||||
|
keybind: "shift+mod+.",
|
||||||
|
disabled: !settings.visibility.customAgents(),
|
||||||
|
onSelect: () => local.agent.move(-1),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -136,9 +136,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const contextCommand = withCategory(language.t("command.category.context"))
|
const contextCommand = withCategory(language.t("command.category.context"))
|
||||||
const viewCommand = withCategory(language.t("command.category.view"))
|
const viewCommand = withCategory(language.t("command.category.view"))
|
||||||
const terminalCommand = withCategory(language.t("command.category.terminal"))
|
const terminalCommand = withCategory(language.t("command.category.terminal"))
|
||||||
const modelCommand = withCategory(language.t("command.category.model"))
|
|
||||||
const mcpCommand = withCategory(language.t("command.category.mcp"))
|
const mcpCommand = withCategory(language.t("command.category.mcp"))
|
||||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
|
||||||
const permissionsCommand = withCategory(language.t("command.category.permissions"))
|
const permissionsCommand = withCategory(language.t("command.category.permissions"))
|
||||||
|
|
||||||
const isAutoAcceptActive = () => {
|
const isAutoAcceptActive = () => {
|
||||||
@@ -271,13 +269,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
view().terminal.open()
|
view().terminal.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
const chooseModel = () => {
|
|
||||||
void openDialog(
|
|
||||||
() => import("@/components/dialog-select-model"),
|
|
||||||
(x) => dialog.show(() => <x.DialogSelectModel model={local.model} />),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const chooseMcp = () => {
|
const chooseMcp = () => {
|
||||||
void openDialog(
|
void openDialog(
|
||||||
() => import("@/components/dialog-select-mcp"),
|
() => import("@/components/dialog-select-mcp"),
|
||||||
@@ -555,24 +546,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
const modelCmds = () => [
|
|
||||||
modelCommand({
|
|
||||||
id: "model.choose",
|
|
||||||
title: language.t("command.model.choose"),
|
|
||||||
description: language.t("command.model.choose.description"),
|
|
||||||
keybind: "mod+'",
|
|
||||||
slash: "model",
|
|
||||||
onSelect: chooseModel,
|
|
||||||
}),
|
|
||||||
modelCommand({
|
|
||||||
id: "model.variant.cycle",
|
|
||||||
title: language.t("command.model.variant.cycle"),
|
|
||||||
description: language.t("command.model.variant.cycle.description"),
|
|
||||||
keybind: "shift+mod+d",
|
|
||||||
onSelect: () => local.model.variant.cycle(),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
const mcpCmds = () => [
|
const mcpCmds = () => [
|
||||||
mcpCommand({
|
mcpCommand({
|
||||||
id: "mcp.toggle",
|
id: "mcp.toggle",
|
||||||
@@ -584,26 +557,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
const agentCmds = () => [
|
|
||||||
agentCommand({
|
|
||||||
id: "agent.cycle",
|
|
||||||
title: language.t("command.agent.cycle"),
|
|
||||||
description: language.t("command.agent.cycle.description"),
|
|
||||||
keybind: "mod+.",
|
|
||||||
slash: "agent",
|
|
||||||
disabled: !settings.visibility.customAgents(),
|
|
||||||
onSelect: () => local.agent.move(1),
|
|
||||||
}),
|
|
||||||
agentCommand({
|
|
||||||
id: "agent.cycle.reverse",
|
|
||||||
title: language.t("command.agent.cycle.reverse"),
|
|
||||||
description: language.t("command.agent.cycle.reverse.description"),
|
|
||||||
keybind: "shift+mod+.",
|
|
||||||
disabled: !settings.visibility.customAgents(),
|
|
||||||
onSelect: () => local.agent.move(-1),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
const permissionsCmds = () => [
|
const permissionsCmds = () => [
|
||||||
permissionsCommand({
|
permissionsCommand({
|
||||||
id: "permissions.autoaccept",
|
id: "permissions.autoaccept",
|
||||||
@@ -624,9 +577,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
...viewCmds(),
|
...viewCmds(),
|
||||||
...terminalCmds(),
|
...terminalCmds(),
|
||||||
...messageCmds(),
|
...messageCmds(),
|
||||||
...modelCmds(),
|
|
||||||
...mcpCmds(),
|
...mcpCmds(),
|
||||||
...agentCmds(),
|
|
||||||
...permissionsCmds(),
|
...permissionsCmds(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
@@ -30,8 +31,14 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const openAddWsl = () => {
|
const openAddWsl = () => {
|
||||||
dialog.push(() => (
|
dialog.push(() => (
|
||||||
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
|
<Dialog size="large" fit class="settings-v2-wsl-dialog">
|
||||||
|
<DialogHeader hideClose={true}>
|
||||||
|
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DividerV2 />
|
||||||
|
<DialogBody>
|
||||||
<DialogAddWslServer />
|
<DialogAddWslServer />
|
||||||
|
</DialogBody>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Stream, Schema } from "effect"
|
||||||
import { Sse } from "effect/unstable/encoding"
|
import { Sse } from "effect/unstable/encoding"
|
||||||
import { HttpClientError } from "effect/unstable/http"
|
import { HttpClientError } from "effect/unstable/http"
|
||||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||||
@@ -55,43 +55,49 @@ const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1In
|
|||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
const Endpoint0_2 = (raw: RawClient["server.session"]) => () =>
|
||||||
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
|
raw["session.active"]({}).pipe(
|
||||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||||
|
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] }
|
||||||
|
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
||||||
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
|
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||||
type Endpoint0_3Input = {
|
type Endpoint0_4Input = {
|
||||||
readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
|
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||||
readonly agent: Endpoint0_3Request["payload"]["agent"]
|
readonly agent: Endpoint0_4Request["payload"]["agent"]
|
||||||
}
|
}
|
||||||
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
||||||
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
|
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||||
type Endpoint0_4Input = {
|
type Endpoint0_5Input = {
|
||||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||||
readonly model: Endpoint0_4Request["payload"]["model"]
|
readonly model: Endpoint0_5Request["payload"]["model"]
|
||||||
}
|
}
|
||||||
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
||||||
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
|
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||||
type Endpoint0_5Input = {
|
type Endpoint0_6Input = {
|
||||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
|
||||||
readonly id?: Endpoint0_5Request["payload"]["id"]
|
readonly id?: Endpoint0_6Request["payload"]["id"]
|
||||||
readonly prompt: Endpoint0_5Request["payload"]["prompt"]
|
readonly prompt: Endpoint0_6Request["payload"]["prompt"]
|
||||||
readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
|
readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
|
||||||
readonly resume?: Endpoint0_5Request["payload"]["resume"]
|
readonly resume?: Endpoint0_6Request["payload"]["resume"]
|
||||||
}
|
}
|
||||||
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
||||||
raw["session.prompt"]({
|
raw["session.prompt"]({
|
||||||
params: { sessionID: input.sessionID },
|
params: { sessionID: input.sessionID },
|
||||||
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
||||||
@@ -100,23 +106,23 @@ const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Inp
|
|||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||||
type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
|
|
||||||
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
|
||||||
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
|
||||||
|
|
||||||
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
|
||||||
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
||||||
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
|
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
|
||||||
|
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||||
|
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
|
||||||
|
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
||||||
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||||
type Endpoint0_8Input = {
|
type Endpoint0_9Input = {
|
||||||
readonly sessionID: Endpoint0_8Request["params"]["sessionID"]
|
readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint0_8Request["payload"]["messageID"]
|
readonly messageID: Endpoint0_9Request["payload"]["messageID"]
|
||||||
readonly files?: Endpoint0_8Request["payload"]["files"]
|
readonly files?: Endpoint0_9Request["payload"]["files"]
|
||||||
}
|
}
|
||||||
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
|
||||||
raw["session.revert.stage"]({
|
raw["session.revert.stage"]({
|
||||||
params: { sessionID: input.sessionID },
|
params: { sessionID: input.sessionID },
|
||||||
payload: { messageID: input.messageID, files: input.files },
|
payload: { messageID: input.messageID, files: input.files },
|
||||||
@@ -125,37 +131,70 @@ const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Inp
|
|||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||||
type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
|
|
||||||
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
|
|
||||||
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
|
||||||
|
|
||||||
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
|
||||||
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
|
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
|
||||||
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
|
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
|
||||||
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||||
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
|
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
|
||||||
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
|
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
|
||||||
|
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||||
|
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
|
||||||
|
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
|
||||||
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
|
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||||
|
type Endpoint0_13Input = {
|
||||||
|
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||||
|
readonly after?: Endpoint0_13Request["query"]["after"]
|
||||||
|
}
|
||||||
|
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||||
|
Stream.unwrap(
|
||||||
|
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
|
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
||||||
|
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||||
|
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
|
type Endpoint0_15Input = {
|
||||||
|
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
||||||
|
readonly messageID: Endpoint0_15Request["params"]["messageID"]
|
||||||
|
}
|
||||||
|
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||||
|
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
)
|
||||||
|
|
||||||
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
||||||
list: Endpoint0_0(raw),
|
list: Endpoint0_0(raw),
|
||||||
create: Endpoint0_1(raw),
|
create: Endpoint0_1(raw),
|
||||||
get: Endpoint0_2(raw),
|
active: Endpoint0_2(raw),
|
||||||
switchAgent: Endpoint0_3(raw),
|
get: Endpoint0_3(raw),
|
||||||
switchModel: Endpoint0_4(raw),
|
switchAgent: Endpoint0_4(raw),
|
||||||
prompt: Endpoint0_5(raw),
|
switchModel: Endpoint0_5(raw),
|
||||||
compact: Endpoint0_6(raw),
|
prompt: Endpoint0_6(raw),
|
||||||
wait: Endpoint0_7(raw),
|
compact: Endpoint0_7(raw),
|
||||||
stage: Endpoint0_8(raw),
|
wait: Endpoint0_8(raw),
|
||||||
clear: Endpoint0_9(raw),
|
stage: Endpoint0_9(raw),
|
||||||
commit: Endpoint0_10(raw),
|
clear: Endpoint0_10(raw),
|
||||||
context: Endpoint0_11(raw),
|
commit: Endpoint0_11(raw),
|
||||||
|
context: Endpoint0_12(raw),
|
||||||
|
events: Endpoint0_13(raw),
|
||||||
|
interrupt: Endpoint0_14(raw),
|
||||||
|
message: Endpoint0_15(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
SessionsListOutput,
|
SessionsListOutput,
|
||||||
SessionsCreateInput,
|
SessionsCreateInput,
|
||||||
SessionsCreateOutput,
|
SessionsCreateOutput,
|
||||||
|
SessionsActiveOutput,
|
||||||
SessionsGetInput,
|
SessionsGetInput,
|
||||||
SessionsGetOutput,
|
SessionsGetOutput,
|
||||||
SessionsSwitchAgentInput,
|
SessionsSwitchAgentInput,
|
||||||
@@ -23,6 +24,12 @@ import type {
|
|||||||
SessionsCommitOutput,
|
SessionsCommitOutput,
|
||||||
SessionsContextInput,
|
SessionsContextInput,
|
||||||
SessionsContextOutput,
|
SessionsContextOutput,
|
||||||
|
SessionsEventsInput,
|
||||||
|
SessionsEventsOutput,
|
||||||
|
SessionsInterruptInput,
|
||||||
|
SessionsInterruptOutput,
|
||||||
|
SessionsMessageInput,
|
||||||
|
SessionsMessageOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
@@ -192,6 +199,17 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
|
active: (requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: SessionsActiveOutput }>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/active`,
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
|
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
|
||||||
request<{ readonly data: SessionsGetOutput }>(
|
request<{ readonly data: SessionsGetOutput }>(
|
||||||
{
|
{
|
||||||
@@ -306,6 +324,40 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
|
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
||||||
|
sse<SessionsEventsOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
|
||||||
|
query: { after: input.after },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
interrupt: (input: SessionsInterruptInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<SessionsInterruptOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
message: (input: SessionsMessageInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: SessionsMessageOutput }>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/* This file is auto-generated by SST. Do not edit. */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/* deno-fmt-ignore-file */
|
||||||
|
/* biome-ignore-all lint: auto-generated */
|
||||||
|
|
||||||
|
/// <reference path="../../sst-env.d.ts" />
|
||||||
|
|
||||||
|
import "sst"
|
||||||
|
export {}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { DateTime, Effect } from "effect"
|
import { DateTime, Effect, Stream } from "effect"
|
||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect"
|
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
|
||||||
|
|
||||||
test("sessions.get returns the decoded Effect projection", async () => {
|
test("sessions.get returns the decoded Effect projection", async () => {
|
||||||
const httpClient = HttpClient.make((request) =>
|
const httpClient = HttpClient.make((request) =>
|
||||||
@@ -18,12 +18,30 @@ test("sessions.get returns the decoded Effect projection", async () => {
|
|||||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||||
const httpClient = HttpClient.make((request) => {
|
const httpClient = HttpClient.make((request) => {
|
||||||
const url = request.url
|
const url = request.url
|
||||||
|
if (url.includes("/event")) {
|
||||||
|
return Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (url.includes("/prompt")) {
|
if (url.includes("/prompt")) {
|
||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||||
}
|
}
|
||||||
if (url.includes("/context")) {
|
if (url.includes("/context")) {
|
||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
|
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
|
||||||
}
|
}
|
||||||
|
if (url.includes("/message/")) {
|
||||||
|
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage })))
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/session/active")) {
|
||||||
|
return Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (request.method === "POST" && url.endsWith("/api/session")) {
|
if (request.method === "POST" && url.endsWith("/api/session")) {
|
||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
|
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
|
||||||
}
|
}
|
||||||
@@ -37,6 +55,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
const result = await Effect.gen(function* () {
|
const result = await Effect.gen(function* () {
|
||||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
const page = yield* client.sessions.list({ limit: 10 })
|
const page = yield* client.sessions.list({ limit: 10 })
|
||||||
|
const active = yield* client.sessions.active()
|
||||||
const created = yield* client.sessions.create({
|
const created = yield* client.sessions.create({
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
||||||
})
|
})
|
||||||
@@ -53,10 +72,19 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
||||||
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
||||||
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
||||||
return { page, created, admitted, context }
|
const events = yield* client.sessions
|
||||||
|
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||||
|
.pipe(Stream.runCollect)
|
||||||
|
yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||||
|
const message = yield* client.sessions.message({
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
messageID: SessionMessage.ID.make("msg_model"),
|
||||||
|
})
|
||||||
|
return { page, active, created, admitted, context, events, message }
|
||||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||||
|
expect(result.active).toEqual({ ses_test: { type: "running" } })
|
||||||
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
||||||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||||
expect(result.created.id).toBe("ses_test")
|
expect(result.created.id).toBe("ses_test")
|
||||||
@@ -64,6 +92,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||||
expect(result.context).toEqual([])
|
expect(result.context).toEqual([])
|
||||||
|
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||||
|
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
@@ -96,3 +126,22 @@ const admission = {
|
|||||||
timeCreated: 1_717_171_717_000,
|
timeCreated: 1_717_171_717_000,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelSwitchedMessage = {
|
||||||
|
id: "msg_model",
|
||||||
|
type: "model-switched",
|
||||||
|
time: { created: 1_717_171_717_000 },
|
||||||
|
model: { id: "claude", providerID: "anthropic" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelSwitchedEvent = {
|
||||||
|
id: "evt_model",
|
||||||
|
type: "session.next.model.switched",
|
||||||
|
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||||
|
data: {
|
||||||
|
timestamp: 1_717_171_717_000,
|
||||||
|
sessionID: "ses_test",
|
||||||
|
messageID: "msg_model",
|
||||||
|
model: { id: "claude", providerID: "anthropic" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,8 +24,15 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
fetch: async (input, init) => {
|
fetch: async (input, init) => {
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||||
requests.push({ url, init })
|
requests.push({ url, init })
|
||||||
|
if (url.includes("/event")) {
|
||||||
|
return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}
|
||||||
if (url.includes("/prompt")) return Response.json(admission)
|
if (url.includes("/prompt")) return Response.json(admission)
|
||||||
if (url.includes("/context")) return Response.json({ data: [] })
|
if (url.includes("/context")) return Response.json({ data: [] })
|
||||||
|
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||||
|
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
|
||||||
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
|
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
|
||||||
if (init?.method === "POST") return new Response(null, { status: 204 })
|
if (init?.method === "POST") return new Response(null, { status: 204 })
|
||||||
return Response.json({ data: [session.data], cursor: { next: "next" } })
|
return Response.json({ data: [session.data], cursor: { next: "next" } })
|
||||||
@@ -33,6 +40,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const page = await client.sessions.list({ limit: "10", order: "desc" })
|
const page = await client.sessions.list({ limit: "10", order: "desc" })
|
||||||
|
const active = await client.sessions.active()
|
||||||
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||||
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||||
await client.sessions.switchModel({
|
await client.sessions.switchModel({
|
||||||
@@ -47,13 +55,21 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
await client.sessions.compact({ sessionID: "ses_test" })
|
await client.sessions.compact({ sessionID: "ses_test" })
|
||||||
await client.sessions.wait({ sessionID: "ses_test" })
|
await client.sessions.wait({ sessionID: "ses_test" })
|
||||||
const context = await client.sessions.context({ sessionID: "ses_test" })
|
const context = await client.sessions.context({ sessionID: "ses_test" })
|
||||||
|
const events = []
|
||||||
|
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
|
||||||
|
await client.sessions.interrupt({ sessionID: "ses_test" })
|
||||||
|
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||||
|
|
||||||
expect(page.cursor.next).toBe("next")
|
expect(page.cursor.next).toBe("next")
|
||||||
|
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||||
expect(created.id).toBe("ses_test")
|
expect(created.id).toBe("ses_test")
|
||||||
expect(admitted.id).toBe("msg_test")
|
expect(admitted.id).toBe("msg_test")
|
||||||
expect(context).toEqual([])
|
expect(context).toEqual([])
|
||||||
|
expect(events).toEqual([modelSwitchedEvent])
|
||||||
|
expect(message).toEqual(modelSwitchedMessage)
|
||||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
|
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
|
||||||
|
["GET", "http://localhost:3000/api/session/active"],
|
||||||
["POST", "http://localhost:3000/api/session"],
|
["POST", "http://localhost:3000/api/session"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/model"],
|
["POST", "http://localhost:3000/api/session/ses_test/model"],
|
||||||
@@ -61,8 +77,11 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||||
|
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
||||||
|
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||||
|
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||||
])
|
])
|
||||||
const body = requests[4]?.init?.body
|
const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
|
||||||
if (typeof body !== "string") throw new Error("Expected JSON request body")
|
if (typeof body !== "string") throw new Error("Expected JSON request body")
|
||||||
expect(JSON.parse(body)).toEqual({
|
expect(JSON.parse(body)).toEqual({
|
||||||
prompt: { text: "Hello" },
|
prompt: { text: "Hello" },
|
||||||
@@ -115,3 +134,22 @@ const admission = {
|
|||||||
timeCreated: 1_717_171_717_000,
|
timeCreated: 1_717_171_717_000,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelSwitchedMessage = {
|
||||||
|
id: "msg_model",
|
||||||
|
type: "model-switched",
|
||||||
|
time: { created: 1_717_171_717_000 },
|
||||||
|
model: { id: "claude", providerID: "anthropic" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelSwitchedEvent = {
|
||||||
|
id: "evt_model",
|
||||||
|
type: "session.next.model.switched",
|
||||||
|
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||||
|
data: {
|
||||||
|
timestamp: 1_717_171_717_000,
|
||||||
|
sessionID: "ses_test",
|
||||||
|
messageID: "msg_model",
|
||||||
|
model: { id: "claude", providerID: "anthropic" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -196,11 +196,12 @@ export async function handler(
|
|||||||
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
|
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
|
||||||
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
|
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
|
||||||
if (v === "$ip") return headers.set(k, ip)
|
if (v === "$ip") return headers.set(k, ip)
|
||||||
if (v === "$caller") return headers.set(k, `caller:${ip}`)
|
if (v === "$caller") return headers.set(k, stickyId)
|
||||||
if (v === "$session") return headers.set(k, sessionId)
|
if (v === "$session") return headers.set(k, sessionId)
|
||||||
if (v === "$model") return headers.set(k, model)
|
if (v === "$model") return headers.set(k, model)
|
||||||
if (v === "$request") return headers.set(k, requestId)
|
if (v === "$request") return headers.set(k, requestId)
|
||||||
if (v === "$project") return headers.set(k, projectId)
|
if (v === "$project") return headers.set(k, projectId)
|
||||||
|
if (v === "$workspace" && authInfo?.workspaceID) return headers.set(k, authInfo.workspaceID)
|
||||||
headers.set(k, v)
|
headers.set(k, v)
|
||||||
})
|
})
|
||||||
headers.delete("host")
|
headers.delete("host")
|
||||||
|
|||||||
@@ -235,6 +235,11 @@ export const layer = Layer.effect(
|
|||||||
if (!record) return
|
if (!record) return
|
||||||
const provider = record.provider
|
const provider = record.provider
|
||||||
|
|
||||||
|
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
|
||||||
|
if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (providerID === ProviderV2.ID.opencode) {
|
if (providerID === ProviderV2.ID.opencode) {
|
||||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
|
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ export * as ConfigMCP from "./mcp"
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { PositiveInt } from "../schema"
|
import { PositiveInt } from "../schema"
|
||||||
|
|
||||||
|
export class Timeout extends Schema.Class<Timeout>("ConfigV2.MCP.Timeout")({
|
||||||
|
startup: PositiveInt.pipe(Schema.optional).annotate({
|
||||||
|
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
|
||||||
|
}),
|
||||||
|
request: PositiveInt.pipe(Schema.optional).annotate({
|
||||||
|
description: "Maximum time in milliseconds to wait for each MCP request after initialization.",
|
||||||
|
}),
|
||||||
|
}) {}
|
||||||
|
|
||||||
export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
|
export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
|
||||||
type: Schema.Literal("local"),
|
type: Schema.Literal("local"),
|
||||||
command: Schema.String.pipe(Schema.Array),
|
command: Schema.String.pipe(Schema.Array),
|
||||||
@@ -11,7 +20,7 @@ export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
|
|||||||
}),
|
}),
|
||||||
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
timeout: PositiveInt.pipe(Schema.optional),
|
timeout: Timeout.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class OAuth extends Schema.Class<OAuth>("ConfigV2.MCP.OAuth")({
|
export class OAuth extends Schema.Class<OAuth>("ConfigV2.MCP.OAuth")({
|
||||||
@@ -28,12 +37,12 @@ export class Remote extends Schema.Class<Remote>("ConfigV2.MCP.Remote")({
|
|||||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
|
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
|
||||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
timeout: PositiveInt.pipe(Schema.optional),
|
timeout: Timeout.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type"))
|
export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
|
export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
|
||||||
timeout: PositiveInt.pipe(Schema.optional),
|
timeout: Timeout.pipe(Schema.optional),
|
||||||
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
|
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const Plugin = define({
|
|||||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||||
if (!validAlias(name)) continue
|
if (!validAlias(name)) continue
|
||||||
|
const description = typeof entry === "string" ? undefined : entry.description
|
||||||
|
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||||
entries.set(
|
entries.set(
|
||||||
name,
|
name,
|
||||||
local(entry)
|
local(entry)
|
||||||
@@ -33,15 +35,15 @@ export const Plugin = define({
|
|||||||
path: AbsolutePath.make(
|
path: AbsolutePath.make(
|
||||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||||
),
|
),
|
||||||
description: typeof entry === "string" ? undefined : entry.description,
|
...(description === undefined ? {} : { description }),
|
||||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
...(hidden === undefined ? {} : { hidden }),
|
||||||
})
|
})
|
||||||
: Reference.GitSource.make({
|
: Reference.GitSource.make({
|
||||||
type: "git",
|
type: "git",
|
||||||
repository: typeof entry === "string" ? entry : entry.repository,
|
repository: typeof entry === "string" ? entry : entry.repository,
|
||||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
||||||
description: typeof entry === "string" ? undefined : entry.description,
|
...(description === undefined ? {} : { description }),
|
||||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
...(hidden === undefined ? {} : { hidden }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { Git } from "../git"
|
|||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectV2 } from "../project"
|
||||||
import { SessionV2 } from "../session"
|
import { SessionV2 } from "../session"
|
||||||
import { SessionExecution } from "../session/execution"
|
|
||||||
import { SessionEvent } from "../session/event"
|
import { SessionEvent } from "../session/event"
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
|
import { SessionStore } from "../session/store"
|
||||||
import { AbsolutePath, RelativePath } from "../schema"
|
import { AbsolutePath, RelativePath } from "../schema"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
@@ -71,10 +71,11 @@ export const layer = Layer.effect(
|
|||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const project = yield* ProjectV2.Service
|
const project = yield* ProjectV2.Service
|
||||||
const session = yield* SessionV2.Service
|
const sessions = yield* SessionStore.Service
|
||||||
|
|
||||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
||||||
const current = yield* session.get(input.sessionID)
|
const current = yield* sessions.get(input.sessionID)
|
||||||
|
if (!current) return yield* new SessionV2.NotFoundError({ sessionID: input.sessionID })
|
||||||
const directory = AbsolutePath.make(input.destination.directory)
|
const directory = AbsolutePath.make(input.destination.directory)
|
||||||
if (current.location.directory === directory) return
|
if (current.location.directory === directory) return
|
||||||
|
|
||||||
@@ -143,6 +144,5 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
Layer.provide(ProjectV2.defaultLayer),
|
Layer.provide(ProjectV2.defaultLayer),
|
||||||
Layer.provide(SessionExecution.noopLayer),
|
Layer.provide(SessionStore.defaultLayer),
|
||||||
Layer.provide(SessionV2.defaultLayer),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -503,6 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||||
export const node = LayerNode.make(layer, [filesystem, path])
|
export const node = LayerNode.make({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||||
|
|
||||||
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
||||||
|
|||||||
@@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap(
|
|||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(Global.defaultLayer))
|
).pipe(Layer.provide(Global.defaultLayer))
|
||||||
|
|
||||||
export const node = LayerNode.make(layerFromPath(path()), [])
|
export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||||
|
import { FileSystem, Path } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
import { HttpClient } from "effect/unstable/http"
|
||||||
import { LayerNode } from "./layer-node"
|
import { LayerNode } from "./layer-node"
|
||||||
|
|
||||||
export const filesystem = LayerNode.make(NodeFileSystem.layer, [])
|
export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||||
export const path = LayerNode.make(NodePath.layer, [])
|
export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||||
export const httpClient = LayerNode.make(FetchHttpClient.layer, [])
|
export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||||
export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient])
|
export const requestExecutor = LayerNode.make({
|
||||||
export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor])
|
service: RequestExecutor.Service,
|
||||||
|
layer: RequestExecutor.layer,
|
||||||
|
deps: [httpClient],
|
||||||
|
})
|
||||||
|
export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||||
|
|
||||||
export * as LayerNodePlatform from "./layer-node-platform"
|
export * as LayerNodePlatform from "./layer-node-platform"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Layer } from "effect"
|
import { Brand, Context, Layer } from "effect"
|
||||||
|
|
||||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||||
type AnyNode = Node<unknown, unknown>
|
type AnyNode = Node<unknown, unknown, any>
|
||||||
type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]]
|
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
||||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown> ? A : never
|
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E> ? E : never
|
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
||||||
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
||||||
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
||||||
Missing<Layer.Services<Implementation>, Dependencies>,
|
Missing<Layer.Services<Implementation>, Dependencies>,
|
||||||
@@ -14,89 +14,235 @@ type CheckDependencies<Implementation extends Layer.Any, Dependencies extends No
|
|||||||
declare const $OutputType: unique symbol
|
declare const $OutputType: unique symbol
|
||||||
declare const $ErrorType: unique symbol
|
declare const $ErrorType: unique symbol
|
||||||
|
|
||||||
export type Node<A, E = never> = {
|
export type Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
|
||||||
|
|
||||||
|
const makeTier = Brand.nominal<Tier>()
|
||||||
|
|
||||||
|
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
|
||||||
readonly kind: "layer" | "group"
|
readonly kind: "layer" | "group"
|
||||||
|
readonly name: string
|
||||||
|
readonly service?: Context.Service.Any
|
||||||
readonly implementation?: Layer.Any
|
readonly implementation?: Layer.Any
|
||||||
readonly dependencies: readonly AnyNode[]
|
readonly dependencies: readonly AnyNode[]
|
||||||
|
readonly tier?: T
|
||||||
readonly [$OutputType]?: () => A
|
readonly [$OutputType]?: () => A
|
||||||
readonly [$ErrorType]?: () => E
|
readonly [$ErrorType]?: () => E
|
||||||
}
|
}
|
||||||
|
|
||||||
export function make<const Implementation extends Layer.Any, const Items extends NodeList>(
|
type NodeIdentity =
|
||||||
implementation: Implementation,
|
| { readonly service: Context.Service.Any; readonly name?: never }
|
||||||
dependencies: Items & CheckDependencies<Implementation, NoInfer<Items>>,
|
| { readonly name: string; readonly service?: never }
|
||||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>> {
|
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
||||||
return { kind: "layer", implementation: implementation as Layer.Any, dependencies }
|
|
||||||
|
type NodeInput<
|
||||||
|
Implementation extends Layer.Any,
|
||||||
|
Items extends NodeList,
|
||||||
|
T extends Tier | undefined = undefined,
|
||||||
|
> = NodeIdentity & {
|
||||||
|
readonly layer: Implementation
|
||||||
|
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
||||||
|
readonly tier?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function make<
|
||||||
|
const Implementation extends Layer.Any,
|
||||||
|
const Items extends NodeList,
|
||||||
|
const T extends Tier | undefined = undefined,
|
||||||
|
>(
|
||||||
|
input: NodeInput<Implementation, Items, T>,
|
||||||
|
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
|
||||||
|
return {
|
||||||
|
kind: "layer",
|
||||||
|
name: input.service !== undefined ? input.service.key : input.name,
|
||||||
|
service: input.service,
|
||||||
|
implementation: input.layer,
|
||||||
|
dependencies: input.deps,
|
||||||
|
tier: input.tier,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function group<const Items extends NodeList>(
|
export function group<const Items extends NodeList>(
|
||||||
dependencies: Items,
|
dependencies: Items,
|
||||||
): Node<Output<Items[number]>, Error<Items[number]>> {
|
): Node<Output<Items[number]>, Error<Items[number]>> {
|
||||||
return { kind: "group", dependencies }
|
return { kind: "group", name: "group", dependencies }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Replacement<A = unknown> = {
|
type AllowedTierNames<Names extends readonly string[], Name extends Names[number]> = Names extends readonly [
|
||||||
readonly source: Node<A, unknown>
|
infer Head extends string,
|
||||||
readonly replacement: Node<A, unknown>
|
...infer Tail extends readonly string[],
|
||||||
|
]
|
||||||
|
? Head extends Name
|
||||||
|
? Head | Tail[number]
|
||||||
|
: AllowedTierNames<Tail, Name>
|
||||||
|
: never
|
||||||
|
|
||||||
|
type NodeInTiers<Names extends string> = Node<unknown, unknown, Tier<Names>>
|
||||||
|
|
||||||
|
export interface Tiers<Names extends readonly [string, ...string[]]> {
|
||||||
|
readonly names: Names
|
||||||
|
readonly values: { readonly [K in Names[number]]: Tier<K> }
|
||||||
|
readonly make: <Name extends Names[number]>(
|
||||||
|
name: Name,
|
||||||
|
) => <
|
||||||
|
const Implementation extends Layer.Any,
|
||||||
|
const Items extends NodeList<NodeInTiers<AllowedTierNames<Names, Name>>>,
|
||||||
|
>(
|
||||||
|
input: DistributiveOmit<NodeInput<Implementation, Items, Tier<Name>>, "tier">,
|
||||||
|
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tier<Name>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tiers<const Names extends readonly [string, ...string[]]>(names: Names): Tiers<Names> {
|
||||||
|
const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers<Names>["values"]
|
||||||
|
return {
|
||||||
|
names,
|
||||||
|
values,
|
||||||
|
make: ((name: Names[number]) => (input: DistributiveOmit<NodeInput<Layer.Any, NodeList, Tier>, "tier">) =>
|
||||||
|
make({ ...input, tier: values[name] })) as Tiers<Names>["make"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTiers = tiers(["untiered"])
|
||||||
|
const untiered = defaultTiers.values.untiered
|
||||||
|
|
||||||
|
export type Replacement = {
|
||||||
|
readonly source: Layer.Any
|
||||||
|
readonly replacement: Layer.Any
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
||||||
? unknown
|
? unknown
|
||||||
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
||||||
|
|
||||||
export function replaceWithNode<A, E, E2>(
|
export function replace<A, E, R, E2>(
|
||||||
source: Node<A, E>,
|
source: Layer.Layer<A, E, R>,
|
||||||
replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
|
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||||
): Replacement<A> {
|
): Replacement {
|
||||||
return { source, replacement }
|
return { source, replacement }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replace<A, E, E2>(
|
export function buildLayer<
|
||||||
source: Node<A, E>,
|
A,
|
||||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
E,
|
||||||
): Replacement<A> {
|
const Names extends readonly [string, ...string[]] = readonly ["untiered"],
|
||||||
return { source, replacement: make(replacement as Layer.Layer<A, E2>, []) }
|
const Built extends Layer.Any = Layer.Layer<never, never, never>,
|
||||||
|
>(
|
||||||
|
node: Node<A, E, any>,
|
||||||
|
options?: {
|
||||||
|
readonly tiers?: Tiers<Names>
|
||||||
|
readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built
|
||||||
|
readonly replacements?: readonly Replacement[]
|
||||||
|
},
|
||||||
|
): Layer.Layer<A | Layer.Success<Built>, E | Layer.Error<Built>, never> {
|
||||||
|
const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers<Names>)
|
||||||
|
const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
||||||
|
const plans = plan(node, tiers, replacementMap)
|
||||||
|
const layers: RuntimeLayer[] = tiers.names.map((name) => {
|
||||||
|
const tier = tiers.values[name as Names[number]]
|
||||||
|
const layers = plans.get(tier) ?? []
|
||||||
|
return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer
|
||||||
|
})
|
||||||
|
if (layers.length === 0) return Layer.empty as never
|
||||||
|
return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
|
export function combine(layers: readonly Layer.Any[]): RuntimeLayer {
|
||||||
const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
return layers.reduce<RuntimeLayer>(
|
||||||
const cache = new Map<AnyNode, RuntimeLayer>()
|
(result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)),
|
||||||
|
Layer.empty as RuntimeLayer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function plan(
|
||||||
|
root: AnyNode,
|
||||||
|
tiers: Tiers<readonly [string, ...string[]]>,
|
||||||
|
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||||
|
) {
|
||||||
|
const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index]))
|
||||||
|
const plans = new Map<Tier, Layer.Any[]>()
|
||||||
|
const activeImplementations = new Map<Tier, Map<string, AnyNode>>()
|
||||||
|
const serviceTiers = new Map<string, Tier>()
|
||||||
const visiting = new Set<AnyNode>()
|
const visiting = new Set<AnyNode>()
|
||||||
const stack: AnyNode[] = []
|
const stack: AnyNode[] = []
|
||||||
const ids = new Map<AnyNode, number>()
|
const boundaryVisited = new Map<AnyNode, Set<Tier>>()
|
||||||
|
const boundaryServices = new Map<Tier, Map<string, AnyNode>>()
|
||||||
|
|
||||||
|
const validateBoundary = (node: AnyNode, origin: Tier) => {
|
||||||
|
const checked = boundaryVisited.get(node) ?? new Set<Tier>()
|
||||||
|
boundaryVisited.set(node, checked)
|
||||||
|
if (checked.has(origin)) return false
|
||||||
|
checked.add(origin)
|
||||||
|
const services = boundaryServices.get(origin) ?? new Map<string, AnyNode>()
|
||||||
|
boundaryServices.set(origin, services)
|
||||||
|
const key = node.name
|
||||||
|
const existing = services.get(key)
|
||||||
|
if (existing && existing !== node) {
|
||||||
|
throw new Error(`Tier ${origin} has conflicting implementations for ${key}`)
|
||||||
|
}
|
||||||
|
services.set(key, node)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => {
|
||||||
|
if (node.kind === "group") {
|
||||||
|
node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = node.tier ?? untiered
|
||||||
|
if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
||||||
|
const key = node.name
|
||||||
|
const serviceTier = serviceTiers.get(key)
|
||||||
|
if (serviceTier && serviceTier !== tier) {
|
||||||
|
throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`)
|
||||||
|
}
|
||||||
|
serviceTiers.set(key, tier)
|
||||||
|
const nextOrigins = [...origins]
|
||||||
|
if (currentTier) {
|
||||||
|
const current = indexes.get(currentTier)!
|
||||||
|
const required = indexes.get(tier)!
|
||||||
|
if (required < current) {
|
||||||
|
throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`)
|
||||||
|
}
|
||||||
|
if (required > current) nextOrigins.push(currentTier)
|
||||||
|
}
|
||||||
|
const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin))
|
||||||
|
|
||||||
|
// A node may need to be emitted more than once because the final output is a
|
||||||
|
// flat list of layers applied with Layer.provideMerge. If another node for
|
||||||
|
// the same service was emitted afterward, this node is no longer the active
|
||||||
|
// implementation for subsequent consumers. Re-emitting restores the intended
|
||||||
|
// implementation ordering while Effect memoization avoids reacquiring the layer.
|
||||||
|
const implementations = activeImplementations.get(tier) ?? new Map<string, AnyNode>()
|
||||||
|
activeImplementations.set(tier, implementations)
|
||||||
|
if (implementations.get(key) === node && unseenOrigins.length === 0) return
|
||||||
|
|
||||||
const visit = (input: AnyNode): RuntimeLayer => {
|
|
||||||
const node = replacements.get(input) ?? input
|
|
||||||
const cached = cache.get(node)
|
|
||||||
if (cached) return cached
|
|
||||||
if (visiting.has(node)) {
|
if (visiting.has(node)) {
|
||||||
const start = stack.indexOf(node)
|
const start = stack.indexOf(node)
|
||||||
const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ")
|
throw new Error(
|
||||||
throw new Error(`Cycle detected in app graph: ${cycle}`)
|
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (!ids.has(node)) ids.set(node, ids.size + 1)
|
|
||||||
visiting.add(node)
|
visiting.add(node)
|
||||||
stack.push(node)
|
stack.push(node)
|
||||||
try {
|
try {
|
||||||
const dependencies = node.dependencies.map(visit)
|
node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins))
|
||||||
const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]]
|
const layers = plans.get(tier) ?? []
|
||||||
const result =
|
plans.set(tier, layers)
|
||||||
node.kind === "group"
|
layers.push(replacements.get(node.implementation!) ?? node.implementation!)
|
||||||
? dependencies.length === 0
|
implementations.set(key, node)
|
||||||
? Layer.empty
|
|
||||||
: Layer.mergeAll(...nonEmpty)
|
|
||||||
: dependencies.length === 0
|
|
||||||
? (node.implementation as RuntimeLayer)
|
|
||||||
: Layer.provide(node.implementation as RuntimeLayer, nonEmpty)
|
|
||||||
cache.set(node, result)
|
|
||||||
return result
|
|
||||||
} finally {
|
} finally {
|
||||||
stack.pop()
|
stack.pop()
|
||||||
visiting.delete(node)
|
visiting.delete(node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return visit(node) as unknown as Layer.Layer<A, E, never>
|
visit(root)
|
||||||
|
return plans
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireTier(node: AnyNode, indexes: ReadonlyMap<Tier, number>) {
|
||||||
|
if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as LayerNode from "./layer-node"
|
export * as LayerNode from "./layer-node"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { LayerNode } from "./layer-node"
|
||||||
|
|
||||||
|
export const tiers = LayerNode.tiers(["location", "global"])
|
||||||
|
|
||||||
|
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["global"]>
|
||||||
|
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["location"]>
|
||||||
|
|
||||||
|
export const makeGlobalNode = tiers.make("global")
|
||||||
|
export const makeLocationNode = tiers.make("location")
|
||||||
|
|
||||||
|
export * as ScopedNode from "./scoped-node"
|
||||||
+60
-67
@@ -2,14 +2,7 @@ export * as EventV2 from "./event"
|
|||||||
|
|
||||||
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type {
|
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||||
Data,
|
|
||||||
Definition,
|
|
||||||
DurableDefinition,
|
|
||||||
LiveDefinition,
|
|
||||||
Payload,
|
|
||||||
UncommittedPayload,
|
|
||||||
} from "@opencode-ai/schema/event"
|
|
||||||
import { and, asc, eq, gt } from "drizzle-orm"
|
import { and, asc, eq, gt } from "drizzle-orm"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||||
@@ -131,25 +124,8 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
function commitDurableEvent(
|
function commitDurableEvent(
|
||||||
definition: DurableDefinition,
|
definition: Definition,
|
||||||
event: UncommittedPayload<DurableDefinition>,
|
event: Payload,
|
||||||
input: undefined,
|
|
||||||
commit?: (seq: number) => Effect.Effect<void>,
|
|
||||||
): Effect.Effect<Payload<DurableDefinition>>
|
|
||||||
function commitDurableEvent(
|
|
||||||
definition: DurableDefinition,
|
|
||||||
event: UncommittedPayload<DurableDefinition>,
|
|
||||||
input: {
|
|
||||||
readonly seq: number
|
|
||||||
readonly aggregateID: string
|
|
||||||
readonly ownerID?: string
|
|
||||||
readonly strictOwner?: boolean
|
|
||||||
},
|
|
||||||
commit?: (seq: number) => Effect.Effect<void>,
|
|
||||||
): Effect.Effect<Payload<DurableDefinition> | undefined>
|
|
||||||
function commitDurableEvent(
|
|
||||||
definition: DurableDefinition,
|
|
||||||
event: UncommittedPayload<DurableDefinition>,
|
|
||||||
input?: {
|
input?: {
|
||||||
readonly seq: number
|
readonly seq: number
|
||||||
readonly aggregateID: string
|
readonly aggregateID: string
|
||||||
@@ -159,7 +135,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
commit?: (seq: number) => Effect.Effect<void>,
|
commit?: (seq: number) => Effect.Effect<void>,
|
||||||
) {
|
) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const durable = definition.durable
|
const durable = definition?.durable
|
||||||
if (durable) {
|
if (durable) {
|
||||||
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||||
if (typeof aggregateID !== "string") {
|
if (typeof aggregateID !== "string") {
|
||||||
@@ -224,7 +200,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}
|
}
|
||||||
return undefined
|
return
|
||||||
}
|
}
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidDurableEventError({
|
new InvalidDurableEventError({
|
||||||
@@ -234,7 +210,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||||
return undefined
|
return
|
||||||
}
|
}
|
||||||
const seq = input?.seq ?? latest + 1
|
const seq = input?.seq ?? latest + 1
|
||||||
if (input && seq !== latest + 1) {
|
if (input && seq !== latest + 1) {
|
||||||
@@ -258,10 +234,10 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const committed: Payload<DurableDefinition> = {
|
const committed = {
|
||||||
...event,
|
...event,
|
||||||
durable: { aggregateID, seq, version: durable.version },
|
durable: { aggregateID, seq, version: durable.version },
|
||||||
}
|
} as Payload
|
||||||
for (const projector of list) {
|
for (const projector of list) {
|
||||||
yield* projector(committed)
|
yield* projector(committed)
|
||||||
}
|
}
|
||||||
@@ -291,14 +267,14 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
])
|
])
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
return committed
|
return { aggregateID, seq }
|
||||||
}),
|
}),
|
||||||
{ behavior: "immediate" },
|
{ behavior: "immediate" },
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (committed) {
|
if (committed) {
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
pubsub.durable.get(committed.durable.aggregateID) ?? [],
|
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||||
(wake) => PubSub.publish(wake, undefined),
|
(wake) => PubSub.publish(wake, undefined),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
@@ -311,6 +287,35 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
if (!definition?.durable && commit)
|
||||||
|
return yield* Effect.die(
|
||||||
|
new InvalidDurableEventError({
|
||||||
|
type: event.type,
|
||||||
|
message: "Local commit hooks require a durable event",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (definition?.durable) {
|
||||||
|
const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
|
||||||
|
if (committed) {
|
||||||
|
event = {
|
||||||
|
...event,
|
||||||
|
durable: {
|
||||||
|
aggregateID: committed.aggregateID,
|
||||||
|
seq: committed.seq,
|
||||||
|
version: definition.durable.version,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield* notify(event as Payload, true)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield* notify(event as Payload, false)
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
|
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
|
||||||
Effect.suspend(() => observer(event)).pipe(
|
Effect.suspend(() => observer(event)).pipe(
|
||||||
Effect.catchCauseIf(
|
Effect.catchCauseIf(
|
||||||
@@ -332,12 +337,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function publish<D extends Definition>(
|
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
|
||||||
definition: D,
|
|
||||||
data: Data<D>,
|
|
||||||
options?: PublishOptions,
|
|
||||||
): Effect.Effect<Payload<D>>
|
|
||||||
function publish(definition: Definition, data: unknown, options?: PublishOptions): Effect.Effect<Payload> {
|
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||||
const location =
|
const location =
|
||||||
@@ -345,34 +345,17 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
(serviceLocation
|
(serviceLocation
|
||||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||||
: undefined)
|
: undefined)
|
||||||
if (definition.durable) {
|
return yield* publishEvent(
|
||||||
const event: UncommittedPayload<DurableDefinition> = {
|
definition,
|
||||||
|
{
|
||||||
id: options?.id ?? ID.create(),
|
id: options?.id ?? ID.create(),
|
||||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||||
type: definition.type,
|
type: definition.type,
|
||||||
...(location ? { location } : {}),
|
...(location ? { location } : {}),
|
||||||
data,
|
data,
|
||||||
}
|
} as Payload<D>,
|
||||||
const committed = yield* commitDurableEvent(definition, event, undefined, options?.commit)
|
options?.commit,
|
||||||
yield* notify(committed, true)
|
|
||||||
return committed
|
|
||||||
}
|
|
||||||
if (options?.commit)
|
|
||||||
return yield* Effect.die(
|
|
||||||
new InvalidDurableEventError({
|
|
||||||
type: definition.type,
|
|
||||||
message: "Local commit hooks require a durable event",
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
const event: Payload<LiveDefinition> = {
|
|
||||||
id: options?.id ?? ID.create(),
|
|
||||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
|
||||||
type: definition.type,
|
|
||||||
...(location ? { location } : {}),
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
yield* notify(event, false)
|
|
||||||
return event
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,11 +370,11 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
|
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const payload: UncommittedPayload<DurableDefinition> = {
|
const payload = {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
type: definition.type,
|
type: definition.type,
|
||||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||||
}
|
} as Payload
|
||||||
const committed = yield* commitDurableEvent(definition, payload, {
|
const committed = yield* commitDurableEvent(definition, payload, {
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
aggregateID: event.aggregateID,
|
aggregateID: event.aggregateID,
|
||||||
@@ -399,7 +382,17 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
strictOwner: options?.strictOwner,
|
strictOwner: options?.strictOwner,
|
||||||
})
|
})
|
||||||
if (committed && options?.publish) {
|
if (committed && options?.publish) {
|
||||||
yield* notify(committed, true)
|
yield* notify(
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
durable: {
|
||||||
|
aggregateID: committed.aggregateID,
|
||||||
|
seq: committed.seq,
|
||||||
|
version: definition.durable.version,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -466,7 +459,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
|
|
||||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||||
|
|
||||||
const decodeSerializedEvent = (event: SerializedEvent): Payload<DurableDefinition> => {
|
const decodeSerializedEvent = (event: SerializedEvent) => {
|
||||||
const definition = Durable.get(event.type)
|
const definition = Durable.get(event.type)
|
||||||
if (!definition?.durable) {
|
if (!definition?.durable) {
|
||||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||||
@@ -576,6 +569,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const layer = layerWith()
|
export const layer = layerWith()
|
||||||
export const node = LayerNode.make(layer, [Database.node])
|
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export namespace FSUtil {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||||
export const node = LayerNode.make(layer, [filesystem])
|
export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] })
|
||||||
|
|
||||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||||
export function mimeType(p: string): string {
|
export function mimeType(p: string): string {
|
||||||
|
|||||||
@@ -944,7 +944,7 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
||||||
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
|
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||||
|
|
||||||
interface Result {
|
interface Result {
|
||||||
readonly exitCode: number
|
readonly exitCode: number
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer
|
export const defaultLayer = layer
|
||||||
export const node = LayerNode.make(layer, [])
|
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
||||||
|
|
||||||
export const layerWith = (input: Partial<Interface>) =>
|
export const layerWith = (input: Partial<Interface>) =>
|
||||||
Layer.effect(
|
Layer.effect(
|
||||||
|
|||||||
@@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
)
|
)
|
||||||
export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient])
|
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||||
|
|
||||||
export * as ModelsDev from "./models-dev"
|
export * as ModelsDev from "./models-dev"
|
||||||
|
|||||||
@@ -253,7 +253,11 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Global.layer),
|
Layer.provide(Global.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
)
|
)
|
||||||
export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node])
|
export const node = LayerNode.make({
|
||||||
|
service: Service,
|
||||||
|
layer: layer,
|
||||||
|
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
|
||||||
|
})
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
// Branded HTML pages for local OAuth callback servers.
|
||||||
|
//
|
||||||
|
// These are served by the loopback HTTP servers that finish an OAuth exchange
|
||||||
|
// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return
|
||||||
|
// a fully self-contained HTML string with no external assets, so they work
|
||||||
|
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
|
||||||
|
// etc.).
|
||||||
|
//
|
||||||
|
// The visual language mirrors the opencode app: the design tokens are a curated
|
||||||
|
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
|
||||||
|
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
|
||||||
|
// Keep this file in sync with those sources when the brand changes.
|
||||||
|
|
||||||
|
export interface CallbackPageOptions {
|
||||||
|
/** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */
|
||||||
|
provider?: string
|
||||||
|
/** Attempt to close the window shortly after success. Defaults to true. */
|
||||||
|
autoClose?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function success(options?: CallbackPageOptions) {
|
||||||
|
const provider = options?.provider
|
||||||
|
return renderDocument({
|
||||||
|
title: "Authorization successful",
|
||||||
|
body: renderCard({
|
||||||
|
status: "success",
|
||||||
|
headline: "Authorization successful",
|
||||||
|
message: provider ? `opencode is now connected to ${escapeHtml(provider)}.` : "opencode is now authorized.",
|
||||||
|
footnote: "You can close this window and return to opencode.",
|
||||||
|
}),
|
||||||
|
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function error(detail: string, options?: CallbackPageOptions) {
|
||||||
|
const provider = options?.provider
|
||||||
|
return renderDocument({
|
||||||
|
title: "Authorization failed",
|
||||||
|
body: renderCard({
|
||||||
|
status: "error",
|
||||||
|
headline: "Authorization failed",
|
||||||
|
message: provider
|
||||||
|
? `opencode couldn't finish connecting to ${escapeHtml(provider)}.`
|
||||||
|
: "opencode couldn't complete authorization.",
|
||||||
|
detail,
|
||||||
|
footnote: "Close this window and try again from opencode.",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapOptions {
|
||||||
|
/** Same-origin path the in-browser script POSTs the parsed callback to. */
|
||||||
|
tokenPath: string
|
||||||
|
provider?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// For flows where the credential arrives in the URL fragment (implicit grant),
|
||||||
|
// the browser must relay it back to the loopback server. This renders a pending
|
||||||
|
// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves
|
||||||
|
// to the success or error state in place.
|
||||||
|
export function bootstrap(options: BootstrapOptions) {
|
||||||
|
return renderDocument({
|
||||||
|
title: "Finishing sign-in",
|
||||||
|
body: renderCard({
|
||||||
|
status: "pending",
|
||||||
|
headline: "Finishing sign-in",
|
||||||
|
message: options.provider
|
||||||
|
? `Completing your ${escapeHtml(options.provider)} authorization.`
|
||||||
|
: "Completing authorization.",
|
||||||
|
footnote: "You can close this window once sign-in finishes.",
|
||||||
|
}),
|
||||||
|
script: bootstrapScript(options),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as OauthCallbackPage from "./page"
|
||||||
|
|
||||||
|
type Status = "pending" | "success" | "error"
|
||||||
|
|
||||||
|
function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) {
|
||||||
|
const detail = input.detail?.trim()
|
||||||
|
return `<main class="card" id="oc-card" data-status="${input.status}" role="status" aria-live="polite">
|
||||||
|
<div class="brand">${WORDMARK}</div>
|
||||||
|
<div class="status" aria-hidden="true">
|
||||||
|
<span class="icon icon-pending">${ICON_SPINNER}</span>
|
||||||
|
<span class="icon icon-success">${ICON_CHECK}</span>
|
||||||
|
<span class="icon icon-error">${ICON_CROSS}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="headline" id="oc-headline">${escapeHtml(input.headline)}</h1>
|
||||||
|
<p class="message" id="oc-message">${input.message}</p>
|
||||||
|
<pre class="detail" id="oc-detail"${detail ? "" : " hidden"}>${detail ? escapeHtml(detail) : ""}</pre>
|
||||||
|
<p class="footnote" id="oc-footnote">${escapeHtml(input.footnote)}</p>
|
||||||
|
</main>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDocument(input: { title: string; body: string; script?: string }) {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
<title>${escapeHtml(input.title)} · opencode</title>
|
||||||
|
<style>${STYLES}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${input.body}${input.script ? `\n <script>${input.script}</script>` : ""}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)`
|
||||||
|
|
||||||
|
function bootstrapScript(options: BootstrapOptions) {
|
||||||
|
return `var PROVIDER=${scriptString(options.provider ?? "")};
|
||||||
|
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
|
||||||
|
(function(){
|
||||||
|
var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote");
|
||||||
|
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("opencode couldn't finish connecting to "+PROVIDER+"."):"opencode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from opencode."}
|
||||||
|
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("opencode is now connected to "+PROVIDER+"."):"opencode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window and return to opencode.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
|
||||||
|
try{
|
||||||
|
var hash=new URLSearchParams((window.location.hash||"").slice(1));
|
||||||
|
var search=new URLSearchParams(window.location.search||"");
|
||||||
|
var err=hash.get("error")||search.get("error");
|
||||||
|
var errDescription=hash.get("error_description")||search.get("error_description");
|
||||||
|
var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""};
|
||||||
|
fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){
|
||||||
|
if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))});
|
||||||
|
if(err){fail(errDescription||err);return}
|
||||||
|
ok();
|
||||||
|
}).catch(function(e){fail(String(e&&e.message?e.message:e))});
|
||||||
|
}catch(e){fail(String(e&&e.message?e.message:e))}
|
||||||
|
})()`
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptString(value: string) {
|
||||||
|
return JSON.stringify(value).replaceAll("<", "\\u003c")
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is
|
||||||
|
// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a
|
||||||
|
// host force a scheme without changing the default.
|
||||||
|
const LIGHT_VARS = `
|
||||||
|
--oc-bg: #f8f8f8;
|
||||||
|
--oc-card: #fcfcfc;
|
||||||
|
--oc-text-strong: #171717;
|
||||||
|
--oc-text-base: #6f6f6f;
|
||||||
|
--oc-text-weak: #8f8f8f;
|
||||||
|
--oc-border-weak: #e5e5e5;
|
||||||
|
--oc-icon-strong: #171717;
|
||||||
|
--oc-icon-base: #8f8f8f;
|
||||||
|
--oc-icon-weak: #dbdbdb;
|
||||||
|
--oc-success: #2dba26;
|
||||||
|
--oc-error: #ed4831;
|
||||||
|
--oc-detail-bg: #fff8f6;
|
||||||
|
--oc-detail-border: #fdc3b7;
|
||||||
|
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);`
|
||||||
|
|
||||||
|
const DARK_VARS = `
|
||||||
|
--oc-bg: #101010;
|
||||||
|
--oc-card: #161616;
|
||||||
|
--oc-text-strong: rgba(255,255,255,.936);
|
||||||
|
--oc-text-base: rgba(255,255,255,.618);
|
||||||
|
--oc-text-weak: rgba(255,255,255,.422);
|
||||||
|
--oc-border-weak: #282828;
|
||||||
|
--oc-icon-strong: #ededed;
|
||||||
|
--oc-icon-base: #7e7e7e;
|
||||||
|
--oc-icon-weak: #343434;
|
||||||
|
--oc-success: #12c905;
|
||||||
|
--oc-error: #fc533a;
|
||||||
|
--oc-detail-bg: #28110c;
|
||||||
|
--oc-detail-border: #6a1206;
|
||||||
|
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);`
|
||||||
|
|
||||||
|
const STYLES = `
|
||||||
|
:root { color-scheme: light dark;${LIGHT_VARS}
|
||||||
|
--oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } }
|
||||||
|
:root[data-theme="dark"] {${DARK_VARS} }
|
||||||
|
:root[data-theme="light"] {${LIGHT_VARS} }
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--oc-bg);
|
||||||
|
color: var(--oc-text-base);
|
||||||
|
font-family: var(--oc-font-sans);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: min(100%, 25rem);
|
||||||
|
padding: 2.25rem 2rem 1.75rem;
|
||||||
|
background: var(--oc-card);
|
||||||
|
border: 1px solid var(--oc-border-weak);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--oc-shadow);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.brand { display: flex; justify-content: center; margin-bottom: 1.75rem; }
|
||||||
|
.brand svg { height: 19px; width: auto; }
|
||||||
|
.status { display: flex; justify-content: center; margin-bottom: 1.125rem; }
|
||||||
|
.icon { display: none; line-height: 0; }
|
||||||
|
.icon svg { display: block; }
|
||||||
|
.card[data-status="pending"] .icon-pending,
|
||||||
|
.card[data-status="success"] .icon-success,
|
||||||
|
.card[data-status="error"] .icon-error { display: block; }
|
||||||
|
.icon-success { color: var(--oc-success); }
|
||||||
|
.icon-error { color: var(--oc-error); }
|
||||||
|
.icon-pending { color: var(--oc-text-weak); }
|
||||||
|
.headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); }
|
||||||
|
.message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); }
|
||||||
|
.detail {
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--oc-font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--oc-text-strong);
|
||||||
|
background: var(--oc-detail-bg);
|
||||||
|
border: 1px solid var(--oc-detail-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 9.5rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.detail[hidden] { display: none; }
|
||||||
|
.footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); }
|
||||||
|
.spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; }
|
||||||
|
@keyframes oc-spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||||
|
`
|
||||||
|
|
||||||
|
// opencode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
|
||||||
|
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="opencode" role="img">
|
||||||
|
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
|
||||||
|
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--oc-icon-base)" />
|
||||||
|
<path d="M84 24V30H66V24H84Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--oc-icon-base)" />
|
||||||
|
<path d="M108 36H96V18H108V36Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--oc-icon-base)" />
|
||||||
|
<path d="M144 30H126V18H144V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--oc-icon-strong)" />
|
||||||
|
<path d="M168 30H156V18H168V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--oc-icon-strong)" />
|
||||||
|
<path d="M198 30H186V18H198V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--oc-icon-strong)" />
|
||||||
|
<path d="M234 24V30H216V24H234Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--oc-icon-strong)" />
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
const ICON_CHECK = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m8.5 12.5 2.4 2.4 4.6-5.4" /></svg>`
|
||||||
|
|
||||||
|
const ICON_CROSS = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m9 9 6 6m0-6-6 6" /></svg>`
|
||||||
|
|
||||||
|
const ICON_SPINNER = `<svg class="spinner" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9" opacity="0.2" /><path d="M21 12a9 9 0 0 0-9-9" /></svg>`
|
||||||
@@ -12,8 +12,11 @@ import { ModelV2 } from "../model"
|
|||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
import { ProviderV2 } from "../provider"
|
import { ProviderV2 } from "../provider"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
|
import type { DeepMutable } from "../schema"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
|
|
||||||
|
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||||
|
|
||||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
@@ -30,8 +33,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
transform: (callback) =>
|
transform: (callback) =>
|
||||||
agents.transform((draft) =>
|
agents.transform((draft) =>
|
||||||
callback({
|
callback({
|
||||||
list: draft.list,
|
list: () => mutable(draft.list()),
|
||||||
get: (id) => draft.get(AgentV2.ID.make(id)),
|
get: (id) => mutable(draft.get(AgentV2.ID.make(id))),
|
||||||
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
|
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
|
||||||
update: (id, update) => draft.update(AgentV2.ID.make(id), update),
|
update: (id, update) => draft.update(AgentV2.ID.make(id), update),
|
||||||
remove: (id) => draft.remove(AgentV2.ID.make(id)),
|
remove: (id) => draft.remove(AgentV2.ID.make(id)),
|
||||||
@@ -42,7 +45,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
sdk: (callback) =>
|
sdk: (callback) =>
|
||||||
aisdk.hook.sdk((event) => {
|
aisdk.hook.sdk((event) => {
|
||||||
const output = {
|
const output = {
|
||||||
model: event.model,
|
model: mutable(event.model),
|
||||||
package: event.package,
|
package: event.package,
|
||||||
options: event.options,
|
options: event.options,
|
||||||
sdk: event.sdk,
|
sdk: event.sdk,
|
||||||
@@ -55,7 +58,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
language: (callback) =>
|
language: (callback) =>
|
||||||
aisdk.hook.language((event) => {
|
aisdk.hook.language((event) => {
|
||||||
const output = {
|
const output = {
|
||||||
model: event.model,
|
model: mutable(event.model),
|
||||||
sdk: event.sdk,
|
sdk: event.sdk,
|
||||||
options: event.options,
|
options: event.options,
|
||||||
language: event.language,
|
language: event.language,
|
||||||
@@ -72,13 +75,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
catalog.transform((draft) =>
|
catalog.transform((draft) =>
|
||||||
callback({
|
callback({
|
||||||
provider: {
|
provider: {
|
||||||
list: draft.provider.list,
|
list: () => mutable(draft.provider.list()),
|
||||||
get: (id) => draft.provider.get(ProviderV2.ID.make(id)),
|
get: (id) => mutable(draft.provider.get(ProviderV2.ID.make(id))),
|
||||||
update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update),
|
update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update),
|
||||||
remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)),
|
remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)),
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
get: (providerID, modelID) => draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
get: (providerID, modelID) =>
|
||||||
|
mutable(draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID))),
|
||||||
update: (providerID, modelID, update) =>
|
update: (providerID, modelID, update) =>
|
||||||
draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update),
|
draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update),
|
||||||
remove: (providerID, modelID) =>
|
remove: (providerID, modelID) =>
|
||||||
@@ -108,12 +112,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
transform: (callback) =>
|
transform: (callback) =>
|
||||||
integration.transform((draft) =>
|
integration.transform((draft) =>
|
||||||
callback({
|
callback({
|
||||||
list: draft.list,
|
list: () => mutable(draft.list()),
|
||||||
get: (id) => draft.get(Integration.ID.make(id)),
|
get: (id) => mutable(draft.get(Integration.ID.make(id))),
|
||||||
update: (id, update) => draft.update(Integration.ID.make(id), update),
|
update: (id, update) => draft.update(Integration.ID.make(id), update),
|
||||||
remove: (id) => draft.remove(Integration.ID.make(id)),
|
remove: (id) => draft.remove(Integration.ID.make(id)),
|
||||||
method: {
|
method: {
|
||||||
list: (id) => draft.method.list(Integration.ID.make(id)),
|
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
|
||||||
update: (input) => {
|
update: (input) => {
|
||||||
if ("authorize" in input) {
|
if ("authorize" in input) {
|
||||||
const methodID = Integration.MethodID.make(input.method.id)
|
const methodID = Integration.MethodID.make(input.method.id)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Credential } from "../../credential"
|
|||||||
import { InstallationVersion } from "../../installation/version"
|
import { InstallationVersion } from "../../installation/version"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
|
import { OauthCallbackPage } from "../../oauth/page"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
import type { PluginInternal } from "../internal"
|
import type { PluginInternal } from "../internal"
|
||||||
|
|
||||||
@@ -58,17 +59,21 @@ const browser = {
|
|||||||
const value = url.searchParams.get("code")
|
const value = url.searchParams.get("code")
|
||||||
if (error) {
|
if (error) {
|
||||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
|
response
|
||||||
|
.writeHead(400, { "Content-Type": "text/html" })
|
||||||
|
.end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!value || url.searchParams.get("state") !== state) {
|
if (!value || url.searchParams.get("state") !== state) {
|
||||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
|
response
|
||||||
|
.writeHead(400, { "Content-Type": "text/html" })
|
||||||
|
.end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Effect.runFork(Deferred.succeed(code, value))
|
Effect.runFork(Deferred.succeed(code, value))
|
||||||
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
|
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" }))
|
||||||
})
|
})
|
||||||
yield* Effect.callback<void, Error>((resume) => {
|
yield* Effect.callback<void, Error>((resume) => {
|
||||||
server.once("error", (error) => resume(Effect.fail(error)))
|
server.once("error", (error) => resume(Effect.fail(error)))
|
||||||
@@ -285,8 +290,3 @@ function claim(token: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const successPage =
|
|
||||||
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
|
|
||||||
const errorPage = (message: string) =>
|
|
||||||
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
|
|
||||||
|
|||||||
@@ -238,6 +238,6 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||||
export const node = LayerNode.make(layer, [CrossSpawnSpawner.node])
|
export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
||||||
|
|
||||||
export * as AppProcess from "./process"
|
export * as AppProcess from "./process"
|
||||||
|
|||||||
@@ -134,4 +134,8 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provideMerge(ProjectDirectories.defaultLayer),
|
Layer.provideMerge(ProjectDirectories.defaultLayer),
|
||||||
)
|
)
|
||||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node])
|
export const node = LayerNode.make({
|
||||||
|
service: Service,
|
||||||
|
layer: layer,
|
||||||
|
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -279,4 +279,8 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])
|
export const node = LayerNode.make({
|
||||||
|
service: Service,
|
||||||
|
layer: layer,
|
||||||
|
deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -156,4 +156,4 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||||
export const node = LayerNode.make(layer, [Database.node])
|
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||||
|
|||||||
@@ -54,4 +54,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
|
|||||||
export const layer = Layer.effect(Service, make())
|
export const layer = Layer.effect(Service, make())
|
||||||
|
|
||||||
export const defaultLayer = layer
|
export const defaultLayer = layer
|
||||||
export const node = LayerNode.make(layer, [])
|
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ export const layer = Layer.effect(
|
|||||||
new Info({
|
new Info({
|
||||||
name,
|
name,
|
||||||
path: source.path,
|
path: source.path,
|
||||||
description: source.description,
|
...(source.description === undefined ? {} : { description: source.description }),
|
||||||
hidden: source.hidden,
|
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||||
source,
|
source,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -89,8 +89,8 @@ export const layer = Layer.effect(
|
|||||||
new Info({
|
new Info({
|
||||||
name,
|
name,
|
||||||
path: AbsolutePath.make(target),
|
path: AbsolutePath.make(target),
|
||||||
description: source.description,
|
...(source.description === undefined ? {} : { description: source.description }),
|
||||||
hidden: source.hidden,
|
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||||
source,
|
source,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -279,4 +279,4 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
|
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
|
||||||
export const node = LayerNode.make(layer, [RipgrepBinary.node, AppProcess.node])
|
export const node = LayerNode.make({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||||
|
|||||||
@@ -130,5 +130,9 @@ export namespace RipgrepBinary {
|
|||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = LayerNode.make(layer, [FSUtil.node, httpClient, CrossSpawnSpawner.node])
|
export const node = LayerNode.make({
|
||||||
|
service: Service,
|
||||||
|
layer: layer,
|
||||||
|
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export interface Interface {
|
|||||||
}) => Effect.Effect<void, OperationUnavailableError>
|
}) => Effect.Effect<void, OperationUnavailableError>
|
||||||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||||
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
readonly revert: {
|
readonly revert: {
|
||||||
@@ -402,6 +403,7 @@ export const layer = Layer.unwrap(
|
|||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||||
}),
|
}),
|
||||||
|
active: execution.active,
|
||||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
yield* execution.resume(sessionID)
|
yield* execution.resume(sessionID)
|
||||||
@@ -443,7 +445,6 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))),
|
Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))),
|
||||||
),
|
),
|
||||||
Layer.provide(SessionExecution.noopLayer),
|
|
||||||
Layer.provide(SessionStore.defaultLayer),
|
Layer.provide(SessionStore.defaultLayer),
|
||||||
Layer.provide(SessionProjector.defaultLayer),
|
Layer.provide(SessionProjector.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { SessionRunner } from "./runner/index"
|
|||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
/** Snapshots active execution owned by this process. */
|
||||||
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||||
/** Starts execution while idle or joins the active execution. */
|
/** Starts execution while idle or joins the active execution. */
|
||||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||||
@@ -19,5 +21,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||||
export const noopLayer = Layer.succeed(
|
export const noopLayer = Layer.succeed(
|
||||||
Service,
|
Service,
|
||||||
Service.of({ resume: () => Effect.void, wake: () => Effect.void, interrupt: () => Effect.void }),
|
Service.of({
|
||||||
|
active: Effect.succeed(new Set()),
|
||||||
|
resume: () => Effect.void,
|
||||||
|
wake: () => Effect.void,
|
||||||
|
interrupt: () => Effect.void,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return SessionExecution.Service.of({
|
return SessionExecution.Service.of({
|
||||||
|
active: coordinator.active,
|
||||||
interrupt: coordinator.interrupt,
|
interrupt: coordinator.interrupt,
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
|
|||||||
@@ -456,4 +456,4 @@ export const layer = Layer.effectDiscard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||||
export const node = LayerNode.make(layer, [EventV2.node, Database.node])
|
export const node = LayerNode.make({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
|||||||
|
|
||||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||||
export interface Coordinator<Key, E> {
|
export interface Coordinator<Key, E> {
|
||||||
|
/** Snapshots keys with an execution owned by this coordinator. */
|
||||||
|
readonly active: Effect.Effect<ReadonlySet<Key>>
|
||||||
/** Starts execution while idle or joins the active execution. */
|
/** Starts execution while idle or joins the active execution. */
|
||||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||||
/** Registers one coalesced follow-up after newly recorded work. */
|
/** Registers one coalesced follow-up after newly recorded work. */
|
||||||
@@ -98,5 +100,5 @@ export const make = <Key, E>(options: {
|
|||||||
return Fiber.interrupt(entry.owner)
|
return Fiber.interrupt(entry.owner)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { run, wake, interrupt }
|
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,17 +70,28 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||||||
const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||||
const sameModel =
|
const sameModel =
|
||||||
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
|
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
|
||||||
|
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||||
const content = message.content.flatMap((item): ContentPart[] => {
|
const content = message.content.flatMap((item): ContentPart[] => {
|
||||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||||
if (item.type === "reasoning")
|
if (item.type === "reasoning")
|
||||||
return sameModel
|
return sameModel
|
||||||
? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }]
|
? [
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: item.text,
|
||||||
|
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
: item.text.length > 0
|
: item.text.length > 0
|
||||||
? [{ type: "text", text: item.text }]
|
? [{ type: "text", text: item.text }]
|
||||||
: []
|
: []
|
||||||
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined)
|
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
|
||||||
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
|
if (item.provider?.executed !== true) return [call]
|
||||||
return item.provider?.executed === true && result ? [call, result] : [call]
|
const result = toolResult(
|
||||||
|
item,
|
||||||
|
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
|
||||||
|
)
|
||||||
|
return result ? [call, result] : [call]
|
||||||
})
|
})
|
||||||
const meaningful = content.filter((part) => {
|
const meaningful = content.filter((part) => {
|
||||||
if (part.type === "text") return part.text !== ""
|
if (part.type === "text") return part.text !== ""
|
||||||
@@ -89,7 +100,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
|||||||
})
|
})
|
||||||
const results = message.content
|
const results = message.content
|
||||||
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
||||||
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
|
.map((item) =>
|
||||||
|
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
|
||||||
|
)
|
||||||
.filter((message) => message !== undefined)
|
.filter((message) => message !== undefined)
|
||||||
.map(Message.tool)
|
.map(Message.tool)
|
||||||
if (meaningful.length === 0) return results
|
if (meaningful.length === 0) return results
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function isSafeRelativePath(value: string) {
|
|||||||
|
|
||||||
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
|
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
version: Schema.optional(Schema.String),
|
||||||
files: Schema.Array(Schema.String),
|
files: Schema.Array(Schema.String),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
@@ -80,12 +81,15 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) {
|
const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) {
|
||||||
if (yield* fs.exists(destination).pipe(Effect.orDie)) return
|
if (yield* fs.exists(destination).pipe(Effect.orDie)) return true
|
||||||
yield* HttpClientRequest.get(url).pipe(
|
return yield* HttpClientRequest.get(url).pipe(
|
||||||
http.execute,
|
http.execute,
|
||||||
Effect.flatMap((response) => response.arrayBuffer),
|
Effect.flatMap((response) => response.arrayBuffer),
|
||||||
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
|
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
|
||||||
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
|
Effect.as(true),
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -120,6 +124,7 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
|
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
|
||||||
|
const versionFile = path.join(root, ".opencode-version")
|
||||||
const files = skill.files.map((file) => {
|
const files = skill.files.map((file) => {
|
||||||
if (!isSafeRelativePath(file)) return undefined
|
if (!isSafeRelativePath(file)) return undefined
|
||||||
let resource: URL
|
let resource: URL
|
||||||
@@ -135,23 +140,66 @@ export const layer = Layer.effect(
|
|||||||
return {
|
return {
|
||||||
url: resource.href,
|
url: resource.href,
|
||||||
destination,
|
destination,
|
||||||
|
file,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (files.some((file) => file === undefined)) {
|
if (files.some((file) => file === undefined)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return [{ skill, root, files: files as { url: string; destination: string }[] }]
|
return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }]
|
||||||
}),
|
}),
|
||||||
({ skill, root, files }) =>
|
({ skill, root, versionFile, files }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const version = skill.version
|
||||||
|
const current =
|
||||||
|
version === undefined
|
||||||
|
? undefined
|
||||||
|
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (version === undefined || current === version) {
|
||||||
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
|
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
|
||||||
concurrency: fileConcurrency,
|
concurrency: fileConcurrency,
|
||||||
discard: true,
|
discard: true,
|
||||||
})
|
})
|
||||||
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
|
} else {
|
||||||
|
const token = crypto.randomUUID()
|
||||||
|
const staging = `${root}.tmp-${token}`
|
||||||
|
const backup = `${root}.old-${token}`
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const downloaded = yield* Effect.forEach(
|
||||||
|
files,
|
||||||
|
(file) => download(file.url, path.resolve(staging, file.file)),
|
||||||
|
{ concurrency: fileConcurrency },
|
||||||
|
)
|
||||||
|
if (!downloaded.every(Boolean)) return
|
||||||
|
const exists =
|
||||||
|
(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) ||
|
||||||
|
(yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie))
|
||||||
|
if (!exists) return
|
||||||
|
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
|
||||||
|
yield* Effect.uninterruptible(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cached = yield* fs.exists(root).pipe(Effect.orDie)
|
||||||
|
if (cached) yield* fs.rename(root, backup)
|
||||||
|
yield* fs.rename(staging, root).pipe(
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
|
||||||
|
return yield* Effect.fail(error)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
|
||||||
|
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const exists =
|
||||||
|
(yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
|
||||||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
|
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
|
||||||
? [AbsolutePath.make(root)]
|
return exists ? [AbsolutePath.make(root)] : []
|
||||||
: []
|
|
||||||
}),
|
}),
|
||||||
{ concurrency: skillConcurrency },
|
{ concurrency: skillConcurrency },
|
||||||
).pipe(Effect.map((directories) => directories.flat()))
|
).pipe(Effect.map((directories) => directories.flat()))
|
||||||
|
|||||||
@@ -281,5 +281,5 @@ export namespace EffectFlock {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
|
||||||
export const node = LayerNode.make(layer, [Global.node, FSUtil.node])
|
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
|
|||||||
)
|
)
|
||||||
const timeout = info.experimental?.mcp_timeout
|
const timeout = info.experimental?.mcp_timeout
|
||||||
if (!timeout && !Object.keys(servers).length) return undefined
|
if (!timeout && !Object.keys(servers).length) return undefined
|
||||||
return { timeout, servers }
|
return { timeout: timeout === undefined ? undefined : { request: timeout }, servers }
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateMcp(info: ConfigMCPV1.Info) {
|
function migrateMcp(info: ConfigMCPV1.Info) {
|
||||||
@@ -144,7 +144,7 @@ function migrateMcp(info: ConfigMCPV1.Info) {
|
|||||||
cwd: info.cwd,
|
cwd: info.cwd,
|
||||||
environment: info.environment,
|
environment: info.environment,
|
||||||
disabled,
|
disabled,
|
||||||
timeout: info.timeout,
|
timeout: info.timeout === undefined ? undefined : { request: info.timeout },
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: info.type,
|
type: info.type,
|
||||||
@@ -158,7 +158,7 @@ function migrateMcp(info: ConfigMCPV1.Info) {
|
|||||||
redirect_uri: info.oauth.redirectUri,
|
redirect_uri: info.oauth.redirectUri,
|
||||||
},
|
},
|
||||||
disabled,
|
disabled,
|
||||||
timeout: info.timeout,
|
timeout: info.timeout === undefined ? undefined : { request: info.timeout },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +170,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
|
|||||||
function migrateProvider(info: ConfigProviderV1.Info) {
|
function migrateProvider(info: ConfigProviderV1.Info) {
|
||||||
const lowerer = ConfigProviderOptionsV1.get(info.npm)
|
const lowerer = ConfigProviderOptionsV1.get(info.npm)
|
||||||
const options = lowerer.provider(info.options ?? {})
|
const options = lowerer.provider(info.options ?? {})
|
||||||
|
const url = info.api ?? options.url
|
||||||
return {
|
return {
|
||||||
name: info.name,
|
name: info.name,
|
||||||
env: info.env,
|
env: info.env,
|
||||||
@@ -177,7 +178,7 @@ function migrateProvider(info: ConfigProviderV1.Info) {
|
|||||||
? {
|
? {
|
||||||
type: "aisdk" as const,
|
type: "aisdk" as const,
|
||||||
package: info.npm,
|
package: info.npm,
|
||||||
url: info.api ?? options.url,
|
...(url === undefined ? {} : { url }),
|
||||||
settings: options.settings ?? {},
|
settings: options.settings ?? {},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -221,7 +222,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
|
|||||||
...(info.id === undefined ? {} : { id: info.id }),
|
...(info.id === undefined ? {} : { id: info.id }),
|
||||||
type: "aisdk" as const,
|
type: "aisdk" as const,
|
||||||
package: info.provider.npm,
|
package: info.provider.npm,
|
||||||
url: info.provider.api,
|
...(info.provider.api === undefined ? {} : { url: info.provider.api }),
|
||||||
settings: {},
|
settings: {},
|
||||||
}
|
}
|
||||||
: info.id === undefined
|
: info.id === undefined
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ describe("Config", () => {
|
|||||||
expect(migrated.providers?.bedrock?.api).toEqual({
|
expect(migrated.providers?.bedrock?.api).toEqual({
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
package: "@ai-sdk/amazon-bedrock",
|
package: "@ai-sdk/amazon-bedrock",
|
||||||
url: undefined,
|
|
||||||
settings: { region: "us-east-1", profile: "dev" },
|
settings: { region: "us-east-1", profile: "dev" },
|
||||||
})
|
})
|
||||||
expect(migrated.providers?.bedrock?.request).toEqual({
|
expect(migrated.providers?.bedrock?.request).toEqual({
|
||||||
@@ -299,14 +298,14 @@ describe("Config", () => {
|
|||||||
},
|
},
|
||||||
tool_output: { max_lines: 1000, max_bytes: 32768 },
|
tool_output: { max_lines: 1000, max_bytes: 32768 },
|
||||||
mcp: {
|
mcp: {
|
||||||
timeout: 5000,
|
timeout: { startup: 5000, request: 60000 },
|
||||||
servers: {
|
servers: {
|
||||||
local: {
|
local: {
|
||||||
type: "local",
|
type: "local",
|
||||||
command: ["node", "./mcp/server.js"],
|
command: ["node", "./mcp/server.js"],
|
||||||
environment: { API_KEY: "secret" },
|
environment: { API_KEY: "secret" },
|
||||||
disabled: false,
|
disabled: false,
|
||||||
timeout: 10000,
|
timeout: { request: 10000 },
|
||||||
},
|
},
|
||||||
remote: {
|
remote: {
|
||||||
type: "remote",
|
type: "remote",
|
||||||
@@ -314,6 +313,7 @@ describe("Config", () => {
|
|||||||
headers: { Authorization: "Bearer token" },
|
headers: { Authorization: "Bearer token" },
|
||||||
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
|
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
|
||||||
disabled: true,
|
disabled: true,
|
||||||
|
timeout: { startup: 15000 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -384,14 +384,14 @@ describe("Config", () => {
|
|||||||
})
|
})
|
||||||
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
|
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
|
||||||
expect(documents[0]?.info.mcp).toEqual({
|
expect(documents[0]?.info.mcp).toEqual({
|
||||||
timeout: 5000,
|
timeout: { startup: 5000, request: 60000 },
|
||||||
servers: {
|
servers: {
|
||||||
local: {
|
local: {
|
||||||
type: "local",
|
type: "local",
|
||||||
command: ["node", "./mcp/server.js"],
|
command: ["node", "./mcp/server.js"],
|
||||||
environment: { API_KEY: "secret" },
|
environment: { API_KEY: "secret" },
|
||||||
disabled: false,
|
disabled: false,
|
||||||
timeout: 10000,
|
timeout: { request: 10000 },
|
||||||
},
|
},
|
||||||
remote: {
|
remote: {
|
||||||
type: "remote",
|
type: "remote",
|
||||||
@@ -399,6 +399,7 @@ describe("Config", () => {
|
|||||||
headers: { Authorization: "Bearer token" },
|
headers: { Authorization: "Bearer token" },
|
||||||
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
|
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
|
||||||
disabled: true,
|
disabled: true,
|
||||||
|
timeout: { startup: 15000 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -542,11 +543,12 @@ describe("Config", () => {
|
|||||||
compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
|
compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
|
||||||
experimental: { mcp_timeout: 5000 },
|
experimental: { mcp_timeout: 5000 },
|
||||||
mcp: {
|
mcp: {
|
||||||
local: { type: "local", command: ["node", "server.js"], enabled: false },
|
local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 },
|
||||||
remote: {
|
remote: {
|
||||||
type: "remote",
|
type: "remote",
|
||||||
url: "https://mcp.example.com",
|
url: "https://mcp.example.com",
|
||||||
oauth: { clientId: "client", callbackPort: 19876 },
|
oauth: { clientId: "client", callbackPort: 19876 },
|
||||||
|
timeout: 20000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -624,13 +626,19 @@ describe("Config", () => {
|
|||||||
buffer: 10000,
|
buffer: 10000,
|
||||||
})
|
})
|
||||||
expect(documents[0]?.info.mcp).toMatchObject({
|
expect(documents[0]?.info.mcp).toMatchObject({
|
||||||
timeout: 5000,
|
timeout: { request: 5000 },
|
||||||
servers: {
|
servers: {
|
||||||
local: { type: "local", command: ["node", "server.js"], disabled: true },
|
local: {
|
||||||
|
type: "local",
|
||||||
|
command: ["node", "server.js"],
|
||||||
|
disabled: true,
|
||||||
|
timeout: { request: 10000 },
|
||||||
|
},
|
||||||
remote: {
|
remote: {
|
||||||
type: "remote",
|
type: "remote",
|
||||||
url: "https://mcp.example.com",
|
url: "https://mcp.example.com",
|
||||||
oauth: { client_id: "client", callback_port: 19876 },
|
oauth: { client_id: "client", callback_port: 19876 },
|
||||||
|
timeout: { request: 20000 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe("EventV2", () => {
|
|||||||
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
|
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
|
||||||
|
|
||||||
expect(event.type).toBe("test.versioned")
|
expect(event.type).toBe("test.versioned")
|
||||||
expect(event.durable.version).toBe(2)
|
expect(event.durable?.version).toBe(2)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,50 +138,6 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves same-type projector routing across durable versions", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const historical = EventV2.define({
|
|
||||||
type: "test.projector-version",
|
|
||||||
durable: { version: 1, aggregate: "id" },
|
|
||||||
schema: { id: Schema.String },
|
|
||||||
})
|
|
||||||
const current = EventV2.define({
|
|
||||||
type: "test.projector-version",
|
|
||||||
durable: { version: 2, aggregate: "id" },
|
|
||||||
schema: { id: Schema.String },
|
|
||||||
})
|
|
||||||
const received = new Array<EventV2.Payload>()
|
|
||||||
yield* events.project(historical, (event) => Effect.sync(() => received.push(event)))
|
|
||||||
|
|
||||||
const published = yield* events.publish(current, { id: "aggregate" })
|
|
||||||
|
|
||||||
expect(received).toEqual([published])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("preserves same-type subscription routing across durable versions", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const historical = EventV2.define({
|
|
||||||
type: "test.subscription-version",
|
|
||||||
durable: { version: 1, aggregate: "id" },
|
|
||||||
schema: { id: Schema.String },
|
|
||||||
})
|
|
||||||
const current = EventV2.define({
|
|
||||||
type: "test.subscription-version",
|
|
||||||
durable: { version: 2, aggregate: "id" },
|
|
||||||
schema: { id: Schema.String },
|
|
||||||
})
|
|
||||||
const fiber = yield* events.subscribe(historical).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
|
|
||||||
const published = yield* events.publish(current, { id: "aggregate" })
|
|
||||||
|
|
||||||
expect(Array.from(yield* Fiber.join(fiber))).toEqual([published])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("publishes to typed and wildcard subscriptions", () =>
|
it.effect("publishes to typed and wildcard subscriptions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
@@ -808,7 +764,7 @@ describe("EventV2", () => {
|
|||||||
const replayed = {
|
const replayed = {
|
||||||
id: published.id,
|
id: published.id,
|
||||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||||
seq: published.durable.seq,
|
seq: published.durable!.seq,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
data: published.data,
|
data: published.data,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const layer = MoveSession.layer.pipe(
|
|||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
Layer.provide(project),
|
Layer.provide(project),
|
||||||
Layer.provide(sessions),
|
Layer.provide(SessionStore.defaultLayer),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
@@ -53,7 +53,6 @@ const it = testEffect(
|
|||||||
project,
|
project,
|
||||||
SessionProjector.defaultLayer,
|
SessionProjector.defaultLayer,
|
||||||
SessionStore.defaultLayer,
|
SessionStore.defaultLayer,
|
||||||
SessionExecution.noopLayer,
|
|
||||||
sessions,
|
sessions,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { OauthCallbackPage } from "../src/oauth/page"
|
||||||
|
|
||||||
|
describe("OauthCallbackPage", () => {
|
||||||
|
test("escapes bootstrap options embedded in the inline script", () => {
|
||||||
|
const html = OauthCallbackPage.bootstrap({
|
||||||
|
provider: `xAI</script><script>alert("provider")</script>`,
|
||||||
|
tokenPath: `/token</script><script>alert("path")</script>`,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(html.match(/<\/script>/g)).toHaveLength(1)
|
||||||
|
expect(html).toContain(`xAI\\u003c/script>\\u003cscript>alert(\\\"provider\\\")\\u003c/script>`)
|
||||||
|
expect(html).toContain(`/token\\u003c/script>\\u003cscript>alert(\\\"path\\\")\\u003c/script>`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -22,9 +22,11 @@ import { testEffect } from "./lib/effect"
|
|||||||
const executionCalls: SessionV2.ID[] = []
|
const executionCalls: SessionV2.ID[] = []
|
||||||
const interruptCalls: SessionV2.ID[] = []
|
const interruptCalls: SessionV2.ID[] = []
|
||||||
const wakeCalls: SessionV2.ID[] = []
|
const wakeCalls: SessionV2.ID[] = []
|
||||||
|
const activeSessions = new Set<SessionV2.ID>()
|
||||||
const execution = Layer.succeed(
|
const execution = Layer.succeed(
|
||||||
SessionExecution.Service,
|
SessionExecution.Service,
|
||||||
SessionExecution.Service.of({
|
SessionExecution.Service.of({
|
||||||
|
active: Effect.sync(() => new Set(activeSessions)),
|
||||||
resume: (sessionID) =>
|
resume: (sessionID) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
executionCalls.push(sessionID)
|
executionCalls.push(sessionID)
|
||||||
@@ -108,6 +110,13 @@ const eventCount = (type: string) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
describe("SessionV2.prompt", () => {
|
describe("SessionV2.prompt", () => {
|
||||||
|
it.effect("exposes the execution registry", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
activeSessions.add(sessionID)
|
||||||
|
expect(Array.from(yield* (yield* SessionV2.Service).active)).toEqual([sessionID])
|
||||||
|
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("delegates execution continuation through SessionExecution", () =>
|
it.effect("delegates execution continuation through SessionExecution", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|||||||
@@ -66,6 +66,78 @@ describe("SessionRunCoordinator", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("snapshots only active executions", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
const secondStarted = yield* Deferred.make<void>()
|
||||||
|
const firstGate = yield* Deferred.make<void>()
|
||||||
|
const secondGate = yield* Deferred.make<void>()
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: (key: string) =>
|
||||||
|
Deferred.succeed(key === "first" ? firstStarted : secondStarted, undefined).pipe(
|
||||||
|
Effect.andThen(Deferred.await(key === "first" ? firstGate : secondGate)),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||||
|
const first = yield* coordinator.run("first").pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(firstStarted)
|
||||||
|
expect(Array.from(yield* coordinator.active)).toEqual(["first"])
|
||||||
|
|
||||||
|
const second = yield* coordinator.run("second").pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(secondStarted)
|
||||||
|
expect(Array.from(yield* coordinator.active)).toEqual(["first", "second"])
|
||||||
|
|
||||||
|
yield* Deferred.succeed(firstGate, undefined)
|
||||||
|
yield* Fiber.join(first)
|
||||||
|
expect(Array.from(yield* coordinator.active)).toEqual(["second"])
|
||||||
|
yield* Deferred.succeed(secondGate, undefined)
|
||||||
|
yield* Fiber.join(second)
|
||||||
|
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("cleans active executions after failure and defect", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const failure = new Error("failed")
|
||||||
|
const defect = new Error("defect")
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
|
||||||
|
expect(Exit.isFailure(failed) && Cause.hasFails(failed.cause)).toBeTrue()
|
||||||
|
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||||
|
|
||||||
|
const died = yield* coordinator.run("defect").pipe(Effect.exit)
|
||||||
|
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
|
||||||
|
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("cleans active executions when its scope closes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
const coordinator = yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||||
|
})
|
||||||
|
yield* coordinator.wake("session")
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
expect(Array.from(yield* coordinator.active)).toEqual(["session"])
|
||||||
|
return coordinator
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("coalesces wakes received during active execution", () =>
|
it.effect("coalesces wakes received during active execution", () =>
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -166,6 +238,7 @@ describe("SessionRunCoordinator", () => {
|
|||||||
|
|
||||||
const exit = yield* Fiber.await(resumed)
|
const exit = yield* Fiber.await(resumed)
|
||||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||||
|
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||||
expect(runs).toBe(1)
|
expect(runs).toBe(1)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -327,6 +327,78 @@ Recent work
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("drops provider-native continuation metadata from failed assistant turns", () => {
|
||||||
|
const messages = toLLMMessages(
|
||||||
|
[
|
||||||
|
SessionMessage.Assistant.make({
|
||||||
|
id: id("assistant-failed"),
|
||||||
|
type: "assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
|
content: [
|
||||||
|
SessionMessage.AssistantReasoning.make({
|
||||||
|
type: "reasoning",
|
||||||
|
id: "reasoning-failed",
|
||||||
|
text: "Partial thought",
|
||||||
|
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
|
||||||
|
}),
|
||||||
|
SessionMessage.AssistantTool.make({
|
||||||
|
type: "tool",
|
||||||
|
id: "hosted-failed",
|
||||||
|
name: "web_search",
|
||||||
|
provider: {
|
||||||
|
executed: true,
|
||||||
|
metadata: { openai: { itemId: "call_failed" } },
|
||||||
|
resultMetadata: { openai: { itemId: "result_failed" } },
|
||||||
|
},
|
||||||
|
state: SessionMessage.ToolStateError.make({
|
||||||
|
status: "error",
|
||||||
|
input: { query: "Effect" },
|
||||||
|
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||||
|
content: [],
|
||||||
|
structured: {},
|
||||||
|
}),
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
finish: "error",
|
||||||
|
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(messages[0]?.content).toEqual([
|
||||||
|
{ type: "reasoning", text: "Partial thought", providerMetadata: undefined },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "hosted-failed",
|
||||||
|
name: "web_search",
|
||||||
|
input: { query: "Effect" },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "hosted-failed",
|
||||||
|
name: "web_search",
|
||||||
|
result: {
|
||||||
|
type: "error",
|
||||||
|
value: {
|
||||||
|
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||||
|
content: [],
|
||||||
|
structured: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providerExecuted: true,
|
||||||
|
cache: undefined,
|
||||||
|
metadata: undefined,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("drops provider-native continuation metadata after a model switch", () => {
|
test("drops provider-native continuation metadata after a model switch", () => {
|
||||||
const messages = toLLMMessages(
|
const messages = toLLMMessages(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ const execution = Layer.effect(
|
|||||||
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
||||||
})
|
})
|
||||||
return SessionExecution.Service.of({
|
return SessionExecution.Service.of({
|
||||||
|
active: coordinator.active,
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
interrupt: coordinator.interrupt,
|
interrupt: coordinator.interrupt,
|
||||||
|
|||||||
@@ -17,14 +17,7 @@ const capture = () => {
|
|||||||
const events = EventV2.Service.of({
|
const events = EventV2.Service.of({
|
||||||
publish: (definition, data) =>
|
publish: (definition, data) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const event = {
|
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
|
||||||
id: EventV2.ID.create(),
|
|
||||||
type: definition.type,
|
|
||||||
...(definition.durable
|
|
||||||
? { durable: { aggregateID: sessionID, seq: published.length, version: definition.durable.version } }
|
|
||||||
: {}),
|
|
||||||
data,
|
|
||||||
} as EventV2.Payload<typeof definition>
|
|
||||||
published.push({
|
published.push({
|
||||||
type: definition.durable
|
type: definition.durable
|
||||||
? EventV2.versionedType(definition.type, definition.durable.version)
|
? EventV2.versionedType(definition.type, definition.durable.version)
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ const execution = Layer.effect(
|
|||||||
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
||||||
})
|
})
|
||||||
return SessionExecution.Service.of({
|
return SessionExecution.Service.of({
|
||||||
|
active: coordinator.active,
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
interrupt: coordinator.interrupt,
|
interrupt: coordinator.interrupt,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { tmpdir } from "./fixture/tmpdir"
|
|||||||
|
|
||||||
const base = "https://skills.example.test/catalog/"
|
const base = "https://skills.example.test/catalog/"
|
||||||
|
|
||||||
async function pull(skills: unknown[], files: Record<string, string> = {}) {
|
async function pull(skills: unknown[], files: Record<string, string> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
|
||||||
const tmp = await tmpdir()
|
const tmp = cache ?? (await tmpdir())
|
||||||
const requests: string[] = []
|
const requests: string[] = []
|
||||||
const http = Layer.succeed(
|
const http = Layer.succeed(
|
||||||
HttpClient.HttpClient,
|
HttpClient.HttpClient,
|
||||||
@@ -101,4 +101,64 @@ describe("SkillDiscovery.pull", () => {
|
|||||||
await result.tmp[Symbol.asyncDispose]()
|
await result.tmp[Symbol.asyncDispose]()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("refreshes cached files when the version changes", async () => {
|
||||||
|
const tmp = await tmpdir()
|
||||||
|
try {
|
||||||
|
const first = await pull(
|
||||||
|
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
|
||||||
|
{
|
||||||
|
[`${base}deploy/SKILL.md`]: "# Old",
|
||||||
|
},
|
||||||
|
tmp,
|
||||||
|
)
|
||||||
|
const second = await pull(
|
||||||
|
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
||||||
|
{
|
||||||
|
[`${base}deploy/SKILL.md`]: "# New",
|
||||||
|
},
|
||||||
|
tmp,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
|
||||||
|
expect(second.requests).toContain(`${base}deploy/SKILL.md`)
|
||||||
|
const third = await pull(
|
||||||
|
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
||||||
|
{ [`${base}deploy/SKILL.md`]: "# Ignored" },
|
||||||
|
tmp,
|
||||||
|
)
|
||||||
|
expect(third.requests).toEqual([`${base}index.json`])
|
||||||
|
} finally {
|
||||||
|
await tmp[Symbol.asyncDispose]()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("publishes complete updates and removes stale files", async () => {
|
||||||
|
const tmp = await tmpdir()
|
||||||
|
try {
|
||||||
|
const first = await pull(
|
||||||
|
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
|
||||||
|
{
|
||||||
|
[`${base}deploy/SKILL.md`]: "# Old",
|
||||||
|
[`${base}deploy/old.md`]: "old reference",
|
||||||
|
},
|
||||||
|
tmp,
|
||||||
|
)
|
||||||
|
const root = first.directories[0]
|
||||||
|
|
||||||
|
await pull(
|
||||||
|
[{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
|
||||||
|
{ [`${base}deploy/SKILL.md`]: "# Partial" },
|
||||||
|
tmp,
|
||||||
|
)
|
||||||
|
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
|
||||||
|
expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
|
||||||
|
|
||||||
|
await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp)
|
||||||
|
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
|
||||||
|
expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
|
||||||
|
} finally {
|
||||||
|
await tmp[Symbol.asyncDispose]()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user