mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eff735d3b | |||
| 2caa016fe1 | |||
| 6861fedd09 | |||
| 3d072112ce | |||
| bdfea046db | |||
| a2d08fb63b | |||
| 5a55135d89 | |||
| 8870d36e0f | |||
| eb923c27ca | |||
| 9903abc704 | |||
| 225a1fbf35 | |||
| 93159bccbf | |||
| fab8ec4f54 | |||
| a3035c53ea | |||
| 36c416e143 | |||
| e1e0304a96 | |||
| 71c3a7c8f2 | |||
| ecdfff5a42 | |||
| 4b948c5d74 | |||
| cd56c51e2d | |||
| 43e39d7f68 | |||
| 7a17925495 | |||
| 5acb2530b4 | |||
| 44a6787359 | |||
| 42e6b7db32 | |||
| 2c02f8bace | |||
| 2ec20e576b | |||
| 20f47fec7a | |||
| 65210f2d97 | |||
| af0b7ffae7 | |||
| 0e2dd4ad15 | |||
| 11d2f3e5f8 | |||
| 1ac6b4bec4 | |||
| 0befd9b049 | |||
| 850a0dfe7c | |||
| 18a419e634 | |||
| 82f47cb312 | |||
| 7b1fe33ed3 | |||
| c72cca8def | |||
| 05ce6bc275 | |||
| 1de7368580 | |||
| 36f901588a | |||
| 92f1a17b67 | |||
| ebf4007efd | |||
| 1aea999d7c | |||
| 4a8fee3b2d | |||
| 0d3e0fc8f4 | |||
| f254476043 | |||
| 639c8e6475 | |||
| e3bfd4cce6 | |||
| c2882268d6 | |||
| 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 | |||
| 77f2d2227a | |||
| 7854f5b9f7 | |||
| 5565a8ee10 | |||
| 9ad7067971 |
@@ -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
-2
@@ -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.
|
||||||
@@ -159,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:",
|
||||||
@@ -79,6 +83,7 @@
|
|||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"ai-gateway-provider": "3.1.2",
|
"ai-gateway-provider": "3.1.2",
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
@@ -986,6 +992,7 @@
|
|||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
@@ -1451,6 +1458,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 +2308,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 +3804,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=="],
|
||||||
|
|
||||||
@@ -5295,6 +5318,8 @@
|
|||||||
|
|
||||||
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
||||||
|
|
||||||
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
||||||
|
|
||||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
|
"x86_64-linux": "sha256-A3dgTsHBfIn+trqbserr1CAERfKeJjTiUxZxzkz4tPU=",
|
||||||
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
|
"aarch64-linux": "sha256-G72CWFF7gTNHxmq5TFMRFiffqp9Voqcoe1oz8Kk47vU=",
|
||||||
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
|
"aarch64-darwin": "sha256-7/lxVQWuebPDFo+cuel/S1IpwLuTxFYIUYBsNimqcI4=",
|
||||||
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
|
"x86_64-darwin": "sha256-GTRYa17f0SpwrWh+M3Q2MTIUzzGINknKMmRzYUgqMus="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
}
|
||||||
@@ -38,12 +38,17 @@
|
|||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
"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 +77,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:",
|
||||||
|
|||||||
+47
-20
@@ -16,6 +16,7 @@ import {
|
|||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
|
createRenderEffect,
|
||||||
createResource,
|
createResource,
|
||||||
createSignal,
|
createSignal,
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
@@ -32,12 +33,12 @@ 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"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider, useNotification } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
@@ -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)
|
||||||
@@ -312,9 +316,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
|||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</NotificationProvider>
|
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
@@ -341,13 +343,23 @@ function NewAppLayout(props: ParentProps) {
|
|||||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</NotificationProvider>
|
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||||
|
const notification = useNotification()
|
||||||
|
createEffect(() => {
|
||||||
|
const sessionID = props.sessionID?.()
|
||||||
|
if (!notification.ready() || !sessionID) return
|
||||||
|
if (notification.session.unseenCount(sessionID) === 0) return
|
||||||
|
notification.session.markViewed(sessionID)
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function SessionProviders(props: ParentProps) {
|
function SessionProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<TerminalProvider>
|
<TerminalProvider>
|
||||||
@@ -556,11 +568,13 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
|
<NotificationProvider>
|
||||||
<ServerShell>
|
<ServerShell>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||||
</Show>
|
</Show>
|
||||||
</ServerShell>
|
</ServerShell>
|
||||||
|
</NotificationProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -588,18 +602,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
|
promptInputs: undefined as undefined | Record<string, string>,
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
@@ -73,6 +74,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
| { type: "method.select"; index: number }
|
| { type: "method.select"; index: number }
|
||||||
| { type: "method.reset" }
|
| { type: "method.reset" }
|
||||||
| { type: "auth.prompt" }
|
| { type: "auth.prompt" }
|
||||||
|
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
@@ -83,6 +85,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
if (action.type === "method.select") {
|
if (action.type === "method.select") {
|
||||||
draft.methodIndex = action.index
|
draft.methodIndex = action.index
|
||||||
draft.authorization = undefined
|
draft.authorization = undefined
|
||||||
|
draft.promptInputs = undefined
|
||||||
draft.state = undefined
|
draft.state = undefined
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
return
|
return
|
||||||
@@ -90,6 +93,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
if (action.type === "method.reset") {
|
if (action.type === "method.reset") {
|
||||||
draft.methodIndex = undefined
|
draft.methodIndex = undefined
|
||||||
draft.authorization = undefined
|
draft.authorization = undefined
|
||||||
|
draft.promptInputs = undefined
|
||||||
draft.state = undefined
|
draft.state = undefined
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
return
|
return
|
||||||
@@ -99,6 +103,12 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (action.type === "auth.inputs") {
|
||||||
|
draft.promptInputs = action.inputs
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action.type === "auth.pending") {
|
if (action.type === "auth.pending") {
|
||||||
draft.state = "pending"
|
draft.state = "pending"
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
@@ -151,6 +161,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
dispatch({ type: "method.select", index })
|
dispatch({ type: "method.select", index })
|
||||||
|
|
||||||
|
if (method.type === "api" && method.prompts?.length) {
|
||||||
|
if (!inputs) {
|
||||||
|
dispatch({ type: "auth.prompt" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch({ type: "auth.inputs", inputs })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
if (method.prompts?.length && !inputs) {
|
if (method.prompts?.length && !inputs) {
|
||||||
dispatch({ type: "auth.prompt" })
|
dispatch({ type: "auth.prompt" })
|
||||||
@@ -190,7 +209,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function OAuthPromptsView() {
|
function AuthPromptsView() {
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
value: {} as Record<string, string>,
|
value: {} as Record<string, string>,
|
||||||
index: 0,
|
index: 0,
|
||||||
@@ -198,8 +217,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
|
|
||||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||||
const value = method()
|
const value = method()
|
||||||
if (value?.type !== "oauth") return []
|
return value?.prompts ?? []
|
||||||
return value.prompts ?? []
|
|
||||||
})
|
})
|
||||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
if (!prompt.when) return true
|
if (!prompt.when) return true
|
||||||
@@ -230,6 +248,10 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
setFormStore("index", next)
|
setFormStore("index", next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (method()?.type === "api") {
|
||||||
|
dispatch({ type: "auth.inputs", inputs: value })
|
||||||
|
return
|
||||||
|
}
|
||||||
await selectMethod(store.methodIndex, value)
|
await selectMethod(store.methodIndex, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +436,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: apiKey,
|
key: apiKey,
|
||||||
|
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await complete()
|
await complete()
|
||||||
@@ -622,7 +645,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "prompt"}>
|
<Match when={store.state === "prompt"}>
|
||||||
<OAuthPromptsView />
|
<AuthPromptsView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "error"}>
|
<Match when={store.state === "error"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const ModelList: Component<{
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
gutter={12}
|
||||||
|
openDelay={0}
|
||||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||||
>
|
>
|
||||||
{node}
|
{node}
|
||||||
|
|||||||
@@ -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,9 +32,11 @@ 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 { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -70,8 +68,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 +95,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 +165,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 +214,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 +341,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,
|
||||||
@@ -1360,9 +1345,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agentsLoading = () => props.controls.agents.loading
|
const agentsLoading = () => props.controls.agents.loading
|
||||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
|
||||||
const providersLoading = () => props.controls.model.loading
|
const providersLoading = () => props.controls.model.loading
|
||||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
|
||||||
|
|
||||||
const [promptReady] = createResource(
|
const [promptReady] = createResource(
|
||||||
() => prompt.ready.promise,
|
() => prompt.ready.promise,
|
||||||
@@ -1376,9 +1361,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||||
loading: providersLoading(),
|
loading: providersLoading(),
|
||||||
|
shouldAnimate: providersShouldFadeIn(),
|
||||||
paid: props.controls.model.paid,
|
paid: props.controls.model.paid,
|
||||||
title: language.t("command.model.choose"),
|
title: language.t("command.model.choose"),
|
||||||
keybind: command.keybind("model.choose"),
|
keybind: command.keybindParts("model.choose"),
|
||||||
model: props.controls.model.selection,
|
model: props.controls.model.selection,
|
||||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||||
@@ -1392,75 +1378,10 @@ 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.keybindParts("agent.cycle"),
|
||||||
options: props.controls.agents.options,
|
options: props.controls.agents.options,
|
||||||
current: props.controls.agents.current,
|
current: props.controls.agents.current,
|
||||||
style: control(),
|
style: control(),
|
||||||
@@ -1469,15 +1390,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)}
|
||||||
@@ -1587,10 +1499,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<div class="flex h-11 items-center px-2">
|
<div class="flex h-11 items-center px-2">
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-0">
|
<div class="flex min-w-0 flex-1 items-center gap-0">
|
||||||
{fileAttachmentInput()}
|
{fileAttachmentInput()}
|
||||||
<TooltipKeybind
|
<TooltipV2
|
||||||
placement="top"
|
placement="top"
|
||||||
title={language.t("prompt.action.attachFile")}
|
value={
|
||||||
keybind={command.keybind("file.attach")}
|
<>
|
||||||
|
{language.t("prompt.action.attachFile")}
|
||||||
|
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-attach"
|
data-action="prompt-attach"
|
||||||
@@ -1604,27 +1520,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
aria-label={language.t("prompt.action.attachFile")}
|
aria-label={language.t("prompt.action.attachFile")}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipV2>
|
||||||
<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={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
classList={{
|
classList={{
|
||||||
|
"animate-in fade-in": providersShouldFadeIn(),
|
||||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipV2
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
title={language.t("command.model.variant.cycle")}
|
value={
|
||||||
keybind={command.keybind("model.variant.cycle")}
|
<>
|
||||||
|
{language.t("command.model.variant.cycle")}
|
||||||
|
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
size="normal"
|
size="normal"
|
||||||
@@ -1642,11 +1561,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-submit"
|
data-action="prompt-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -1661,14 +1580,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</TooltipV2>
|
||||||
</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>
|
||||||
@@ -1863,7 +1777,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={!agentsLoading()}>
|
<Show when={!agentsLoading()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-agent-control"
|
data-component="prompt-agent-control"
|
||||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -1892,7 +1806,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={store.mode !== "shell"}>
|
<Show when={store.mode !== "shell"}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-model-control"
|
data-component="prompt-model-control"
|
||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.controls.model.paid}
|
when={props.controls.model.paid}
|
||||||
@@ -1971,7 +1885,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={showVariantControl()}>
|
<Show when={showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -2010,40 +1924,9 @@ 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[]
|
||||||
options: string[]
|
options: string[]
|
||||||
current: string
|
current: string
|
||||||
style: JSX.CSSProperties | undefined
|
style: JSX.CSSProperties | undefined
|
||||||
@@ -2052,9 +1935,10 @@ type ComposerAgentControlState = {
|
|||||||
|
|
||||||
type ComposerModelControlState = {
|
type ComposerModelControlState = {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
shouldAnimate: boolean
|
||||||
paid: boolean
|
paid: boolean
|
||||||
title: string
|
title: string
|
||||||
keybind: string
|
keybind: string[]
|
||||||
model: ReturnType<typeof useLocal>["model"]
|
model: ReturnType<typeof useLocal>["model"]
|
||||||
providerID?: string
|
providerID?: string
|
||||||
modelName: string
|
modelName: string
|
||||||
@@ -2063,97 +1947,22 @@ 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">
|
||||||
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
|
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
|
||||||
<Icon name="sliders" size="small" />
|
<Icon name="sliders" size="small" />
|
||||||
</div>
|
</div>
|
||||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{props.state.title}
|
||||||
|
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
size="normal"
|
size="normal"
|
||||||
options={props.state.options}
|
options={props.state.options}
|
||||||
@@ -2165,7 +1974,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
|||||||
triggerProps={{ "data-action": "prompt-agent" }}
|
triggerProps={{ "data-action": "prompt-agent" }}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2176,13 +1985,23 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
<Show
|
<Show
|
||||||
when={props.state.paid}
|
when={props.state.paid}
|
||||||
fallback={
|
fallback={
|
||||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{props.state.title}
|
||||||
|
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
data-action="prompt-model"
|
data-action="prompt-model"
|
||||||
as="div"
|
as="div"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="normal"
|
size="normal"
|
||||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||||
|
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||||
style={props.state.style}
|
style={props.state.style}
|
||||||
onClick={props.state.onUnpaidClick}
|
onClick={props.state.onUnpaidClick}
|
||||||
>
|
>
|
||||||
@@ -2200,10 +2019,19 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipV2>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{props.state.title}
|
||||||
|
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
|
||||||
<ModelSelectorPopover
|
<ModelSelectorPopover
|
||||||
model={props.state.model}
|
model={props.state.model}
|
||||||
triggerAs={Button}
|
triggerAs={Button}
|
||||||
@@ -2213,6 +2041,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
style: props.state.style,
|
style: props.state.style,
|
||||||
class:
|
class:
|
||||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||||
|
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||||
"data-action": "prompt-model",
|
"data-action": "prompt-model",
|
||||||
}}
|
}}
|
||||||
onClose={props.state.onClose}
|
onClose={props.state.onClose}
|
||||||
@@ -2231,7 +2060,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||||
</span>
|
</span>
|
||||||
</ModelSelectorPopover>
|
</ModelSelectorPopover>
|
||||||
</TooltipKeybind>
|
</TooltipV2>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
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(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
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().length > 1 ? undefined : 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
|
||||||
|
},
|
||||||
|
activeAction() {
|
||||||
|
return store.active.startsWith(actionPrefix)
|
||||||
|
},
|
||||||
|
setSearchRef(el: HTMLInputElement) {
|
||||||
|
searchRef = el
|
||||||
|
},
|
||||||
|
focusSearch() {
|
||||||
|
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
||||||
|
|
||||||
|
export function PromptProjectSelector(props: {
|
||||||
|
controller: PromptProjectController
|
||||||
|
placement?: "bottom" | "bottom-start"
|
||||||
|
}) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if (props.controller.activeAction() && props.controller.servers().length > 1) {
|
||||||
|
const item = activeItem()
|
||||||
|
item?.focus()
|
||||||
|
item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
|
||||||
|
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={props.placement ?? "bottom"}
|
||||||
|
gutter={4}
|
||||||
|
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()
|
||||||
|
.filter((server) =>
|
||||||
|
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
|
<div class="flex flex-col p-0.5">
|
||||||
|
<Show
|
||||||
|
when={props.controller.servers().length > 1}
|
||||||
|
fallback={
|
||||||
|
<ProjectAction
|
||||||
|
server={props.controller.servers()[0]?.key}
|
||||||
|
controller={props.controller}
|
||||||
|
onSelect={selectAction}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger
|
||||||
|
id={props.controller.actionKey()}
|
||||||
|
data-option-key={props.controller.actionKey()}
|
||||||
|
class={projectActionClass}
|
||||||
|
classList={{
|
||||||
|
"!bg-v2-overlay-simple-overlay-hover": props.controller.active() === props.controller.actionKey(),
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size="small" />
|
||||||
|
<span data-slot="dropdown-menu-item-label" class="min-w-0 flex-1 truncate leading-5">
|
||||||
|
{props.controller.labels.add()}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-right" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||||
|
<For each={props.controller.servers()}>
|
||||||
|
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
|
||||||
|
</For>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</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-1.5 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(),
|
||||||
|
"text-v2-text-text-muted": 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectActionClass =
|
||||||
|
"h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
|
||||||
|
<DropdownMenu.ItemLabel class="min-w-0 flex-1 truncate leading-5">{props.server.name}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
export function PromptWorkspaceSelector(props: {
|
||||||
|
value: string
|
||||||
|
projectRoot: string
|
||||||
|
workspaces: string[]
|
||||||
|
branch?: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
onDone: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
let pending: string | undefined
|
||||||
|
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||||
|
const icon = () => {
|
||||||
|
if (selected() === "main") return "monitor"
|
||||||
|
if (selected() === "create") return "workspace-new"
|
||||||
|
return "workspace"
|
||||||
|
}
|
||||||
|
const select = (value: string) => {
|
||||||
|
pending = value
|
||||||
|
}
|
||||||
|
const onOpenChange = (open: boolean) => {
|
||||||
|
if (open) return
|
||||||
|
const value = pending
|
||||||
|
pending = undefined
|
||||||
|
if (value) props.onChange(value)
|
||||||
|
props.onDone()
|
||||||
|
}
|
||||||
|
const label = () => {
|
||||||
|
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
|
||||||
|
if (props.value === "create") return language.t("workspace.new")
|
||||||
|
return getFilename(props.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
|
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||||
|
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||||
|
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{label()}</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content class="w-[180px]">
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||||
|
<MenuV2.Item onSelect={() => select("main")}>
|
||||||
|
<IconV2 name="monitor" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||||
|
<Show when={selected() === "main"}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
<MenuV2.Item onSelect={() => select("create")}>
|
||||||
|
<IconV2 name="workspace-new" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||||
|
<Show when={selected() === "create"}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Group>
|
||||||
|
<Show when={props.workspaces.length > 0}>
|
||||||
|
<MenuV2.Separator />
|
||||||
|
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||||
|
<MenuV2.SubTrigger>
|
||||||
|
<IconV2 name="workspace" />
|
||||||
|
{language.t("session.new.workspace.existing")}
|
||||||
|
</MenuV2.SubTrigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.SubContent class="max-w-[200px]">
|
||||||
|
<For each={props.workspaces}>
|
||||||
|
{(workspace) => (
|
||||||
|
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||||
|
<IconV2 name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||||
|
<Show when={selected() === workspace}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</MenuV2.SubContent>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2.Sub>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
|
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||||
|
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{props.branch || "main"}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
|
|
||||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||||
const creating = createMemo(() => {
|
const creating = createMemo(() => {
|
||||||
|
const route = layout.route()
|
||||||
|
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
|
||||||
if (!params.dir) return false
|
if (!params.dir) return false
|
||||||
if (params.id) return false
|
if (params.id) return false
|
||||||
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
||||||
@@ -319,6 +321,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 }, "")
|
||||||
@@ -460,7 +468,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
}}
|
}}
|
||||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||||
/>
|
/>
|
||||||
<Show when={!(creating() && params.dir)}>
|
<Show when={!creating()}>
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
value={
|
value={
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import type { ServerSDK } from "./server-sdk"
|
||||||
import { useServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
@@ -12,6 +12,11 @@ import { decode64 } from "@/utils/base64"
|
|||||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { playSoundById } from "@/utils/sound"
|
import { playSoundById } from "@/utils/sound"
|
||||||
|
import { useGlobal } from "./global"
|
||||||
|
import { ServerConnection, useServer } from "./server"
|
||||||
|
import { type DraftTab, useTabs } from "./tabs"
|
||||||
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
type NotificationBase = {
|
type NotificationBase = {
|
||||||
directory?: string
|
directory?: string
|
||||||
@@ -107,21 +112,115 @@ function buildNotificationIndex(list: Notification[]) {
|
|||||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||||
name: "Notification",
|
name: "Notification",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||||
const serverSDK = useServerSDK()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const serverSync = useServerSync()
|
const global = useGlobal()
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const owner = getOwner()
|
||||||
|
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
|
||||||
|
|
||||||
|
const activeServer = createMemo(() => {
|
||||||
|
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||||
|
if (search.draftId) {
|
||||||
|
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||||
|
if (draft) return draft.server
|
||||||
|
}
|
||||||
|
return server.key
|
||||||
|
})
|
||||||
|
const activeDirectory = createMemo(() => decode64(params.dir))
|
||||||
|
const activeSession = createMemo(() => params.id)
|
||||||
|
|
||||||
|
const ensure = (key: ServerConnection.Key) => {
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||||
|
if (!conn) throw new Error(`Notification server not found: ${key}`)
|
||||||
|
const ctx = global.ensureServerCtx(conn)
|
||||||
|
const existing = states.get(ctx.sdk.scope)
|
||||||
|
if (existing) return existing.state
|
||||||
|
const root = createRoot(
|
||||||
|
(dispose) => ({
|
||||||
|
dispose,
|
||||||
|
state: createServerNotificationState({
|
||||||
|
sdk: ctx.sdk,
|
||||||
|
sync: ctx.sync,
|
||||||
|
active: () => server.scope(activeServer()) === ctx.sdk.scope,
|
||||||
|
directory: activeDirectory,
|
||||||
|
sessionID: activeSession,
|
||||||
|
platform,
|
||||||
|
settings,
|
||||||
|
language,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
owner ?? undefined,
|
||||||
|
)
|
||||||
|
states.set(ctx.sdk.scope, root)
|
||||||
|
return root.state
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
|
||||||
|
states.forEach((value, scope) => {
|
||||||
|
if (scopes.has(scope)) return
|
||||||
|
value.dispose()
|
||||||
|
states.delete(scope)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||||
|
|
||||||
|
const selected = () => ensure(activeServer())
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: () => selected().ready(),
|
||||||
|
ensureServerState: ensure,
|
||||||
|
session: {
|
||||||
|
all: (session: string) => selected().session.all(session),
|
||||||
|
unseen: (session: string) => selected().session.unseen(session),
|
||||||
|
unseenCount: (session: string) => selected().session.unseenCount(session),
|
||||||
|
unseenHasError: (session: string) => selected().session.unseenHasError(session),
|
||||||
|
markViewed: (session: string) => selected().session.markViewed(session),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
all: (directory: string) => selected().project.all(directory),
|
||||||
|
unseen: (directory: string) => selected().project.unseen(directory),
|
||||||
|
unseenCount: (directory: string) => selected().project.unseenCount(directory),
|
||||||
|
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
|
||||||
|
markViewed: (directory: string) => selected().project.markViewed(directory),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type NotificationState = ReturnType<typeof createServerNotificationState>
|
||||||
|
|
||||||
|
function createServerNotificationState(input: {
|
||||||
|
sdk: ServerSDK
|
||||||
|
sync: ServerSync
|
||||||
|
active: Accessor<boolean>
|
||||||
|
directory: Accessor<string | undefined>
|
||||||
|
sessionID: Accessor<string | undefined>
|
||||||
|
platform: ReturnType<typeof usePlatform>
|
||||||
|
settings: ReturnType<typeof useSettings>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const serverSDK = () => input.sdk
|
||||||
|
const serverSync = () => input.sync
|
||||||
|
const platform = input.platform
|
||||||
|
const settings = input.settings
|
||||||
|
const language = input.language
|
||||||
|
|
||||||
const empty: Notification[] = []
|
const empty: Notification[] = []
|
||||||
|
|
||||||
const currentDirectory = createMemo(() => {
|
const currentDirectory = input.directory
|
||||||
return props.directory?.() ?? decode64(params.dir)
|
const currentSession = input.sessionID
|
||||||
})
|
|
||||||
|
|
||||||
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
|
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||||
@@ -217,12 +316,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||||
|
if (!input.active()) return false
|
||||||
const activeDirectory = currentDirectory()
|
const activeDirectory = currentDirectory()
|
||||||
const activeSession = currentSession()
|
const activeSession = currentSession()
|
||||||
if (!activeDirectory) return false
|
|
||||||
if (!activeSession) return false
|
if (!activeSession) return false
|
||||||
if (!sessionID) return false
|
if (!sessionID) return false
|
||||||
if (directory !== activeDirectory) return false
|
if (activeDirectory && directory !== activeDirectory) return false
|
||||||
return sessionID === activeSession
|
return sessionID === activeSession
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,5 +468,4 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
})
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
@@ -15,19 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("coalesceServerEvents", () => {
|
describe("coalesceServerEvents", () => {
|
||||||
const delta = (value: string, field = "text") => ({
|
const delta = (value: string, field = "text", partID = "part") => ({
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
payload: {
|
payload: {
|
||||||
type: "message.part.delta",
|
type: "message.part.delta",
|
||||||
properties: { messageID: "msg", partID: "part", field, delta: value },
|
properties: { messageID: "msg", partID, field, delta: value },
|
||||||
} as Event,
|
} as Event,
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges adjacent deltas for the same field", () => {
|
test("merges adjacent deltas for the same field", () => {
|
||||||
const result = coalesceServerEvents([delta("hello "), delta("world")])
|
const first = delta("hello ")
|
||||||
|
const second = delta("world")
|
||||||
|
first.payload.id = "first"
|
||||||
|
second.payload.id = "second"
|
||||||
|
const result = coalesceServerEvents([first, second])
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
|
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves event boundaries and distinct fields", () => {
|
test("preserves event boundaries and distinct fields", () => {
|
||||||
@@ -45,9 +49,112 @@ describe("coalesceServerEvents", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("drops stale deltas", () => {
|
test("preserves event ID order across interleaved deltas", () => {
|
||||||
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
|
const first = delta("a")
|
||||||
|
const other = delta("b", "text", "other")
|
||||||
|
const last = delta("c")
|
||||||
|
first.payload.id = "1"
|
||||||
|
other.payload.id = "2"
|
||||||
|
last.payload.id = "3"
|
||||||
|
|
||||||
expect(result).toEqual([])
|
const result = coalesceServerEvents([first, other, last])
|
||||||
|
|
||||||
|
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("enqueueServerEvent", () => {
|
||||||
|
const partUpdated = (text: string) =>
|
||||||
|
({
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session",
|
||||||
|
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
|
||||||
|
},
|
||||||
|
}) as Event
|
||||||
|
|
||||||
|
test("preserves part updates across message remove and re-add barriers", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
|
enqueue(partUpdated("old"))
|
||||||
|
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
|
||||||
|
enqueue({
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session",
|
||||||
|
info: {
|
||||||
|
id: "message",
|
||||||
|
sessionID: "session",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Event)
|
||||||
|
enqueue(partUpdated("new"))
|
||||||
|
|
||||||
|
expect(events.map((event) => event.payload.type)).toEqual([
|
||||||
|
"message.part.updated",
|
||||||
|
"message.removed",
|
||||||
|
"message.updated",
|
||||||
|
"message.part.updated",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves deltas after a replacement snapshot", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
|
enqueue(partUpdated("a"))
|
||||||
|
enqueue(partUpdated("ab"))
|
||||||
|
enqueue({
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
|
||||||
|
} as Event)
|
||||||
|
|
||||||
|
const result = coalesceServerEvents(events)
|
||||||
|
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
|
||||||
|
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
|
||||||
|
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves updates after session deletion", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
|
enqueue(partUpdated("old"))
|
||||||
|
enqueue({
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: { sessionID: "session", info: { id: "session" } },
|
||||||
|
} as Event)
|
||||||
|
enqueue(partUpdated("new"))
|
||||||
|
|
||||||
|
expect(events.map((event) => event.payload.type)).toEqual([
|
||||||
|
"message.part.updated",
|
||||||
|
"session.deleted",
|
||||||
|
"message.part.updated",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not coalesce edge-triggered session statuses", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (status: "retry" | "busy") =>
|
||||||
|
enqueueServerEvent(events, {
|
||||||
|
directory: "/repo",
|
||||||
|
payload: {
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session",
|
||||||
|
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
|
||||||
|
},
|
||||||
|
} as Event,
|
||||||
|
})
|
||||||
|
|
||||||
|
enqueue("retry")
|
||||||
|
enqueue("busy")
|
||||||
|
|
||||||
|
expect(events).toHaveLength(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,34 +17,56 @@ const isAbortError = (error: unknown) =>
|
|||||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||||
type QueuedServerEvent = { directory: string; payload: Event }
|
type QueuedServerEvent = { directory: string; payload: Event }
|
||||||
|
|
||||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
const coalescedKey = (event: QueuedServerEvent) => {
|
||||||
|
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||||
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
|
if (event.payload.type === "message.part.updated") {
|
||||||
const output: QueuedServerEvent[] = []
|
const part = event.payload.properties.part
|
||||||
const deltas = new Map<string, number>()
|
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
|
||||||
events.forEach((event) => {
|
|
||||||
if (stale && event.payload.type === "message.part.delta") {
|
|
||||||
const props = event.payload.properties
|
|
||||||
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
|
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
||||||
|
const key = coalescedKey(event)
|
||||||
|
const previous = queue[queue.length - 1]
|
||||||
|
if (key && previous && coalescedKey(previous) === key) {
|
||||||
|
queue[queue.length - 1] = event
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
queue.push(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||||
|
const output: QueuedServerEvent[] = []
|
||||||
|
events.forEach((event) => {
|
||||||
if (event.payload.type !== "message.part.delta") {
|
if (event.payload.type !== "message.part.delta") {
|
||||||
deltas.clear()
|
|
||||||
output.push(event)
|
output.push(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const props = event.payload.properties
|
const props = event.payload.properties
|
||||||
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
|
const previous = output[output.length - 1]
|
||||||
const index = deltas.get(id)
|
if (
|
||||||
const existing = index === undefined ? undefined : output[index]
|
!previous ||
|
||||||
if (!existing || existing.payload.type !== "message.part.delta") {
|
previous.payload.type !== "message.part.delta" ||
|
||||||
deltas.set(id, output.length)
|
previous.directory !== event.directory ||
|
||||||
|
previous.payload.properties.messageID !== props.messageID ||
|
||||||
|
previous.payload.properties.partID !== props.partID ||
|
||||||
|
previous.payload.properties.field !== props.field
|
||||||
|
) {
|
||||||
output.push({
|
output.push({
|
||||||
directory: event.directory,
|
directory: event.directory,
|
||||||
payload: { ...event.payload, properties: { ...props } },
|
payload: { ...event.payload, properties: { ...props } },
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
existing.payload.properties.delta += props.delta
|
output[output.length - 1] = {
|
||||||
|
directory: event.directory,
|
||||||
|
payload: {
|
||||||
|
...event.payload,
|
||||||
|
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
@@ -85,20 +107,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
|
|
||||||
let queue: Queued[] = []
|
let queue: Queued[] = []
|
||||||
let buffer: Queued[] = []
|
let buffer: Queued[] = []
|
||||||
const coalesced = new Map<string, number>()
|
|
||||||
const staleDeltas = new Set<string>()
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
|
||||||
const key = (directory: string, payload: Event) => {
|
|
||||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
|
||||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
|
||||||
if (payload.type === "message.part.updated") {
|
|
||||||
const part = payload.properties.part
|
|
||||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
timer = undefined
|
timer = undefined
|
||||||
@@ -106,15 +117,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
if (queue.length === 0) return
|
if (queue.length === 0) return
|
||||||
|
|
||||||
const events = queue
|
const events = queue
|
||||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
|
||||||
queue = buffer
|
queue = buffer
|
||||||
buffer = events
|
buffer = events
|
||||||
queue.length = 0
|
queue.length = 0
|
||||||
coalesced.clear()
|
|
||||||
staleDeltas.clear()
|
|
||||||
|
|
||||||
last = Date.now()
|
last = Date.now()
|
||||||
const output = coalesceServerEvents(events, skip)
|
const output = coalesceServerEvents(events)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||||
})
|
})
|
||||||
@@ -184,28 +192,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
resetHeartbeat()
|
resetHeartbeat()
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
|
if (event.payload.type !== "sync") {
|
||||||
const directory = event.directory ?? "global"
|
const directory = event.directory ?? "global"
|
||||||
if (event.payload.type === "sync") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = event.payload as Event
|
const payload = event.payload as Event
|
||||||
|
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||||
const k = key(directory, payload)
|
|
||||||
if (k) {
|
|
||||||
const i = coalesced.get(k)
|
|
||||||
if (i !== undefined) {
|
|
||||||
queue[i] = { directory, payload }
|
|
||||||
if (payload.type === "message.part.updated") {
|
|
||||||
const part = payload.properties.part
|
|
||||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
coalesced.set(k, queue.length)
|
|
||||||
}
|
|
||||||
queue.push({ directory, payload })
|
|
||||||
schedule()
|
|
||||||
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
yielded = Date.now()
|
yielded = Date.now()
|
||||||
@@ -269,6 +260,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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,44 +18,67 @@ import { rootSession } from "@/utils/session-route"
|
|||||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const initialMessagePageSize = 2
|
const initialMessagePageSize = 2
|
||||||
const historyMessagePageSize = 200
|
const historyMessagePageSize = 200
|
||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
type OptimisticItem = {
|
type OptimisticItem = {
|
||||||
message: Message
|
message: Message
|
||||||
parts: Part[]
|
parts: Part[]
|
||||||
|
confirmedParts?: Part[]
|
||||||
|
confirmedMessage?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
type MessagePage = {
|
||||||
if (!parts) return want.length === 0
|
session: Message[]
|
||||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
part: { id: string; part: Part[] }[]
|
||||||
|
cursor?: string
|
||||||
|
complete: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeOptimisticPage(
|
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||||
page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
|
type MessageLoadState = {
|
||||||
items: OptimisticItem[],
|
touchedMessages: Set<string>
|
||||||
) {
|
removedMessages: Set<string>
|
||||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
retainedMessages: Set<string>
|
||||||
|
touchedParts: Map<string, Set<string>>
|
||||||
|
deltaParts: Map<string, Set<string>>
|
||||||
|
carriedDeltaParts: Map<string, Set<string>>
|
||||||
|
removedParts: Map<string, Set<string>>
|
||||||
|
optimisticParts: Map<string, Set<string>>
|
||||||
|
orphanParents: Set<string>
|
||||||
|
clearedMessageParts: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||||
|
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||||
const session = [...page.session]
|
const session = [...page.session]
|
||||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||||
const confirmed: string[] = []
|
const observed: { messageID: string; parts: Part[] }[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||||
if (!result.found) session.splice(result.index, 0, item.message)
|
if (!result.found) session.splice(result.index, 0, item.message)
|
||||||
const current = part.get(item.message.id)
|
const current = part.get(item.message.id)
|
||||||
if (result.found && hasParts(current, item.parts)) {
|
const confirmed = result.found
|
||||||
confirmed.push(item.message.id)
|
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
|
||||||
continue
|
: []
|
||||||
}
|
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||||
part.set(item.message.id, merge(current ?? [], item.parts))
|
part.set(
|
||||||
|
item.message.id,
|
||||||
|
merge(
|
||||||
|
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
||||||
|
item.parts.filter((part) => !confirmed.includes(part)),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...page,
|
...page,
|
||||||
session,
|
session,
|
||||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
||||||
confirmed,
|
observed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +98,38 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
|||||||
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
|
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSession(client: OpencodeClient) {
|
function reconcileFetched<T extends { id: string }>(
|
||||||
|
fetched: T[],
|
||||||
|
current: readonly T[],
|
||||||
|
options: {
|
||||||
|
touched?: ReadonlySet<string>
|
||||||
|
retained?: ReadonlySet<string>
|
||||||
|
preserveUnfetched?: boolean | ((item: T) => boolean)
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const result = new Map(fetched.map((item) => [item.id, item]))
|
||||||
|
const live = new Map(current.map((item) => [item.id, item]))
|
||||||
|
if (options.preserveUnfetched) {
|
||||||
|
for (const item of current) {
|
||||||
|
if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item)))
|
||||||
|
result.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of options.retained ?? emptyIDs) {
|
||||||
|
if (result.has(id)) continue
|
||||||
|
const item = live.get(id)
|
||||||
|
if (item) result.set(id, item)
|
||||||
|
}
|
||||||
|
// Events observed while the request is pending are the freshest client state for those identities.
|
||||||
|
for (const id of options.touched ?? emptyIDs) {
|
||||||
|
const item = live.get(id)
|
||||||
|
if (item) result.set(id, item)
|
||||||
|
if (!item) result.delete(id)
|
||||||
|
}
|
||||||
|
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
||||||
const [data, setData] = createStore({
|
const [data, setData] = createStore({
|
||||||
info: {} as Record<string, Session | undefined>,
|
info: {} as Record<string, Session | undefined>,
|
||||||
session_status: {} as Record<string, SessionStatus>,
|
session_status: {} as Record<string, SessionStatus>,
|
||||||
@@ -95,10 +149,32 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||||
|
const messageLoads = new Map<string, MessageLoadState>()
|
||||||
|
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||||
|
const orphanParts = new Map<string, Set<string>>()
|
||||||
|
const removedMessages = new Map<string, Set<string>>()
|
||||||
|
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||||
|
const deleteMessageParts = (
|
||||||
|
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||||
|
messageID: string,
|
||||||
|
) => {
|
||||||
|
for (const part of cache.part[messageID] ?? []) {
|
||||||
|
delete cache.part_text_accum_delta[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
delete cache.part[messageID]
|
||||||
|
}
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const infoSeen = new Set<string>()
|
const infoSeen = new Set<string>()
|
||||||
const pinned = new Map<string, number>()
|
const pinned = new Map<string, number>()
|
||||||
const generations = new Map<string, number>()
|
const generations = new Map<string, object>()
|
||||||
|
const generation = (sessionID: string) => {
|
||||||
|
const current = generations.get(sessionID)
|
||||||
|
if (current) return current
|
||||||
|
const created = {}
|
||||||
|
generations.set(sessionID, created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
const [meta, setMeta] = createStore({
|
const [meta, setMeta] = createStore({
|
||||||
limit: {} as Record<string, number | undefined>,
|
limit: {} as Record<string, number | undefined>,
|
||||||
cursor: {} as Record<string, string | undefined>,
|
cursor: {} as Record<string, string | undefined>,
|
||||||
@@ -115,6 +191,11 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
const preserve = new Set([
|
const preserve = new Set([
|
||||||
...pinned.keys(),
|
...pinned.keys(),
|
||||||
...requests.keys(),
|
...requests.keys(),
|
||||||
|
...inflight.keys(),
|
||||||
|
...inflightDiff.keys(),
|
||||||
|
...inflightTodo.keys(),
|
||||||
|
...messageLoads.keys(),
|
||||||
|
...optimistic.keys(),
|
||||||
...Object.entries(data.permission)
|
...Object.entries(data.permission)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
@@ -138,6 +219,7 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
if (!preserve.has(sessionID)) stale.push(sessionID)
|
if (!preserve.has(sessionID)) stale.push(sessionID)
|
||||||
}
|
}
|
||||||
stale.forEach((sessionID) => infoSeen.delete(sessionID))
|
stale.forEach((sessionID) => infoSeen.delete(sessionID))
|
||||||
|
stale.forEach((sessionID) => generations.delete(sessionID))
|
||||||
setData(
|
setData(
|
||||||
"info",
|
"info",
|
||||||
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
|
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
|
||||||
@@ -151,21 +233,27 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
if (cached && !options?.force) return Promise.resolve(cached)
|
if (cached && !options?.force) return Promise.resolve(cached)
|
||||||
const pending = requests.get(sessionID)
|
const pending = requests.get(sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
const request = client.session.get({ sessionID }).then((result) => {
|
const request = client.session.get({ sessionID }).then((result) => {
|
||||||
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return result.data
|
if (generations.get(sessionID) !== active) return result.data
|
||||||
return remember(result.data)
|
return remember(result.data)
|
||||||
})
|
})
|
||||||
requests.set(sessionID, request)
|
requests.set(sessionID, request)
|
||||||
void request.then(
|
const cleanup = () => {
|
||||||
() => {
|
|
||||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||||
},
|
if (
|
||||||
() => {
|
generations.get(sessionID) === active &&
|
||||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
!data.info[sessionID] &&
|
||||||
},
|
!requests.has(sessionID) &&
|
||||||
|
!messageLoads.has(sessionID) &&
|
||||||
|
!inflight.has(sessionID) &&
|
||||||
|
!inflightDiff.has(sessionID) &&
|
||||||
|
!inflightTodo.has(sessionID)
|
||||||
)
|
)
|
||||||
|
generations.delete(sessionID)
|
||||||
|
}
|
||||||
|
void request.then(cleanup, cleanup)
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,15 +283,121 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
if (items.size === 0) optimistic.delete(sessionID)
|
if (items.size === 0) optimistic.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
|
||||||
|
const items = optimistic.get(sessionID)
|
||||||
|
const item = items?.get(messageID)
|
||||||
|
if (!items || !item) return
|
||||||
|
const parts = item.parts.filter((part) => part.id !== partID)
|
||||||
|
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
|
||||||
|
if (parts.length === 0) {
|
||||||
|
clearOptimistic(sessionID, messageID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
||||||
|
const items = optimistic.get(sessionID)
|
||||||
|
const item = items?.get(messageID)
|
||||||
|
if (!items || !item) return
|
||||||
|
const parts = item.parts.filter((value) => value.id !== part.id)
|
||||||
|
if (parts.length === 0) {
|
||||||
|
clearOptimistic(sessionID, messageID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.set(messageID, {
|
||||||
|
...item,
|
||||||
|
parts,
|
||||||
|
confirmedParts: merge(item.confirmedParts ?? [], [part]),
|
||||||
|
confirmedMessage: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
||||||
|
const items = optimistic.get(sessionID)
|
||||||
|
const item = items?.get(messageID)
|
||||||
|
if (!items || !item) return
|
||||||
|
const confirmed = new Set(confirmedParts.map((part) => part.id))
|
||||||
|
const parts = item.parts.filter((part) => !confirmed.has(part.id))
|
||||||
|
if (parts.length === 0) {
|
||||||
|
clearOptimistic(sessionID, messageID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.set(messageID, {
|
||||||
|
...item,
|
||||||
|
parts,
|
||||||
|
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
|
||||||
|
confirmedMessage: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
|
||||||
|
const load = messageLoads.get(sessionID)
|
||||||
|
if (!load) return
|
||||||
|
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
|
||||||
|
const messages = data.message[sessionID]
|
||||||
|
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||||
|
load.retainedMessages.add(messageID)
|
||||||
|
const parts = load.touchedParts.get(messageID)
|
||||||
|
if (parts) {
|
||||||
|
parts.add(partID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
load.touchedParts.set(messageID, new Set([partID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
|
||||||
|
load.touchedMessages.clear()
|
||||||
|
load.retainedMessages.clear()
|
||||||
|
load.touchedParts.clear()
|
||||||
|
load.carriedDeltaParts.clear()
|
||||||
|
load.clearedMessageParts.clear()
|
||||||
|
for (const messageID of load.removedMessages) {
|
||||||
|
load.touchedMessages.add(messageID)
|
||||||
|
load.clearedMessageParts.add(messageID)
|
||||||
|
}
|
||||||
|
for (const [messageID, parts] of load.deltaParts) {
|
||||||
|
load.touchedParts.set(messageID, new Set(parts))
|
||||||
|
load.carriedDeltaParts.set(messageID, new Set(parts))
|
||||||
|
const messages = data.message[sessionID]
|
||||||
|
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||||
|
load.retainedMessages.add(messageID)
|
||||||
|
}
|
||||||
|
for (const [messageID, parts] of load.removedParts) {
|
||||||
|
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||||
|
parts.forEach((partID) => touched.add(partID))
|
||||||
|
load.touchedParts.set(messageID, touched)
|
||||||
|
const messages = data.message[sessionID]
|
||||||
|
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||||
|
load.retainedMessages.add(messageID)
|
||||||
|
}
|
||||||
|
for (const [messageID, parts] of load.optimisticParts) {
|
||||||
|
load.removedMessages.delete(messageID)
|
||||||
|
load.clearedMessageParts.add(messageID)
|
||||||
|
load.touchedMessages.add(messageID)
|
||||||
|
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||||
|
parts.forEach((partID) => touched.add(partID))
|
||||||
|
load.touchedParts.set(messageID, touched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const evict = (sessionIDs: string[]) => {
|
const evict = (sessionIDs: string[]) => {
|
||||||
if (sessionIDs.length === 0) return
|
if (sessionIDs.length === 0) return
|
||||||
|
const evicted = new Set(sessionIDs)
|
||||||
|
for (const [partID, item] of deltaBases) {
|
||||||
|
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
|
||||||
|
}
|
||||||
sessionIDs.forEach((sessionID) => {
|
sessionIDs.forEach((sessionID) => {
|
||||||
generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
|
generations.delete(sessionID)
|
||||||
clearOptimistic(sessionID)
|
clearOptimistic(sessionID)
|
||||||
requests.delete(sessionID)
|
requests.delete(sessionID)
|
||||||
inflight.delete(sessionID)
|
inflight.delete(sessionID)
|
||||||
inflightDiff.delete(sessionID)
|
inflightDiff.delete(sessionID)
|
||||||
inflightTodo.delete(sessionID)
|
inflightTodo.delete(sessionID)
|
||||||
|
messageLoads.delete(sessionID)
|
||||||
|
pendingParts.delete(sessionID)
|
||||||
|
orphanParts.delete(sessionID)
|
||||||
|
removedMessages.delete(sessionID)
|
||||||
})
|
})
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -230,6 +424,7 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
...inflight.keys(),
|
...inflight.keys(),
|
||||||
...inflightDiff.keys(),
|
...inflightDiff.keys(),
|
||||||
...inflightTodo.keys(),
|
...inflightTodo.keys(),
|
||||||
|
...messageLoads.keys(),
|
||||||
...optimistic.keys(),
|
...optimistic.keys(),
|
||||||
...Object.entries(data.permission)
|
...Object.entries(data.permission)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
@@ -247,8 +442,11 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
|
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||||
const response = await retry(() => client.session.messages({ sessionID, limit, before }))
|
const response = await (options?.retry ?? retry)(() => {
|
||||||
|
onAttempt?.()
|
||||||
|
return client.session.messages({ sessionID, limit, before })
|
||||||
|
})
|
||||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||||
return {
|
return {
|
||||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||||
@@ -261,30 +459,153 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||||
if (meta.loading[sessionID]) return
|
const messageIDs = new Set(messages.map((message) => message.id))
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||||
setMeta("loading", sessionID, true)
|
|
||||||
await fetchMessages(sessionID, limit, before)
|
|
||||||
.then((page) => {
|
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
|
||||||
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
|
||||||
next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
|
|
||||||
const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
|
|
||||||
batch(() => {
|
|
||||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||||
for (const item of next.part) {
|
setData(
|
||||||
const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
produce((draft) => {
|
||||||
if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
|
for (const message of dropped) deleteMessageParts(draft, message.id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return messageIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceParts = (
|
||||||
|
sessionID: string,
|
||||||
|
items: MessagePage["part"],
|
||||||
|
messageIDs: Set<string>,
|
||||||
|
load?: MessageLoadState,
|
||||||
|
) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (!messageIDs.has(item.id)) continue
|
||||||
|
const fetched = load?.clearedMessageParts.has(item.id)
|
||||||
|
? []
|
||||||
|
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||||
|
const fetchedIDs = new Set(fetched.map((part) => part.id))
|
||||||
|
const pending = pendingParts.get(sessionID)?.get(item.id)
|
||||||
|
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
|
||||||
|
for (const part of fetched) {
|
||||||
|
const accumulated = data.part_text_accum_delta[part.id]
|
||||||
|
const base = deltaBases.get(part.id)?.base
|
||||||
|
const preserveDelta =
|
||||||
|
base !== undefined &&
|
||||||
|
accumulated !== undefined &&
|
||||||
|
"text" in part &&
|
||||||
|
typeof part.text === "string" &&
|
||||||
|
part.text.startsWith(base) &&
|
||||||
|
accumulated.startsWith(part.text) &&
|
||||||
|
accumulated !== part.text
|
||||||
|
if (preserveDelta) touched.add(part.id)
|
||||||
|
if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id)
|
||||||
|
}
|
||||||
|
for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) {
|
||||||
|
if (!fetchedIDs.has(partID)) touched.delete(partID)
|
||||||
|
}
|
||||||
|
const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched })
|
||||||
|
if (!parts.length) {
|
||||||
|
orphanParts.get(sessionID)?.delete(item.id)
|
||||||
|
setData(produce((draft) => deleteMessageParts(draft, item.id)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const partIDs = new Set(parts.map((part) => part.id))
|
||||||
|
setData(
|
||||||
|
"part_text_accum_delta",
|
||||||
|
produce((draft) => {
|
||||||
|
for (const part of data.part[item.id] ?? []) {
|
||||||
|
if (!partIDs.has(part.id) || !touched.has(part.id)) {
|
||||||
|
delete draft[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setData("part", item.id, reconcile(parts, { key: "id" }))
|
||||||
|
orphanParts.get(sessionID)?.delete(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyMessagePage = (
|
||||||
|
sessionID: string,
|
||||||
|
page: MessagePage,
|
||||||
|
load: MessageLoadState | undefined,
|
||||||
|
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||||
|
cleanupOrphans: boolean,
|
||||||
|
) => {
|
||||||
|
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||||
|
merged.observed.forEach((item) => {
|
||||||
|
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||||
|
})
|
||||||
|
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
|
||||||
|
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||||
|
touched: touchedMessages,
|
||||||
|
retained: load?.retainedMessages,
|
||||||
|
preserveUnfetched,
|
||||||
|
})
|
||||||
|
batch(() => {
|
||||||
|
const messageIDs = replaceMessages(sessionID, messages)
|
||||||
|
replaceParts(sessionID, merged.part, messageIDs, load)
|
||||||
|
const orphans = orphanParts.get(sessionID)
|
||||||
|
if (cleanupOrphans && page.complete && orphans) {
|
||||||
|
for (const messageID of orphans) {
|
||||||
|
if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||||
|
}
|
||||||
|
orphanParts.delete(sessionID)
|
||||||
}
|
}
|
||||||
setMeta("limit", sessionID, messages.length)
|
setMeta("limit", sessionID, messages.length)
|
||||||
setMeta("cursor", sessionID, next.cursor)
|
setMeta("cursor", sessionID, merged.cursor)
|
||||||
setMeta("complete", sessionID, next.complete)
|
setMeta("complete", sessionID, merged.complete)
|
||||||
setMeta("at", sessionID, Date.now())
|
setMeta("at", sessionID, Date.now())
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
||||||
|
if (meta.loading[sessionID]) return
|
||||||
|
const active = generation(sessionID)
|
||||||
|
const load: MessageLoadState = {
|
||||||
|
touchedMessages: new Set(),
|
||||||
|
removedMessages: new Set(),
|
||||||
|
retainedMessages: new Set(),
|
||||||
|
touchedParts: new Map(),
|
||||||
|
deltaParts: new Map(),
|
||||||
|
carriedDeltaParts: new Map(),
|
||||||
|
removedParts: new Map(),
|
||||||
|
optimisticParts: new Map(),
|
||||||
|
orphanParents: new Set(),
|
||||||
|
clearedMessageParts: new Set(),
|
||||||
|
}
|
||||||
|
messageLoads.set(sessionID, load)
|
||||||
|
setMeta("loading", sessionID, true)
|
||||||
|
let applied = false
|
||||||
|
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||||
|
.then((page) => {
|
||||||
|
if (generations.get(sessionID) !== active) return
|
||||||
|
const first = page.session.reduce<Message | undefined>(
|
||||||
|
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
const preserveUnfetched =
|
||||||
|
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||||
|
applyMessagePage(
|
||||||
|
sessionID,
|
||||||
|
page,
|
||||||
|
messageLoads.get(sessionID) === load ? load : undefined,
|
||||||
|
preserveUnfetched,
|
||||||
|
mode !== "prepend",
|
||||||
|
)
|
||||||
|
applied = true
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
|
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||||
|
for (const messageID of load.orphanParents) {
|
||||||
|
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||||
|
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||||
|
orphanParts.get(sessionID)?.delete(messageID)
|
||||||
|
}
|
||||||
|
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||||
|
}
|
||||||
|
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||||
|
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +660,13 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
const eventID = eventSessionID(event)
|
const eventID = eventSessionID(event)
|
||||||
if (eventID) {
|
if (eventID) {
|
||||||
touch(eventID)
|
touch(eventID)
|
||||||
if (!data.info[eventID]) void resolve(eventID).catch(() => {})
|
if (
|
||||||
|
!data.info[eventID] &&
|
||||||
|
event.type !== "session.created" &&
|
||||||
|
event.type !== "session.updated" &&
|
||||||
|
event.type !== "session.deleted"
|
||||||
|
)
|
||||||
|
void resolve(eventID).catch(() => {})
|
||||||
}
|
}
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.created":
|
case "session.created":
|
||||||
@@ -378,6 +705,21 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||||
|
const load = messageLoads.get(info.sessionID)
|
||||||
|
load?.touchedMessages.add(info.id)
|
||||||
|
load?.removedMessages.delete(info.id)
|
||||||
|
const items = optimistic.get(info.sessionID)
|
||||||
|
const item = items?.get(info.id)
|
||||||
|
if (items && item) {
|
||||||
|
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
|
||||||
|
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
|
||||||
|
}
|
||||||
|
const orphans = orphanParts.get(info.sessionID)
|
||||||
|
orphans?.delete(info.id)
|
||||||
|
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
|
||||||
|
const removedMessagesForSession = removedMessages.get(info.sessionID)
|
||||||
|
removedMessagesForSession?.delete(info.id)
|
||||||
|
if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID)
|
||||||
const messages = data.message[info.sessionID]
|
const messages = data.message[info.sessionID]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
setData("message", info.sessionID, [info])
|
setData("message", info.sessionID, [info])
|
||||||
@@ -395,6 +737,20 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
}
|
}
|
||||||
case "message.removed": {
|
case "message.removed": {
|
||||||
const props = event.properties as { sessionID: string; messageID: string }
|
const props = event.properties as { sessionID: string; messageID: string }
|
||||||
|
const load = messageLoads.get(props.sessionID)
|
||||||
|
load?.touchedMessages.add(props.messageID)
|
||||||
|
load?.removedMessages.add(props.messageID)
|
||||||
|
load?.clearedMessageParts.add(props.messageID)
|
||||||
|
load?.deltaParts.delete(props.messageID)
|
||||||
|
load?.carriedDeltaParts.delete(props.messageID)
|
||||||
|
load?.removedParts.delete(props.messageID)
|
||||||
|
load?.optimisticParts.delete(props.messageID)
|
||||||
|
pendingParts.get(props.sessionID)?.delete(props.messageID)
|
||||||
|
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
|
||||||
|
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
|
||||||
|
removedMessagesForSession.add(props.messageID)
|
||||||
|
removedMessages.set(props.sessionID, removedMessagesForSession)
|
||||||
|
clearOptimistic(props.sessionID, props.messageID)
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[props.sessionID]
|
const messages = draft.message[props.sessionID]
|
||||||
@@ -402,8 +758,7 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
const result = Binary.search(messages, props.messageID, (message) => message.id)
|
const result = Binary.search(messages, props.messageID, (message) => message.id)
|
||||||
if (result.found) messages.splice(result.index, 1)
|
if (result.found) messages.splice(result.index, 1)
|
||||||
}
|
}
|
||||||
for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
|
deleteMessageParts(draft, props.messageID)
|
||||||
delete draft.part[props.messageID]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -411,6 +766,42 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
case "message.part.updated": {
|
case "message.part.updated": {
|
||||||
const part = (event.properties as { part: Part }).part
|
const part = (event.properties as { part: Part }).part
|
||||||
if (SKIP_PARTS.has(part.type)) return
|
if (SKIP_PARTS.has(part.type)) return
|
||||||
|
const messages = data.message[part.sessionID]
|
||||||
|
const load = messageLoads.get(part.sessionID)
|
||||||
|
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
|
||||||
|
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
|
||||||
|
if (
|
||||||
|
missing &&
|
||||||
|
(!load ||
|
||||||
|
load.clearedMessageParts.has(part.messageID) ||
|
||||||
|
removedMessages.get(part.sessionID)?.has(part.messageID))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (missing) {
|
||||||
|
const orphans = orphanParts.get(part.sessionID) ?? new Set<string>()
|
||||||
|
orphans.add(part.messageID)
|
||||||
|
orphanParts.set(part.sessionID, orphans)
|
||||||
|
load?.orphanParents.add(part.messageID)
|
||||||
|
}
|
||||||
|
const deltas = load?.deltaParts.get(part.messageID)
|
||||||
|
deltas?.delete(part.id)
|
||||||
|
if (deltas?.size === 0) load?.deltaParts.delete(part.messageID)
|
||||||
|
const carried = load?.carriedDeltaParts.get(part.messageID)
|
||||||
|
carried?.delete(part.id)
|
||||||
|
if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID)
|
||||||
|
const removed = load?.removedParts.get(part.messageID)
|
||||||
|
removed?.delete(part.id)
|
||||||
|
if (removed?.size === 0) load?.removedParts.delete(part.messageID)
|
||||||
|
const pending = pendingParts.get(part.sessionID)?.get(part.messageID)
|
||||||
|
pending?.delete(part.id)
|
||||||
|
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
|
||||||
|
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
|
||||||
|
const optimistic = load?.optimisticParts.get(part.messageID)
|
||||||
|
optimistic?.delete(part.id)
|
||||||
|
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
trackPartChange(part.sessionID, part.messageID, part.id)
|
||||||
|
confirmOptimisticPart(part.sessionID, part.messageID, part)
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
produce((draft) => void delete draft[part.id]),
|
produce((draft) => void delete draft[part.id]),
|
||||||
@@ -431,10 +822,34 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.part.removed": {
|
case "message.part.removed": {
|
||||||
const props = event.properties as { messageID: string; partID: string }
|
const props = event.properties as { sessionID: string; messageID: string; partID: string }
|
||||||
|
// Part removal is event-only on the server, so its tombstone lasts until a later update or eviction.
|
||||||
|
const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>()
|
||||||
|
const parts = pending.get(props.messageID) ?? new Set<string>()
|
||||||
|
parts.add(props.partID)
|
||||||
|
pending.set(props.messageID, parts)
|
||||||
|
pendingParts.set(props.sessionID, pending)
|
||||||
|
const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID)
|
||||||
|
deltas?.delete(props.partID)
|
||||||
|
if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID)
|
||||||
|
const load = messageLoads.get(props.sessionID)
|
||||||
|
const carried = load?.carriedDeltaParts.get(props.messageID)
|
||||||
|
carried?.delete(props.partID)
|
||||||
|
if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID)
|
||||||
|
if (load) {
|
||||||
|
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
|
||||||
|
parts.add(props.partID)
|
||||||
|
load.removedParts.set(props.messageID, parts)
|
||||||
|
const optimistic = load.optimisticParts.get(props.messageID)
|
||||||
|
optimistic?.delete(props.partID)
|
||||||
|
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
|
||||||
|
}
|
||||||
|
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||||
|
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
delete draft.part_text_accum_delta[props.partID]
|
delete draft.part_text_accum_delta[props.partID]
|
||||||
|
deltaBases.delete(props.partID)
|
||||||
const parts = draft.part[props.messageID]
|
const parts = draft.part[props.messageID]
|
||||||
if (!parts) return
|
if (!parts) return
|
||||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||||
@@ -445,13 +860,31 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.part.delta": {
|
case "message.part.delta": {
|
||||||
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
|
const props = event.properties as {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
partID: string
|
||||||
|
field: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
const parts = data.part[props.messageID]
|
const parts = data.part[props.messageID]
|
||||||
if (!parts) return
|
if (!parts) return
|
||||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||||
if (!result.found) return
|
if (!result.found) return
|
||||||
|
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||||
|
const load = messageLoads.get(props.sessionID)
|
||||||
|
if (load) {
|
||||||
|
const parts = load.deltaParts.get(props.messageID) ?? new Set<string>()
|
||||||
|
parts.add(props.partID)
|
||||||
|
load.deltaParts.set(props.messageID, parts)
|
||||||
|
const carried = load.carriedDeltaParts.get(props.messageID)
|
||||||
|
carried?.delete(props.partID)
|
||||||
|
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
|
||||||
|
}
|
||||||
const field = props.field as keyof (typeof parts)[number]
|
const field = props.field as keyof (typeof parts)[number]
|
||||||
const current = parts[result.index]?.[field]
|
const current = parts[result.index]?.[field]
|
||||||
|
if (!deltaBases.has(props.partID) && typeof current === "string")
|
||||||
|
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
props.partID,
|
props.partID,
|
||||||
@@ -559,32 +992,70 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
},
|
},
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
||||||
|
const parts = input.parts
|
||||||
|
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
||||||
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
const load = messageLoads.get(input.sessionID)
|
||||||
|
if (load?.clearedMessageParts.has(input.message.id)) {
|
||||||
|
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
|
||||||
|
parts.forEach((part) => touched.add(part.id))
|
||||||
|
load.touchedParts.set(input.message.id, touched)
|
||||||
|
}
|
||||||
|
if (load) {
|
||||||
|
load.removedMessages.delete(input.message.id)
|
||||||
|
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
|
||||||
|
}
|
||||||
const items = optimistic.get(input.sessionID)
|
const items = optimistic.get(input.sessionID)
|
||||||
if (items) items.set(input.message.id, input)
|
const removedMessagesForSession = removedMessages.get(input.sessionID)
|
||||||
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
|
removedMessagesForSession?.delete(input.message.id)
|
||||||
|
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
|
||||||
|
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||||
|
if (!items)
|
||||||
|
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
||||||
setData(
|
setData(
|
||||||
"part",
|
"part_text_accum_delta",
|
||||||
input.message.id,
|
produce((draft) => {
|
||||||
input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
|
||||||
|
delete draft[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
setData("part", input.message.id, parts)
|
||||||
},
|
},
|
||||||
remove(input: { sessionID: string; messageID: string }) {
|
remove(input: { sessionID: string; messageID: string }) {
|
||||||
|
const item = optimistic.get(input.sessionID)?.get(input.messageID)
|
||||||
|
if (!item) return
|
||||||
|
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
|
||||||
clearOptimistic(input.sessionID, input.messageID)
|
clearOptimistic(input.sessionID, input.messageID)
|
||||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
if (item.confirmedMessage) {
|
||||||
|
const partIDs = new Set(item.parts.map((part) => part.id))
|
||||||
setData(
|
setData(
|
||||||
"part",
|
produce((draft) => {
|
||||||
produce((draft) => void delete draft[input.messageID]),
|
for (const part of item.parts) {
|
||||||
|
delete draft.part_text_accum_delta[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
const parts = draft.part[input.messageID]
|
||||||
|
if (!parts) return
|
||||||
|
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
|
||||||
|
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||||
|
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff(sessionID: string, options?: { force?: boolean }) {
|
diff(sessionID: string, options?: { force?: boolean }) {
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||||
return runInflight(inflightDiff, sessionID, () => {
|
return runInflight(inflightDiff, sessionID, () => {
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
if (generations.get(sessionID) !== active) return
|
||||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -593,9 +1064,9 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||||
return runInflight(inflightTodo, sessionID, () => {
|
return runInflight(inflightTodo, sessionID, () => {
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
if (generations.get(sessionID) !== active) return
|
||||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -163,12 +163,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
||||||
const draftID = uuid()
|
const draftID = uuid()
|
||||||
|
void startTransition(() => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
tabs.push({ type: "draft", draftID, ...draft })
|
tabs.push({ type: "draft", draftID, ...draft })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||||
|
})
|
||||||
},
|
},
|
||||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||||
setStore(
|
setStore(
|
||||||
@@ -177,13 +179,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||||
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
|
// Keep the replacement and navigation atomic so /new-session never renders
|
||||||
// replaces the draft tab with a session tab, so the draft route would stop resolving
|
// after its backing draft tab has been removed from the store.
|
||||||
// and fall back home. Navigate to the new session first so we leave /new-session
|
|
||||||
// before the draft is removed from the store.
|
|
||||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||||
const next = { type: "session" as const, ...session }
|
const next = { type: "session" as const, ...session }
|
||||||
startTransition(() => {
|
void startTransition(() => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||||
|
|||||||
@@ -656,6 +656,10 @@ export const dict = {
|
|||||||
"session.new.worktree.main": "Main branch",
|
"session.new.worktree.main": "Main branch",
|
||||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||||
"session.new.worktree.create": "Create new worktree",
|
"session.new.worktree.create": "Create new worktree",
|
||||||
|
"session.new.workspace.runIn": "Run session in",
|
||||||
|
"session.new.workspace.triggerLocal": "Local",
|
||||||
|
"session.new.workspace.local": "Local repository",
|
||||||
|
"session.new.workspace.existing": "Workspace…",
|
||||||
"session.new.lastModified": "Last modified",
|
"session.new.lastModified": "Last modified",
|
||||||
|
|
||||||
"session.header.search.placeholder": "Search {{project}}",
|
"session.header.search.placeholder": "Search {{project}}",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
@import "@opencode-ai/session-ui/styles";
|
@import "@opencode-ai/session-ui/styles";
|
||||||
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "JetBrainsMono Nerd Font Mono";
|
font-family: "JetBrainsMono Nerd Font Mono";
|
||||||
@@ -131,13 +132,4 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
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,
|
||||||
|
createResource,
|
||||||
createRoot,
|
createRoot,
|
||||||
For,
|
For,
|
||||||
Match,
|
Match,
|
||||||
@@ -26,7 +26,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 +34,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 +48,6 @@ import {
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
getProjectAvatarSource,
|
getProjectAvatarSource,
|
||||||
homeProjectDirectories,
|
homeProjectDirectories,
|
||||||
type HomeProjectSelection,
|
|
||||||
projectForSession,
|
projectForSession,
|
||||||
sortedRootSessions,
|
sortedRootSessions,
|
||||||
toggleHomeProjectSelection,
|
toggleHomeProjectSelection,
|
||||||
@@ -68,6 +68,7 @@ import { archiveHomeSession } from "./home-session-archive"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
|
||||||
const HOME_SESSION_LIMIT = 64
|
const HOME_SESSION_LIMIT = 64
|
||||||
|
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||||
const HOME_ROW_LAYOUT =
|
const HOME_ROW_LAYOUT =
|
||||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||||
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
||||||
@@ -146,15 +147,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 +165,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 +193,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 +259,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 +284,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 +309,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[]) {
|
||||||
@@ -343,15 +342,15 @@ export function NewHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
if (ServerConnection.key(conn) !== server.key) return 0
|
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||||
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
if (ServerConnection.key(conn) !== server.key) return
|
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||||
directories(project)
|
directories(project)
|
||||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
.filter((directory) => state.project.unseenCount(directory) > 0)
|
||||||
.forEach((directory) => notification.project.markViewed(directory))
|
.forEach((directory) => state.project.markViewed(directory))
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSession(session: Session) {
|
function openSession(session: Session) {
|
||||||
@@ -407,18 +406,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 +419,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 +444,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
|
||||||
@@ -462,7 +455,7 @@ export function NewHome() {
|
|||||||
onClose={closeSearch}
|
onClose={closeSearch}
|
||||||
onSelect={selectSearchSession}
|
onSelect={selectSearchSession}
|
||||||
/>
|
/>
|
||||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
<ScrollView class="mt-3 -mr-3 min-h-0 flex-1">
|
||||||
<Show
|
<Show
|
||||||
when={!sessionLoad.isLoading}
|
when={!sessionLoad.isLoading}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -475,7 +468,7 @@ export function NewHome() {
|
|||||||
when={groups().length > 0}
|
when={groups().length > 0}
|
||||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||||
>
|
>
|
||||||
<div class="pt-3 flex flex-col gap-6">
|
<div class="flex flex-col gap-6 pt-3 pr-3">
|
||||||
<For each={groups()}>
|
<For each={groups()}>
|
||||||
{(group, index) => (
|
{(group, index) => (
|
||||||
<div class="flex min-w-0 flex-col gap-4">
|
<div class="flex min-w-0 flex-col gap-4">
|
||||||
@@ -489,8 +482,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}
|
||||||
/>
|
/>
|
||||||
@@ -534,10 +527,16 @@ function HomeProjectColumn(props: {
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const controller = useServerManagementController({ navigateOnAdd: false })
|
const controller = useServerManagementController({ navigateOnAdd: false })
|
||||||
const [state, setState] = persisted(
|
const [_state, setState, _, ready] = persisted(
|
||||||
Persist.global("home.servers", ["home.servers.v1"]),
|
Persist.global("home.servers", ["home.servers.v1"]),
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
)
|
)
|
||||||
|
const [state] = createResource(
|
||||||
|
() => ready.promise ?? Promise.resolve(),
|
||||||
|
(p) => p.then(() => _state),
|
||||||
|
{ initialValue: _state },
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
|
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
|
||||||
@@ -569,7 +568,7 @@ function HomeProjectColumn(props: {
|
|||||||
const key = ServerConnection.key(item)
|
const key = ServerConnection.key(item)
|
||||||
const healthy = () => !!global.servers.health[key]?.healthy
|
const healthy = () => !!global.servers.health[key]?.healthy
|
||||||
const serverCtx = global.ensureServerCtx(item)
|
const serverCtx = global.ensureServerCtx(item)
|
||||||
const collapsed = () => !!state.collapsed[key]
|
const collapsed = () => !!state().collapsed[key]
|
||||||
return (
|
return (
|
||||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
<HomeServerRow
|
<HomeServerRow
|
||||||
@@ -582,7 +581,7 @@ function HomeProjectColumn(props: {
|
|||||||
focusServer={props.focusServer}
|
focusServer={props.focusServer}
|
||||||
chooseProject={props.chooseProject}
|
chooseProject={props.chooseProject}
|
||||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||||
toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
|
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
||||||
language={props.language}
|
language={props.language}
|
||||||
/>
|
/>
|
||||||
<Show when={healthy() && !collapsed()}>
|
<Show when={healthy() && !collapsed()}>
|
||||||
@@ -957,7 +956,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 +1133,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-3">
|
||||||
<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) => (
|
||||||
@@ -1191,6 +1190,7 @@ function HomeSessionRow(props: {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
|
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
@@ -1207,6 +1207,7 @@ function HomeSessionRow(props: {
|
|||||||
/>
|
/>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { 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 { 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 platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const notification = useNotification()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const params = useParams<{ id?: string }>()
|
|
||||||
setNavigate(navigate)
|
setNavigate(navigate)
|
||||||
|
|
||||||
createEffect(() => setV2Toast(true))
|
createEffect(() => setV2Toast(true))
|
||||||
createEffect(() => {
|
|
||||||
if (!notification.ready() || !params.id) return
|
|
||||||
if (notification.session.unseenCount(params.id) === 0) return
|
|
||||||
notification.session.markViewed(params.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const update: TitlebarUpdate = {
|
const update: TitlebarUpdate = {
|
||||||
version: () => {
|
version: () => {
|
||||||
|
|||||||
@@ -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,27 @@
|
|||||||
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"
|
||||||
|
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||||
|
|
||||||
|
const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,28 +34,41 @@ 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<{ 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 [store, setStore] = createStore({
|
const projectController = createPromptProjectController({
|
||||||
worktree: "main",
|
controls: projectControls,
|
||||||
|
onDone: () => inputRef?.focus(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||||
|
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.worktree === "create") return "create"
|
if (store.worktree) return store.worktree
|
||||||
const project = sync().project
|
const project = sync().project
|
||||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||||
return "main"
|
return "main"
|
||||||
})
|
})
|
||||||
|
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||||
|
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
|
||||||
|
const selectedBranch = createMemo(() => {
|
||||||
|
const worktree = newSessionWorktree()
|
||||||
|
if (worktree === "main" || worktree === "create") return localBranch()
|
||||||
|
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!prompt.ready()) return
|
if (!prompt.ready()) return
|
||||||
@@ -58,9 +80,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 +96,65 @@ 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" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
|
||||||
|
<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", undefined)}
|
||||||
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 min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||||
|
classList={{
|
||||||
|
"flex-col justify-center sm:flex-row": showWorkspaceBar,
|
||||||
|
"justify-start": !showWorkspaceBar,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PromptProjectSelector
|
||||||
|
controller={projectController}
|
||||||
|
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
|
||||||
|
/>
|
||||||
|
<Show when={showWorkspaceBar}>
|
||||||
|
<PromptWorkspaceSelector
|
||||||
|
value={newSessionWorktree()}
|
||||||
|
projectRoot={projectRoot()}
|
||||||
|
workspaces={sync().project?.sandboxes ?? []}
|
||||||
|
branch={selectedBranch()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setStore(
|
||||||
|
"worktree",
|
||||||
|
value === "main" && sync().project?.worktree !== sdk().directory
|
||||||
|
? sync().project?.worktree
|
||||||
|
: value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDone={() => inputRef?.focus()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</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,20 +1,29 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||||
import { Api } from "@opencode-ai/server/api"
|
import { ClientApi } from "../src/contract"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
|
const contract = compile(ClientApi, {
|
||||||
groupNames: { "server.session": "sessions" },
|
groupNames: { "server.session": "sessions", "server.event": "events" },
|
||||||
})
|
})
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.all(
|
Effect.all(
|
||||||
[
|
[
|
||||||
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
|
|
||||||
write(
|
write(
|
||||||
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
|
emitPromise(contract, {
|
||||||
|
outputTypes: {
|
||||||
|
"events.subscribe": {
|
||||||
|
name: "OpenCodeEventEncoded",
|
||||||
|
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fileURLToPath(new URL("../src/generated", import.meta.url)),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
|
||||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
||||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||||
|
|
||||||
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
||||||
"@opencode-ai/client/LocationMiddleware",
|
"@opencode-ai/client/LocationMiddleware",
|
||||||
@@ -17,3 +17,5 @@ const Api = makeDefaultApi({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const SessionGroup = Api.groups["server.session"]
|
export const SessionGroup = Api.groups["server.session"]
|
||||||
|
export const EventGroup = Api.groups["server.event"]
|
||||||
|
export const ClientApi = HttpApi.make("opencode-client").add(SessionGroup).add(EventGroup)
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { Session } from "@opencode-ai/schema/session"
|
|||||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
|
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
// 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 { HttpApiClient } from "effect/unstable/httpapi"
|
||||||
import { SessionGroup } from "../contract"
|
import { ClientApi } from "../contract"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
const Api = HttpApi.make("generated").add(SessionGroup)
|
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||||
|
|
||||||
type RawClient = HttpApiClient.ForApi<typeof Api>
|
|
||||||
|
|
||||||
const mapClientError = <E>(error: E) =>
|
const mapClientError = <E>(error: E) =>
|
||||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||||
@@ -55,43 +53,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 +104,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,40 +129,99 @@ 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.history"]>[0]
|
||||||
|
type Endpoint0_13Input = {
|
||||||
|
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||||
|
readonly limit?: Endpoint0_13Request["query"]["limit"]
|
||||||
|
readonly after?: Endpoint0_13Request["query"]["after"]
|
||||||
|
}
|
||||||
|
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||||
|
raw["session.history"]({
|
||||||
|
params: { sessionID: input.sessionID },
|
||||||
|
query: { limit: input.limit, after: input.after },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||||
|
type Endpoint0_14Input = {
|
||||||
|
readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
|
||||||
|
readonly after?: Endpoint0_14Request["query"]["after"]
|
||||||
|
}
|
||||||
|
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||||
|
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_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
|
type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] }
|
||||||
|
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||||
|
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint0_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
|
type Endpoint0_16Input = {
|
||||||
|
readonly sessionID: Endpoint0_16Request["params"]["sessionID"]
|
||||||
|
readonly messageID: Endpoint0_16Request["params"]["messageID"]
|
||||||
|
}
|
||||||
|
const Endpoint0_16 = (raw: RawClient["server.session"]) => (input: Endpoint0_16Input) =>
|
||||||
|
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),
|
||||||
|
history: Endpoint0_13(raw),
|
||||||
|
events: Endpoint0_14(raw),
|
||||||
|
interrupt: Endpoint0_15(raw),
|
||||||
|
message: Endpoint0_16(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
const Endpoint1_0 = (raw: RawClient["server.event"]) => () =>
|
||||||
|
Stream.unwrap(
|
||||||
|
raw["event.subscribe"]({}).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const adaptGroup1 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint1_0(raw) })
|
||||||
|
|
||||||
|
const adaptClient = (raw: RawClient) => ({
|
||||||
|
sessions: adaptGroup0(raw["server.session"]),
|
||||||
|
events: adaptGroup1(raw["server.event"]),
|
||||||
|
})
|
||||||
|
|
||||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
SessionsListOutput,
|
SessionsListOutput,
|
||||||
SessionsCreateInput,
|
SessionsCreateInput,
|
||||||
SessionsCreateOutput,
|
SessionsCreateOutput,
|
||||||
|
SessionsActiveOutput,
|
||||||
SessionsGetInput,
|
SessionsGetInput,
|
||||||
SessionsGetOutput,
|
SessionsGetOutput,
|
||||||
SessionsSwitchAgentInput,
|
SessionsSwitchAgentInput,
|
||||||
@@ -23,6 +24,15 @@ import type {
|
|||||||
SessionsCommitOutput,
|
SessionsCommitOutput,
|
||||||
SessionsContextInput,
|
SessionsContextInput,
|
||||||
SessionsContextOutput,
|
SessionsContextOutput,
|
||||||
|
SessionsHistoryInput,
|
||||||
|
SessionsHistoryOutput,
|
||||||
|
SessionsEventsInput,
|
||||||
|
SessionsEventsOutput,
|
||||||
|
SessionsInterruptInput,
|
||||||
|
SessionsInterruptOutput,
|
||||||
|
SessionsMessageInput,
|
||||||
|
SessionsMessageOutput,
|
||||||
|
EventsSubscribeOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
@@ -192,6 +202,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 +327,59 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
|
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<SessionsHistoryOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
|
||||||
|
query: { limit: input.limit, after: input.after },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
|
||||||
|
sse<EventsSubscribeOutput>(
|
||||||
|
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1221
-178
File diff suppressed because it is too large
Load Diff
@@ -1 +1,2 @@
|
|||||||
export * from "./generated/index"
|
export * from "./generated/index"
|
||||||
|
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||||
|
|||||||
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 {}
|
||||||
@@ -20,7 +20,7 @@ import { Workspace } from "@opencode-ai/schema/workspace"
|
|||||||
import { Api } from "@opencode-ai/server/api"
|
import { Api } from "@opencode-ai/server/api"
|
||||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
import { HttpApi } from "effect/unstable/httpapi"
|
||||||
import { SessionGroup } from "../src/contract"
|
import { EventGroup, SessionGroup } from "../src/contract"
|
||||||
|
|
||||||
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
||||||
expect(AgentV2.ID).toBe(Agent.ID)
|
expect(AgentV2.ID).toBe(Agent.ID)
|
||||||
@@ -32,6 +32,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
|||||||
expect(CorePrompt).toBe(Prompt)
|
expect(CorePrompt).toBe(Prompt)
|
||||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||||
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
||||||
|
expect(EventGroup.identifier).toBe(Api.groups["server.event"].identifier)
|
||||||
expect(Session.ID.create()).toStartWith("ses_")
|
expect(Session.ID.create()).toStartWith("ses_")
|
||||||
expect(Project.ID.global).toBe("global")
|
expect(Project.ID.global).toBe("global")
|
||||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
expect(Provider.ID.anthropic).toBe("anthropic")
|
||||||
|
|||||||
@@ -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) =>
|
||||||
@@ -15,15 +15,91 @@ test("sessions.get returns the decoded Effect projection", async () => {
|
|||||||
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
new Response(
|
||||||
|
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||||
|
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const events = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.events.subscribe().pipe(Stream.runCollect)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
|
||||||
|
const durable = events[1]
|
||||||
|
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
|
||||||
|
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
|
||||||
|
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events.subscribe terminates on Effect protocol decode failures", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
new Response(`data: {"type":"server.connected"}\n\n`, {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const error = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(error._tag).toBe("ClientError")
|
||||||
|
})
|
||||||
|
|
||||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||||
|
const historyQueries: Array<Record<string, string>> = []
|
||||||
|
let historyPage = 0
|
||||||
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("/history")) {
|
||||||
|
historyPage++
|
||||||
|
historyQueries.push(Object.fromEntries(request.urlParams.params))
|
||||||
|
return Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
Response.json(
|
||||||
|
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
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 +113,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 +130,31 @@ 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 history = yield* client.sessions.history({
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
after: 0,
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
const historyNext = history.hasMore
|
||||||
|
? yield* client.sessions.history({
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
after: history.data.at(-1)?.durable?.seq,
|
||||||
|
limit: 2,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
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, history, historyNext, 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 +162,37 @@ 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.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||||
|
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
|
||||||
|
expect(result.historyNext).toEqual({ data: [], hasMore: false })
|
||||||
|
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
|
||||||
|
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
|
||||||
|
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" }))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sessions.history retains the typed SessionNotFoundError", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
Response.json(
|
||||||
|
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const error = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.sessions
|
||||||
|
.history({
|
||||||
|
sessionID: Session.ID.make("ses_missing"),
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(error._tag).toBe("SessionNotFoundError")
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
@@ -96,3 +225,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" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { isUnauthorizedError, OpenCode } from "../src"
|
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
|
||||||
|
|
||||||
test("sessions.get returns the wire projection", async () => {
|
test("sessions.get returns the wire projection", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
@@ -17,22 +17,66 @@ test("sessions.get returns the wire projection", async () => {
|
|||||||
expect(result.time.created).toBe(1_717_171_717_000)
|
expect(result.time.created).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("events.subscribe exposes the Promise event stream wire projection", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () =>
|
||||||
|
new Response(
|
||||||
|
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||||
|
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const events = []
|
||||||
|
for await (const event of client.events.subscribe()) events.push(event)
|
||||||
|
|
||||||
|
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
|
||||||
|
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events.subscribe terminates on malformed Promise SSE data", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
||||||
|
name: "ClientError",
|
||||||
|
reason: "MalformedResponse",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("session methods use the public HTTP contract", async () => {
|
test("session methods use the public HTTP contract", async () => {
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||||
|
let historyPage = 0
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
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("/history")) {
|
||||||
|
historyPage++
|
||||||
|
return Response.json(
|
||||||
|
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
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" } })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
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 +91,28 @@ 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 history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
|
||||||
|
const historyAfter = history.data.at(-1)?.durable?.seq
|
||||||
|
const historyNext = history.hasMore
|
||||||
|
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
|
||||||
|
: undefined
|
||||||
|
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(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
|
||||||
|
expect(historyNext).toEqual({ data: [], hasMore: false })
|
||||||
|
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 +120,13 @@ 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/history?limit=1&after=0"],
|
||||||
|
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
|
||||||
|
["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" },
|
||||||
@@ -85,6 +149,24 @@ test("middleware errors remain declared client errors", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sessions.history decodes SessionNotFoundError", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () =>
|
||||||
|
Response.json(
|
||||||
|
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.sessions.history({ sessionID: "ses_missing" })
|
||||||
|
throw new Error("Expected request to fail")
|
||||||
|
} catch (error) {
|
||||||
|
expect(isSessionNotFoundError(error)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
data: {
|
data: {
|
||||||
id: "ses_test",
|
id: "ses_test",
|
||||||
@@ -115,3 +197,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" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { LOCALE_HEADER, cookie, localeFromRequest, route, tag } from "~/lib/language"
|
||||||
|
|
||||||
const dataPath = "/data"
|
const dataPath = "/data"
|
||||||
|
|
||||||
export async function statsProxy(evt: APIEvent) {
|
export async function statsProxy(evt: APIEvent) {
|
||||||
const req = evt.request.clone()
|
const req = evt.request.clone()
|
||||||
|
const locale = localeFromRequest(req)
|
||||||
|
const redirect = redirectToLocalizedData(req, new URL(req.url), locale)
|
||||||
|
if (redirect) return redirect
|
||||||
|
|
||||||
const targetUrl = new URL(req.url)
|
const targetUrl = new URL(req.url)
|
||||||
targetUrl.protocol = "https:"
|
targetUrl.protocol = "https:"
|
||||||
targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai"
|
targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai"
|
||||||
@@ -18,9 +23,13 @@ export async function statsProxy(evt: APIEvent) {
|
|||||||
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
|
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestHeaders = new Headers(req.headers)
|
||||||
|
requestHeaders.set(LOCALE_HEADER, locale)
|
||||||
|
requestHeaders.set("accept-language", tag(locale))
|
||||||
|
|
||||||
const response = await fetch(targetUrl, {
|
const response = await fetch(targetUrl, {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: req.headers,
|
headers: requestHeaders,
|
||||||
body: req.body,
|
body: req.body,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,6 +39,7 @@ export async function statsProxy(evt: APIEvent) {
|
|||||||
headers.delete("content-encoding")
|
headers.delete("content-encoding")
|
||||||
headers.delete("content-length")
|
headers.delete("content-length")
|
||||||
headers.delete("etag")
|
headers.delete("etag")
|
||||||
|
appendVary(headers, "Accept-Language", "Cookie")
|
||||||
|
|
||||||
return new Response(rewriteStatsHtml(await response.text()), {
|
return new Response(rewriteStatsHtml(await response.text()), {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@@ -52,3 +62,60 @@ export function statsRedirect(evt: APIEvent) {
|
|||||||
function rewriteStatsHtml(html: string) {
|
function rewriteStatsHtml(html: string) {
|
||||||
return html.replaceAll('"/_build/', `"${dataPath}/_build/`).replaceAll("'/_build/", `'${dataPath}/_build/`)
|
return html.replaceAll('"/_build/', `"${dataPath}/_build/`).replaceAll("'/_build/", `'${dataPath}/_build/`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redirectToLocalizedData(request: Request, url: URL, locale: ReturnType<typeof localeFromRequest>) {
|
||||||
|
if (locale === "en") return null
|
||||||
|
if (request.headers.get(LOCALE_HEADER)) return null
|
||||||
|
if (request.method !== "GET" && request.method !== "HEAD") return null
|
||||||
|
if (!acceptsHtml(request)) return null
|
||||||
|
if (!url.pathname.startsWith(`${dataPath}/`) && url.pathname !== dataPath) return null
|
||||||
|
if (isDataBypassPath(url.pathname)) return null
|
||||||
|
|
||||||
|
const next = new URL(url)
|
||||||
|
next.pathname = route(locale, url.pathname)
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
Location: next.toString(),
|
||||||
|
})
|
||||||
|
headers.append("set-cookie", cookie(locale))
|
||||||
|
appendVary(headers, "Accept-Language", "Cookie")
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 308,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptsHtml(request: Request) {
|
||||||
|
const accept = request.headers.get("accept")
|
||||||
|
return !accept || accept.includes("text/html") || accept.includes("*/*")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDataBypassPath(pathname: string) {
|
||||||
|
return (
|
||||||
|
pathname.startsWith(`${dataPath}/_build/`) ||
|
||||||
|
pathname.startsWith(`${dataPath}/api/`) ||
|
||||||
|
pathname.startsWith(`${dataPath}/_server`) ||
|
||||||
|
pathname === `${dataPath}/banner.jpg` ||
|
||||||
|
pathname === `${dataPath}/banner.png`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendVary(headers: Headers, ...values: string[]) {
|
||||||
|
const existing = headers
|
||||||
|
.get("vary")
|
||||||
|
?.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
headers.set(
|
||||||
|
"vary",
|
||||||
|
values
|
||||||
|
.reduce(
|
||||||
|
(result, value) =>
|
||||||
|
result.some((item) => item.toLowerCase() === value.toLowerCase()) ? result : [...result, value],
|
||||||
|
existing ?? [],
|
||||||
|
)
|
||||||
|
.join(", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -214,6 +215,16 @@ export async function handler(
|
|||||||
body: reqBody,
|
body: reqBody,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (providerInfo.id.startsWith("console.")) {
|
||||||
|
const resEndpointId = res.headers.get("x-opencode-endpoint-id")
|
||||||
|
const resEndpointModelId = res.headers.get("x-opencode-upstream-model-id")
|
||||||
|
if (resEndpointId && resEndpointModelId)
|
||||||
|
logger.metric({
|
||||||
|
provider: resEndpointId,
|
||||||
|
"provider.model": resEndpointModelId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
logger.metric({
|
logger.metric({
|
||||||
"llm.error.code": res.status,
|
"llm.error.code": res.status,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"opencode": "./bin/opencode"
|
"opencode": "./bin/opencode"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"./effect/layer-node": "./src/effect/layer-node/index.ts",
|
||||||
|
"./effect/node": "./src/effect/node.ts",
|
||||||
"./session/runner": "./src/session/runner/index.ts",
|
"./session/runner": "./src/session/runner/index.ts",
|
||||||
"./system-context": "./src/system-context/index.ts",
|
"./system-context": "./src/system-context/index.ts",
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
@@ -102,6 +104,7 @@
|
|||||||
"ai-gateway-provider": "3.1.2",
|
"ai-gateway-provider": "3.1.2",
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as AgentV2 from "./agent"
|
export * as AgentV2 from "./agent"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
@@ -106,3 +107,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as AISDK from "./aisdk"
|
export * as AISDK from "./aisdk"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
@@ -231,4 +232,6 @@ export const locationLayer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||||
|
|
||||||
export const defaultLayer = locationLayer
|
export const defaultLayer = locationLayer
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * as BackgroundJob from "./background-job"
|
|||||||
|
|
||||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||||
import { Identifier } from "./id/id"
|
import { Identifier } from "./id/id"
|
||||||
|
import { makeGlobalNode } from "./effect/node"
|
||||||
|
|
||||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||||
|
|
||||||
@@ -362,3 +363,5 @@ export const make = Effect.gen(function* () {
|
|||||||
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 = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Catalog from "./catalog"
|
export * as Catalog from "./catalog"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
@@ -235,6 +236,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)
|
||||||
@@ -291,3 +297,5 @@ export const locationLayer = layer.pipe(
|
|||||||
Layer.provideMerge(Integration.locationLayer),
|
Layer.provideMerge(Integration.locationLayer),
|
||||||
Layer.provideMerge(Policy.locationLayer),
|
Layer.provideMerge(Policy.locationLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as CommandV2 from "./command"
|
export * as CommandV2 from "./command"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Effect, Layer, Types } from "effect"
|
import { Context, Effect, Layer, Types } from "effect"
|
||||||
import { Command } from "@opencode-ai/schema/command"
|
import { Command } from "@opencode-ai/schema/command"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
@@ -59,3 +60,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Config from "./config"
|
export * as Config from "./config"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { type ParseError, parse } from "jsonc-parser"
|
import { type ParseError, parse } from "jsonc-parser"
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
@@ -218,3 +219,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))
|
export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -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),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
|
|||||||
import { Credential } from "@opencode-ai/schema/credential"
|
import { Credential } from "@opencode-ai/schema/credential"
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { CredentialTable } from "./credential/sql"
|
import { CredentialTable } from "./credential/sql"
|
||||||
|
|
||||||
export const ID = Credential.ID
|
export const ID = Credential.ID
|
||||||
@@ -135,3 +136,5 @@ 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 = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import * as NodeChildProcess from "node:child_process"
|
import * as NodeChildProcess from "node:child_process"
|
||||||
import { PassThrough } from "node:stream"
|
import { PassThrough } from "node:stream"
|
||||||
import launch from "cross-spawn"
|
import launch from "cross-spawn"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { filesystem, path } from "./effect/layer-node-platform"
|
import { filesystem, path } from "./effect/layer-node-platform"
|
||||||
|
|
||||||
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
|
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
|
||||||
@@ -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 = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||||
|
|
||||||
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Flag } from "../flag/flag"
|
|||||||
import { isAbsolute, join } from "path"
|
import { isAbsolute, join } from "path"
|
||||||
import { DatabaseMigration } from "./migration"
|
import { DatabaseMigration } from "./migration"
|
||||||
import { InstallationChannel } from "../installation/version"
|
import { InstallationChannel } from "../installation/version"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
|
|
||||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||||
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
||||||
@@ -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 = makeGlobalNode({ 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 { LayerNode } from "./layer-node"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
|
import { makeGlobalNode } from "./node"
|
||||||
|
|
||||||
export const filesystem = LayerNode.make(NodeFileSystem.layer, [])
|
export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||||
export const path = LayerNode.make(NodePath.layer, [])
|
export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||||
export const httpClient = LayerNode.make(FetchHttpClient.layer, [])
|
export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||||
export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient])
|
export const requestExecutor = makeGlobalNode({
|
||||||
export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor])
|
service: RequestExecutor.Service,
|
||||||
|
layer: RequestExecutor.layer,
|
||||||
|
deps: [httpClient],
|
||||||
|
})
|
||||||
|
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||||
|
|
||||||
export * as LayerNodePlatform from "./layer-node-platform"
|
export * as LayerNodePlatform from "./layer-node-platform"
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { Layer } from "effect"
|
|
||||||
|
|
||||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
|
||||||
type AnyNode = Node<unknown, unknown>
|
|
||||||
type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]]
|
|
||||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown> ? A : never
|
|
||||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E> ? E : never
|
|
||||||
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
|
||||||
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
|
||||||
Missing<Layer.Services<Implementation>, Dependencies>,
|
|
||||||
] extends [never]
|
|
||||||
? unknown
|
|
||||||
: { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
|
|
||||||
declare const $OutputType: unique symbol
|
|
||||||
declare const $ErrorType: unique symbol
|
|
||||||
|
|
||||||
export type Node<A, E = never> = {
|
|
||||||
readonly kind: "layer" | "group"
|
|
||||||
readonly implementation?: Layer.Any
|
|
||||||
readonly dependencies: readonly AnyNode[]
|
|
||||||
readonly [$OutputType]?: () => A
|
|
||||||
readonly [$ErrorType]?: () => E
|
|
||||||
}
|
|
||||||
|
|
||||||
export function make<const Implementation extends Layer.Any, const Items extends NodeList>(
|
|
||||||
implementation: Implementation,
|
|
||||||
dependencies: Items & CheckDependencies<Implementation, NoInfer<Items>>,
|
|
||||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>> {
|
|
||||||
return { kind: "layer", implementation: implementation as Layer.Any, dependencies }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function group<const Items extends NodeList>(
|
|
||||||
dependencies: Items,
|
|
||||||
): Node<Output<Items[number]>, Error<Items[number]>> {
|
|
||||||
return { kind: "group", dependencies }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Replacement<A = unknown> = {
|
|
||||||
readonly source: Node<A, unknown>
|
|
||||||
readonly replacement: Node<A, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
|
||||||
? unknown
|
|
||||||
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
|
||||||
|
|
||||||
export function replaceWithNode<A, E, E2>(
|
|
||||||
source: Node<A, E>,
|
|
||||||
replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
|
|
||||||
): Replacement<A> {
|
|
||||||
return { source, replacement }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replace<A, E, E2>(
|
|
||||||
source: Node<A, E>,
|
|
||||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
|
||||||
): Replacement<A> {
|
|
||||||
return { source, replacement: make(replacement as Layer.Layer<A, E2>, []) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
|
|
||||||
const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
|
||||||
const cache = new Map<AnyNode, RuntimeLayer>()
|
|
||||||
const visiting = new Set<AnyNode>()
|
|
||||||
const stack: AnyNode[] = []
|
|
||||||
const ids = new Map<AnyNode, number>()
|
|
||||||
|
|
||||||
const visit = (input: AnyNode): RuntimeLayer => {
|
|
||||||
const node = replacements.get(input) ?? input
|
|
||||||
const cached = cache.get(node)
|
|
||||||
if (cached) return cached
|
|
||||||
if (visiting.has(node)) {
|
|
||||||
const start = stack.indexOf(node)
|
|
||||||
const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ")
|
|
||||||
throw new Error(`Cycle detected in app graph: ${cycle}`)
|
|
||||||
}
|
|
||||||
if (!ids.has(node)) ids.set(node, ids.size + 1)
|
|
||||||
visiting.add(node)
|
|
||||||
stack.push(node)
|
|
||||||
try {
|
|
||||||
const dependencies = node.dependencies.map(visit)
|
|
||||||
const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]]
|
|
||||||
const result =
|
|
||||||
node.kind === "group"
|
|
||||||
? dependencies.length === 0
|
|
||||||
? 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 {
|
|
||||||
stack.pop()
|
|
||||||
visiting.delete(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return visit(node) as unknown as Layer.Layer<A, E, never>
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as LayerNode from "./layer-node"
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./layer-node"
|
||||||
|
export * as LayerNodeTree from "./layer-node-tree"
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Layer } from "effect"
|
||||||
|
import { LayerNode } from "./layer-node"
|
||||||
|
|
||||||
|
type AnyNode = LayerNode.Node<unknown, unknown, any>
|
||||||
|
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||||
|
|
||||||
|
export function hoist<A, E, T extends LayerNode.Tag>(
|
||||||
|
root: LayerNode.Node<A, E, any>,
|
||||||
|
tag: T,
|
||||||
|
): {
|
||||||
|
readonly node: LayerNode.Node<A, E>
|
||||||
|
readonly hoisted: LayerNode.Node<unknown, E>
|
||||||
|
} {
|
||||||
|
const visited = new Map<AnyNode, AnyNode>()
|
||||||
|
const hoisted = new Map<string, AnyNode>()
|
||||||
|
const visiting = new Set<AnyNode>()
|
||||||
|
const stack: AnyNode[] = []
|
||||||
|
|
||||||
|
const visit = (node: AnyNode): AnyNode => {
|
||||||
|
if (node.kind === "group") {
|
||||||
|
return { ...node, dependencies: node.dependencies.map(visit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNode = visited.get(node)
|
||||||
|
if (existingNode) return existingNode
|
||||||
|
|
||||||
|
if (node.tag === tag) {
|
||||||
|
const existing = hoisted.get(node.name)
|
||||||
|
if (existing && existing !== node) {
|
||||||
|
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
|
||||||
|
}
|
||||||
|
hoisted.set(node.name, node)
|
||||||
|
const empty = LayerNode.group([])
|
||||||
|
visited.set(node, empty)
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
if (node.kind === "unbound") {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visiting.has(node)) {
|
||||||
|
const start = stack.indexOf(node)
|
||||||
|
throw new Error(
|
||||||
|
`Cycle detected in layer tree: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
visiting.add(node)
|
||||||
|
stack.push(node)
|
||||||
|
try {
|
||||||
|
const dependencies = node.dependencies.map(visit)
|
||||||
|
const clone = { ...node, dependencies }
|
||||||
|
visited.set(node, clone)
|
||||||
|
return clone
|
||||||
|
} finally {
|
||||||
|
stack.pop()
|
||||||
|
visiting.delete(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: visit(root) as LayerNode.Node<A, E>,
|
||||||
|
hoisted: LayerNode.group(Array.from(hoisted.values())) as LayerNode.Node<unknown, E>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compile<A, E>(
|
||||||
|
root: LayerNode.Node<A, E, any>,
|
||||||
|
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||||
|
): Layer.Layer<A, E> {
|
||||||
|
const cache = new Map<AnyNode, RuntimeLayer>()
|
||||||
|
const compileNode = (node: AnyNode): RuntimeLayer => {
|
||||||
|
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
|
||||||
|
const cached = cache.get(node)
|
||||||
|
if (cached) return cached
|
||||||
|
const dependencies = node.dependencies.flatMap(flatten).map(compileNode)
|
||||||
|
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
|
||||||
|
const layer =
|
||||||
|
dependencies.length === 0
|
||||||
|
? implementation
|
||||||
|
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
|
||||||
|
cache.set(node, layer)
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
const layers = flatten(root).map((node) => compileNode(node))
|
||||||
|
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
|
||||||
|
return layer as Layer.Layer<A, E>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bind<A, E, T extends LayerNode.Tag | undefined>(
|
||||||
|
root: LayerNode.Node<A, E, T>,
|
||||||
|
source: AnyNode,
|
||||||
|
replacement: AnyNode,
|
||||||
|
): LayerNode.Node<A, E, T> {
|
||||||
|
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
|
||||||
|
if (source.name !== replacement.name) {
|
||||||
|
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
|
||||||
|
}
|
||||||
|
if (source.tag !== replacement.tag) {
|
||||||
|
throw new Error(`Cannot bind ${source.name} across tags`)
|
||||||
|
}
|
||||||
|
const visited = new Map<AnyNode, AnyNode>()
|
||||||
|
const visit = (node: AnyNode): AnyNode => {
|
||||||
|
if (node === source) return replacement
|
||||||
|
const existing = visited.get(node)
|
||||||
|
if (existing) return existing
|
||||||
|
if (node.kind === "unbound") return node
|
||||||
|
const clone = { ...node, dependencies: node.dependencies.map(visit) }
|
||||||
|
visited.set(node, clone)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
return visit(root) as LayerNode.Node<A, E, T>
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatten(node: AnyNode): readonly AnyNode[] {
|
||||||
|
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LayerNodeTree from "./layer-node-tree"
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Brand, Context, Layer } from "effect"
|
||||||
|
|
||||||
|
type AnyNode = Node<unknown, unknown, any>
|
||||||
|
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
||||||
|
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||||
|
export type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
||||||
|
type NodeTag<Item> = [Item] extends [never] ? undefined : Item extends Node<unknown, unknown, infer T> ? T : never
|
||||||
|
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
||||||
|
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
||||||
|
Missing<Layer.Services<Implementation>, Dependencies>,
|
||||||
|
] extends [never]
|
||||||
|
? unknown
|
||||||
|
: { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
|
||||||
|
declare const $OutputType: unique symbol
|
||||||
|
declare const $ErrorType: unique symbol
|
||||||
|
|
||||||
|
export type Tag<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tag">
|
||||||
|
|
||||||
|
const makeTag = Brand.nominal<Tag>()
|
||||||
|
|
||||||
|
export interface Node<A, E = never, T extends Tag | undefined = undefined> {
|
||||||
|
readonly kind: "layer" | "unbound" | "group"
|
||||||
|
readonly name: string
|
||||||
|
readonly service?: Context.Service.Any
|
||||||
|
readonly implementation?: Layer.Any
|
||||||
|
readonly dependencies: readonly AnyNode[]
|
||||||
|
readonly tag?: T
|
||||||
|
readonly [$OutputType]?: () => A
|
||||||
|
readonly [$ErrorType]?: () => E
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeIdentity =
|
||||||
|
| { readonly service: Context.Service.Any; readonly name?: never }
|
||||||
|
| { readonly name: string; readonly service?: never }
|
||||||
|
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
||||||
|
|
||||||
|
type MakeInput<
|
||||||
|
Implementation extends Layer.Any,
|
||||||
|
Items extends NodeList,
|
||||||
|
T extends Tag | undefined = undefined,
|
||||||
|
> = NodeIdentity & {
|
||||||
|
readonly layer: Implementation
|
||||||
|
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
||||||
|
readonly tag?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function make<
|
||||||
|
const Implementation extends Layer.Any,
|
||||||
|
const Items extends NodeList,
|
||||||
|
const T extends Tag | undefined = undefined,
|
||||||
|
>(
|
||||||
|
input: MakeInput<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,
|
||||||
|
tag: input.tag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unbound<R, Shape, const T extends Tag>(service: Context.Key<R, Shape>, tag: T): Node<R, never, T> {
|
||||||
|
return {
|
||||||
|
kind: "unbound",
|
||||||
|
name: service.key,
|
||||||
|
service,
|
||||||
|
dependencies: [],
|
||||||
|
tag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function group<const Items extends readonly AnyNode[]>(
|
||||||
|
dependencies: Items,
|
||||||
|
): Node<Output<Items[number]>, Error<Items[number]>, NodeTag<Items[number]>> {
|
||||||
|
return { kind: "group", name: "group", dependencies }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TagConfig = Readonly<Record<string, readonly string[]>>
|
||||||
|
type TagNames<Config extends TagConfig> = keyof Config & string
|
||||||
|
type NodeInTags<Names extends string> = Node<unknown, unknown, Tag<Names> | undefined>
|
||||||
|
type CheckTags<Items extends NodeList, Names extends string> = [Exclude<Items[number], NodeInTags<Names>>] extends [
|
||||||
|
never,
|
||||||
|
]
|
||||||
|
? unknown
|
||||||
|
: { readonly "Invalid tag dependencies": Exclude<Items[number], NodeInTags<Names>> }
|
||||||
|
|
||||||
|
export interface Tags<Config extends TagConfig> {
|
||||||
|
readonly values: { readonly [Name in TagNames<Config>]: Tag<Name> }
|
||||||
|
readonly make: <Name extends TagNames<Config>>(
|
||||||
|
name: Name,
|
||||||
|
) => <const Implementation extends Layer.Any, const Items extends NodeList>(
|
||||||
|
input: DistributiveOmit<MakeInput<Implementation, Items, Tag<Name>>, "tag"> &
|
||||||
|
CheckTags<Items, Name | Extract<Config[Name][number], string>>,
|
||||||
|
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tag<Name>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tags<const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }>(
|
||||||
|
config: Config,
|
||||||
|
): Tags<Config> {
|
||||||
|
const names = Object.keys(config) as TagNames<Config>[]
|
||||||
|
const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags<Config>["values"]
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
make: ((name: TagNames<Config>) => (input: DistributiveOmit<MakeInput<Layer.Any, NodeList, Tag>, "tag">) =>
|
||||||
|
make({ ...input, tag: values[name] })) as Tags<Config>["make"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Replacement = {
|
||||||
|
readonly source: Layer.Any
|
||||||
|
readonly replacement: Layer.Any
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
||||||
|
? unknown
|
||||||
|
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
||||||
|
|
||||||
|
export function replace<A, E, R, E2>(
|
||||||
|
source: Layer.Layer<A, E, R>,
|
||||||
|
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||||
|
): Replacement {
|
||||||
|
return { source, replacement }
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LayerNode from "./layer-node"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Layer } from "effect"
|
||||||
|
import { buildLocationServiceMap } from "../location-services"
|
||||||
|
import { LocationServiceMap } from "../location-service-map"
|
||||||
|
import { LayerNode, LayerNodeTree } from "./layer-node"
|
||||||
|
import { makeGlobalNode } from "./node"
|
||||||
|
|
||||||
|
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
|
||||||
|
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
|
||||||
|
|
||||||
|
const locationMap = buildLocationServiceMap(replacementMap)
|
||||||
|
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||||
|
|
||||||
|
const app = LayerNodeTree.bind(root, LocationServiceMap.node, locationMapNode)
|
||||||
|
|
||||||
|
return LayerNodeTree.compile(app, replacementMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as NodeBuild from "./node-build"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { LayerNode } from "./layer-node"
|
||||||
|
|
||||||
|
export const tags = LayerNode.tags({
|
||||||
|
location: ["global"],
|
||||||
|
global: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["global"]>
|
||||||
|
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["location"]>
|
||||||
|
|
||||||
|
export const makeGlobalNode = tags.make("global")
|
||||||
|
export const makeLocationNode = tags.make("location")
|
||||||
|
|
||||||
|
export * as Node from "./node"
|
||||||
+83
-17
@@ -1,13 +1,13 @@
|
|||||||
export * as EventV2 from "./event"
|
export * as EventV2 from "./event"
|
||||||
|
|
||||||
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||||
import { and, asc, eq, gt } from "drizzle-orm"
|
import { and, asc, eq, gt, inArray } 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"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
|
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
|
||||||
|
|
||||||
@@ -47,6 +47,71 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
|
|||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||||
|
const definition = Durable.get(event.type)
|
||||||
|
if (!definition?.durable) {
|
||||||
|
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
type: definition.type,
|
||||||
|
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||||
|
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
|
||||||
|
db: Database.Interface["db"],
|
||||||
|
input: {
|
||||||
|
readonly aggregateID: string
|
||||||
|
readonly after?: number
|
||||||
|
readonly limit: number
|
||||||
|
readonly manifest: {
|
||||||
|
readonly definitions: ReadonlyMap<string, Definition>
|
||||||
|
readonly schema: Schema.Decoder<A, never>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const after = input.after ?? -1
|
||||||
|
const rows = yield* db
|
||||||
|
.select()
|
||||||
|
.from(EventTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(EventTable.aggregate_id, input.aggregateID),
|
||||||
|
gt(EventTable.seq, after),
|
||||||
|
inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(EventTable.seq))
|
||||||
|
.limit(input.limit + 1)
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const page = rows.slice(0, input.limit)
|
||||||
|
const decode = Schema.decodeUnknownSync(input.manifest.schema)
|
||||||
|
const events = page.map((event) =>
|
||||||
|
decode({
|
||||||
|
id: event.id,
|
||||||
|
type: input.manifest.definitions.get(event.type)?.type ?? event.type,
|
||||||
|
durable: {
|
||||||
|
aggregateID: event.aggregate_id,
|
||||||
|
seq: event.seq,
|
||||||
|
version: input.manifest.definitions.get(event.type)?.durable?.version,
|
||||||
|
},
|
||||||
|
data: event.data,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
hasMore: rows.length > input.limit,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
||||||
|
"EventV2.SubscriberOverflow",
|
||||||
|
{ capacity: Schema.Int },
|
||||||
|
) {}
|
||||||
|
|
||||||
export const define = Event.define
|
export const define = Event.define
|
||||||
export const versionedType = Event.versionedType
|
export const versionedType = Event.versionedType
|
||||||
|
|
||||||
@@ -84,6 +149,20 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||||
|
|
||||||
|
export const allBounded = (events: Interface, capacity: number) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
|
||||||
|
const unsubscribe = yield* events.listen((event) =>
|
||||||
|
Queue.offer(queue, event).pipe(
|
||||||
|
Effect.flatMap((accepted) =>
|
||||||
|
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
|
||||||
|
return Stream.fromQueue(queue)
|
||||||
|
})
|
||||||
|
|
||||||
export interface LayerOptions {
|
export interface LayerOptions {
|
||||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
@@ -459,19 +538,6 @@ 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) => {
|
|
||||||
const definition = Durable.get(event.type)
|
|
||||||
if (!definition?.durable) {
|
|
||||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: event.id,
|
|
||||||
type: definition.type,
|
|
||||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
|
||||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const readAfter = (aggregateID: string, after: number) =>
|
const readAfter = (aggregateID: string, after: number) =>
|
||||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
@@ -569,6 +635,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 = makeGlobalNode({ 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))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as FileMutation from "./file-mutation"
|
export * as FileMutation from "./file-mutation"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { dirname } from "path"
|
import { dirname } from "path"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
@@ -192,6 +193,8 @@ function sameBytes(left: Uint8Array, right: Uint8Array) {
|
|||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deferred until the corresponding V2 integrations exist.
|
* Deferred until the corresponding V2 integrations exist.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as FileSystem from "./filesystem"
|
export * as FileSystem from "./filesystem"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
@@ -113,3 +114,9 @@ const baseLayer = Layer.effect(
|
|||||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: baseLayer,
|
||||||
|
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as FileSystemSearch from "./search"
|
export * as FileSystemSearch from "./search"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
import { Fff } from "#fff"
|
import { Fff } from "#fff"
|
||||||
@@ -229,6 +230,8 @@ export const fffLayer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = Layer.unwrap(
|
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
|
||||||
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
|
||||||
)
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as Watcher from "./watcher"
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||||
import type ParcelWatcher from "@parcel/watcher"
|
import type ParcelWatcher from "@parcel/watcher"
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { Cause, Context, Effect, Layer } from "effect"
|
import { Cause, Context, Effect, Layer } from "effect"
|
||||||
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -133,3 +134,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
|
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
|||||||
import type { PlatformError } from "effect/PlatformError"
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
import { Glob } from "./util/glob"
|
import { Glob } from "./util/glob"
|
||||||
import { serviceUse } from "./effect/service-use"
|
import { serviceUse } from "./effect/service-use"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { filesystem } from "./effect/layer-node-platform"
|
import { filesystem } from "./effect/layer-node-platform"
|
||||||
|
|
||||||
export namespace FSUtil {
|
export namespace FSUtil {
|
||||||
@@ -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 = makeGlobalNode({ 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 {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ChildProcess } from "effect/unstable/process"
|
|||||||
import { AbsolutePath, RelativePath } from "./schema"
|
import { AbsolutePath, RelativePath } from "./schema"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { AppProcess } from "./process"
|
import { AppProcess } from "./process"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { File } from "./file"
|
import { File } from "./file"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
|
|
||||||
@@ -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 = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||||
|
|
||||||
interface Result {
|
interface Result {
|
||||||
readonly exitCode: number
|
readonly exitCode: number
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import os from "os"
|
|||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { Flock } from "./util/flock"
|
import { Flock } from "./util/flock"
|
||||||
import { Flag } from "./flag/flag"
|
import { Flag } from "./flag/flag"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
|
|
||||||
const app = "opencode"
|
const app = "opencode"
|
||||||
const data = path.join(xdgData!, app)
|
const data = path.join(xdgData!, app)
|
||||||
@@ -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 = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||||
|
|
||||||
export const layerWith = (input: Partial<Interface>) =>
|
export const layerWith = (input: Partial<Interface>) =>
|
||||||
Layer.effect(
|
Layer.effect(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Image from "./image"
|
export * as Image from "./image"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { FileSystem } from "./filesystem"
|
import { FileSystem } from "./filesystem"
|
||||||
@@ -76,3 +77,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Location } from "./location"
|
|||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { SystemContext } from "./system-context/index"
|
import { SystemContext } from "./system-context/index"
|
||||||
import { SystemContextRegistry } from "./system-context/registry"
|
import { SystemContextRegistry } from "./system-context/registry"
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
|
|
||||||
class File extends Schema.Class<File>("InstructionContext.File")({
|
class File extends Schema.Class<File>("InstructionContext.File")({
|
||||||
path: AbsolutePath,
|
path: AbsolutePath,
|
||||||
@@ -87,6 +88,12 @@ export const layer = Layer.effectDiscard(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
name: "instruction-context",
|
||||||
|
layer,
|
||||||
|
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
|
||||||
|
})
|
||||||
|
|
||||||
function render(files: ReadonlyArray<File>) {
|
function render(files: ReadonlyArray<File>) {
|
||||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user