mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 09:28:27 -04:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1398ee459 | |||
| 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 |
+2
-4
@@ -162,14 +162,12 @@ _Avoid_: Response envelope
|
|||||||
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
||||||
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
||||||
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
||||||
- `sessions.events({ sessionID, after })` is one public Session-scoped event stream. It verifies the Session, captures a fixed durable cutoff after registering observation, replays durable events in `(after, cutoff]`, emits one authoritative process-local `session.activity` value, then continues with committed durable events and live-only Session output fragments. Only durable events carry aggregate-sequence cursor metadata.
|
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
||||||
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
||||||
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
||||||
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
||||||
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold Session event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers resume with the last observed durable sequence; live-only fragments are not replayed, and every connection receives a fresh authoritative activity value.
|
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||||
- The Session event stream treats database rows as authoritative and process notifications as bounded, non-blocking wakeups. Before emitting an observed live-only fragment it drains later durable rows, preserving causal durable start boundaries without exposing an additional fence field.
|
|
||||||
- Aggregate deletion currently removes the Session's durable event rows and sequence, including deletion history. The Session event stream therefore cannot promise replay after deletion until retention or a typed history-expired outcome is designed.
|
|
||||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
||||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
- 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.
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -567,11 +572,9 @@
|
|||||||
"@effect/opentelemetry": "catalog:",
|
"@effect/opentelemetry": "catalog:",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@ff-labs/fff-bun": "0.9.4",
|
"@ff-labs/fff-bun": "0.9.4",
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
|
||||||
"@opencode-ai/llm": "workspace:*",
|
"@opencode-ai/llm": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -582,25 +585,17 @@
|
|||||||
"@opencode-ai/tui": "workspace:*",
|
"@opencode-ai/tui": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||||
"@opentelemetry/api": "1.9.0",
|
"@opentelemetry/api": "1.9.0",
|
||||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
|
||||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||||
"@opentelemetry/sdk-trace-node": "2.6.1",
|
"@opentelemetry/sdk-trace-node": "2.6.1",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
|
||||||
"@solid-primitives/scheduled": "1.5.2",
|
|
||||||
"@standard-schema/spec": "1.0.0",
|
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@zip.js/zip.js": "2.7.62",
|
|
||||||
"ai": "catalog:",
|
"ai": "catalog:",
|
||||||
"ai-gateway-provider": "3.1.2",
|
"ai-gateway-provider": "3.1.2",
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"chokidar": "4.0.3",
|
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
@@ -608,21 +603,17 @@
|
|||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.9.3",
|
"gitlab-ai-provider": "6.9.3",
|
||||||
"glob": "13.0.5",
|
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
"htmlparser2": "8.0.2",
|
"htmlparser2": "8.0.2",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"immer": "11.1.4",
|
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.0.3",
|
|
||||||
"npm-package-arg": "13.0.2",
|
"npm-package-arg": "13.0.2",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opencode-gitlab-auth": "2.1.0",
|
"opencode-gitlab-auth": "2.1.0",
|
||||||
"opencode-poe-auth": "0.0.1",
|
"opencode-poe-auth": "0.0.1",
|
||||||
"opentui-spinner": "catalog:",
|
"opentui-spinner": "catalog:",
|
||||||
"partial-json": "0.1.7",
|
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
@@ -635,19 +626,15 @@
|
|||||||
"vscode-jsonrpc": "8.2.1",
|
"vscode-jsonrpc": "8.2.1",
|
||||||
"web-tree-sitter": "0.25.10",
|
"web-tree-sitter": "0.25.10",
|
||||||
"ws": "8.21.0",
|
"ws": "8.21.0",
|
||||||
"xdg-basedir": "5.1.0",
|
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.4",
|
|
||||||
"@octokit/webhooks-types": "7.6.1",
|
"@octokit/webhooks-types": "7.6.1",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/http-recorder": "workspace:*",
|
"@opencode-ai/http-recorder": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@standard-schema/spec": "1.0.0",
|
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/babel__core": "7.20.5",
|
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/cross-spawn": "catalog:",
|
"@types/cross-spawn": "catalog:",
|
||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
@@ -660,7 +647,6 @@
|
|||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vscode-languageserver-types": "3.17.5",
|
"vscode-languageserver-types": "3.17.5",
|
||||||
"why-is-node-running": "3.2.2",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
@@ -986,6 +972,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 +1438,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=="],
|
||||||
@@ -1611,8 +1612,6 @@
|
|||||||
|
|
||||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||||
|
|
||||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
|
||||||
|
|
||||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||||
|
|
||||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||||
@@ -1677,10 +1676,6 @@
|
|||||||
|
|
||||||
"@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="],
|
"@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="],
|
||||||
|
|
||||||
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
|
|
||||||
|
|
||||||
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="],
|
|
||||||
|
|
||||||
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
|
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
|
||||||
|
|
||||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||||
@@ -2287,6 +2282,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=="],
|
||||||
@@ -2661,7 +2658,7 @@
|
|||||||
|
|
||||||
"@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="],
|
"@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="],
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.4.1", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.4.1" } }, "sha512-MGft/IXjJ20a9KbaSVG9bHTAAoanbucKrgEiJJRNqpim8DsXA01+XTdSk17LmiOCB203Rrq9mWgdQ6+79cc8iA=="],
|
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.4.1", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.4.1" } }, "sha512-MGft/IXjJ20a9KbaSVG9bHTAAoanbucKrgEiJJRNqpim8DsXA01+XTdSk17LmiOCB203Rrq9mWgdQ6+79cc8iA=="],
|
||||||
|
|
||||||
@@ -3781,7 +3778,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=="],
|
||||||
|
|
||||||
@@ -4625,8 +4622,6 @@
|
|||||||
|
|
||||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
"partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
|
|
||||||
|
|
||||||
"pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="],
|
"pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="],
|
||||||
|
|
||||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
@@ -5295,6 +5290,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=="],
|
||||||
@@ -5493,7 +5490,7 @@
|
|||||||
|
|
||||||
"which-typed-array": ["which-typed-array@1.1.21", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw=="],
|
"which-typed-array": ["which-typed-array@1.1.21", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw=="],
|
||||||
|
|
||||||
"why-is-node-running": ["why-is-node-running@3.2.2", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A=="],
|
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||||
|
|
||||||
"widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
|
"widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
|
||||||
|
|
||||||
@@ -5645,8 +5642,6 @@
|
|||||||
|
|
||||||
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
@@ -5861,6 +5856,8 @@
|
|||||||
|
|
||||||
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
|
|
||||||
|
"@hono/standard-validator/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||||
|
|
||||||
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||||
|
|
||||||
"@jsx-email/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"@jsx-email/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
@@ -6185,8 +6182,6 @@
|
|||||||
|
|
||||||
"editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
"editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||||
|
|
||||||
"effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
"electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||||
@@ -6311,10 +6306,6 @@
|
|||||||
|
|
||||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
"opencode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
|
|
||||||
|
|
||||||
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
|
|
||||||
|
|
||||||
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||||
|
|
||||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||||
@@ -6443,8 +6434,6 @@
|
|||||||
|
|
||||||
"vitest/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
"vitest/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||||
|
|
||||||
"vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
|
||||||
|
|
||||||
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
||||||
|
|
||||||
"wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
"wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
||||||
@@ -6473,44 +6462,6 @@
|
|||||||
|
|
||||||
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
|
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/cohere/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/deepgram/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/fireworks/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/perplexity/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/togetherai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||||
|
|
||||||
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
@@ -6845,10 +6796,6 @@
|
|||||||
|
|
||||||
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||||
|
|
||||||
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||||
@@ -7047,10 +6994,6 @@
|
|||||||
|
|
||||||
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||||
|
|
||||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||||
|
|
||||||
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
||||||
@@ -7271,10 +7214,6 @@
|
|||||||
|
|
||||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
|
"x86_64-linux": "sha256-rB/CcrBUQVZ08nBFSYA8u2w88rQmTpKxKPkIreDEKgI=",
|
||||||
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
|
"aarch64-linux": "sha256-ZRTphtic8Ip96MnILteFgZAUxjK9O4YfJu2O6u/0H8k=",
|
||||||
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
|
"aarch64-darwin": "sha256-VK5XIzraP0HtqnPwPCejiDKer4ewtNtX1vxP5uuyjSk=",
|
||||||
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
|
"x86_64-darwin": "sha256-ZLPHqcCZB1EmxQk95cmUpiODTTKOyi7PSF0yr/rDk6Y="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
+25
-13
@@ -16,6 +16,7 @@ import {
|
|||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
|
createRenderEffect,
|
||||||
createResource,
|
createResource,
|
||||||
createSignal,
|
createSignal,
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
@@ -37,7 +38,7 @@ 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"
|
||||||
@@ -278,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)
|
||||||
@@ -314,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>
|
||||||
)
|
)
|
||||||
@@ -343,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>
|
||||||
@@ -558,11 +568,13 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<ServerShell>
|
<NotificationProvider>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<ServerShell>
|
||||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
</Show>
|
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||||
</ServerShell>
|
</Show>
|
||||||
|
</ServerShell>
|
||||||
|
</NotificationProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1343,9 +1343,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,
|
||||||
@@ -1359,6 +1359,7 @@ 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.keybind("model.choose"),
|
||||||
@@ -1519,10 +1520,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
{props.toolbar}
|
{props.toolbar}
|
||||||
<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,
|
||||||
}}
|
}}
|
||||||
@@ -1765,7 +1767,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"
|
||||||
@@ -1794,7 +1796,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}
|
||||||
@@ -1873,7 +1875,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"
|
||||||
@@ -1923,6 +1925,7 @@ type ComposerAgentControlState = {
|
|||||||
|
|
||||||
type ComposerModelControlState = {
|
type ComposerModelControlState = {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
shouldAnimate: boolean
|
||||||
paid: boolean
|
paid: boolean
|
||||||
title: string
|
title: string
|
||||||
keybind: string
|
keybind: string
|
||||||
@@ -1970,6 +1973,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
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}
|
||||||
>
|
>
|
||||||
@@ -2000,6 +2004,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}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Popover } from "@kobalte/core/popover"
|
|
||||||
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
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 { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
import { getProjectAvatarVariant } from "@/context/layout"
|
import { getProjectAvatarVariant } from "@/context/layout"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -25,12 +26,23 @@ export type PromptProjectControls = {
|
|||||||
add: (title: 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: {
|
export function createPromptProjectController(input: {
|
||||||
controls: Accessor<PromptProjectControls>
|
controls: Accessor<PromptProjectControls>
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [store, setStore] = createStore({ open: false, search: "" })
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
let searchRef: HTMLInputElement | undefined
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
const selected = () => {
|
const selected = () => {
|
||||||
@@ -53,8 +65,27 @@ export function createPromptProjectController(input: {
|
|||||||
.controls()
|
.controls()
|
||||||
.available.map((project) => project.server)
|
.available.map((project) => project.server)
|
||||||
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
|
.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 = () => {
|
const close = () => {
|
||||||
setStore({ open: false, search: "" })
|
setStore({ open: false, search: "", active: "" })
|
||||||
input.onDone()
|
input.onDone()
|
||||||
}
|
}
|
||||||
const select = (project: PromptProject) => {
|
const select = (project: PromptProject) => {
|
||||||
@@ -67,7 +98,7 @@ export function createPromptProjectController(input: {
|
|||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
const add = (server?: string) => {
|
const add = (server?: string) => {
|
||||||
setStore("open", false)
|
setStore({ open: false, search: "", active: "" })
|
||||||
input.controls().add(language.t("command.project.open"), server)
|
input.controls().add(language.t("command.project.open"), server)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +106,11 @@ export function createPromptProjectController(input: {
|
|||||||
selected,
|
selected,
|
||||||
projects,
|
projects,
|
||||||
servers,
|
servers,
|
||||||
|
projectKey,
|
||||||
|
actionKey,
|
||||||
open: () => store.open,
|
open: () => store.open,
|
||||||
search: () => store.search,
|
search: () => store.search,
|
||||||
|
active: () => store.active,
|
||||||
labels: {
|
labels: {
|
||||||
add: () => language.t("session.new.project.add"),
|
add: () => language.t("session.new.project.add"),
|
||||||
clear: () => language.t("common.clear"),
|
clear: () => language.t("common.clear"),
|
||||||
@@ -86,50 +120,202 @@ export function createPromptProjectController(input: {
|
|||||||
add,
|
add,
|
||||||
select,
|
select,
|
||||||
setOpen(open: boolean) {
|
setOpen(open: boolean) {
|
||||||
setStore("open", open)
|
if (open) {
|
||||||
if (open) requestAnimationFrame(() => searchRef?.focus())
|
setStore({ open: true, active: initialActive() })
|
||||||
|
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore({ open: false, search: "", active: "" })
|
||||||
},
|
},
|
||||||
setSearch(value: string) {
|
setSearch(value: string) {
|
||||||
setStore("search", value)
|
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) {
|
setSearchRef(el: HTMLInputElement) {
|
||||||
searchRef = el
|
searchRef = el
|
||||||
},
|
},
|
||||||
|
focusSearch() {
|
||||||
|
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
||||||
|
|
||||||
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
|
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 (
|
return (
|
||||||
<Popover
|
<DropdownMenu
|
||||||
open={props.controller.open()}
|
open={props.controller.open()}
|
||||||
placement="bottom-start"
|
placement={props.placement ?? "bottom"}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||||
>
|
>
|
||||||
<Popover.Trigger as={ProjectTrigger} controller={props.controller} />
|
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
|
||||||
<Popover.Portal>
|
<DropdownMenu.Portal>
|
||||||
<Popover.Content
|
<DropdownMenu.Content
|
||||||
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
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()}
|
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 flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
|
<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" />
|
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||||
<input
|
<input
|
||||||
ref={(el) => props.controller.setSearchRef(el)}
|
ref={(el) => props.controller.setSearchRef(el)}
|
||||||
value={props.controller.search()}
|
value={props.controller.search()}
|
||||||
placeholder={props.controller.labels.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"
|
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)}
|
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()}>
|
<Show when={props.controller.search().trim()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||||
onClick={() => props.controller.setSearch("")}
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => props.controller.clearSearch()}
|
||||||
aria-label={props.controller.labels.clear()}
|
aria-label={props.controller.labels.clear()}
|
||||||
>
|
>
|
||||||
<Icon name="close-small" size="small" />
|
<Icon name="close-small" size="small" />
|
||||||
@@ -139,53 +325,80 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
|||||||
<Show
|
<Show
|
||||||
when={props.controller.servers().length > 1}
|
when={props.controller.servers().length > 1}
|
||||||
fallback={
|
fallback={
|
||||||
<For each={props.controller.projects()}>
|
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||||
{(project) => (
|
<For each={props.controller.projects()}>
|
||||||
<ProjectItem
|
{(project) => (
|
||||||
project={project}
|
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||||
selected={props.controller.selected()}
|
)}
|
||||||
onSelect={props.controller.select}
|
</For>
|
||||||
/>
|
</DropdownMenu.RadioGroup>
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={props.controller.servers()}>
|
<For
|
||||||
|
each={props.controller
|
||||||
|
.servers()
|
||||||
|
.filter((server) =>
|
||||||
|
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||||
|
)}
|
||||||
|
>
|
||||||
{(server) => (
|
{(server) => (
|
||||||
<div>
|
<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">
|
<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}
|
{server!.name}
|
||||||
</div>
|
</div>
|
||||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||||
{(project) => (
|
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
||||||
<ProjectItem
|
{(project) => (
|
||||||
project={project}
|
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||||
selected={props.controller.selected()}
|
)}
|
||||||
onSelect={props.controller.select}
|
</For>
|
||||||
/>
|
</DropdownMenu.RadioGroup>
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<ProjectAction
|
|
||||||
label={props.controller.labels.add()}
|
|
||||||
onSelect={() => props.controller.add(server!.key)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.controller.servers().length <= 1}>
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
<div class="h-px bg-v2-border-border-muted" />
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex flex-col p-0.5">
|
<Show
|
||||||
<ProjectAction
|
when={props.controller.servers().length > 1}
|
||||||
label={props.controller.labels.add()}
|
fallback={
|
||||||
onSelect={() => props.controller.add(props.controller.servers()[0]?.key)}
|
<ProjectAction
|
||||||
/>
|
server={props.controller.servers()[0]?.key}
|
||||||
</div>
|
controller={props.controller}
|
||||||
</Show>
|
onSelect={selectAction}
|
||||||
</Popover.Content>
|
/>
|
||||||
</Popover.Portal>
|
}
|
||||||
</Popover>
|
>
|
||||||
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,15 +418,29 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
|
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
|
||||||
const [local, rest] = splitProps(props, ["controller", "class", "onClick"])
|
const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
|
||||||
const project = () => local.controller.selected()
|
const project = () => local.controller.selected()
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
{...rest}
|
{...rest}
|
||||||
data-action="prompt-project"
|
data-action="prompt-project"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
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"
|
||||||
onClick={() => local.controller.setOpen(true)}
|
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
|
<Show
|
||||||
when={project()}
|
when={project()}
|
||||||
@@ -237,42 +464,88 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
|
|||||||
|
|
||||||
function ProjectItem(props: {
|
function ProjectItem(props: {
|
||||||
project: PromptProject
|
project: PromptProject
|
||||||
selected?: PromptProject
|
controller: PromptProjectController
|
||||||
onSelect: (project: PromptProject) => void
|
onSelect: (project: PromptProject) => void
|
||||||
}) {
|
}) {
|
||||||
|
const key = () => props.controller.projectKey(props.project)
|
||||||
return (
|
return (
|
||||||
<button
|
<DropdownMenu.RadioItem
|
||||||
type="button"
|
id={key()}
|
||||||
class="flex h-7 w-full items-center gap-2 rounded-sm 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"
|
value={key()}
|
||||||
onClick={() => props.onSelect(props.project)}
|
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
|
<ProjectAvatar
|
||||||
fallback={displayName(props.project)}
|
fallback={displayName(props.project)}
|
||||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||||
/>
|
/>
|
||||||
<span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span>
|
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
|
||||||
<Show
|
<DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
|
||||||
when={
|
<IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||||
props.selected?.worktree === props.project.worktree &&
|
</DropdownMenu.ItemIndicator>
|
||||||
props.selected?.server?.key === props.project.server?.key
|
</DropdownMenu.RadioItem>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectAction(props: { label: string; onSelect: () => void }) {
|
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 (
|
return (
|
||||||
<button
|
<DropdownMenu.Item
|
||||||
type="button"
|
id={key()}
|
||||||
class="flex h-7 w-full items-center gap-2 rounded-sm 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"
|
data-option-key={key()}
|
||||||
onClick={props.onSelect}
|
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" />
|
<Icon name="plus" size="small" />
|
||||||
<span class="min-w-0 flex-1 truncate leading-5">{props.label}</span>
|
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
|
||||||
</button>
|
{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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -173,112 +170,105 @@ export function TabNavItem(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-titlebar-tab-slot
|
ref={(el) => {
|
||||||
data-tab-key={props.tabKey}
|
tabRoot = el
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
forwardTabRef(props.ref, el)
|
||||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
}}
|
||||||
onPointerDown={props.onPointerDown}
|
data-titlebar-tab
|
||||||
|
data-slot="titlebar-tab-item"
|
||||||
|
data-title-overflow={titleOverflowing()}
|
||||||
|
data-editing={editing()}
|
||||||
|
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
|
classList={{ invisible: props.hidden }}
|
||||||
|
data-active={props.active}
|
||||||
|
data-dragging={props.dragging}
|
||||||
|
data-pressed={props.pressed}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.button !== 1) return
|
||||||
|
closeTab(event)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Show when={props.session()}>
|
||||||
ref={(el) => {
|
{(session) => {
|
||||||
tabRoot = el
|
return (
|
||||||
forwardTabRef(props.ref, el)
|
<a
|
||||||
}}
|
data-slot="tab-link"
|
||||||
data-titlebar-tab
|
data-titlebar-tab-link
|
||||||
data-slot="titlebar-tab-item"
|
href={props.href}
|
||||||
data-title-overflow={titleOverflowing()}
|
draggable={false}
|
||||||
data-editing={editing()}
|
onDragStart={(event) => {
|
||||||
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)]"
|
event.preventDefault()
|
||||||
data-active={props.active}
|
event.stopPropagation()
|
||||||
data-dragging={props.dragging}
|
}}
|
||||||
data-pressed={props.pressed}
|
onClick={(event) => {
|
||||||
onMouseDown={(event) => {
|
event.preventDefault()
|
||||||
if (event.button !== 1) return
|
if (editing()) return
|
||||||
closeTab(event)
|
if (props.suppressNavigation?.()) return
|
||||||
}}
|
props.onNavigate()
|
||||||
>
|
}}
|
||||||
<Show when={props.session()}>
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
{(session) => {
|
>
|
||||||
return (
|
<span data-slot="project-avatar-slot">
|
||||||
<a
|
<SessionTabAvatar
|
||||||
data-slot="tab-link"
|
project={project()}
|
||||||
data-titlebar-tab-link
|
directory={session().directory}
|
||||||
href={props.href}
|
sessionId={session().id}
|
||||||
draggable={false}
|
activeServer={props.activeServer}
|
||||||
onDragStart={(event) => {
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
ref={(el) => {
|
||||||
|
titleEl = el
|
||||||
|
titleEl.textContent = session().title
|
||||||
|
}}
|
||||||
|
data-slot="tab-title"
|
||||||
|
data-titlebar-tab-title
|
||||||
|
class="min-w-0 flex-1 outline-none leading-4"
|
||||||
|
classList={{
|
||||||
|
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||||
|
"select-text": editing(),
|
||||||
|
}}
|
||||||
|
contenteditable={editing() ? true : undefined}
|
||||||
|
onDblClick={openRename}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void closeRename(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== "Escape") return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
titleEl.textContent = session().title
|
||||||
|
void closeRename(false)
|
||||||
|
}}
|
||||||
|
onBlur={() => void closeRename(true)}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (!editing()) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
if (!editing()) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (editing()) return
|
|
||||||
if (props.suppressNavigation?.()) return
|
|
||||||
props.onNavigate()
|
|
||||||
}}
|
}}
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
/>
|
||||||
>
|
</a>
|
||||||
<span data-slot="project-avatar-slot">
|
)
|
||||||
<SessionTabAvatar
|
}}
|
||||||
project={project()}
|
</Show>
|
||||||
directory={session().directory}
|
|
||||||
sessionId={session().id}
|
|
||||||
activeServer={props.activeServer}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
ref={(el) => {
|
|
||||||
titleEl = el
|
|
||||||
titleEl.textContent = session().title
|
|
||||||
}}
|
|
||||||
data-slot="tab-title"
|
|
||||||
data-titlebar-tab-title
|
|
||||||
class="min-w-0 flex-1 outline-none leading-4"
|
|
||||||
classList={{
|
|
||||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
|
||||||
"select-text": editing(),
|
|
||||||
}}
|
|
||||||
contenteditable={editing() ? true : undefined}
|
|
||||||
onDblClick={openRename}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault()
|
|
||||||
void closeRename(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.key !== "Escape") return
|
|
||||||
event.preventDefault()
|
|
||||||
titleEl.textContent = session().title
|
|
||||||
void closeRename(false)
|
|
||||||
}}
|
|
||||||
onBlur={() => void closeRename(true)}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (!editing()) return
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (!editing()) return
|
|
||||||
event.preventDefault()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onClick={closeTab}
|
onClick={closeTab}
|
||||||
icon={<IconV2 name="xmark-small" />}
|
icon={<IconV2 name="xmark-small" />}
|
||||||
/>
|
/>
|
||||||
</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()
|
||||||
@@ -306,69 +293,62 @@ export function DraftTabItem(props: {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-titlebar-tab-slot
|
ref={(el) => forwardTabRef(props.ref, el)}
|
||||||
data-tab-key={props.tabKey}
|
data-titlebar-tab
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
data-slot="titlebar-tab-item"
|
||||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
data-active={props.active}
|
||||||
onPointerDown={props.onPointerDown}
|
data-dragging={props.dragging}
|
||||||
|
data-pressed={props.pressed}
|
||||||
|
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) => {
|
||||||
|
if (event.button !== 1) return
|
||||||
|
closeTab(event)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<a
|
||||||
ref={(el) => forwardTabRef(props.ref, el)}
|
data-slot="tab-link"
|
||||||
data-titlebar-tab
|
data-titlebar-tab-link
|
||||||
data-slot="titlebar-tab-item"
|
href={props.href}
|
||||||
data-active={props.active}
|
draggable={false}
|
||||||
data-dragging={props.dragging}
|
onDragStart={(event) => {
|
||||||
data-pressed={props.pressed}
|
event.preventDefault()
|
||||||
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)]"
|
event.stopPropagation()
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.button !== 1) return
|
|
||||||
closeTab(event)
|
|
||||||
}}
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (props.suppressNavigation?.()) return
|
||||||
|
props.onNavigate()
|
||||||
|
}}
|
||||||
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
>
|
>
|
||||||
<a
|
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||||
data-slot="tab-link"
|
<IconV2 name="edit" />
|
||||||
data-titlebar-tab-link
|
</span>
|
||||||
href={props.href}
|
<span
|
||||||
draggable={false}
|
data-titlebar-tab-title
|
||||||
onDragStart={(event) => {
|
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||||
|
>
|
||||||
|
{props.title}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||||
|
<IconButtonV2
|
||||||
|
size="small"
|
||||||
|
variant="ghost-muted"
|
||||||
|
onPointerDown={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onMouseDown={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (props.suppressNavigation?.()) return
|
event.stopPropagation()
|
||||||
props.onNavigate()
|
|
||||||
}}
|
}}
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||||
>
|
onClick={closeTab}
|
||||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
icon={<IconV2 name="xmark-small" />}
|
||||||
<IconV2 name="edit" />
|
aria-label="Close tab"
|
||||||
</span>
|
/>
|
||||||
<span
|
|
||||||
data-titlebar-tab-title
|
|
||||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
|
||||||
>
|
|
||||||
{props.title}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
|
||||||
<IconButtonV2
|
|
||||||
size="small"
|
|
||||||
variant="ghost-muted"
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
|
||||||
onClick={closeTab}
|
|
||||||
icon={<IconV2 name="xmark-small" />}
|
|
||||||
aria-label="Close tab"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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,33 +81,79 @@ function SessionTabSlot(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabNavItem
|
<div
|
||||||
tabKey={props.id}
|
ref={sortable.ref}
|
||||||
dragActive={props.dragActive}
|
data-titlebar-tab-slot
|
||||||
onPointerDown={props.onPointerDown}
|
data-tab-key={props.id}
|
||||||
ref={ref}
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
href={tabHref(props.tab)}
|
classList={{ hidden: !session() }}
|
||||||
server={props.tab.server}
|
>
|
||||||
session={session}
|
<TabNavItem
|
||||||
onTitleChange={(title) => {
|
ref={(el) => {
|
||||||
const value = session()
|
ref = el
|
||||||
const ctx = props.serverCtx()
|
}}
|
||||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
href={tabHref(props.tab)}
|
||||||
}}
|
server={props.tab.server}
|
||||||
onTitleChangeFailed={(title) => {
|
session={session}
|
||||||
const value = session()
|
onTitleChange={(title) => {
|
||||||
const ctx = props.serverCtx()
|
const value = session()
|
||||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
const ctx = props.serverCtx()
|
||||||
}}
|
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
}}
|
||||||
onClose={props.onClose}
|
onTitleChangeFailed={(title) => {
|
||||||
active={props.active()}
|
const value = session()
|
||||||
activeServer={props.tab.server === props.activeServerKey}
|
const ctx = props.serverCtx()
|
||||||
forceTruncate={props.forceTruncate}
|
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||||
suppressNavigation={props.suppressNavigation}
|
}}
|
||||||
pressed={props.pressed()}
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
hidden={props.dragged() || !session()}
|
onClose={props.onClose}
|
||||||
/>
|
active={props.active()}
|
||||||
|
activeServer={props.tab.server === props.activeServerKey}
|
||||||
|
forceTruncate={props.forceTruncate}
|
||||||
|
dragging={sortable.isDragSource()}
|
||||||
|
/>
|
||||||
|
</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}
|
>
|
||||||
|
<DragDropProvider
|
||||||
|
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 min-w-0 flex-row items-center" ref={listRef}>
|
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
||||||
<For each={displayTabs()}>
|
<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,68 +264,50 @@ 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>
|
||||||
</div>
|
</DragDropProvider>
|
||||||
<div
|
|
||||||
data-slot="titlebar-tabs-fade-left"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
data-slot="titlebar-tabs-fade-right"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={drag.active && draggedTab() && dragPreview}>
|
<div
|
||||||
{(_) => (
|
data-slot="titlebar-tabs-fade-left"
|
||||||
<Portal>
|
aria-hidden="true"
|
||||||
<div data-titlebar-tab-preview style={floaterStyle()}>
|
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
||||||
{dragPreview}
|
/>
|
||||||
</div>
|
<div
|
||||||
</Portal>
|
data-slot="titlebar-tabs-fade-right"
|
||||||
)}
|
aria-hidden="true"
|
||||||
</Show>
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,267 +112,360 @@ 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 empty: Notification[] = []
|
const activeServer = createMemo(() => {
|
||||||
|
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||||
const currentDirectory = createMemo(() => {
|
if (search.draftId) {
|
||||||
return props.directory?.() ?? decode64(params.dir)
|
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 currentSession = createMemo(() => props.sessionID?.() ?? params.id)
|
const ensure = (key: ServerConnection.Key) => {
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||||
const [store, setStore, _, ready] = persisted(
|
if (!conn) throw new Error(`Notification server not found: ${key}`)
|
||||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
const ctx = global.ensureServerCtx(conn)
|
||||||
createStore({
|
const existing = states.get(ctx.sdk.scope)
|
||||||
list: [] as Notification[],
|
if (existing) return existing.state
|
||||||
}),
|
const root = createRoot(
|
||||||
)
|
(dispose) => ({
|
||||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
dispose,
|
||||||
|
state: createServerNotificationState({
|
||||||
const meta = { pruned: false, disposed: false }
|
sdk: ctx.sdk,
|
||||||
|
sync: ctx.sync,
|
||||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
active: () => server.scope(activeServer()) === ctx.sdk.scope,
|
||||||
setIndex(scope, "unseen", key, unseen)
|
directory: activeDirectory,
|
||||||
setIndex(scope, "unseenCount", key, unseen.length)
|
sessionID: activeSession,
|
||||||
setIndex(
|
platform,
|
||||||
scope,
|
settings,
|
||||||
"unseenHasError",
|
language,
|
||||||
key,
|
}),
|
||||||
unseen.some((notification) => notification.type === "error"),
|
}),
|
||||||
|
owner ?? undefined,
|
||||||
)
|
)
|
||||||
}
|
states.set(ctx.sdk.scope, root)
|
||||||
|
return root.state
|
||||||
const appendToIndex = (notification: Notification) => {
|
|
||||||
if (notification.session) {
|
|
||||||
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
|
||||||
if (!notification.viewed) {
|
|
||||||
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
|
||||||
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
|
||||||
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.directory) {
|
|
||||||
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
|
||||||
if (!notification.viewed) {
|
|
||||||
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
|
||||||
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
|
||||||
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFromIndex = (notification: Notification) => {
|
|
||||||
if (notification.session) {
|
|
||||||
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
|
||||||
if (!notification.viewed) {
|
|
||||||
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
|
||||||
updateUnseen("session", notification.session, unseen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.directory) {
|
|
||||||
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
|
||||||
if (!notification.viewed) {
|
|
||||||
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
|
||||||
updateUnseen("project", notification.directory, unseen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready()) return
|
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||||
if (meta.pruned) return
|
})
|
||||||
meta.pruned = true
|
|
||||||
const list = pruneNotifications(store.list)
|
createEffect(() => {
|
||||||
batch(() => {
|
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
|
||||||
setStore("list", list)
|
states.forEach((value, scope) => {
|
||||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
if (scopes.has(scope)) return
|
||||||
|
value.dispose()
|
||||||
|
states.delete(scope)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const append = (notification: Notification) => {
|
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||||
const list = pruneNotifications([...store.list, notification])
|
|
||||||
const keep = new Set(list)
|
|
||||||
const removed = store.list.filter((n) => !keep.has(n))
|
|
||||||
|
|
||||||
batch(() => {
|
const selected = () => ensure(activeServer())
|
||||||
if (keep.has(notification)) appendToIndex(notification)
|
|
||||||
removed.forEach((n) => removeFromIndex(n))
|
|
||||||
setStore("list", list)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookup = async (directory: string, sessionID?: string) => {
|
|
||||||
if (!sessionID) return undefined
|
|
||||||
const sync = serverSync().ensureDirSyncContext(directory)
|
|
||||||
const session = sync.session.get(sessionID)
|
|
||||||
if (session) return session
|
|
||||||
return sync.session
|
|
||||||
.sync(sessionID)
|
|
||||||
.then(() => sync.session.get(sessionID))
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
|
||||||
const activeDirectory = currentDirectory()
|
|
||||||
const activeSession = currentSession()
|
|
||||||
if (!activeDirectory) return false
|
|
||||||
if (!activeSession) return false
|
|
||||||
if (!sessionID) return false
|
|
||||||
if (directory !== activeDirectory) return false
|
|
||||||
return sessionID === activeSession
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
|
||||||
const sessionID = event.properties.sessionID
|
|
||||||
void lookup(directory, sessionID).then((session) => {
|
|
||||||
if (meta.disposed) return
|
|
||||||
if (!session) return
|
|
||||||
if (session.parentID) return
|
|
||||||
|
|
||||||
if (settings.sounds.agentEnabled()) {
|
|
||||||
void playSoundById(settings.sounds.agent())
|
|
||||||
}
|
|
||||||
|
|
||||||
append({
|
|
||||||
directory,
|
|
||||||
time,
|
|
||||||
viewed: viewedInCurrentSession(directory, sessionID),
|
|
||||||
type: "turn-complete",
|
|
||||||
session: sessionID,
|
|
||||||
})
|
|
||||||
|
|
||||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
|
||||||
if (settings.notifications.agent()) {
|
|
||||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSessionError = (
|
|
||||||
directory: string,
|
|
||||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
|
||||||
time: number,
|
|
||||||
) => {
|
|
||||||
const sessionID = event.properties.sessionID
|
|
||||||
void lookup(directory, sessionID).then((session) => {
|
|
||||||
if (meta.disposed) return
|
|
||||||
if (session?.parentID) return
|
|
||||||
|
|
||||||
if (settings.sounds.errorsEnabled()) {
|
|
||||||
void playSoundById(settings.sounds.errors())
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = "error" in event.properties ? event.properties.error : undefined
|
|
||||||
append({
|
|
||||||
directory,
|
|
||||||
time,
|
|
||||||
viewed: viewedInCurrentSession(directory, sessionID),
|
|
||||||
type: "error",
|
|
||||||
session: sessionID ?? "global",
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
const description =
|
|
||||||
session?.title ??
|
|
||||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
|
||||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
|
||||||
if (settings.notifications.errors()) {
|
|
||||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsub = serverSDK().event.listen((e) => {
|
|
||||||
const event = e.details
|
|
||||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
|
||||||
|
|
||||||
const directory = e.name
|
|
||||||
const time = Date.now()
|
|
||||||
if (event.type === "session.idle") {
|
|
||||||
handleSessionIdle(directory, event, time)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleSessionError(directory, event, time)
|
|
||||||
})
|
|
||||||
onCleanup(() => {
|
|
||||||
meta.disposed = true
|
|
||||||
unsub()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => selected().ready(),
|
||||||
|
ensureServerState: ensure,
|
||||||
session: {
|
session: {
|
||||||
all(session: string) {
|
all: (session: string) => selected().session.all(session),
|
||||||
return index.session.all[session] ?? empty
|
unseen: (session: string) => selected().session.unseen(session),
|
||||||
},
|
unseenCount: (session: string) => selected().session.unseenCount(session),
|
||||||
unseen(session: string) {
|
unseenHasError: (session: string) => selected().session.unseenHasError(session),
|
||||||
return index.session.unseen[session] ?? empty
|
markViewed: (session: string) => selected().session.markViewed(session),
|
||||||
},
|
|
||||||
unseenCount(session: string) {
|
|
||||||
return index.session.unseenCount[session] ?? 0
|
|
||||||
},
|
|
||||||
unseenHasError(session: string) {
|
|
||||||
return index.session.unseenHasError[session] ?? false
|
|
||||||
},
|
|
||||||
markViewed(session: string) {
|
|
||||||
const unseen = index.session.unseen[session] ?? empty
|
|
||||||
if (!unseen.length) return
|
|
||||||
|
|
||||||
const projects = [
|
|
||||||
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
|
||||||
]
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
|
||||||
updateUnseen("session", session, [])
|
|
||||||
projects.forEach((directory) => {
|
|
||||||
const next = (index.project.unseen[directory] ?? empty).filter(
|
|
||||||
(notification) => notification.session !== session,
|
|
||||||
)
|
|
||||||
updateUnseen("project", directory, next)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
all(directory: string) {
|
all: (directory: string) => selected().project.all(directory),
|
||||||
return index.project.all[directory] ?? empty
|
unseen: (directory: string) => selected().project.unseen(directory),
|
||||||
},
|
unseenCount: (directory: string) => selected().project.unseenCount(directory),
|
||||||
unseen(directory: string) {
|
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
|
||||||
return index.project.unseen[directory] ?? empty
|
markViewed: (directory: string) => selected().project.markViewed(directory),
|
||||||
},
|
|
||||||
unseenCount(directory: string) {
|
|
||||||
return index.project.unseenCount[directory] ?? 0
|
|
||||||
},
|
|
||||||
unseenHasError(directory: string) {
|
|
||||||
return index.project.unseenHasError[directory] ?? false
|
|
||||||
},
|
|
||||||
markViewed(directory: string) {
|
|
||||||
const unseen = index.project.unseen[directory] ?? empty
|
|
||||||
if (!unseen.length) return
|
|
||||||
|
|
||||||
const sessions = [
|
|
||||||
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
|
||||||
]
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
|
||||||
updateUnseen("project", directory, [])
|
|
||||||
sessions.forEach((session) => {
|
|
||||||
const next = (index.session.unseen[session] ?? empty).filter(
|
|
||||||
(notification) => notification.directory !== directory,
|
|
||||||
)
|
|
||||||
updateUnseen("session", session, next)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 currentDirectory = input.directory
|
||||||
|
const currentSession = input.sessionID
|
||||||
|
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||||
|
createStore({
|
||||||
|
list: [] as Notification[],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||||
|
|
||||||
|
const meta = { pruned: false, disposed: false }
|
||||||
|
|
||||||
|
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
||||||
|
setIndex(scope, "unseen", key, unseen)
|
||||||
|
setIndex(scope, "unseenCount", key, unseen.length)
|
||||||
|
setIndex(
|
||||||
|
scope,
|
||||||
|
"unseenHasError",
|
||||||
|
key,
|
||||||
|
unseen.some((notification) => notification.type === "error"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendToIndex = (notification: Notification) => {
|
||||||
|
if (notification.session) {
|
||||||
|
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
||||||
|
if (!notification.viewed) {
|
||||||
|
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
||||||
|
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
||||||
|
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.directory) {
|
||||||
|
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
||||||
|
if (!notification.viewed) {
|
||||||
|
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
||||||
|
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
||||||
|
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromIndex = (notification: Notification) => {
|
||||||
|
if (notification.session) {
|
||||||
|
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
||||||
|
if (!notification.viewed) {
|
||||||
|
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
||||||
|
updateUnseen("session", notification.session, unseen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.directory) {
|
||||||
|
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
||||||
|
if (!notification.viewed) {
|
||||||
|
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
||||||
|
updateUnseen("project", notification.directory, unseen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (meta.pruned) return
|
||||||
|
meta.pruned = true
|
||||||
|
const list = pruneNotifications(store.list)
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", list)
|
||||||
|
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const append = (notification: Notification) => {
|
||||||
|
const list = pruneNotifications([...store.list, notification])
|
||||||
|
const keep = new Set(list)
|
||||||
|
const removed = store.list.filter((n) => !keep.has(n))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
if (keep.has(notification)) appendToIndex(notification)
|
||||||
|
removed.forEach((n) => removeFromIndex(n))
|
||||||
|
setStore("list", list)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookup = async (directory: string, sessionID?: string) => {
|
||||||
|
if (!sessionID) return undefined
|
||||||
|
const sync = serverSync().ensureDirSyncContext(directory)
|
||||||
|
const session = sync.session.get(sessionID)
|
||||||
|
if (session) return session
|
||||||
|
return sync.session
|
||||||
|
.sync(sessionID)
|
||||||
|
.then(() => sync.session.get(sessionID))
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||||
|
if (!input.active()) return false
|
||||||
|
const activeDirectory = currentDirectory()
|
||||||
|
const activeSession = currentSession()
|
||||||
|
if (!activeSession) return false
|
||||||
|
if (!sessionID) return false
|
||||||
|
if (activeDirectory && directory !== activeDirectory) return false
|
||||||
|
return sessionID === activeSession
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||||
|
const sessionID = event.properties.sessionID
|
||||||
|
void lookup(directory, sessionID).then((session) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (!session) return
|
||||||
|
if (session.parentID) return
|
||||||
|
|
||||||
|
if (settings.sounds.agentEnabled()) {
|
||||||
|
void playSoundById(settings.sounds.agent())
|
||||||
|
}
|
||||||
|
|
||||||
|
append({
|
||||||
|
directory,
|
||||||
|
time,
|
||||||
|
viewed: viewedInCurrentSession(directory, sessionID),
|
||||||
|
type: "turn-complete",
|
||||||
|
session: sessionID,
|
||||||
|
})
|
||||||
|
|
||||||
|
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||||
|
if (settings.notifications.agent()) {
|
||||||
|
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionError = (
|
||||||
|
directory: string,
|
||||||
|
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||||
|
time: number,
|
||||||
|
) => {
|
||||||
|
const sessionID = event.properties.sessionID
|
||||||
|
void lookup(directory, sessionID).then((session) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (session?.parentID) return
|
||||||
|
|
||||||
|
if (settings.sounds.errorsEnabled()) {
|
||||||
|
void playSoundById(settings.sounds.errors())
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = "error" in event.properties ? event.properties.error : undefined
|
||||||
|
append({
|
||||||
|
directory,
|
||||||
|
time,
|
||||||
|
viewed: viewedInCurrentSession(directory, sessionID),
|
||||||
|
type: "error",
|
||||||
|
session: sessionID ?? "global",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
const description =
|
||||||
|
session?.title ??
|
||||||
|
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||||
|
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||||
|
if (settings.notifications.errors()) {
|
||||||
|
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsub = serverSDK().event.listen((e) => {
|
||||||
|
const event = e.details
|
||||||
|
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||||
|
|
||||||
|
const directory = e.name
|
||||||
|
const time = Date.now()
|
||||||
|
if (event.type === "session.idle") {
|
||||||
|
handleSessionIdle(directory, event, time)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleSessionError(directory, event, time)
|
||||||
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
meta.disposed = true
|
||||||
|
unsub()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
session: {
|
||||||
|
all(session: string) {
|
||||||
|
return index.session.all[session] ?? empty
|
||||||
|
},
|
||||||
|
unseen(session: string) {
|
||||||
|
return index.session.unseen[session] ?? empty
|
||||||
|
},
|
||||||
|
unseenCount(session: string) {
|
||||||
|
return index.session.unseenCount[session] ?? 0
|
||||||
|
},
|
||||||
|
unseenHasError(session: string) {
|
||||||
|
return index.session.unseenHasError[session] ?? false
|
||||||
|
},
|
||||||
|
markViewed(session: string) {
|
||||||
|
const unseen = index.session.unseen[session] ?? empty
|
||||||
|
if (!unseen.length) return
|
||||||
|
|
||||||
|
const projects = [
|
||||||
|
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
||||||
|
]
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
||||||
|
updateUnseen("session", session, [])
|
||||||
|
projects.forEach((directory) => {
|
||||||
|
const next = (index.project.unseen[directory] ?? empty).filter(
|
||||||
|
(notification) => notification.session !== session,
|
||||||
|
)
|
||||||
|
updateUnseen("project", directory, next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
all(directory: string) {
|
||||||
|
return index.project.all[directory] ?? empty
|
||||||
|
},
|
||||||
|
unseen(directory: string) {
|
||||||
|
return index.project.unseen[directory] ?? empty
|
||||||
|
},
|
||||||
|
unseenCount(directory: string) {
|
||||||
|
return index.project.unseenCount[directory] ?? 0
|
||||||
|
},
|
||||||
|
unseenHasError(directory: string) {
|
||||||
|
return index.project.unseenHasError[directory] ?? false
|
||||||
|
},
|
||||||
|
markViewed(directory: string) {
|
||||||
|
const unseen = index.project.unseen[directory] ?? empty
|
||||||
|
if (!unseen.length) return
|
||||||
|
|
||||||
|
const sessions = [
|
||||||
|
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
||||||
|
]
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
||||||
|
updateUnseen("project", directory, [])
|
||||||
|
sessions.forEach((session) => {
|
||||||
|
const next = (index.session.unseen[session] ?? empty).filter(
|
||||||
|
(notification) => notification.directory !== directory,
|
||||||
|
)
|
||||||
|
updateUnseen("session", session, next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
|
|||||||
import {
|
import {
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
|
createResource,
|
||||||
createRoot,
|
createRoot,
|
||||||
For,
|
For,
|
||||||
Match,
|
Match,
|
||||||
@@ -67,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`
|
||||||
@@ -340,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) {
|
||||||
@@ -525,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]"
|
||||||
@@ -560,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
|
||||||
@@ -573,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()}>
|
||||||
@@ -1182,22 +1190,24 @@ function HomeSessionRow(props: {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.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">
|
||||||
<IconButtonV2
|
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||||
data-action="home-session-archive"
|
<IconButtonV2
|
||||||
variant="ghost-muted"
|
data-action="home-session-archive"
|
||||||
size="large"
|
variant="ghost-muted"
|
||||||
icon={<IconV2 name="archive" />}
|
size="large"
|
||||||
aria-label={language.t("common.archive")}
|
icon={<IconV2 name="archive" />}
|
||||||
onClick={(event) => {
|
aria-label={language.t("common.archive")}
|
||||||
event.preventDefault()
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.preventDefault()
|
||||||
void props.archiveSession(props.record.session)
|
event.stopPropagation()
|
||||||
}}
|
void props.archiveSession(props.record.session)
|
||||||
/>
|
}}
|
||||||
</TooltipV2>
|
/>
|
||||||
</div>
|
</TooltipV2>
|
||||||
|
</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: () => {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
|
|||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
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
|
||||||
@@ -51,16 +54,21 @@ export default function NewSessionPage() {
|
|||||||
onDone: () => inputRef?.focus(),
|
onDone: () => inputRef?.focus(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||||
worktree: "main",
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
||||||
@@ -97,7 +105,7 @@ export default function NewSessionPage() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
controls={inputController()}
|
controls={inputController()}
|
||||||
variant="new-session"
|
variant="new-session"
|
||||||
@@ -105,7 +113,7 @@ export default function NewSessionPage() {
|
|||||||
inputRef = el
|
inputRef = el
|
||||||
}}
|
}}
|
||||||
newSessionWorktree={newSessionWorktree()}
|
newSessionWorktree={newSessionWorktree()}
|
||||||
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
|
||||||
onSubmit={() => comments.clear()}
|
onSubmit={() => comments.clear()}
|
||||||
toolbar={
|
toolbar={
|
||||||
<Show when={!projectController.selected()}>
|
<Show when={!projectController.selected()}>
|
||||||
@@ -114,8 +122,34 @@ export default function NewSessionPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Show when={projectController.selected()}>
|
<Show when={projectController.selected()}>
|
||||||
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
|
<div
|
||||||
<PromptProjectSelector controller={projectController} />
|
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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -908,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(),
|
||||||
@@ -1713,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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Show, type JSX } from "solid-js"
|
import { Show, type JSX } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
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"
|
||||||
@@ -13,6 +14,7 @@ export function SessionComposerRegion(props: {
|
|||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const controller = props.controller
|
const controller = props.controller
|
||||||
|
const settings = useSettings()
|
||||||
const rolled = () => {
|
const rolled = () => {
|
||||||
const revert = controller.revert()
|
const revert = controller.revert()
|
||||||
return revert?.items.length ? revert : undefined
|
return revert?.items.length ? revert : undefined
|
||||||
@@ -22,7 +24,11 @@ export function SessionComposerRegion(props: {
|
|||||||
<div
|
<div
|
||||||
ref={controller.setDockRef}
|
ref={controller.setDockRef}
|
||||||
data-component="session-prompt-dock"
|
data-component="session-prompt-dock"
|
||||||
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 bg-background-stronger pointer-events-none"
|
classList={{
|
||||||
|
"w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none": true,
|
||||||
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
|
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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">
|
||||||
<DialogAddWslServer />
|
<DialogHeader hideClose={true}>
|
||||||
|
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DividerV2 />
|
||||||
|
<DialogBody>
|
||||||
|
<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"
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
import { Effect, Stream, 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)
|
||||||
@@ -149,12 +147,24 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I
|
|||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||||
type Endpoint0_13Input = {
|
type Endpoint0_13Input = {
|
||||||
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||||
|
readonly limit?: Endpoint0_13Request["query"]["limit"]
|
||||||
readonly after?: Endpoint0_13Request["query"]["after"]
|
readonly after?: Endpoint0_13Request["query"]["after"]
|
||||||
}
|
}
|
||||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
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(
|
Stream.unwrap(
|
||||||
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
@@ -162,17 +172,17 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] }
|
||||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||||
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint0_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
type Endpoint0_15Input = {
|
type Endpoint0_16Input = {
|
||||||
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
readonly sessionID: Endpoint0_16Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint0_15Request["params"]["messageID"]
|
readonly messageID: Endpoint0_16Request["params"]["messageID"]
|
||||||
}
|
}
|
||||||
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
const Endpoint0_16 = (raw: RawClient["server.session"]) => (input: Endpoint0_16Input) =>
|
||||||
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
@@ -192,12 +202,26 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
|||||||
clear: Endpoint0_10(raw),
|
clear: Endpoint0_10(raw),
|
||||||
commit: Endpoint0_11(raw),
|
commit: Endpoint0_11(raw),
|
||||||
context: Endpoint0_12(raw),
|
context: Endpoint0_12(raw),
|
||||||
events: Endpoint0_13(raw),
|
history: Endpoint0_13(raw),
|
||||||
interrupt: Endpoint0_14(raw),
|
events: Endpoint0_14(raw),
|
||||||
message: Endpoint0_15(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))
|
||||||
|
|||||||
@@ -24,12 +24,15 @@ import type {
|
|||||||
SessionsCommitOutput,
|
SessionsCommitOutput,
|
||||||
SessionsContextInput,
|
SessionsContextInput,
|
||||||
SessionsContextOutput,
|
SessionsContextOutput,
|
||||||
|
SessionsHistoryInput,
|
||||||
|
SessionsHistoryOutput,
|
||||||
SessionsEventsInput,
|
SessionsEventsInput,
|
||||||
SessionsEventsOutput,
|
SessionsEventsOutput,
|
||||||
SessionsInterruptInput,
|
SessionsInterruptInput,
|
||||||
SessionsInterruptOutput,
|
SessionsInterruptOutput,
|
||||||
SessionsMessageInput,
|
SessionsMessageInput,
|
||||||
SessionsMessageOutput,
|
SessionsMessageOutput,
|
||||||
|
EventsSubscribeOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
@@ -324,6 +327,18 @@ 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> =>
|
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
||||||
sse<SessionsEventsOutput>(
|
sse<SessionsEventsOutput>(
|
||||||
{
|
{
|
||||||
@@ -359,6 +374,13 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).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,
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"
|
||||||
|
|
||||||
export type JsonValue =
|
export type JsonValue =
|
||||||
| null
|
| null
|
||||||
| boolean
|
| boolean
|
||||||
@@ -67,7 +69,7 @@ export const isUnknownError = (value: unknown): value is UnknownError =>
|
|||||||
export type SessionsListInput = {
|
export type SessionsListInput = {
|
||||||
readonly workspace?: {
|
readonly workspace?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -77,7 +79,7 @@ export type SessionsListInput = {
|
|||||||
}["workspace"]
|
}["workspace"]
|
||||||
readonly limit?: {
|
readonly limit?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -87,7 +89,7 @@ export type SessionsListInput = {
|
|||||||
}["limit"]
|
}["limit"]
|
||||||
readonly order?: {
|
readonly order?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -97,7 +99,7 @@ export type SessionsListInput = {
|
|||||||
}["order"]
|
}["order"]
|
||||||
readonly search?: {
|
readonly search?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -107,7 +109,7 @@ export type SessionsListInput = {
|
|||||||
}["search"]
|
}["search"]
|
||||||
readonly directory?: {
|
readonly directory?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -117,7 +119,7 @@ export type SessionsListInput = {
|
|||||||
}["directory"]
|
}["directory"]
|
||||||
readonly project?: {
|
readonly project?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -127,7 +129,7 @@ export type SessionsListInput = {
|
|||||||
}["project"]
|
}["project"]
|
||||||
readonly subpath?: {
|
readonly subpath?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -137,7 +139,7 @@ export type SessionsListInput = {
|
|||||||
}["subpath"]
|
}["subpath"]
|
||||||
readonly cursor?: {
|
readonly cursor?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -305,7 +307,6 @@ export type SessionsPromptInput = {
|
|||||||
readonly text: string
|
readonly text: string
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
@@ -324,7 +325,6 @@ export type SessionsPromptInput = {
|
|||||||
readonly text: string
|
readonly text: string
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
@@ -343,7 +343,6 @@ export type SessionsPromptInput = {
|
|||||||
readonly text: string
|
readonly text: string
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
@@ -362,7 +361,6 @@ export type SessionsPromptInput = {
|
|||||||
readonly text: string
|
readonly text: string
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
@@ -595,20 +593,472 @@ export type SessionsContextOutput = {
|
|||||||
>
|
>
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
|
export type SessionsHistoryInput = {
|
||||||
|
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||||
|
readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"]
|
||||||
|
readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionsHistoryOutput = {
|
||||||
|
readonly data: ReadonlyArray<
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.agent.switched"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly agent: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.model.switched"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.moved"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly subdirectory?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.prompted"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly prompt: {
|
||||||
|
readonly text: string
|
||||||
|
readonly files?: ReadonlyArray<{
|
||||||
|
readonly uri: string
|
||||||
|
readonly mime: string
|
||||||
|
readonly name?: string
|
||||||
|
readonly description?: string
|
||||||
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
}>
|
||||||
|
readonly agents?: ReadonlyArray<{
|
||||||
|
readonly name: string
|
||||||
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
readonly delivery: "steer" | "queue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.prompt.admitted"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly prompt: {
|
||||||
|
readonly text: string
|
||||||
|
readonly files?: ReadonlyArray<{
|
||||||
|
readonly uri: string
|
||||||
|
readonly mime: string
|
||||||
|
readonly name?: string
|
||||||
|
readonly description?: string
|
||||||
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
}>
|
||||||
|
readonly agents?: ReadonlyArray<{
|
||||||
|
readonly name: string
|
||||||
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
readonly delivery: "steer" | "queue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.context.updated"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.synthetic"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.shell.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly command: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.shell.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly output: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.step.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly agent: string
|
||||||
|
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||||
|
readonly snapshot?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.step.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly finish: string
|
||||||
|
readonly cost: number
|
||||||
|
readonly tokens: {
|
||||||
|
readonly input: number
|
||||||
|
readonly output: number
|
||||||
|
readonly reasoning: number
|
||||||
|
readonly cache: { readonly read: number; readonly write: number }
|
||||||
|
}
|
||||||
|
readonly snapshot?: string
|
||||||
|
readonly files?: ReadonlyArray<string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.step.failed"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.text.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly textID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.text.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly textID: string
|
||||||
|
readonly text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.input.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.input.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.called"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly tool: string
|
||||||
|
readonly input: { readonly [x: string]: JsonValue }
|
||||||
|
readonly provider: {
|
||||||
|
readonly executed: boolean
|
||||||
|
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.progress"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly structured: { readonly [x: string]: JsonValue }
|
||||||
|
readonly content: ReadonlyArray<
|
||||||
|
| { readonly type: "text"; readonly text: string }
|
||||||
|
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.success"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly structured: { readonly [x: string]: JsonValue }
|
||||||
|
readonly content: ReadonlyArray<
|
||||||
|
| { readonly type: "text"; readonly text: string }
|
||||||
|
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||||
|
>
|
||||||
|
readonly outputPaths?: ReadonlyArray<string>
|
||||||
|
readonly result?: JsonValue
|
||||||
|
readonly provider: {
|
||||||
|
readonly executed: boolean
|
||||||
|
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.failed"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||||
|
readonly result?: JsonValue
|
||||||
|
readonly provider: {
|
||||||
|
readonly executed: boolean
|
||||||
|
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.reasoning.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly reasoningID: string
|
||||||
|
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.reasoning.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly reasoningID: string
|
||||||
|
readonly text: string
|
||||||
|
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.retried"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly attempt: number
|
||||||
|
readonly error: {
|
||||||
|
readonly message: string
|
||||||
|
readonly statusCode?: number
|
||||||
|
readonly isRetryable: boolean
|
||||||
|
readonly responseHeaders?: { readonly [x: string]: string }
|
||||||
|
readonly responseBody?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.compaction.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly reason: "auto" | "manual"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.compaction.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly reason: "auto" | "manual"
|
||||||
|
readonly text: string
|
||||||
|
readonly recent: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.revert.staged"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly revert: {
|
||||||
|
readonly messageID: string
|
||||||
|
readonly partID?: string
|
||||||
|
readonly snapshot?: string
|
||||||
|
readonly diff?: string
|
||||||
|
readonly files?: ReadonlyArray<{
|
||||||
|
readonly path: string
|
||||||
|
readonly status: "added" | "modified" | "deleted"
|
||||||
|
readonly additions: number
|
||||||
|
readonly deletions: number
|
||||||
|
readonly patch: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.revert.cleared"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly timestamp: number; readonly sessionID: string }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.revert.committed"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
readonly hasMore: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionsEventsInput = {
|
export type SessionsEventsInput = {
|
||||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||||
readonly after?: { readonly after?: string | undefined }["after"]
|
readonly after?: { readonly after?: number | undefined }["after"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionsEventsOutput =
|
export type SessionsEventsOutput =
|
||||||
| {
|
|
||||||
readonly id: string
|
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
|
||||||
readonly type: "session.activity"
|
|
||||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
|
||||||
readonly data: { readonly sessionID: string; readonly active: boolean }
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
@@ -818,20 +1268,6 @@ export type SessionsEventsOutput =
|
|||||||
readonly textID: string
|
readonly textID: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
readonly id: string
|
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
|
||||||
readonly type: "session.next.text.delta"
|
|
||||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
|
||||||
readonly data: {
|
|
||||||
readonly timestamp: number
|
|
||||||
readonly sessionID: string
|
|
||||||
readonly assistantMessageID: string
|
|
||||||
readonly textID: string
|
|
||||||
readonly delta: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
@@ -846,49 +1282,6 @@ export type SessionsEventsOutput =
|
|||||||
readonly text: string
|
readonly text: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
readonly id: string
|
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
|
||||||
readonly type: "session.next.reasoning.started"
|
|
||||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
|
||||||
readonly data: {
|
|
||||||
readonly timestamp: number
|
|
||||||
readonly sessionID: string
|
|
||||||
readonly assistantMessageID: string
|
|
||||||
readonly reasoningID: string
|
|
||||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly id: string
|
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
|
||||||
readonly type: "session.next.reasoning.delta"
|
|
||||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
|
||||||
readonly data: {
|
|
||||||
readonly timestamp: number
|
|
||||||
readonly sessionID: string
|
|
||||||
readonly assistantMessageID: string
|
|
||||||
readonly reasoningID: string
|
|
||||||
readonly delta: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly id: string
|
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
|
||||||
readonly type: "session.next.reasoning.ended"
|
|
||||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
|
||||||
readonly data: {
|
|
||||||
readonly timestamp: number
|
|
||||||
readonly sessionID: string
|
|
||||||
readonly assistantMessageID: string
|
|
||||||
readonly reasoningID: string
|
|
||||||
readonly text: string
|
|
||||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
@@ -903,20 +1296,6 @@ export type SessionsEventsOutput =
|
|||||||
readonly name: string
|
readonly name: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
readonly id: string
|
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
|
||||||
readonly type: "session.next.tool.input.delta"
|
|
||||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
|
||||||
readonly data: {
|
|
||||||
readonly timestamp: number
|
|
||||||
readonly sessionID: string
|
|
||||||
readonly assistantMessageID: string
|
|
||||||
readonly callID: string
|
|
||||||
readonly delta: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
@@ -1011,6 +1390,35 @@ export type SessionsEventsOutput =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly type: "session.next.reasoning.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly reasoningID: string
|
||||||
|
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly type: "session.next.reasoning.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly reasoningID: string
|
||||||
|
readonly text: string
|
||||||
|
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||||
|
}
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
@@ -1044,19 +1452,6 @@ export type SessionsEventsOutput =
|
|||||||
readonly reason: "auto" | "manual"
|
readonly reason: "auto" | "manual"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
readonly id: string
|
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
|
||||||
readonly type: "session.next.compaction.delta"
|
|
||||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
|
||||||
readonly data: {
|
|
||||||
readonly timestamp: number
|
|
||||||
readonly sessionID: string
|
|
||||||
readonly messageID: string
|
|
||||||
readonly text: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
@@ -1273,3 +1668,5 @@ export type SessionsMessageOutput = {
|
|||||||
readonly time: { readonly created: number }
|
readonly time: { readonly created: number }
|
||||||
}
|
}
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
|
export type EventsSubscribeOutput = OpenCodeEventEncoded
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./generated/index"
|
export * from "./generated/index"
|
||||||
|
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -15,19 +15,77 @@ 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")) {
|
if (url.includes("/event")) {
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(activityEvent)}\n\n`, {
|
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
||||||
headers: { "content-type": "text/event-stream" },
|
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)))
|
||||||
}
|
}
|
||||||
@@ -72,6 +130,18 @@ 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") })
|
||||||
|
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
|
const events = yield* client.sessions
|
||||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||||
.pipe(Stream.runCollect)
|
.pipe(Stream.runCollect)
|
||||||
@@ -80,7 +150,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
messageID: SessionMessage.ID.make("msg_model"),
|
messageID: SessionMessage.ID.make("msg_model"),
|
||||||
})
|
})
|
||||||
return { page, active, created, admitted, context, events, message }
|
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)
|
||||||
@@ -92,12 +162,39 @@ 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(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||||
expect(result.events[1]).toEqual(activityEvent)
|
|
||||||
expect(result.events[1]).not.toHaveProperty("durable")
|
|
||||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
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 = {
|
||||||
data: {
|
data: {
|
||||||
id: "ses_test",
|
id: "ses_test",
|
||||||
@@ -147,9 +244,3 @@ const modelSwitchedEvent = {
|
|||||||
model: { id: "claude", providerID: "anthropic" },
|
model: { id: "claude", providerID: "anthropic" },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityEvent = {
|
|
||||||
id: "evt_activity",
|
|
||||||
type: "session.activity" as const,
|
|
||||||
data: { sessionID: "ses_test", active: false },
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,19 +17,52 @@ 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")) {
|
if (url.includes("/event")) {
|
||||||
return new Response(
|
return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
||||||
`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(activityEvent)}\n\n`,
|
headers: { "content-type": "text/event-stream" },
|
||||||
{
|
})
|
||||||
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)
|
||||||
@@ -42,7 +75,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const page = await client.sessions.list({ limit: "10", order: "desc" })
|
const page = await client.sessions.list({ limit: 10, order: "desc" })
|
||||||
const active = await client.sessions.active()
|
const 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" })
|
||||||
@@ -58,8 +91,13 @@ 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 = []
|
const events = []
|
||||||
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
|
for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event)
|
||||||
await client.sessions.interrupt({ sessionID: "ses_test" })
|
await client.sessions.interrupt({ sessionID: "ses_test" })
|
||||||
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||||
|
|
||||||
@@ -68,8 +106,9 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
expect(created.id).toBe("ses_test")
|
expect(created.id).toBe("ses_test")
|
||||||
expect(admitted.id).toBe("msg_test")
|
expect(admitted.id).toBe("msg_test")
|
||||||
expect(context).toEqual([])
|
expect(context).toEqual([])
|
||||||
expect(events).toEqual([modelSwitchedEvent, activityEvent])
|
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
|
||||||
expect(events[1]).not.toHaveProperty("durable")
|
expect(historyNext).toEqual({ data: [], hasMore: false })
|
||||||
|
expect(events).toEqual([modelSwitchedEvent])
|
||||||
expect(message).toEqual(modelSwitchedMessage)
|
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"],
|
||||||
@@ -81,6 +120,8 @@ 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"],
|
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||||
@@ -108,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",
|
||||||
@@ -157,9 +216,3 @@ const modelSwitchedEvent = {
|
|||||||
model: { id: "claude", providerID: "anthropic" },
|
model: { id: "claude", providerID: "anthropic" },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityEvent = {
|
|
||||||
id: "evt_activity",
|
|
||||||
type: "session.activity",
|
|
||||||
data: { sessionID: "ses_test", active: false },
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -215,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"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -296,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],
|
||||||
|
})
|
||||||
|
|||||||
@@ -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({ service: ChildProcessSpawner, layer, deps: [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({ service: Service, layer: layerFromPath(path()), deps: [] })
|
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
|||||||
import { FileSystem, Path } from "effect"
|
import { FileSystem, Path } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { HttpClient } from "effect/unstable/http"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
import { LayerNode } from "./layer-node"
|
import { makeGlobalNode } from "./node"
|
||||||
|
|
||||||
export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||||
export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||||
export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||||
export const requestExecutor = LayerNode.make({
|
export const requestExecutor = makeGlobalNode({
|
||||||
service: RequestExecutor.Service,
|
service: RequestExecutor.Service,
|
||||||
layer: RequestExecutor.layer,
|
layer: RequestExecutor.layer,
|
||||||
deps: [httpClient],
|
deps: [httpClient],
|
||||||
})
|
})
|
||||||
export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
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,248 +0,0 @@
|
|||||||
import { Brand, Context, Layer } from "effect"
|
|
||||||
|
|
||||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
|
||||||
type AnyNode = Node<unknown, unknown, any>
|
|
||||||
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
|
||||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
|
||||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? 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 Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
|
|
||||||
|
|
||||||
const makeTier = Brand.nominal<Tier>()
|
|
||||||
|
|
||||||
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
|
|
||||||
readonly kind: "layer" | "group"
|
|
||||||
readonly name: string
|
|
||||||
readonly service?: Context.Service.Any
|
|
||||||
readonly implementation?: Layer.Any
|
|
||||||
readonly dependencies: readonly AnyNode[]
|
|
||||||
readonly tier?: 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 NodeInput<
|
|
||||||
Implementation extends Layer.Any,
|
|
||||||
Items extends NodeList,
|
|
||||||
T extends Tier | undefined = undefined,
|
|
||||||
> = NodeIdentity & {
|
|
||||||
readonly layer: Implementation
|
|
||||||
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
|
||||||
readonly tier?: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export function make<
|
|
||||||
const Implementation extends Layer.Any,
|
|
||||||
const Items extends NodeList,
|
|
||||||
const T extends Tier | undefined = undefined,
|
|
||||||
>(
|
|
||||||
input: NodeInput<Implementation, Items, T>,
|
|
||||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
|
|
||||||
return {
|
|
||||||
kind: "layer",
|
|
||||||
name: input.service !== undefined ? input.service.key : input.name,
|
|
||||||
service: input.service,
|
|
||||||
implementation: input.layer,
|
|
||||||
dependencies: input.deps,
|
|
||||||
tier: input.tier,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function group<const Items extends NodeList>(
|
|
||||||
dependencies: Items,
|
|
||||||
): Node<Output<Items[number]>, Error<Items[number]>> {
|
|
||||||
return { kind: "group", name: "group", dependencies }
|
|
||||||
}
|
|
||||||
|
|
||||||
type AllowedTierNames<Names extends readonly string[], Name extends Names[number]> = Names extends readonly [
|
|
||||||
infer Head extends string,
|
|
||||||
...infer Tail extends readonly string[],
|
|
||||||
]
|
|
||||||
? Head extends Name
|
|
||||||
? Head | Tail[number]
|
|
||||||
: AllowedTierNames<Tail, Name>
|
|
||||||
: never
|
|
||||||
|
|
||||||
type NodeInTiers<Names extends string> = Node<unknown, unknown, Tier<Names>>
|
|
||||||
|
|
||||||
export interface Tiers<Names extends readonly [string, ...string[]]> {
|
|
||||||
readonly names: Names
|
|
||||||
readonly values: { readonly [K in Names[number]]: Tier<K> }
|
|
||||||
readonly make: <Name extends Names[number]>(
|
|
||||||
name: Name,
|
|
||||||
) => <
|
|
||||||
const Implementation extends Layer.Any,
|
|
||||||
const Items extends NodeList<NodeInTiers<AllowedTierNames<Names, Name>>>,
|
|
||||||
>(
|
|
||||||
input: DistributiveOmit<NodeInput<Implementation, Items, Tier<Name>>, "tier">,
|
|
||||||
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tier<Name>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tiers<const Names extends readonly [string, ...string[]]>(names: Names): Tiers<Names> {
|
|
||||||
const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers<Names>["values"]
|
|
||||||
return {
|
|
||||||
names,
|
|
||||||
values,
|
|
||||||
make: ((name: Names[number]) => (input: DistributiveOmit<NodeInput<Layer.Any, NodeList, Tier>, "tier">) =>
|
|
||||||
make({ ...input, tier: values[name] })) as Tiers<Names>["make"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultTiers = tiers(["untiered"])
|
|
||||||
const untiered = defaultTiers.values.untiered
|
|
||||||
|
|
||||||
export type Replacement = {
|
|
||||||
readonly source: Layer.Any
|
|
||||||
readonly replacement: Layer.Any
|
|
||||||
}
|
|
||||||
|
|
||||||
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
|
||||||
? 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 function buildLayer<
|
|
||||||
A,
|
|
||||||
E,
|
|
||||||
const Names extends readonly [string, ...string[]] = readonly ["untiered"],
|
|
||||||
const Built extends Layer.Any = Layer.Layer<never, never, never>,
|
|
||||||
>(
|
|
||||||
node: Node<A, E, any>,
|
|
||||||
options?: {
|
|
||||||
readonly tiers?: Tiers<Names>
|
|
||||||
readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built
|
|
||||||
readonly replacements?: readonly Replacement[]
|
|
||||||
},
|
|
||||||
): Layer.Layer<A | Layer.Success<Built>, E | Layer.Error<Built>, never> {
|
|
||||||
const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers<Names>)
|
|
||||||
const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
|
||||||
const plans = plan(node, tiers, replacementMap)
|
|
||||||
const layers: RuntimeLayer[] = tiers.names.map((name) => {
|
|
||||||
const tier = tiers.values[name as Names[number]]
|
|
||||||
const layers = plans.get(tier) ?? []
|
|
||||||
return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer
|
|
||||||
})
|
|
||||||
if (layers.length === 0) return Layer.empty as never
|
|
||||||
return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never
|
|
||||||
}
|
|
||||||
|
|
||||||
export function combine(layers: readonly Layer.Any[]): RuntimeLayer {
|
|
||||||
return layers.reduce<RuntimeLayer>(
|
|
||||||
(result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)),
|
|
||||||
Layer.empty as RuntimeLayer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function plan(
|
|
||||||
root: AnyNode,
|
|
||||||
tiers: Tiers<readonly [string, ...string[]]>,
|
|
||||||
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
|
|
||||||
) {
|
|
||||||
const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index]))
|
|
||||||
const plans = new Map<Tier, Layer.Any[]>()
|
|
||||||
const activeImplementations = new Map<Tier, Map<string, AnyNode>>()
|
|
||||||
const serviceTiers = new Map<string, Tier>()
|
|
||||||
const visiting = new Set<AnyNode>()
|
|
||||||
const stack: AnyNode[] = []
|
|
||||||
const boundaryVisited = new Map<AnyNode, Set<Tier>>()
|
|
||||||
const boundaryServices = new Map<Tier, Map<string, AnyNode>>()
|
|
||||||
|
|
||||||
const validateBoundary = (node: AnyNode, origin: Tier) => {
|
|
||||||
const checked = boundaryVisited.get(node) ?? new Set<Tier>()
|
|
||||||
boundaryVisited.set(node, checked)
|
|
||||||
if (checked.has(origin)) return false
|
|
||||||
checked.add(origin)
|
|
||||||
const services = boundaryServices.get(origin) ?? new Map<string, AnyNode>()
|
|
||||||
boundaryServices.set(origin, services)
|
|
||||||
const key = node.name
|
|
||||||
const existing = services.get(key)
|
|
||||||
if (existing && existing !== node) {
|
|
||||||
throw new Error(`Tier ${origin} has conflicting implementations for ${key}`)
|
|
||||||
}
|
|
||||||
services.set(key, node)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => {
|
|
||||||
if (node.kind === "group") {
|
|
||||||
node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const tier = node.tier ?? untiered
|
|
||||||
if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
|
||||||
const key = node.name
|
|
||||||
const serviceTier = serviceTiers.get(key)
|
|
||||||
if (serviceTier && serviceTier !== tier) {
|
|
||||||
throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`)
|
|
||||||
}
|
|
||||||
serviceTiers.set(key, tier)
|
|
||||||
const nextOrigins = [...origins]
|
|
||||||
if (currentTier) {
|
|
||||||
const current = indexes.get(currentTier)!
|
|
||||||
const required = indexes.get(tier)!
|
|
||||||
if (required < current) {
|
|
||||||
throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`)
|
|
||||||
}
|
|
||||||
if (required > current) nextOrigins.push(currentTier)
|
|
||||||
}
|
|
||||||
const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin))
|
|
||||||
|
|
||||||
// A node may need to be emitted more than once because the final output is a
|
|
||||||
// flat list of layers applied with Layer.provideMerge. If another node for
|
|
||||||
// the same service was emitted afterward, this node is no longer the active
|
|
||||||
// implementation for subsequent consumers. Re-emitting restores the intended
|
|
||||||
// implementation ordering while Effect memoization avoids reacquiring the layer.
|
|
||||||
const implementations = activeImplementations.get(tier) ?? new Map<string, AnyNode>()
|
|
||||||
activeImplementations.set(tier, implementations)
|
|
||||||
if (implementations.get(key) === node && unseenOrigins.length === 0) return
|
|
||||||
|
|
||||||
if (visiting.has(node)) {
|
|
||||||
const start = stack.indexOf(node)
|
|
||||||
throw new Error(
|
|
||||||
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
visiting.add(node)
|
|
||||||
stack.push(node)
|
|
||||||
try {
|
|
||||||
node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins))
|
|
||||||
const layers = plans.get(tier) ?? []
|
|
||||||
plans.set(tier, layers)
|
|
||||||
layers.push(replacements.get(node.implementation!) ?? node.implementation!)
|
|
||||||
implementations.set(key, node)
|
|
||||||
} finally {
|
|
||||||
stack.pop()
|
|
||||||
visiting.delete(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
visit(root)
|
|
||||||
return plans
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireTier(node: AnyNode, indexes: ReadonlyMap<Tier, number>) {
|
|
||||||
if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as LayerNode from "./layer-node"
|
|
||||||
@@ -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"
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { LayerNode } from "./layer-node"
|
|
||||||
|
|
||||||
export const tiers = LayerNode.tiers(["location", "global"])
|
|
||||||
|
|
||||||
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["global"]>
|
|
||||||
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["location"]>
|
|
||||||
|
|
||||||
export const makeGlobalNode = tiers.make("global")
|
|
||||||
export const makeLocationNode = tiers.make("location")
|
|
||||||
|
|
||||||
export * as ScopedNode from "./scoped-node"
|
|
||||||
+107
-110
@@ -1,13 +1,13 @@
|
|||||||
export * as EventV2 from "./event"
|
export * as EventV2 from "./event"
|
||||||
|
|
||||||
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Scope, 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, lte } 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
|
||||||
|
|
||||||
@@ -67,19 +132,6 @@ export interface Interface {
|
|||||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||||
readonly all: () => Stream.Stream<Payload>
|
readonly all: () => Stream.Stream<Payload>
|
||||||
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
|
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
|
||||||
readonly observeAggregate: (input: {
|
|
||||||
readonly aggregateID: string
|
|
||||||
readonly after?: number
|
|
||||||
readonly live: (event: Payload) => boolean
|
|
||||||
}) => Effect.Effect<
|
|
||||||
{
|
|
||||||
readonly replay: ReadonlyArray<Payload>
|
|
||||||
readonly updates: Stream.Stream<Payload>
|
|
||||||
readonly offer: (event: Payload, position?: "after" | "before") => boolean
|
|
||||||
},
|
|
||||||
never,
|
|
||||||
Scope.Scope
|
|
||||||
>
|
|
||||||
/** @deprecated Use `all()` and consume the returned stream. */
|
/** @deprecated Use `all()` and consume the returned stream. */
|
||||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||||
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||||
@@ -97,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>
|
||||||
}
|
}
|
||||||
@@ -107,12 +173,12 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pubsub = {
|
const pubsub = {
|
||||||
all: yield* PubSub.unbounded<Payload>(),
|
all: yield* PubSub.unbounded<Payload>(),
|
||||||
durable: new Map<string, Set<PubSub.PubSub<number>>>(),
|
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
|
||||||
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
||||||
}
|
}
|
||||||
const projectors = new Map<string, Subscriber[]>()
|
const projectors = new Map<string, Subscriber[]>()
|
||||||
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
|
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
|
||||||
const listeners = new Set<Subscriber>()
|
const listeners = new Array<Subscriber>()
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
const getOrCreate = (definition: Definition) =>
|
const getOrCreate = (definition: Definition) =>
|
||||||
@@ -288,7 +354,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
if (committed) {
|
if (committed) {
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
pubsub.durable.get(committed.aggregateID) ?? [],
|
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||||
(wake) => PubSub.publish(wake, committed.seq),
|
(wake) => PubSub.publish(wake, undefined),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -472,32 +538,13 @@ 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 readAfter = (aggregateID: string, after: number) =>
|
||||||
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, through?: number) =>
|
|
||||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(EventTable)
|
.from(EventTable)
|
||||||
.where(
|
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
|
||||||
and(
|
|
||||||
eq(EventTable.aggregate_id, aggregateID),
|
|
||||||
gt(EventTable.seq, after),
|
|
||||||
through === undefined ? undefined : lte(EventTable.seq, through),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(EventTable.seq))
|
.orderBy(asc(EventTable.seq))
|
||||||
.all(),
|
.all(),
|
||||||
),
|
),
|
||||||
@@ -517,7 +564,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
|
|
||||||
const subscribeDurable = (aggregateID: string) =>
|
const subscribeDurable = (aggregateID: string) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const wake = yield* PubSub.sliding<number>(1)
|
const wake = yield* PubSub.sliding<void>(1)
|
||||||
const subscription = yield* PubSub.subscribe(wake)
|
const subscription = yield* PubSub.subscribe(wake)
|
||||||
yield* Effect.acquireRelease(
|
yield* Effect.acquireRelease(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -535,83 +582,34 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
return subscription
|
return subscription
|
||||||
})
|
})
|
||||||
|
|
||||||
const aggregateDrain = (
|
|
||||||
aggregateID: string,
|
|
||||||
after: number,
|
|
||||||
): ((through?: number) => Effect.Effect<ReadonlyArray<Payload>>) => {
|
|
||||||
let sequence = after
|
|
||||||
return (through?: number) =>
|
|
||||||
through !== undefined && through <= sequence
|
|
||||||
? Effect.succeed<ReadonlyArray<Payload>>([])
|
|
||||||
: Effect.suspend(() => readAfter(aggregateID, sequence, through)).pipe(
|
|
||||||
Effect.tap((events) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
sequence = events.at(-1)?.durable?.seq ?? sequence
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
|
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const wakes = yield* subscribeDurable(input.aggregateID)
|
const wakes = yield* subscribeDurable(input.aggregateID)
|
||||||
const drain = aggregateDrain(input.aggregateID, input.after ?? -1)
|
let sequence = input.after ?? -1
|
||||||
const historical = yield* drain()
|
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
|
||||||
const live = Stream.fromSubscription(wakes).pipe(Stream.mapEffect(drain), Stream.flattenIterable)
|
Effect.tap((events) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
sequence = events.at(-1)?.durable?.seq ?? sequence
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const historical = yield* read
|
||||||
|
const live = Stream.fromSubscription(wakes).pipe(
|
||||||
|
Stream.mapEffect(() => read),
|
||||||
|
Stream.flattenIterable,
|
||||||
|
)
|
||||||
return Stream.concat(Stream.fromIterable(historical), live)
|
return Stream.concat(Stream.fromIterable(historical), live)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const observeAggregate: Interface["observeAggregate"] = (input) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
type Signal =
|
|
||||||
| { readonly _tag: "durable" }
|
|
||||||
| { readonly _tag: "transient"; readonly event: Payload; readonly position: "after" | "before" }
|
|
||||||
const signals = yield* Queue.dropping<Signal, Cause.Done>(256)
|
|
||||||
const wakes = yield* subscribeDurable(input.aggregateID)
|
|
||||||
let durableQueued = false
|
|
||||||
let durableThrough = -1
|
|
||||||
const offer = (event: Payload, position: "after" | "before" = "after") => {
|
|
||||||
const offered = Queue.offerUnsafe(signals, { _tag: "transient", event, position })
|
|
||||||
if (!offered) Queue.endUnsafe(signals)
|
|
||||||
return offered
|
|
||||||
}
|
|
||||||
const unsubscribe = yield* listen((event) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
if (input.live(event)) offer(event)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(signals))))
|
|
||||||
yield* Stream.runForEach(Stream.fromSubscription(wakes), (sequence) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
durableThrough = Math.max(durableThrough, sequence)
|
|
||||||
if (durableQueued) return
|
|
||||||
durableQueued = Queue.offerUnsafe(signals, { _tag: "durable" })
|
|
||||||
}),
|
|
||||||
).pipe(Effect.forkScoped)
|
|
||||||
|
|
||||||
const cutoff = yield* latestSequence(db, input.aggregateID)
|
|
||||||
const replay = yield* readAfter(input.aggregateID, input.after ?? -1, cutoff)
|
|
||||||
const drain = aggregateDrain(input.aggregateID, cutoff)
|
|
||||||
const updates = Stream.fromQueue(signals).pipe(
|
|
||||||
Stream.mapEffect((signal) => {
|
|
||||||
if (signal._tag === "durable") {
|
|
||||||
durableQueued = false
|
|
||||||
return drain(durableThrough)
|
|
||||||
}
|
|
||||||
if (signal.position === "before") return Effect.succeed([signal.event])
|
|
||||||
return drain().pipe(Effect.map((events) => [...events, signal.event]))
|
|
||||||
}),
|
|
||||||
Stream.flattenIterable,
|
|
||||||
)
|
|
||||||
return { replay, updates, offer }
|
|
||||||
})
|
|
||||||
|
|
||||||
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
listeners.add(listener)
|
listeners.push(listener)
|
||||||
return Effect.sync(() => listeners.delete(listener)).pipe(Effect.asVoid)
|
return Effect.sync(() => {
|
||||||
|
const index = listeners.indexOf(listener)
|
||||||
|
if (index >= 0) listeners.splice(index, 1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
||||||
@@ -626,7 +624,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
subscribe,
|
subscribe,
|
||||||
all: streamAll,
|
all: streamAll,
|
||||||
durable,
|
durable,
|
||||||
observeAggregate,
|
|
||||||
listen,
|
listen,
|
||||||
project,
|
project,
|
||||||
replay,
|
replay,
|
||||||
@@ -638,6 +635,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const layer = layerWith()
|
export const layer = layerWith()
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [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({ service: Service, layer: layer, deps: [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({ service: Service, layer: layer, deps: [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({ service: Service, layer: layer, deps: [] })
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Integration from "./integration"
|
export * as Integration from "./integration"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import {
|
import {
|
||||||
Cause,
|
Cause,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -515,3 +516,5 @@ export const locationLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] })
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import { Effect, Layer, LayerMap } from "effect"
|
|
||||||
import { Location } from "./location"
|
|
||||||
import { Policy } from "./policy"
|
|
||||||
import { Config } from "./config"
|
|
||||||
import { PluginV2 } from "./plugin"
|
|
||||||
import { Catalog } from "./catalog"
|
|
||||||
import { Integration } from "./integration"
|
|
||||||
import { CommandV2 } from "./command"
|
|
||||||
import { AgentV2 } from "./agent"
|
|
||||||
import { PluginInternal } from "./plugin/internal"
|
|
||||||
import { Project } from "./project"
|
|
||||||
import { ProjectCopy } from "./project/copy"
|
|
||||||
import { ProjectDirectories } from "./project/directories"
|
|
||||||
import { EventV2 } from "./event"
|
|
||||||
import { Credential } from "./credential"
|
|
||||||
import { Npm } from "./npm"
|
|
||||||
import { ModelsDev } from "./models-dev"
|
|
||||||
import { FSUtil } from "./fs-util"
|
|
||||||
import { Git } from "./git"
|
|
||||||
import { Global } from "./global"
|
|
||||||
import { Database } from "./database/database"
|
|
||||||
import { PermissionV2 } from "./permission"
|
|
||||||
import { PermissionSaved } from "./permission/saved"
|
|
||||||
import { FileSystem } from "./filesystem"
|
|
||||||
import { Ripgrep } from "./ripgrep"
|
|
||||||
import { Watcher } from "./filesystem/watcher"
|
|
||||||
import { LocationMutation } from "./location-mutation"
|
|
||||||
import { FileMutation } from "./file-mutation"
|
|
||||||
import { Reference } from "./reference"
|
|
||||||
import { ReferenceGuidance } from "./reference/guidance"
|
|
||||||
import { RepositoryCache } from "./repository-cache"
|
|
||||||
import { Pty } from "./pty"
|
|
||||||
import { SkillV2 } from "./skill"
|
|
||||||
import { SkillGuidance } from "./skill/guidance"
|
|
||||||
import { BuiltInTools } from "./tool/builtins"
|
|
||||||
import { Image } from "./image"
|
|
||||||
import { ToolRegistry } from "./tool/registry"
|
|
||||||
import { ApplicationTools } from "./tool/application-tools"
|
|
||||||
import { ToolOutputStore } from "./tool-output-store"
|
|
||||||
import { AppProcess } from "./process"
|
|
||||||
import { SessionStore } from "./session/store"
|
|
||||||
import { SessionTodo } from "./session/todo"
|
|
||||||
import { QuestionV2 } from "./question"
|
|
||||||
import { LLMClient } from "@opencode-ai/llm"
|
|
||||||
import { RequestExecutor } from "@opencode-ai/llm/route"
|
|
||||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
|
||||||
import { SessionRunnerModel } from "./session/runner/model"
|
|
||||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
|
||||||
import { Snapshot } from "./snapshot"
|
|
||||||
|
|
||||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
|
||||||
lookup: (ref: Location.Ref) => {
|
|
||||||
const boot = Layer.effectDiscard(
|
|
||||||
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
|
|
||||||
)
|
|
||||||
const location = Location.layer(ref)
|
|
||||||
const systemContext = SystemContextBuiltIns.locationLayer
|
|
||||||
const base = Layer.mergeAll(
|
|
||||||
location,
|
|
||||||
Policy.locationLayer,
|
|
||||||
Config.locationLayer,
|
|
||||||
Reference.locationLayer,
|
|
||||||
PluginV2.locationLayer,
|
|
||||||
Catalog.locationLayer,
|
|
||||||
Integration.locationLayer,
|
|
||||||
CommandV2.locationLayer,
|
|
||||||
AgentV2.locationLayer,
|
|
||||||
PluginInternal.locationLayer,
|
|
||||||
ProjectCopy.locationLayer,
|
|
||||||
FileSystem.locationLayer,
|
|
||||||
Watcher.locationLayer,
|
|
||||||
Pty.locationLayer,
|
|
||||||
SkillV2.locationLayer,
|
|
||||||
systemContext,
|
|
||||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
|
||||||
).pipe(Layer.provideMerge(location))
|
|
||||||
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
|
|
||||||
const permissionsAndTools = ToolRegistry.layer.pipe(
|
|
||||||
Layer.provideMerge(PermissionV2.locationLayer),
|
|
||||||
Layer.provide(resources),
|
|
||||||
Layer.provide(base),
|
|
||||||
)
|
|
||||||
const services = Layer.mergeAll(base, resources, permissionsAndTools)
|
|
||||||
const image = Image.layer.pipe(Layer.provide(services))
|
|
||||||
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const todos = SessionTodo.layer.pipe(Layer.provide(services))
|
|
||||||
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const builtInTools = BuiltInTools.locationLayer.pipe(
|
|
||||||
Layer.provide(services),
|
|
||||||
Layer.provide(mutation),
|
|
||||||
Layer.provide(resources),
|
|
||||||
Layer.provide(todos),
|
|
||||||
Layer.provide(questions),
|
|
||||||
Layer.provide(image),
|
|
||||||
)
|
|
||||||
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const snapshot = Snapshot.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
|
||||||
Layer.provide(services),
|
|
||||||
Layer.provide(model),
|
|
||||||
Layer.provide(skillGuidance),
|
|
||||||
Layer.provide(referenceGuidance),
|
|
||||||
Layer.provide(snapshot),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Kick off a background project copy refresh to update locations now that we
|
|
||||||
// have a location
|
|
||||||
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
|
|
||||||
|
|
||||||
return Layer.mergeAll(
|
|
||||||
boot,
|
|
||||||
services,
|
|
||||||
image,
|
|
||||||
mutation,
|
|
||||||
resources,
|
|
||||||
todos,
|
|
||||||
questions,
|
|
||||||
model,
|
|
||||||
snapshot,
|
|
||||||
runner,
|
|
||||||
builtInTools,
|
|
||||||
referenceGuidance,
|
|
||||||
projectCopyRefresh,
|
|
||||||
).pipe(Layer.fresh)
|
|
||||||
},
|
|
||||||
idleTimeToLive: "60 minutes",
|
|
||||||
dependencies: [
|
|
||||||
Project.defaultLayer,
|
|
||||||
EventV2.defaultLayer,
|
|
||||||
Credential.defaultLayer,
|
|
||||||
Npm.defaultLayer,
|
|
||||||
ModelsDev.defaultLayer,
|
|
||||||
FSUtil.defaultLayer,
|
|
||||||
Git.defaultLayer,
|
|
||||||
AppProcess.defaultLayer,
|
|
||||||
Global.defaultLayer,
|
|
||||||
Ripgrep.defaultLayer,
|
|
||||||
Database.defaultLayer,
|
|
||||||
ProjectDirectories.defaultLayer,
|
|
||||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
|
||||||
PermissionSaved.defaultLayer,
|
|
||||||
RepositoryCache.defaultLayer,
|
|
||||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
|
|
||||||
FetchHttpClient.layer,
|
|
||||||
ToolOutputStore.defaultCleanupLayer,
|
|
||||||
ApplicationTools.layer,
|
|
||||||
],
|
|
||||||
}) {}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as LocationMutation from "./location-mutation"
|
export * as LocationMutation from "./location-mutation"
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -153,3 +154,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: layer.pipe(Layer.orDie),
|
||||||
|
deps: [FSUtil.node, Location.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||||
|
import { LayerNode } from "./effect/layer-node"
|
||||||
|
import { Node } from "./effect/node"
|
||||||
|
import { Location } from "./location"
|
||||||
|
import type { LocationError, LocationServices } from "./location-services"
|
||||||
|
|
||||||
|
export class Service extends Context.Service<
|
||||||
|
Service,
|
||||||
|
LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
|
||||||
|
>()("@opencode/example/LocationServiceMap") {
|
||||||
|
static get(ref: Location.Ref) {
|
||||||
|
return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||||
|
|
||||||
|
export * as LocationServiceMap from "./location-service-map"
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Effect, Layer, LayerMap } from "effect"
|
||||||
|
import { AgentV2 } from "./agent"
|
||||||
|
import { AISDK } from "./aisdk"
|
||||||
|
import { Catalog } from "./catalog"
|
||||||
|
import { CommandV2 } from "./command"
|
||||||
|
import { Config } from "./config"
|
||||||
|
import { LayerNode, LayerNodeTree } from "./effect/layer-node"
|
||||||
|
import { Node } from "./effect/node"
|
||||||
|
import { FileMutation } from "./file-mutation"
|
||||||
|
import { FileSystem } from "./filesystem"
|
||||||
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
|
import { Watcher } from "./filesystem/watcher"
|
||||||
|
import { Image } from "./image"
|
||||||
|
import { Integration } from "./integration"
|
||||||
|
import { Location } from "./location"
|
||||||
|
import { LocationMutation } from "./location-mutation"
|
||||||
|
import { LocationServiceMap } from "./location-service-map"
|
||||||
|
import { PermissionV2 } from "./permission"
|
||||||
|
import { PluginV2 } from "./plugin"
|
||||||
|
import { PluginInternal } from "./plugin/internal"
|
||||||
|
import { Policy } from "./policy"
|
||||||
|
import { ProjectCopy } from "./project/copy"
|
||||||
|
import { Pty } from "./pty"
|
||||||
|
import { QuestionV2 } from "./question"
|
||||||
|
import { Reference } from "./reference"
|
||||||
|
import { ReferenceGuidance } from "./reference/guidance"
|
||||||
|
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||||
|
import { SessionRunnerModel } from "./session/runner/model"
|
||||||
|
import { SessionTodo } from "./session/todo"
|
||||||
|
import { SkillV2 } from "./skill"
|
||||||
|
import { SkillGuidance } from "./skill/guidance"
|
||||||
|
import { Snapshot } from "./snapshot"
|
||||||
|
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||||
|
import { SystemContextRegistry } from "./system-context/registry"
|
||||||
|
import { BuiltInTools } from "./tool/builtins"
|
||||||
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
|
import { ToolRegistry } from "./tool/registry"
|
||||||
|
import { ToolOutputStore } from "./tool-output-store"
|
||||||
|
|
||||||
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
|
|
||||||
|
export const locationServices = LayerNode.group([
|
||||||
|
Location.node,
|
||||||
|
Policy.node,
|
||||||
|
Config.node,
|
||||||
|
AgentV2.node,
|
||||||
|
CommandV2.node,
|
||||||
|
Reference.node,
|
||||||
|
Integration.node,
|
||||||
|
Catalog.node,
|
||||||
|
AISDK.node,
|
||||||
|
PluginV2.node,
|
||||||
|
PluginInternal.node,
|
||||||
|
ProjectCopy.node,
|
||||||
|
ProjectCopy.refreshNode,
|
||||||
|
FileSystemSearch.node,
|
||||||
|
FileSystem.node,
|
||||||
|
Watcher.node,
|
||||||
|
Pty.node,
|
||||||
|
SkillV2.node,
|
||||||
|
SystemContextRegistry.node,
|
||||||
|
SystemContextBuiltIns.node,
|
||||||
|
LocationMutation.node,
|
||||||
|
FileMutation.node,
|
||||||
|
PermissionV2.node,
|
||||||
|
ToolOutputStore.node,
|
||||||
|
ToolRegistry.node,
|
||||||
|
ToolRegistry.toolsNode,
|
||||||
|
Image.node,
|
||||||
|
SkillGuidance.node,
|
||||||
|
ReferenceGuidance.node,
|
||||||
|
SessionTodo.node,
|
||||||
|
QuestionV2.node,
|
||||||
|
ReadToolFileSystem.node,
|
||||||
|
BuiltInTools.node,
|
||||||
|
SessionRunnerModel.node,
|
||||||
|
Snapshot.node,
|
||||||
|
SessionRunnerLLM.node,
|
||||||
|
])
|
||||||
|
|
||||||
|
export type LocationServices = LayerNode.Output<typeof locationServices>
|
||||||
|
export type LocationError = LayerNode.Error<typeof locationServices>
|
||||||
|
|
||||||
|
export function buildLocationServiceMap(
|
||||||
|
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||||
|
): Layer.Layer<LocationServiceMap.Service> {
|
||||||
|
return Layer.effect(
|
||||||
|
LocationServiceMap.Service,
|
||||||
|
LayerMap.make(
|
||||||
|
(ref: Location.Ref) => {
|
||||||
|
const location = LayerNodeTree.hoist(
|
||||||
|
LayerNodeTree.bind(locationServices, Location.node, Location.boundNode(ref)),
|
||||||
|
Node.tags.values.global,
|
||||||
|
)
|
||||||
|
return LayerNodeTree.compile(location.node, replacements).pipe(
|
||||||
|
Layer.fresh,
|
||||||
|
Layer.tap(() =>
|
||||||
|
Effect.logInfo("booting location services", {
|
||||||
|
directory: ref.directory,
|
||||||
|
workspaceID: ref.workspaceID,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Layer.provide(LayerNodeTree.compile(location.hoisted, replacements)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ idleTimeToLive: "60 minutes" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is temporary for backwards compatibility
|
||||||
|
export const locationServiceMapLayer = buildLocationServiceMap()
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
|
import { LayerNode } from "./effect/layer-node"
|
||||||
|
import { makeLocationNode, tags } from "./effect/node"
|
||||||
|
|
||||||
export * as Location from "./location"
|
export * as Location from "./location"
|
||||||
|
|
||||||
@@ -12,6 +14,8 @@ export interface Interface extends Info {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||||
|
|
||||||
|
export const node = LayerNode.unbound(Service, tags.values.location)
|
||||||
|
|
||||||
export const layer = (ref: Ref) =>
|
export const layer = (ref: Ref) =>
|
||||||
Layer.effect(
|
Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
@@ -26,3 +30,10 @@ export const layer = (ref: Ref) =>
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const boundNode = (ref: Ref) =>
|
||||||
|
makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: layer(ref),
|
||||||
|
deps: [Project.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Hash } from "./util/hash"
|
|||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { httpClient } from "./effect/layer-node-platform"
|
import { httpClient } from "./effect/layer-node-platform"
|
||||||
|
|
||||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||||
@@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
)
|
)
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||||
|
|
||||||
export * as ModelsDev from "./models-dev"
|
export * as ModelsDev from "./models-dev"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NodeFileSystem } from "@effect/platform-node"
|
|||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { EffectFlock } from "./util/effect-flock"
|
import { EffectFlock } from "./util/effect-flock"
|
||||||
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"
|
||||||
import { makeRuntime } from "./effect/runtime"
|
import { makeRuntime } from "./effect/runtime"
|
||||||
import { NpmConfig } from "./npm-config"
|
import { NpmConfig } from "./npm-config"
|
||||||
@@ -253,7 +253,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Global.layer),
|
Layer.provide(Global.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
)
|
)
|
||||||
export const node = LayerNode.make({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
layer: layer,
|
||||||
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
|
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
// Branded HTML pages for local OAuth callback servers.
|
||||||
|
//
|
||||||
|
// These are served by the loopback HTTP servers that finish an OAuth exchange
|
||||||
|
// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return
|
||||||
|
// a fully self-contained HTML string with no external assets, so they work
|
||||||
|
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
|
||||||
|
// etc.).
|
||||||
|
//
|
||||||
|
// The visual language mirrors the OpenCode app: the design tokens are a curated
|
||||||
|
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
|
||||||
|
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
|
||||||
|
// Keep this file in sync with those sources when the brand changes.
|
||||||
|
|
||||||
|
export interface CallbackPageOptions {
|
||||||
|
/** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */
|
||||||
|
provider?: string
|
||||||
|
/** Attempt to close the window shortly after success. Defaults to true. */
|
||||||
|
autoClose?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function success(options?: CallbackPageOptions) {
|
||||||
|
const provider = options?.provider
|
||||||
|
return renderDocument({
|
||||||
|
title: "Authorization successful",
|
||||||
|
body: renderCard({
|
||||||
|
status: "success",
|
||||||
|
headline: "Authorization successful",
|
||||||
|
message: provider ? `OpenCode is now connected to ${escapeHtml(provider)}.` : "OpenCode is now authorized.",
|
||||||
|
footnote: "You can close this window.",
|
||||||
|
}),
|
||||||
|
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function error(detail: string, options?: CallbackPageOptions) {
|
||||||
|
const provider = options?.provider
|
||||||
|
return renderDocument({
|
||||||
|
title: "Authorization failed",
|
||||||
|
body: renderCard({
|
||||||
|
status: "error",
|
||||||
|
headline: "Authorization failed",
|
||||||
|
message: provider
|
||||||
|
? `OpenCode couldn't finish connecting to ${escapeHtml(provider)}.`
|
||||||
|
: "OpenCode couldn't complete authorization.",
|
||||||
|
detail,
|
||||||
|
footnote: "Close this window and try again from OpenCode.",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapOptions {
|
||||||
|
/** Same-origin path the in-browser script POSTs the parsed callback to. */
|
||||||
|
tokenPath: string
|
||||||
|
provider?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// For flows where the credential arrives in the URL fragment (implicit grant),
|
||||||
|
// the browser must relay it back to the loopback server. This renders a pending
|
||||||
|
// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves
|
||||||
|
// to the success or error state in place.
|
||||||
|
export function bootstrap(options: BootstrapOptions) {
|
||||||
|
return renderDocument({
|
||||||
|
title: "Finishing sign-in",
|
||||||
|
body: renderCard({
|
||||||
|
status: "pending",
|
||||||
|
headline: "Finishing sign-in",
|
||||||
|
message: options.provider
|
||||||
|
? `Completing your ${escapeHtml(options.provider)} authorization.`
|
||||||
|
: "Completing authorization.",
|
||||||
|
footnote: "You can close this window once sign-in finishes.",
|
||||||
|
}),
|
||||||
|
script: bootstrapScript(options),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as OauthCallbackPage from "./page"
|
||||||
|
|
||||||
|
type Status = "pending" | "success" | "error"
|
||||||
|
|
||||||
|
function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) {
|
||||||
|
const detail = input.detail?.trim()
|
||||||
|
return `<main class="card" id="oc-card" data-status="${input.status}" role="status" aria-live="polite">
|
||||||
|
<div class="brand">${WORDMARK}</div>
|
||||||
|
<div class="status" aria-hidden="true">
|
||||||
|
<span class="icon icon-pending">${ICON_SPINNER}</span>
|
||||||
|
<span class="icon icon-success">${ICON_CHECK}</span>
|
||||||
|
<span class="icon icon-error">${ICON_CROSS}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="headline" id="oc-headline">${escapeHtml(input.headline)}</h1>
|
||||||
|
<p class="message" id="oc-message">${input.message}</p>
|
||||||
|
<pre class="detail" id="oc-detail"${detail ? "" : " hidden"}>${detail ? escapeHtml(detail) : ""}</pre>
|
||||||
|
<p class="footnote" id="oc-footnote">${escapeHtml(input.footnote)}</p>
|
||||||
|
</main>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDocument(input: { title: string; body: string; script?: string }) {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
<title>${escapeHtml(input.title)} · OpenCode</title>
|
||||||
|
<style>${STYLES}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${input.body}${input.script ? `\n <script>${input.script}</script>` : ""}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)`
|
||||||
|
|
||||||
|
function bootstrapScript(options: BootstrapOptions) {
|
||||||
|
return `var PROVIDER=${scriptString(options.provider ?? "")};
|
||||||
|
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
|
||||||
|
(function(){
|
||||||
|
var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote");
|
||||||
|
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("OpenCode couldn't finish connecting to "+PROVIDER+"."):"OpenCode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from OpenCode."}
|
||||||
|
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("OpenCode is now connected to "+PROVIDER+"."):"OpenCode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
|
||||||
|
try{
|
||||||
|
var hash=new URLSearchParams((window.location.hash||"").slice(1));
|
||||||
|
var search=new URLSearchParams(window.location.search||"");
|
||||||
|
var err=hash.get("error")||search.get("error");
|
||||||
|
var errDescription=hash.get("error_description")||search.get("error_description");
|
||||||
|
var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""};
|
||||||
|
fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){
|
||||||
|
if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))});
|
||||||
|
if(err){fail(errDescription||err);return}
|
||||||
|
ok();
|
||||||
|
}).catch(function(e){fail(String(e&&e.message?e.message:e))});
|
||||||
|
}catch(e){fail(String(e&&e.message?e.message:e))}
|
||||||
|
})()`
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptString(value: string) {
|
||||||
|
return JSON.stringify(value).replaceAll("<", "\\u003c")
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is
|
||||||
|
// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a
|
||||||
|
// host force a scheme without changing the default.
|
||||||
|
const LIGHT_VARS = `
|
||||||
|
--oc-bg: #f8f8f8;
|
||||||
|
--oc-card: #fcfcfc;
|
||||||
|
--oc-text-strong: #171717;
|
||||||
|
--oc-text-base: #6f6f6f;
|
||||||
|
--oc-text-weak: #8f8f8f;
|
||||||
|
--oc-border-weak: #e5e5e5;
|
||||||
|
--oc-icon-strong: #171717;
|
||||||
|
--oc-icon-base: #8f8f8f;
|
||||||
|
--oc-icon-weak: #dbdbdb;
|
||||||
|
--oc-success: #2dba26;
|
||||||
|
--oc-error: #ed4831;
|
||||||
|
--oc-detail-bg: #fff8f6;
|
||||||
|
--oc-detail-border: #fdc3b7;
|
||||||
|
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);`
|
||||||
|
|
||||||
|
const DARK_VARS = `
|
||||||
|
--oc-bg: #101010;
|
||||||
|
--oc-card: #161616;
|
||||||
|
--oc-text-strong: rgba(255,255,255,.936);
|
||||||
|
--oc-text-base: rgba(255,255,255,.618);
|
||||||
|
--oc-text-weak: rgba(255,255,255,.422);
|
||||||
|
--oc-border-weak: #282828;
|
||||||
|
--oc-icon-strong: #ededed;
|
||||||
|
--oc-icon-base: #7e7e7e;
|
||||||
|
--oc-icon-weak: #343434;
|
||||||
|
--oc-success: #12c905;
|
||||||
|
--oc-error: #fc533a;
|
||||||
|
--oc-detail-bg: #28110c;
|
||||||
|
--oc-detail-border: #6a1206;
|
||||||
|
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);`
|
||||||
|
|
||||||
|
const STYLES = `
|
||||||
|
:root { color-scheme: light dark;${LIGHT_VARS}
|
||||||
|
--oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } }
|
||||||
|
:root[data-theme="dark"] {${DARK_VARS} }
|
||||||
|
:root[data-theme="light"] {${LIGHT_VARS} }
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--oc-bg);
|
||||||
|
color: var(--oc-text-base);
|
||||||
|
font-family: var(--oc-font-sans);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: min(100%, 28rem);
|
||||||
|
padding: 2.25rem 2rem 1.75rem;
|
||||||
|
background: var(--oc-card);
|
||||||
|
border: 1px solid var(--oc-border-weak);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--oc-shadow);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.brand { display: flex; justify-content: center; margin-bottom: 1.75rem; }
|
||||||
|
.brand svg { height: 19px; width: auto; }
|
||||||
|
.status { display: flex; justify-content: center; margin-bottom: 1.125rem; }
|
||||||
|
.icon { display: none; line-height: 0; }
|
||||||
|
.icon svg { display: block; }
|
||||||
|
.card[data-status="pending"] .icon-pending,
|
||||||
|
.card[data-status="success"] .icon-success,
|
||||||
|
.card[data-status="error"] .icon-error { display: block; }
|
||||||
|
.icon-success { color: var(--oc-success); }
|
||||||
|
.icon-error { color: var(--oc-error); }
|
||||||
|
.icon-pending { color: var(--oc-text-weak); }
|
||||||
|
.headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); }
|
||||||
|
.message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); }
|
||||||
|
.detail {
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--oc-font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--oc-text-strong);
|
||||||
|
background: var(--oc-detail-bg);
|
||||||
|
border: 1px solid var(--oc-detail-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 9.5rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.detail[hidden] { display: none; }
|
||||||
|
.footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); }
|
||||||
|
.spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; }
|
||||||
|
@keyframes oc-spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||||
|
`
|
||||||
|
|
||||||
|
// OpenCode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
|
||||||
|
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="OpenCode" role="img">
|
||||||
|
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
|
||||||
|
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--oc-icon-base)" />
|
||||||
|
<path d="M84 24V30H66V24H84Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--oc-icon-base)" />
|
||||||
|
<path d="M108 36H96V18H108V36Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--oc-icon-base)" />
|
||||||
|
<path d="M144 30H126V18H144V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--oc-icon-strong)" />
|
||||||
|
<path d="M168 30H156V18H168V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--oc-icon-strong)" />
|
||||||
|
<path d="M198 30H186V18H198V30Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--oc-icon-strong)" />
|
||||||
|
<path d="M234 24V30H216V24H234Z" fill="var(--oc-icon-weak)" />
|
||||||
|
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--oc-icon-strong)" />
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
const ICON_CHECK = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m8.5 12.5 2.4 2.4 4.6-5.4" /></svg>`
|
||||||
|
|
||||||
|
const ICON_CROSS = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m9 9 6 6m0-6-6 6" /></svg>`
|
||||||
|
|
||||||
|
const ICON_SPINNER = `<svg class="spinner" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9" opacity="0.2" /><path d="M21 12a9 9 0 0 0-9-9" /></svg>`
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as PermissionV2 from "./permission"
|
export * as PermissionV2 from "./permission"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
|
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||||
import { Permission } from "@opencode-ai/schema/permission"
|
import { Permission } from "@opencode-ai/schema/permission"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
@@ -300,3 +301,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
|
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as PermissionSaved from "./saved"
|
|||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
|
import { makeGlobalNode } from "../effect/node"
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectV2 } from "../project"
|
||||||
import { PermissionTable } from "./sql"
|
import { PermissionTable } from "./sql"
|
||||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||||
@@ -76,3 +77,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] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as PluginV2 from "./plugin"
|
export * as PluginV2 from "./plugin"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||||
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
|
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||||
@@ -150,3 +151,18 @@ export const locationLayer = layer.pipe(
|
|||||||
Layer.provideMerge(Reference.locationLayer),
|
Layer.provideMerge(Reference.locationLayer),
|
||||||
Layer.provideMerge(SkillV2.locationLayer),
|
Layer.provideMerge(SkillV2.locationLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [
|
||||||
|
EventV2.node,
|
||||||
|
AgentV2.node,
|
||||||
|
AISDK.node,
|
||||||
|
Catalog.node,
|
||||||
|
CommandV2.node,
|
||||||
|
Integration.node,
|
||||||
|
Reference.node,
|
||||||
|
SkillV2.node,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export * as PluginInternal from "./internal"
|
export * as PluginInternal from "./internal"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
|
import { httpClient } from "../effect/layer-node-platform"
|
||||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { Effect, Layer, Scope } from "effect"
|
import { Effect, Layer, Scope } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
@@ -57,7 +59,7 @@ export function define<R>(plugin: Plugin<R>) {
|
|||||||
return plugin
|
return plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
export const locationLayer = Layer.effectDiscard(
|
const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const commands = yield* CommandV2.Service
|
const commands = yield* CommandV2.Service
|
||||||
@@ -117,9 +119,34 @@ export const locationLayer = Layer.effectDiscard(
|
|||||||
yield* add(VariantPlugin.Plugin)
|
yield* add(VariantPlugin.Plugin)
|
||||||
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||||
}),
|
}),
|
||||||
).pipe(
|
)
|
||||||
|
|
||||||
|
export const locationLayer = layer.pipe(
|
||||||
Layer.provideMerge(PluginV2.locationLayer),
|
Layer.provideMerge(PluginV2.locationLayer),
|
||||||
Layer.provideMerge(Config.locationLayer),
|
Layer.provideMerge(Config.locationLayer),
|
||||||
Layer.provideMerge(FileSystem.locationLayer),
|
Layer.provideMerge(FileSystem.locationLayer),
|
||||||
Layer.provideMerge(FetchHttpClient.layer),
|
Layer.provideMerge(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
name: "plugin-internal",
|
||||||
|
layer,
|
||||||
|
deps: [
|
||||||
|
Catalog.node,
|
||||||
|
CommandV2.node,
|
||||||
|
PluginV2.node,
|
||||||
|
Integration.node,
|
||||||
|
AgentV2.node,
|
||||||
|
Config.node,
|
||||||
|
Location.node,
|
||||||
|
ModelsDev.node,
|
||||||
|
Npm.node,
|
||||||
|
EventV2.node,
|
||||||
|
FSUtil.node,
|
||||||
|
FileSystem.node,
|
||||||
|
Global.node,
|
||||||
|
httpClient,
|
||||||
|
SkillV2.node,
|
||||||
|
Reference.node,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Credential } from "../../credential"
|
|||||||
import { InstallationVersion } from "../../installation/version"
|
import { InstallationVersion } from "../../installation/version"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
|
import { OauthCallbackPage } from "../../oauth/page"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
import type { PluginInternal } from "../internal"
|
import type { PluginInternal } from "../internal"
|
||||||
|
|
||||||
@@ -58,17 +59,21 @@ const browser = {
|
|||||||
const value = url.searchParams.get("code")
|
const value = url.searchParams.get("code")
|
||||||
if (error) {
|
if (error) {
|
||||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
|
response
|
||||||
|
.writeHead(400, { "Content-Type": "text/html" })
|
||||||
|
.end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!value || url.searchParams.get("state") !== state) {
|
if (!value || url.searchParams.get("state") !== state) {
|
||||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
|
response
|
||||||
|
.writeHead(400, { "Content-Type": "text/html" })
|
||||||
|
.end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Effect.runFork(Deferred.succeed(code, value))
|
Effect.runFork(Deferred.succeed(code, value))
|
||||||
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
|
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" }))
|
||||||
})
|
})
|
||||||
yield* Effect.callback<void, Error>((resume) => {
|
yield* Effect.callback<void, Error>((resume) => {
|
||||||
server.once("error", (error) => resume(Effect.fail(error)))
|
server.once("error", (error) => resume(Effect.fail(error)))
|
||||||
@@ -285,8 +290,3 @@ function claim(token: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const successPage =
|
|
||||||
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
|
|
||||||
const errorPage = (message: string) =>
|
|
||||||
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Policy from "./policy"
|
export * as Policy from "./policy"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
|
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||||
import { Wildcard } from "./util/wildcard"
|
import { Wildcard } from "./util/wildcard"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
@@ -44,3 +45,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PlatformError } from "effect/PlatformError"
|
|||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
|
|
||||||
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
|
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
|
||||||
command: Schema.String,
|
command: Schema.String,
|
||||||
@@ -238,6 +238,6 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
||||||
|
|
||||||
export * as AppProcess from "./process"
|
export * as AppProcess from "./process"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import path from "path"
|
|||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Git } from "./git"
|
import { Git } from "./git"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { Hash } from "./util/hash"
|
import { Hash } from "./util/hash"
|
||||||
import { ProjectDirectories } from "./project/directories"
|
import { ProjectDirectories } from "./project/directories"
|
||||||
import { ProjectSchema } from "./project/schema"
|
import { ProjectSchema } from "./project/schema"
|
||||||
@@ -134,7 +134,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provideMerge(ProjectDirectories.defaultLayer),
|
Layer.provideMerge(ProjectDirectories.defaultLayer),
|
||||||
)
|
)
|
||||||
export const node = LayerNode.make({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
layer: layer,
|
||||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
|
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from "path"
|
|||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Git } from "../git"
|
import { Git } from "../git"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { Project } from "../project"
|
import { Project } from "../project"
|
||||||
import { ProjectDirectories } from "./directories"
|
import { ProjectDirectories } from "./directories"
|
||||||
import { makeGitWorktreeStrategy } from "./copy-strategies"
|
import { makeGitWorktreeStrategy } from "./copy-strategies"
|
||||||
@@ -279,8 +279,14 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
export const node = LayerNode.make({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
layer: layer,
|
||||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
|
deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const refreshNode = makeLocationNode({
|
||||||
|
name: "project-copy-refresh",
|
||||||
|
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||||
|
deps: [node, Location.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as ProjectDirectories from "./directories"
|
|||||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
import { AbsolutePath, optional } from "../schema"
|
import { AbsolutePath, optional } from "../schema"
|
||||||
import { ProjectSchema } from "./schema"
|
import { ProjectSchema } from "./schema"
|
||||||
import { ProjectDirectoryTable } from "./sql"
|
import { ProjectDirectoryTable } from "./sql"
|
||||||
@@ -156,4 +156,4 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Pty from "./pty"
|
export * as Pty from "./pty"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import type { Disp, Proc } from "#pty"
|
import type { Disp, Proc } from "#pty"
|
||||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||||
import { Pty } from "@opencode-ai/schema/pty"
|
import { Pty } from "@opencode-ai/schema/pty"
|
||||||
@@ -313,3 +314,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: [EventV2.node, Location.node, Config.node] })
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { WorkspaceV2 } from "../workspace"
|
|||||||
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||||
import { PtyID } from "./schema"
|
import { PtyID } from "./schema"
|
||||||
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
|
|
||||||
const DEFAULT_TTL = Duration.seconds(60)
|
const DEFAULT_TTL = Duration.seconds(60)
|
||||||
const CAPACITY = 10_000
|
const CAPACITY = 10_000
|
||||||
@@ -54,4 +54,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
|
|||||||
export const layer = Layer.effect(Service, make())
|
export const layer = Layer.effect(Service, make())
|
||||||
|
|
||||||
export const defaultLayer = layer
|
export const defaultLayer = layer
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as QuestionV2 from "./question"
|
export * as QuestionV2 from "./question"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||||
import { Question } from "@opencode-ai/schema/question"
|
import { Question } from "@opencode-ai/schema/question"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
@@ -148,3 +149,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Reference from "./reference"
|
export * as Reference from "./reference"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Effect, Layer, Scope, Types } from "effect"
|
import { Context, Effect, Layer, Scope, Types } from "effect"
|
||||||
import { Reference } from "@opencode-ai/schema/reference"
|
import { Reference } from "@opencode-ai/schema/reference"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
@@ -120,3 +121,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [Global.node, EventV2.node, RepositoryCache.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as ReferenceGuidance from "./guidance"
|
export * as ReferenceGuidance from "./guidance"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { SystemContext } from "../system-context/index"
|
import { SystemContext } from "../system-context/index"
|
||||||
@@ -64,3 +65,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Git } from "./git"
|
|||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { Repository } from "./repository"
|
import { Repository } from "./repository"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { EffectFlock } from "./util/effect-flock"
|
import { EffectFlock } from "./util/effect-flock"
|
||||||
|
|
||||||
export type Result = {
|
export type Result = {
|
||||||
@@ -229,6 +230,12 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
|||||||
Layer.provide(Global.defaultLayer),
|
Layer.provide(Global.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
|
||||||
|
})
|
||||||
|
|
||||||
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||||
if (!input.reuse) return "cloned" as const
|
if (!input.reuse) return "cloned" as const
|
||||||
if (input.branchMatches === false || input.refresh) return "refreshed" as const
|
if (input.branchMatches === false || input.refresh) return "refreshed" as const
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as Ripgrep from "./ripgrep"
|
|||||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { AppProcess, collectStream, waitForAbort } from "./process"
|
import { AppProcess, collectStream, waitForAbort } from "./process"
|
||||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||||
import { RipgrepBinary } from "./ripgrep/binary"
|
import { RipgrepBinary } from "./ripgrep/binary"
|
||||||
@@ -279,4 +279,4 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
|
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/
|
|||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
import { httpClient } from "../effect/layer-node-platform"
|
import { httpClient } from "../effect/layer-node-platform"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
@@ -130,7 +130,7 @@ export namespace RipgrepBinary {
|
|||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = LayerNode.make({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
layer: layer,
|
||||||
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
||||||
|
|||||||
+305
-298
@@ -10,6 +10,7 @@ import { ModelV2 } from "./model"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { SessionMessage } from "./session/message"
|
import { SessionMessage } from "./session/message"
|
||||||
import { Prompt } from "./session/prompt"
|
import { Prompt } from "./session/prompt"
|
||||||
|
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { SessionProjector } from "./session/projector"
|
import { SessionProjector } from "./session/projector"
|
||||||
@@ -26,12 +27,16 @@ import { fromRow } from "./session/info"
|
|||||||
import { SessionRunner } from "./session/runner/index"
|
import { SessionRunner } from "./session/runner/index"
|
||||||
import { SessionStore } from "./session/store"
|
import { SessionStore } from "./session/store"
|
||||||
import { SessionExecution } from "./session/execution"
|
import { SessionExecution } from "./session/execution"
|
||||||
|
import { makeGlobalNode } from "./effect/node"
|
||||||
|
import { LocationServiceMap } from "./location-service-map"
|
||||||
import { MessageDecodeError } from "./session/error"
|
import { MessageDecodeError } from "./session/error"
|
||||||
import { SessionEvent } from "./session/event"
|
import { SessionEvent } from "./session/event"
|
||||||
import { SessionInput } from "./session/input"
|
import { SessionInput } from "./session/input"
|
||||||
import { Snapshot } from "./snapshot"
|
import { Snapshot } from "./snapshot"
|
||||||
import { SessionRevert } from "./session/revert"
|
import { SessionRevert } from "./session/revert"
|
||||||
import { Revert } from "@opencode-ai/schema/revert"
|
import { Revert } from "@opencode-ai/schema/revert"
|
||||||
|
import { FSUtil } from "./fs-util"
|
||||||
|
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||||
|
|
||||||
export const RevertState = Revert.State
|
export const RevertState = Revert.State
|
||||||
export type RevertState = Revert.State
|
export type RevertState = Revert.State
|
||||||
@@ -128,7 +133,12 @@ export interface Interface {
|
|||||||
readonly events: (input: {
|
readonly events: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
after?: number
|
after?: number
|
||||||
}) => Stream.Stream<SessionEvent.StreamEvent, NotFoundError>
|
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||||
|
readonly history: (input: {
|
||||||
|
sessionID: SessionSchema.ID
|
||||||
|
after?: number
|
||||||
|
limit: number
|
||||||
|
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
|
||||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||||
readonly switchModel: (input: {
|
readonly switchModel: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -137,7 +147,7 @@ export interface Interface {
|
|||||||
readonly prompt: (input: {
|
readonly prompt: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: SessionMessage.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
prompt: Prompt
|
prompt: PromptInput.Prompt
|
||||||
delivery?: SessionInput.Delivery
|
delivery?: SessionInput.Delivery
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
||||||
@@ -171,308 +181,277 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
||||||
|
|
||||||
export const layer = Layer.unwrap(
|
export const layer = Layer.effect(
|
||||||
Effect.promise(() => import("./location-layer")).pipe(
|
Service,
|
||||||
Effect.map(({ LocationServiceMap }) =>
|
Effect.gen(function* () {
|
||||||
Layer.effect(
|
const database = yield* Database.Service
|
||||||
Service,
|
const db = database.db
|
||||||
Effect.gen(function* () {
|
const events = yield* EventV2.Service
|
||||||
const database = yield* Database.Service
|
const projects = yield* ProjectV2.Service
|
||||||
const db = database.db
|
const execution = yield* SessionExecution.Service
|
||||||
const events = yield* EventV2.Service
|
const store = yield* SessionStore.Service
|
||||||
const projects = yield* ProjectV2.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
const execution = yield* SessionExecution.Service
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||||
const store = yield* SessionStore.Service
|
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||||
const locations = yield* LocationServiceMap
|
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||||
const sessionEventTypes = new Set<string>(SessionEvent.Definitions.map((definition) => definition.type))
|
Effect.mapError(
|
||||||
const isSessionEvent = (event: EventV2.Payload): event is SessionEvent.Event =>
|
() =>
|
||||||
sessionEventTypes.has(event.type)
|
new MessageDecodeError({
|
||||||
const isDurableSessionEvent = (event: EventV2.Payload): event is SessionEvent.DurableEvent =>
|
sessionID: SessionSchema.ID.make(row.session_id),
|
||||||
event.durable !== undefined && isSessionEvent(event)
|
messageID: SessionMessage.ID.make(row.id),
|
||||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
}),
|
||||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
),
|
||||||
Effect.mapError(
|
)
|
||||||
() =>
|
|
||||||
new MessageDecodeError({
|
const result = Service.of({
|
||||||
sessionID: SessionSchema.ID.make(row.session_id),
|
create: Effect.fn("V2Session.create")(function* (input) {
|
||||||
messageID: SessionMessage.ID.make(row.id),
|
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||||
}),
|
const recorded = yield* store.get(sessionID)
|
||||||
|
if (recorded) return recorded
|
||||||
|
const project = yield* projects.resolve(input.location.directory)
|
||||||
|
yield* db
|
||||||
|
.insert(ProjectTable)
|
||||||
|
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const now = Date.now()
|
||||||
|
const info = SessionV1.SessionInfo.make({
|
||||||
|
id: sessionID,
|
||||||
|
slug: Slug.create(),
|
||||||
|
version: InstallationVersion,
|
||||||
|
projectID: project.id,
|
||||||
|
directory: input.location.directory,
|
||||||
|
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
||||||
|
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
||||||
|
title: `New session - ${new Date(now).toISOString()}`,
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model
|
||||||
|
? {
|
||||||
|
id: ModelV2.ID.make(input.model.id),
|
||||||
|
providerID: input.model.providerID,
|
||||||
|
variant: input.model.variant,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: now, updated: now },
|
||||||
|
})
|
||||||
|
const projected = yield* events
|
||||||
|
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
||||||
|
.pipe(
|
||||||
|
Effect.as({ type: "created" } as const),
|
||||||
|
Effect.catchDefect((defect) => {
|
||||||
|
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||||
|
return Effect.die(defect)
|
||||||
|
}
|
||||||
|
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||||
|
return store
|
||||||
|
.get(sessionID)
|
||||||
|
.pipe(
|
||||||
|
Effect.flatMap((session) =>
|
||||||
|
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (projected.type === "existing") return projected.session
|
||||||
|
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||||
|
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||||
|
}),
|
||||||
|
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
||||||
|
const session = yield* store.get(sessionID)
|
||||||
|
if (!session) return yield* new NotFoundError({ sessionID })
|
||||||
|
return session
|
||||||
|
}),
|
||||||
|
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
||||||
|
const direction = input.anchor?.direction ?? "next"
|
||||||
|
const requestedOrder = input.order ?? "desc"
|
||||||
|
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||||
|
const sortColumn = SessionTable.time_created
|
||||||
|
const conditions: SQL[] = []
|
||||||
|
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||||
|
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||||
|
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||||
|
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||||
|
if (input.anchor) {
|
||||||
|
conditions.push(
|
||||||
|
order === "asc"
|
||||||
|
? or(
|
||||||
|
gt(sortColumn, input.anchor.time),
|
||||||
|
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||||
|
)!
|
||||||
|
: or(
|
||||||
|
lt(sortColumn, input.anchor.time),
|
||||||
|
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const query = db
|
||||||
|
.select()
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(
|
||||||
|
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||||
|
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||||
|
)
|
||||||
|
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||||
|
}),
|
||||||
|
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
const direction = input.cursor?.direction ?? "next"
|
||||||
|
const requestedOrder = input.order ?? "desc"
|
||||||
|
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||||
|
const anchor = input.cursor
|
||||||
|
? yield* db
|
||||||
|
.select({ seq: SessionMessageTable.seq })
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(
|
||||||
|
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
: undefined
|
||||||
|
if (input.cursor && !anchor) return []
|
||||||
|
const boundary = anchor
|
||||||
|
? order === "asc"
|
||||||
|
? gt(SessionMessageTable.seq, anchor.seq)
|
||||||
|
: lt(SessionMessageTable.seq, anchor.seq)
|
||||||
|
: undefined
|
||||||
|
const where = boundary
|
||||||
|
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||||
|
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||||
|
const query = db
|
||||||
|
.select()
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||||
|
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
|
||||||
|
}),
|
||||||
|
message: Effect.fn("V2Session.message")(function* (input) {
|
||||||
|
const stored = yield* store.message(input.messageID)
|
||||||
|
return stored?.sessionID === input.sessionID ? stored.message : undefined
|
||||||
|
}),
|
||||||
|
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
||||||
|
yield* result.get(sessionID)
|
||||||
|
return yield* store.context(sessionID)
|
||||||
|
}),
|
||||||
|
events: (input) =>
|
||||||
|
Stream.unwrap(
|
||||||
|
result
|
||||||
|
.get(input.sessionID)
|
||||||
|
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||||
|
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||||
|
history: Effect.fn("V2Session.history")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
return yield* EventV2.readAggregate(db, {
|
||||||
|
...input,
|
||||||
|
aggregateID: input.sessionID,
|
||||||
|
manifest: SessionDurable,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||||
|
Effect.uninterruptible(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
const prompt = resolvePrompt(input.prompt)
|
||||||
|
const messageID = input.id ?? SessionMessage.ID.create()
|
||||||
|
const delivery = input.delivery ?? "steer"
|
||||||
|
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||||
|
const admitted = yield* SessionInput.admit(db, events, {
|
||||||
|
id: messageID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt,
|
||||||
|
delivery,
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchDefect((defect) =>
|
||||||
|
defect instanceof SessionInput.LifecycleConflict
|
||||||
|
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
|
: Effect.die(defect),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if (!SessionInput.equivalent(admitted, expected))
|
||||||
const result = Service.of({
|
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
create: Effect.fn("V2Session.create")(function* (input) {
|
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
return admitted
|
||||||
const recorded = yield* store.get(sessionID)
|
}),
|
||||||
if (recorded) return recorded
|
),
|
||||||
const project = yield* projects.resolve(input.location.directory)
|
|
||||||
yield* db
|
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
const now = Date.now()
|
|
||||||
const info = SessionV1.SessionInfo.make({
|
|
||||||
id: sessionID,
|
|
||||||
slug: Slug.create(),
|
|
||||||
version: InstallationVersion,
|
|
||||||
projectID: project.id,
|
|
||||||
directory: input.location.directory,
|
|
||||||
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
|
||||||
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
|
||||||
title: `New session - ${new Date(now).toISOString()}`,
|
|
||||||
agent: input.agent,
|
|
||||||
model: input.model
|
|
||||||
? {
|
|
||||||
id: ModelV2.ID.make(input.model.id),
|
|
||||||
providerID: input.model.providerID,
|
|
||||||
variant: input.model.variant,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
cost: 0,
|
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: { created: now, updated: now },
|
|
||||||
})
|
|
||||||
const projected = yield* events
|
|
||||||
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
|
||||||
.pipe(
|
|
||||||
Effect.as({ type: "created" } as const),
|
|
||||||
Effect.catchDefect((defect) => {
|
|
||||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
|
||||||
return Effect.die(defect)
|
|
||||||
}
|
|
||||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
|
||||||
return store
|
|
||||||
.get(sessionID)
|
|
||||||
.pipe(
|
|
||||||
Effect.flatMap((session) =>
|
|
||||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (projected.type === "existing") return projected.session
|
|
||||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
|
||||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
|
||||||
}),
|
|
||||||
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
|
||||||
const session = yield* store.get(sessionID)
|
|
||||||
if (!session) return yield* new NotFoundError({ sessionID })
|
|
||||||
return session
|
|
||||||
}),
|
|
||||||
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
|
||||||
const direction = input.anchor?.direction ?? "next"
|
|
||||||
const requestedOrder = input.order ?? "desc"
|
|
||||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
|
||||||
const sortColumn = SessionTable.time_created
|
|
||||||
const conditions: SQL[] = []
|
|
||||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
|
||||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
|
||||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
|
||||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
|
||||||
if (input.anchor) {
|
|
||||||
conditions.push(
|
|
||||||
order === "asc"
|
|
||||||
? or(
|
|
||||||
gt(sortColumn, input.anchor.time),
|
|
||||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
|
||||||
)!
|
|
||||||
: or(
|
|
||||||
lt(sortColumn, input.anchor.time),
|
|
||||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
|
||||||
)!,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const query = db
|
|
||||||
.select()
|
|
||||||
.from(SessionTable)
|
|
||||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
||||||
.orderBy(
|
|
||||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
|
||||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
|
||||||
)
|
|
||||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
|
||||||
}),
|
|
||||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
const direction = input.cursor?.direction ?? "next"
|
|
||||||
const requestedOrder = input.order ?? "desc"
|
|
||||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
|
||||||
const anchor = input.cursor
|
|
||||||
? yield* db
|
|
||||||
.select({ seq: SessionMessageTable.seq })
|
|
||||||
.from(SessionMessageTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(SessionMessageTable.session_id, input.sessionID),
|
|
||||||
eq(SessionMessageTable.id, input.cursor.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
: undefined
|
|
||||||
if (input.cursor && !anchor) return []
|
|
||||||
const boundary = anchor
|
|
||||||
? order === "asc"
|
|
||||||
? gt(SessionMessageTable.seq, anchor.seq)
|
|
||||||
: lt(SessionMessageTable.seq, anchor.seq)
|
|
||||||
: undefined
|
|
||||||
const where = boundary
|
|
||||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
|
||||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
|
||||||
const query = db
|
|
||||||
.select()
|
|
||||||
.from(SessionMessageTable)
|
|
||||||
.where(where)
|
|
||||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
|
||||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
|
|
||||||
}),
|
|
||||||
message: Effect.fn("V2Session.message")(function* (input) {
|
|
||||||
const stored = yield* store.message(input.messageID)
|
|
||||||
return stored?.sessionID === input.sessionID ? stored.message : undefined
|
|
||||||
}),
|
|
||||||
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
|
||||||
yield* result.get(sessionID)
|
|
||||||
return yield* store.context(sessionID)
|
|
||||||
}),
|
|
||||||
events: (input) =>
|
|
||||||
Stream.unwrap(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
const activity = yield* execution.activity(input.sessionID)
|
|
||||||
const observed = yield* events.observeAggregate({
|
|
||||||
aggregateID: input.sessionID,
|
|
||||||
after: input.after,
|
|
||||||
live: (event) =>
|
|
||||||
event.durable === undefined && isSessionEvent(event) && event.data.sessionID === input.sessionID,
|
|
||||||
})
|
|
||||||
const initialActivity = SessionEvent.makeActivity(
|
|
||||||
input.sessionID,
|
|
||||||
yield* activity.attach((active) =>
|
|
||||||
observed.offer(SessionEvent.makeActivity(input.sessionID, active), active ? "before" : "after"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return Stream.fromIterable(observed.replay.filter(isDurableSessionEvent)).pipe(
|
|
||||||
Stream.concat(Stream.make(initialActivity)),
|
|
||||||
Stream.concat(
|
|
||||||
observed.updates.pipe(
|
|
||||||
Stream.filter(
|
|
||||||
(event): event is SessionEvent.StreamEvent =>
|
|
||||||
event.type === SessionEvent.Activity.type || isSessionEvent(event),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
|
||||||
Effect.uninterruptible(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
const messageID = input.id ?? SessionMessage.ID.create()
|
|
||||||
const delivery = input.delivery ?? "steer"
|
|
||||||
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
|
|
||||||
const admitted = yield* SessionInput.admit(db, events, {
|
|
||||||
id: messageID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: input.prompt,
|
|
||||||
delivery,
|
|
||||||
}).pipe(
|
|
||||||
Effect.catchDefect((defect) =>
|
|
||||||
defect instanceof SessionInput.LifecycleConflict
|
|
||||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
|
||||||
: Effect.die(defect),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!SessionInput.equivalent(admitted, expected))
|
|
||||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
|
||||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
|
||||||
return admitted
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
shell: Effect.fn("V2Session.shell")(function* () {
|
|
||||||
return yield* new OperationUnavailableError({ operation: "shell" })
|
|
||||||
}),
|
|
||||||
skill: Effect.fn("V2Session.skill")(function* () {
|
|
||||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
|
||||||
}),
|
|
||||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: SessionMessage.ID.create(),
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
agent: input.agent,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: SessionMessage.ID.create(),
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
model: input.model,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
|
||||||
}),
|
|
||||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
|
||||||
yield* result.get(sessionID)
|
|
||||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
|
||||||
}),
|
|
||||||
active: execution.active,
|
|
||||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
|
||||||
yield* result.get(sessionID)
|
|
||||||
yield* execution.resume(sessionID)
|
|
||||||
}),
|
|
||||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
|
||||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
|
||||||
),
|
|
||||||
revert: {
|
|
||||||
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
|
||||||
const session = yield* result.get(input.sessionID)
|
|
||||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
|
||||||
Effect.provideService(Database.Service, database),
|
|
||||||
Effect.provideService(EventV2.Service, events),
|
|
||||||
Effect.provide(locations.get(session.location)),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
|
||||||
const session = yield* result.get(sessionID)
|
|
||||||
yield* SessionRevert.clear(session).pipe(
|
|
||||||
Effect.provideService(EventV2.Service, events),
|
|
||||||
Effect.provide(locations.get(session.location)),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
|
||||||
const session = yield* result.get(sessionID)
|
|
||||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
shell: Effect.fn("V2Session.shell")(function* () {
|
||||||
),
|
return yield* new OperationUnavailableError({ operation: "shell" })
|
||||||
|
}),
|
||||||
|
skill: Effect.fn("V2Session.skill")(function* () {
|
||||||
|
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||||
|
}),
|
||||||
|
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
agent: input.agent,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
model: input.model,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||||
|
}),
|
||||||
|
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||||
|
yield* result.get(sessionID)
|
||||||
|
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||||
|
}),
|
||||||
|
active: execution.active,
|
||||||
|
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||||
|
yield* result.get(sessionID)
|
||||||
|
yield* execution.resume(sessionID)
|
||||||
|
}),
|
||||||
|
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||||
|
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||||
|
),
|
||||||
|
revert: {
|
||||||
|
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
||||||
|
const session = yield* result.get(input.sessionID)
|
||||||
|
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||||
|
Effect.provideService(Database.Service, database),
|
||||||
|
Effect.provideService(EventV2.Service, events),
|
||||||
|
Effect.provide(locations.get(session.location)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
||||||
|
const session = yield* result.get(sessionID)
|
||||||
|
yield* SessionRevert.clear(session).pipe(
|
||||||
|
Effect.provideService(EventV2.Service, events),
|
||||||
|
Effect.provide(locations.get(session.location)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
||||||
|
const session = yield* result.get(sessionID)
|
||||||
|
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(
|
|
||||||
Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))),
|
|
||||||
),
|
|
||||||
Layer.provide(SessionStore.defaultLayer),
|
Layer.provide(SessionStore.defaultLayer),
|
||||||
Layer.provide(SessionProjector.defaultLayer),
|
Layer.provide(SessionProjector.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
@@ -480,3 +459,31 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(ProjectV2.defaultLayer),
|
Layer.provide(ProjectV2.defaultLayer),
|
||||||
Layer.orDie,
|
Layer.orDie,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||||
|
Prompt.make({
|
||||||
|
text: input.text,
|
||||||
|
agents: input.agents,
|
||||||
|
files: input.files?.map((file) => {
|
||||||
|
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
|
||||||
|
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
service: Service,
|
||||||
|
layer: layer.pipe(Layer.orDie),
|
||||||
|
deps: [
|
||||||
|
Database.node,
|
||||||
|
EventV2.node,
|
||||||
|
ProjectV2.node,
|
||||||
|
SessionExecution.node,
|
||||||
|
SessionStore.node,
|
||||||
|
LocationServiceMap.node,
|
||||||
|
SessionProjector.node,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
export * as SessionExecution from "./execution"
|
export * as SessionExecution from "./execution"
|
||||||
|
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { LayerNode } from "../effect/layer-node"
|
||||||
|
import { Node } from "../effect/node"
|
||||||
import { SessionRunner } from "./runner/index"
|
import { SessionRunner } from "./runner/index"
|
||||||
import { SessionRunCoordinator } from "./run-coordinator"
|
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/** Snapshots active execution owned by this process. */
|
/** Snapshots active execution owned by this process. */
|
||||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||||
/** Observes foreground ownership with an authoritative initial snapshot. */
|
|
||||||
readonly activity: (sessionID: SessionSchema.ID) => Effect.Effect<SessionRunCoordinator.Activity, never, Scope.Scope>
|
|
||||||
/** Starts execution while idle or joins the active execution. */
|
/** Starts execution while idle or joins the active execution. */
|
||||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||||
@@ -21,12 +20,13 @@ export interface Interface {
|
|||||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
|
||||||
|
|
||||||
|
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||||
|
|
||||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||||
export const noopLayer = Layer.succeed(
|
export const noopLayer = Layer.succeed(
|
||||||
Service,
|
Service,
|
||||||
Service.of({
|
Service.of({
|
||||||
active: Effect.succeed(new Set()),
|
active: Effect.succeed(new Set()),
|
||||||
activity: () => Effect.succeed({ attach: () => Effect.succeed(false) }),
|
|
||||||
resume: () => Effect.void,
|
resume: () => Effect.void,
|
||||||
wake: () => Effect.void,
|
wake: () => Effect.void,
|
||||||
interrupt: () => Effect.void,
|
interrupt: () => Effect.void,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Cause, Effect, Layer } from "effect"
|
import { Cause, Effect, Layer } from "effect"
|
||||||
import { LocationServiceMap } from "../../location-layer"
|
import { LocationServiceMap } from "../../location-service-map"
|
||||||
|
import { makeGlobalNode } from "../../effect/node"
|
||||||
import { SessionRunCoordinator } from "../run-coordinator"
|
import { SessionRunCoordinator } from "../run-coordinator"
|
||||||
import { SessionRunner } from "../runner"
|
import { SessionRunner } from "../runner"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
@@ -11,7 +12,7 @@ export const layer = Layer.effect(
|
|||||||
SessionExecution.Service,
|
SessionExecution.Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap
|
const locations = yield* LocationServiceMap.Service
|
||||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||||
const session = yield* store.get(sessionID)
|
const session = yield* store.get(sessionID)
|
||||||
@@ -29,7 +30,6 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
return SessionExecution.Service.of({
|
return SessionExecution.Service.of({
|
||||||
active: coordinator.active,
|
active: coordinator.active,
|
||||||
activity: coordinator.activity,
|
|
||||||
interrupt: coordinator.interrupt,
|
interrupt: coordinator.interrupt,
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
@@ -38,3 +38,11 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
service: SessionExecution.Service,
|
||||||
|
layer,
|
||||||
|
deps: [SessionStore.node, LocationServiceMap.node],
|
||||||
|
})
|
||||||
|
|
||||||
|
export * as SessionExecutionLocal from "./local"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, desc, eq, gt, or, sql } from "drizzle-orm"
|
|||||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionV1 } from "../v1/session"
|
import { SessionV1 } from "../v1/session"
|
||||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||||
@@ -456,4 +456,4 @@ export const layer = Layer.effectDiscard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||||
export const node = LayerNode.make({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
|
export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user