mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3592e49dd | |||
| 5146f01e0a | |||
| 59c1f89d39 | |||
| 96fbcd7747 | |||
| 8a5231ec26 |
+4
-2
@@ -162,12 +162,14 @@ _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 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.
|
- `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.
|
||||||
- `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 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.
|
- `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.
|
||||||
|
- 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,10 +31,6 @@
|
|||||||
"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:*",
|
||||||
@@ -63,7 +59,7 @@
|
|||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
@@ -1455,20 +1451,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -2305,8 +2287,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -3801,7 +3781,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#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
|
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="],
|
||||||
|
|
||||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
|
||||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
|
||||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
|
||||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,6 @@
|
|||||||
"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:*",
|
||||||
@@ -76,7 +72,7 @@
|
|||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createRenderEffect,
|
|
||||||
createResource,
|
createResource,
|
||||||
createSignal,
|
createSignal,
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
@@ -279,11 +278,10 @@ function QueryProvider(props: ParentProps) {
|
|||||||
function BodyDesignClass() {
|
function BodyDesignClass() {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
createRenderEffect(() => {
|
createEffect(() => {
|
||||||
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)
|
||||||
|
|||||||
@@ -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, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog, DialogFooter } 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,7 +27,6 @@ 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
|
||||||
@@ -267,12 +266,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
onCleanup(() => tree?.cleanUp())
|
onCleanup(() => tree?.cleanUp())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog size="large" class="directory-picker-v2">
|
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
|
||||||
<DialogHeader>
|
<div class="directory-picker-v2-body">
|
||||||
<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()}
|
||||||
@@ -354,7 +349,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>
|
||||||
</DialogBody>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
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"
|
||||||
@@ -26,23 +25,12 @@ 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: "", active: "" })
|
const [store, setStore] = createStore({ open: false, search: "" })
|
||||||
let searchRef: HTMLInputElement | undefined
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
const selected = () => {
|
const selected = () => {
|
||||||
@@ -65,25 +53,8 @@ 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(server!.key),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
const initialActive = () => {
|
|
||||||
const selectedKey = selected() ? projectKey(selected()!) : undefined
|
|
||||||
const options = keys()
|
|
||||||
if (selectedKey && options.includes(selectedKey)) return selectedKey
|
|
||||||
return options[0] ?? ""
|
|
||||||
}
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
setStore({ open: false, search: "", active: "" })
|
setStore({ open: false, search: "" })
|
||||||
input.onDone()
|
input.onDone()
|
||||||
}
|
}
|
||||||
const select = (project: PromptProject) => {
|
const select = (project: PromptProject) => {
|
||||||
@@ -96,7 +67,7 @@ export function createPromptProjectController(input: {
|
|||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
const add = (server?: string) => {
|
const add = (server?: string) => {
|
||||||
setStore({ open: false, search: "", active: "" })
|
setStore("open", false)
|
||||||
input.controls().add(language.t("command.project.open"), server)
|
input.controls().add(language.t("command.project.open"), server)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,11 +75,8 @@ 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"),
|
||||||
@@ -118,188 +86,50 @@ export function createPromptProjectController(input: {
|
|||||||
add,
|
add,
|
||||||
select,
|
select,
|
||||||
setOpen(open: boolean) {
|
setOpen(open: boolean) {
|
||||||
if (open) {
|
setStore("open", open)
|
||||||
setStore({ open: true, active: initialActive() })
|
if (open) requestAnimationFrame(() => searchRef?.focus())
|
||||||
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore({ open: false, search: "", active: "" })
|
|
||||||
},
|
},
|
||||||
setSearch(value: string) {
|
setSearch(value: string) {
|
||||||
const search = value.trim().toLowerCase()
|
setStore("search", value)
|
||||||
const first = input
|
|
||||||
.controls()
|
|
||||||
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
|
||||||
setStore({ search: value, active: first ? projectKey(first) : actionKey(servers()[0]?.key) })
|
|
||||||
},
|
|
||||||
clearSearch() {
|
|
||||||
setStore({ search: "", active: initialActive() })
|
|
||||||
setTimeout(() => searchRef?.focus())
|
|
||||||
},
|
|
||||||
setActive(key: string) {
|
|
||||||
setStore("active", key)
|
|
||||||
},
|
|
||||||
moveActive(delta: number) {
|
|
||||||
const options = keys()
|
|
||||||
if (options.length === 0) return
|
|
||||||
const index = options.indexOf(store.active)
|
|
||||||
const start = index === -1 ? 0 : index
|
|
||||||
setStore("active", options[(start + delta + options.length) % options.length])
|
|
||||||
},
|
|
||||||
activeProject() {
|
|
||||||
return store.active.startsWith(projectPrefix)
|
|
||||||
? projects().find((project) => projectKey(project) === store.active)
|
|
||||||
: undefined
|
|
||||||
},
|
|
||||||
activeServer() {
|
|
||||||
return store.active.startsWith(actionPrefix)
|
|
||||||
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
|
|
||||||
: undefined
|
|
||||||
},
|
},
|
||||||
setSearchRef(el: HTMLInputElement) {
|
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 }) {
|
||||||
let contentRef: HTMLDivElement | undefined
|
|
||||||
let restoreTrigger = true
|
|
||||||
|
|
||||||
const activeItem = () =>
|
|
||||||
props.controller.active()
|
|
||||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
|
||||||
: undefined
|
|
||||||
const afterClose = (callback: () => void) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (contentRef?.isConnected) {
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
||||||
}
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
}
|
|
||||||
const selectProject = (project: PromptProject) => {
|
|
||||||
restoreTrigger = false
|
|
||||||
props.controller.setOpen(false)
|
|
||||||
afterClose(() => props.controller.select(project))
|
|
||||||
}
|
|
||||||
const selectAction = (server?: string) => {
|
|
||||||
restoreTrigger = false
|
|
||||||
props.controller.setOpen(false)
|
|
||||||
afterClose(() => props.controller.add(server))
|
|
||||||
}
|
|
||||||
const selectActive = () => {
|
|
||||||
const project = props.controller.activeProject()
|
|
||||||
if (project) {
|
|
||||||
selectProject(project)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectAction(props.controller.activeServer())
|
|
||||||
}
|
|
||||||
const moveActive = (delta: number) => {
|
|
||||||
props.controller.moveActive(delta)
|
|
||||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
|
||||||
}
|
|
||||||
const focusPreviousControl = () => {
|
|
||||||
const target = Array.from(
|
|
||||||
document.querySelectorAll<HTMLElement>(
|
|
||||||
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
|
||||||
.findLast((element) => element.offsetParent !== null)
|
|
||||||
restoreTrigger = false
|
|
||||||
target?.focus()
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (props.controller.open()) props.controller.setOpen(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const selectedValue = () => {
|
|
||||||
const project = props.controller.selected()
|
|
||||||
return project ? props.controller.projectKey(project) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<Popover
|
||||||
open={props.controller.open()}
|
open={props.controller.open()}
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
shift={-6}
|
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
|
<Popover.Trigger as={ProjectTrigger} controller={props.controller} />
|
||||||
<DropdownMenu.Portal>
|
<Popover.Portal>
|
||||||
<DropdownMenu.Content
|
<Popover.Content
|
||||||
ref={contentRef}
|
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||||
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-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
|
||||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
<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-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||||
onPointerDown={(event) => event.preventDefault()}
|
onClick={() => props.controller.setSearch("")}
|
||||||
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" />
|
||||||
@@ -309,13 +139,15 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
|||||||
<Show
|
<Show
|
||||||
when={props.controller.servers().length > 1}
|
when={props.controller.servers().length > 1}
|
||||||
fallback={
|
fallback={
|
||||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
<For each={props.controller.projects()}>
|
||||||
<For each={props.controller.projects()}>
|
{(project) => (
|
||||||
{(project) => (
|
<ProjectItem
|
||||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
project={project}
|
||||||
)}
|
selected={props.controller.selected()}
|
||||||
</For>
|
onSelect={props.controller.select}
|
||||||
</DropdownMenu.RadioGroup>
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={props.controller.servers()}>
|
<For each={props.controller.servers()}>
|
||||||
@@ -324,14 +156,19 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
|||||||
<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>
|
||||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
||||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
{(project) => (
|
||||||
{(project) => (
|
<ProjectItem
|
||||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
project={project}
|
||||||
)}
|
selected={props.controller.selected()}
|
||||||
</For>
|
onSelect={props.controller.select}
|
||||||
</DropdownMenu.RadioGroup>
|
/>
|
||||||
<ProjectAction server={server!.key} controller={props.controller} onSelect={selectAction} />
|
)}
|
||||||
|
</For>
|
||||||
|
<ProjectAction
|
||||||
|
label={props.controller.labels.add()}
|
||||||
|
onSelect={() => props.controller.add(server!.key)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -341,15 +178,14 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
|||||||
<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">
|
||||||
<ProjectAction
|
<ProjectAction
|
||||||
server={props.controller.servers()[0]?.key}
|
label={props.controller.labels.add()}
|
||||||
controller={props.controller}
|
onSelect={() => props.controller.add(props.controller.servers()[0]?.key)}
|
||||||
onSelect={selectAction}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</DropdownMenu.Content>
|
</Popover.Content>
|
||||||
</DropdownMenu.Portal>
|
</Popover.Portal>
|
||||||
</DropdownMenu>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,28 +205,15 @@ 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", "classList", "onClick", "onKeyDown"])
|
const [local, rest] = splitProps(props, ["controller", "class", "onClick"])
|
||||||
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 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-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"
|
||||||
classList={{
|
onClick={() => local.controller.setOpen(true)}
|
||||||
...local.classList,
|
|
||||||
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
|
|
||||||
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
|
|
||||||
}}
|
|
||||||
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof local.onKeyDown === "function") local.onKeyDown(event)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={project()}
|
when={project()}
|
||||||
@@ -414,77 +237,42 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
|
|||||||
|
|
||||||
function ProjectItem(props: {
|
function ProjectItem(props: {
|
||||||
project: PromptProject
|
project: PromptProject
|
||||||
controller: PromptProjectController
|
selected?: PromptProject
|
||||||
onSelect: (project: PromptProject) => void
|
onSelect: (project: PromptProject) => void
|
||||||
}) {
|
}) {
|
||||||
const key = () => props.controller.projectKey(props.project)
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.RadioItem
|
<button
|
||||||
id={key()}
|
type="button"
|
||||||
value={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(props.project)}
|
||||||
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)}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
|
<span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span>
|
||||||
<DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
|
<Show
|
||||||
<IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
when={
|
||||||
</DropdownMenu.ItemIndicator>
|
props.selected?.worktree === props.project.worktree &&
|
||||||
</DropdownMenu.RadioItem>
|
props.selected?.server?.key === props.project.server?.key
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectAction(props: {
|
function ProjectAction(props: { label: string; onSelect: () => void }) {
|
||||||
server?: string
|
|
||||||
controller: PromptProjectController
|
|
||||||
onSelect: (server?: string) => void
|
|
||||||
}) {
|
|
||||||
const key = () => props.controller.actionKey(props.server)
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Item
|
<button
|
||||||
id={key()}
|
type="button"
|
||||||
data-option-key={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"
|
||||||
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"
|
onClick={props.onSelect}
|
||||||
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" />
|
||||||
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
|
<span class="min-w-0 flex-1 truncate leading-5">{props.label}</span>
|
||||||
{props.controller.labels.add()}
|
</button>
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog, DialogFooter } 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"
|
||||||
@@ -53,12 +52,8 @@ export const DialogServerV2: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog fit class="settings-v2-server-dialog">
|
<Dialog title={title()} fit class="settings-v2-server-dialog">
|
||||||
<DialogHeader hideClose={true}>
|
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
|
||||||
<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>
|
||||||
@@ -120,7 +115,7 @@ export const DialogServerV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogBody>
|
</div>
|
||||||
<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 16px;
|
padding: 24px 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
|
||||||
|
import {
|
||||||
|
canOpenTabRename,
|
||||||
|
captureTabPointerDown,
|
||||||
|
canStartTabDrag,
|
||||||
|
createTabDragPreview,
|
||||||
|
forwardTabRef,
|
||||||
|
isPrimaryPointerPressed,
|
||||||
|
isTabCloseTarget,
|
||||||
|
} from "./titlebar-tab-gesture"
|
||||||
|
|
||||||
|
describe("titlebar tab drag", () => {
|
||||||
|
const layout = {
|
||||||
|
listLeft: 100,
|
||||||
|
dividerWidth: 13,
|
||||||
|
tabWidthById: new Map([
|
||||||
|
["a", 40],
|
||||||
|
["b", 40],
|
||||||
|
["c", 40],
|
||||||
|
["d", 40],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
test("moves across multiple tabs from one pointer update", () => {
|
||||||
|
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
|
||||||
|
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the current index inside the left hysteresis deadband", () => {
|
||||||
|
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes slot margins in captured divider width", () => {
|
||||||
|
const list = document.createElement("div")
|
||||||
|
const first = document.createElement("div")
|
||||||
|
const second = document.createElement("div")
|
||||||
|
const firstTab = document.createElement("div")
|
||||||
|
const secondTab = document.createElement("div")
|
||||||
|
first.dataset.titlebarTabSlot = ""
|
||||||
|
first.dataset.tabKey = "a"
|
||||||
|
second.dataset.titlebarTabSlot = ""
|
||||||
|
second.dataset.tabKey = "b"
|
||||||
|
second.style.marginLeft = "6px"
|
||||||
|
firstTab.dataset.titlebarTab = ""
|
||||||
|
secondTab.dataset.titlebarTab = ""
|
||||||
|
first.append(firstTab)
|
||||||
|
second.append(secondTab)
|
||||||
|
list.append(first, second)
|
||||||
|
document.body.append(list)
|
||||||
|
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
||||||
|
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
||||||
|
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
|
||||||
|
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
|
||||||
|
|
||||||
|
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
|
||||||
|
list.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the list gap as the divider width", () => {
|
||||||
|
const list = document.createElement("div")
|
||||||
|
const first = document.createElement("div")
|
||||||
|
const second = document.createElement("div")
|
||||||
|
const firstTab = document.createElement("div")
|
||||||
|
const secondTab = document.createElement("div")
|
||||||
|
first.dataset.titlebarTabSlot = ""
|
||||||
|
first.dataset.tabKey = "a"
|
||||||
|
second.dataset.titlebarTabSlot = ""
|
||||||
|
second.dataset.tabKey = "b"
|
||||||
|
firstTab.dataset.titlebarTab = ""
|
||||||
|
secondTab.dataset.titlebarTab = ""
|
||||||
|
first.append(firstTab)
|
||||||
|
second.append(secondTab)
|
||||||
|
list.append(first, second)
|
||||||
|
list.style.columnGap = "13.5px"
|
||||||
|
document.body.append(list)
|
||||||
|
|
||||||
|
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
|
||||||
|
list.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("titlebar tab gestures", () => {
|
||||||
|
test("excludes close controls from tab gestures", () => {
|
||||||
|
const close = document.createElement("div")
|
||||||
|
const button = document.createElement("button")
|
||||||
|
const link = document.createElement("a")
|
||||||
|
close.dataset.slot = "tab-close"
|
||||||
|
close.append(button)
|
||||||
|
expect(isTabCloseTarget(close)).toBe(true)
|
||||||
|
expect(isTabCloseTarget(button)).toBe(true)
|
||||||
|
expect(isTabCloseTarget(link)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forwards component refs", () => {
|
||||||
|
const element = document.createElement("div")
|
||||||
|
let received: HTMLDivElement | undefined
|
||||||
|
forwardTabRef((value) => (received = value), element)
|
||||||
|
expect(received).toBe(element)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not reopen rename while a save is pending", () => {
|
||||||
|
expect(canOpenTabRename(false, false, false)).toBe(true)
|
||||||
|
expect(canOpenTabRename(false, false, true)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the rendered tab content in the drag preview", () => {
|
||||||
|
const tab = document.createElement("div")
|
||||||
|
tab.innerHTML = '<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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
export type TabDragLayout = {
|
||||||
|
tabWidthById: Map<string, number>
|
||||||
|
dividerWidth: number
|
||||||
|
listLeft: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACTIVATION_DISTANCE = 4
|
||||||
|
export const HYSTERESIS_DEADBAND = 8
|
||||||
|
export const AUTOSCROLL_EDGE = 24
|
||||||
|
export const AUTOSCROLL_MAX_SPEED = 8
|
||||||
|
export const FLOATER_OVERSHOOT_MAX = 8
|
||||||
|
|
||||||
|
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
|
||||||
|
const dx = x2 - x1
|
||||||
|
const dy = y2 - y1
|
||||||
|
return Math.sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
|
||||||
|
const tabWidthById = new Map<string, number>()
|
||||||
|
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
|
||||||
|
for (const slot of slots) {
|
||||||
|
const id = slot.dataset.tabKey
|
||||||
|
if (!id) continue
|
||||||
|
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||||
|
if (!tab) continue
|
||||||
|
tabWidthById.set(id, tab.getBoundingClientRect().width)
|
||||||
|
}
|
||||||
|
|
||||||
|
let dividerWidth = 0
|
||||||
|
if (order.length >= 2) {
|
||||||
|
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
|
||||||
|
const secondId = order[1]
|
||||||
|
for (const slot of slots) {
|
||||||
|
if (slot.dataset.tabKey !== secondId) continue
|
||||||
|
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||||
|
if (!tab) break
|
||||||
|
const style = getComputedStyle(slot)
|
||||||
|
dividerWidth =
|
||||||
|
gap ||
|
||||||
|
slot.getBoundingClientRect().width -
|
||||||
|
tab.getBoundingClientRect().width +
|
||||||
|
(Number.parseFloat(style.marginLeft) || 0) +
|
||||||
|
(Number.parseFloat(style.marginRight) || 0)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabWidthById,
|
||||||
|
dividerWidth,
|
||||||
|
listLeft: list.getBoundingClientRect().left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
|
||||||
|
layout.listLeft = list.getBoundingClientRect().left
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
|
||||||
|
const id = order[index]
|
||||||
|
if (!id) return 0
|
||||||
|
const tabWidth = layout.tabWidthById.get(id) ?? 0
|
||||||
|
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
|
||||||
|
let left = layout.listLeft
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
left += slotWidthAt(order, i, layout)
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertIndexFromVirtualLayout(
|
||||||
|
pointerX: number,
|
||||||
|
order: readonly string[],
|
||||||
|
draggedId: string,
|
||||||
|
currentIndex: number,
|
||||||
|
layout: TabDragLayout,
|
||||||
|
deadband = HYSTERESIS_DEADBAND,
|
||||||
|
) {
|
||||||
|
if (order.length === 0) return 0
|
||||||
|
|
||||||
|
const others = order.filter((id) => id !== draggedId)
|
||||||
|
let target = currentIndex
|
||||||
|
|
||||||
|
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
|
||||||
|
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
|
||||||
|
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
|
||||||
|
const fromIndex = order.indexOf(draggedId)
|
||||||
|
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
|
||||||
|
const next = [...order]
|
||||||
|
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
|
||||||
|
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
|
||||||
|
return final.some((key, index) => key !== initial[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeOvershoot(overshoot: number) {
|
||||||
|
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
|
||||||
|
const stripWidth = stripRight - stripLeft
|
||||||
|
if (width >= stripWidth) return stripLeft
|
||||||
|
|
||||||
|
const maxLeft = stripRight - width
|
||||||
|
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
|
||||||
|
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
|
||||||
|
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
|
||||||
|
const leftEdge = containerLeft + AUTOSCROLL_EDGE
|
||||||
|
const rightEdge = containerRight - AUTOSCROLL_EDGE
|
||||||
|
|
||||||
|
if (pointerX < leftEdge) {
|
||||||
|
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
|
||||||
|
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointerX > rightEdge) {
|
||||||
|
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
|
||||||
|
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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,6 +8,22 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -15,3 +31,7 @@ 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,10 +9,6 @@
|
|||||||
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,6 +29,9 @@ 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)
|
||||||
@@ -170,105 +173,112 @@ export function TabNavItem(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
data-titlebar-tab-slot
|
||||||
tabRoot = el
|
data-tab-key={props.tabKey}
|
||||||
forwardTabRef(props.ref, el)
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
}}
|
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
||||||
data-titlebar-tab
|
onPointerDown={props.onPointerDown}
|
||||||
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)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Show when={props.session()}>
|
<div
|
||||||
{(session) => {
|
ref={(el) => {
|
||||||
return (
|
tabRoot = el
|
||||||
<a
|
forwardTabRef(props.ref, el)
|
||||||
data-slot="tab-link"
|
}}
|
||||||
data-titlebar-tab-link
|
data-titlebar-tab
|
||||||
href={props.href}
|
data-slot="titlebar-tab-item"
|
||||||
draggable={false}
|
data-title-overflow={titleOverflowing()}
|
||||||
onDragStart={(event) => {
|
data-editing={editing()}
|
||||||
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)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
event.stopPropagation()
|
data-active={props.active}
|
||||||
}}
|
data-dragging={props.dragging}
|
||||||
onClick={(event) => {
|
data-pressed={props.pressed}
|
||||||
event.preventDefault()
|
onMouseDown={(event) => {
|
||||||
if (editing()) return
|
if (event.button !== 1) return
|
||||||
if (props.suppressNavigation?.()) return
|
closeTab(event)
|
||||||
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]"
|
<Show when={props.session()}>
|
||||||
>
|
{(session) => {
|
||||||
<span data-slot="project-avatar-slot">
|
return (
|
||||||
<SessionTabAvatar
|
<a
|
||||||
project={project()}
|
data-slot="tab-link"
|
||||||
directory={session().directory}
|
data-titlebar-tab-link
|
||||||
sessionId={session().id}
|
href={props.href}
|
||||||
activeServer={props.activeServer}
|
draggable={false}
|
||||||
/>
|
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
|
||||||
</Show>
|
project={project()}
|
||||||
|
directory={session().directory}
|
||||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
sessionId={session().id}
|
||||||
<IconButtonV2
|
activeServer={props.activeServer}
|
||||||
size="small"
|
/>
|
||||||
variant="ghost-muted"
|
</span>
|
||||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
<span
|
||||||
onPointerDown={(event) => {
|
ref={(el) => {
|
||||||
event.preventDefault()
|
titleEl = el
|
||||||
event.stopPropagation()
|
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>
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
onClick={closeTab}
|
</Show>
|
||||||
icon={<IconV2 name="xmark-small" />}
|
|
||||||
/>
|
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||||
|
<IconButtonV2
|
||||||
|
size="small"
|
||||||
|
variant="ghost-muted"
|
||||||
|
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={closeTab}
|
||||||
|
icon={<IconV2 name="xmark-small" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -285,6 +295,9 @@ 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()
|
||||||
@@ -293,62 +306,69 @@ export function DraftTabItem(props: {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={(el) => forwardTabRef(props.ref, el)}
|
data-titlebar-tab-slot
|
||||||
data-titlebar-tab
|
data-tab-key={props.tabKey}
|
||||||
data-slot="titlebar-tab-item"
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
data-active={props.active}
|
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
||||||
data-dragging={props.dragging}
|
onPointerDown={props.onPointerDown}
|
||||||
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)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<a
|
<div
|
||||||
data-slot="tab-link"
|
ref={(el) => forwardTabRef(props.ref, el)}
|
||||||
data-titlebar-tab-link
|
data-titlebar-tab
|
||||||
href={props.href}
|
data-slot="titlebar-tab-item"
|
||||||
draggable={false}
|
data-active={props.active}
|
||||||
onDragStart={(event) => {
|
data-dragging={props.dragging}
|
||||||
event.preventDefault()
|
data-pressed={props.pressed}
|
||||||
event.stopPropagation()
|
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.button !== 1) return
|
||||||
|
closeTab(event)
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (props.suppressNavigation?.()) return
|
|
||||||
props.onNavigate()
|
|
||||||
}}
|
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
|
||||||
>
|
>
|
||||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
<a
|
||||||
<IconV2 name="edit" />
|
data-slot="tab-link"
|
||||||
</span>
|
data-titlebar-tab-link
|
||||||
<span
|
href={props.href}
|
||||||
data-titlebar-tab-title
|
draggable={false}
|
||||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
onDragStart={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (props.suppressNavigation?.()) return
|
||||||
|
props.onNavigate()
|
||||||
|
}}
|
||||||
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
>
|
>
|
||||||
{props.title}
|
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||||
</span>
|
<IconV2 name="edit" />
|
||||||
</a>
|
</span>
|
||||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
<span
|
||||||
<IconButtonV2
|
data-titlebar-tab-title
|
||||||
size="small"
|
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||||
variant="ghost-muted"
|
>
|
||||||
onPointerDown={(event) => {
|
{props.title}
|
||||||
event.preventDefault()
|
</span>
|
||||||
event.stopPropagation()
|
</a>
|
||||||
}}
|
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||||
onMouseDown={(event) => {
|
<IconButtonV2
|
||||||
event.preventDefault()
|
size="small"
|
||||||
event.stopPropagation()
|
variant="ghost-muted"
|
||||||
}}
|
onPointerDown={(event) => {
|
||||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
event.preventDefault()
|
||||||
onClick={closeTab}
|
event.stopPropagation()
|
||||||
icon={<IconV2 name="xmark-small" />}
|
}}
|
||||||
aria-label="Close tab"
|
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,11 +1,18 @@
|
|||||||
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
createRoot,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
Show,
|
||||||
|
} from "solid-js"
|
||||||
|
import { Portal } from "solid-js/web"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { 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"
|
||||||
@@ -15,30 +22,42 @@ 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 { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
import {
|
||||||
|
captureTabPointerDown,
|
||||||
const sortableTransition = { duration: 0 }
|
canStartTabDrag,
|
||||||
|
createTabDragPreview,
|
||||||
|
isPrimaryPointerPressed,
|
||||||
|
isTabCloseTarget,
|
||||||
|
} from "./titlebar-tab-gesture"
|
||||||
|
import {
|
||||||
|
ACTIVATION_DISTANCE,
|
||||||
|
autoscrollSpeed,
|
||||||
|
captureTabDragLayout,
|
||||||
|
clampFloaterLeft,
|
||||||
|
draftOrderChanged,
|
||||||
|
insertIndexFromVirtualLayout,
|
||||||
|
movePlaceholder,
|
||||||
|
pointerDistance,
|
||||||
|
syncLayoutScroll,
|
||||||
|
type TabDragLayout,
|
||||||
|
} from "@/components/titlebar-tab-drag"
|
||||||
|
|
||||||
function SessionTabSlot(props: {
|
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))
|
||||||
@@ -81,79 +100,33 @@ function SessionTabSlot(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<TabNavItem
|
||||||
ref={sortable.ref}
|
tabKey={props.id}
|
||||||
data-titlebar-tab-slot
|
dragActive={props.dragActive}
|
||||||
data-tab-key={props.id}
|
onPointerDown={props.onPointerDown}
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
ref={ref}
|
||||||
classList={{ hidden: !session() }}
|
href={tabHref(props.tab)}
|
||||||
>
|
server={props.tab.server}
|
||||||
<TabNavItem
|
session={session}
|
||||||
ref={(el) => {
|
onTitleChange={(title) => {
|
||||||
ref = el
|
const value = session()
|
||||||
}}
|
const ctx = props.serverCtx()
|
||||||
href={tabHref(props.tab)}
|
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||||
server={props.tab.server}
|
}}
|
||||||
session={session}
|
onTitleChangeFailed={(title) => {
|
||||||
onTitleChange={(title) => {
|
const value = session()
|
||||||
const value = session()
|
const ctx = props.serverCtx()
|
||||||
const ctx = props.serverCtx()
|
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
}}
|
||||||
}}
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
onTitleChangeFailed={(title) => {
|
onClose={props.onClose}
|
||||||
const value = session()
|
active={props.active()}
|
||||||
const ctx = props.serverCtx()
|
activeServer={props.tab.server === props.activeServerKey}
|
||||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
forceTruncate={props.forceTruncate}
|
||||||
}}
|
suppressNavigation={props.suppressNavigation}
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
pressed={props.pressed()}
|
||||||
onClose={props.onClose}
|
hidden={props.dragged() || !session()}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,12 +142,52 @@ 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)
|
||||||
@@ -187,14 +200,220 @@ 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()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onMount(() => {
|
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()
|
refreshOverflow()
|
||||||
|
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(id: string, event: PointerEvent) {
|
||||||
|
if (event.button !== 0 || drag.active) return
|
||||||
|
if (!canStartTabDrag(event.pointerType)) return
|
||||||
|
if (isTabCloseTarget(event.target)) return
|
||||||
|
const target = event.currentTarget as HTMLDivElement
|
||||||
|
const tabEl = target.matches("[data-titlebar-tab]")
|
||||||
|
? target
|
||||||
|
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||||
|
if (!tabEl) return
|
||||||
|
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
|
||||||
|
const tab = props.tabs.find((item) => tabKey(item) === id)
|
||||||
|
if (!tab) return
|
||||||
|
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
|
||||||
|
setSuppressNavigation(true)
|
||||||
|
props.onNavigate(tab, tabEl)
|
||||||
|
setPressedId(id)
|
||||||
|
setGesture("pending", {
|
||||||
|
id,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
...pointer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent) {
|
||||||
|
const pending = gesture.pending
|
||||||
|
if (pending && event.pointerId !== pending.pointerId) return
|
||||||
|
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||||
|
if (!isPrimaryPointerPressed(event.buttons)) {
|
||||||
|
if (drag.active) endDrag(true)
|
||||||
|
if (pending) {
|
||||||
|
setGesture("pending", undefined)
|
||||||
|
setPressedId(undefined)
|
||||||
|
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending && !drag.active) {
|
||||||
|
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
|
||||||
|
startDrag(pending.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drag.active) return
|
||||||
|
|
||||||
|
setDrag("pointerX", event.clientX)
|
||||||
|
syncScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event: PointerEvent) {
|
||||||
|
if (drag.active) {
|
||||||
|
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||||
|
setDrag("pointerX", event.clientX)
|
||||||
|
syncScroll()
|
||||||
|
endDrag(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = gesture.pending
|
||||||
|
if (pending && event.pointerId !== pending.pointerId) return
|
||||||
|
|
||||||
|
setGesture("pending", undefined)
|
||||||
|
setPressedId(undefined)
|
||||||
|
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel(event: PointerEvent) {
|
||||||
|
if (drag.active) {
|
||||||
|
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||||
|
endDrag(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gesture.pending) return
|
||||||
|
if (gesture.pending.pointerId !== event.pointerId) return
|
||||||
|
setGesture("pending", undefined)
|
||||||
|
setPressedId(undefined)
|
||||||
|
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const cleanups = [
|
||||||
|
makeEventListener(window, "pointermove", onPointerMove),
|
||||||
|
makeEventListener(window, "pointerup", onPointerUp),
|
||||||
|
makeEventListener(window, "pointercancel", onPointerCancel),
|
||||||
|
]
|
||||||
|
refreshOverflow()
|
||||||
|
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onCleanup(stopAutoscroll)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||||
})
|
})
|
||||||
@@ -205,54 +424,50 @@ export function TitlebarTabStrip(props: {
|
|||||||
refreshOverflow()
|
refreshOverflow()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
createEffect(() => {
|
||||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
if (!drag.active || !scrollRef) return
|
||||||
<div
|
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
|
||||||
data-slot="titlebar-tabs-scroll"
|
})
|
||||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
|
||||||
ref={scrollRef}
|
|
||||||
>
|
|
||||||
<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
|
const floaterStyle = () => {
|
||||||
if (initialIndex !== index) {
|
stripScrollLeft()
|
||||||
props.onReorder(arrayMove(current, source.initialIndex, source.index))
|
const strip = scrollRef?.getBoundingClientRect()
|
||||||
}
|
const left = strip
|
||||||
}}
|
? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
|
||||||
|
: drag.pointerX - drag.grabOffsetX
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: "fixed" as const,
|
||||||
|
top: `${drag.floaterTop}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${drag.draggedWidth}px`,
|
||||||
|
"z-index": "10000",
|
||||||
|
"pointer-events": "none" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedTab = createMemo(() => {
|
||||||
|
const id = drag.draggedId
|
||||||
|
if (!id) return
|
||||||
|
return props.tabs.find((tab) => tabKey(tab) === id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||||
|
<div
|
||||||
|
data-slot="titlebar-tabs-scroll"
|
||||||
|
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||||
|
ref={scrollRef}
|
||||||
>
|
>
|
||||||
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
<div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
|
||||||
<For each={props.tabs}>
|
<For each={displayTabs()}>
|
||||||
{(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)
|
||||||
@@ -264,50 +479,68 @@ 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}
|
||||||
onNavigate={(element) => {
|
suppressNavigation={() => suppressNavigation()}
|
||||||
ref = element
|
onPointerDown={(event) => {
|
||||||
props.onNavigate(tab, element)
|
if (dragged()) return
|
||||||
|
onPointerDown(id, event)
|
||||||
}}
|
}}
|
||||||
|
onNavigate={(element) => props.onNavigate(tab, element)}
|
||||||
onClose={() => props.onClose(tab)}
|
onClose={() => props.onClose(tab)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DraftTabSlot
|
<DraftTabItem
|
||||||
tab={tab}
|
tabKey={id}
|
||||||
id={id}
|
dragActive={drag.active}
|
||||||
index={index}
|
onPointerDown={(event) => {
|
||||||
active={() => props.currentTab() === tab}
|
if (dragged()) return
|
||||||
title={language.t("command.session.new")}
|
onPointerDown(id, event)
|
||||||
onNavigate={(element) => {
|
|
||||||
ref = element
|
|
||||||
props.onNavigate(tab, element)
|
|
||||||
}}
|
}}
|
||||||
|
ref={ref}
|
||||||
|
href={tabHref(tab)}
|
||||||
|
title={language.t("command.session.new")}
|
||||||
|
onNavigate={() => props.onNavigate(tab, ref)}
|
||||||
onClose={() => props.onClose(tab)}
|
onClose={() => props.onClose(tab)}
|
||||||
|
suppressNavigation={() => suppressNavigation()}
|
||||||
|
active={props.currentTab() === tab}
|
||||||
|
pressed={pressedId() === id}
|
||||||
|
hidden={dragged()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</div>
|
||||||
|
<div
|
||||||
|
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>
|
||||||
<div
|
<Show when={drag.active && draggedTab() && dragPreview}>
|
||||||
data-slot="titlebar-tabs-fade-left"
|
{(_) => (
|
||||||
aria-hidden="true"
|
<Portal>
|
||||||
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-titlebar-tab-preview style={floaterStyle()}>
|
||||||
/>
|
{dragPreview}
|
||||||
<div
|
</div>
|
||||||
data-slot="titlebar-tabs-fade-right"
|
</Portal>
|
||||||
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)]"
|
</Show>
|
||||||
/>
|
</>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -908,13 +908,7 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const reviewPanel = () => (
|
const reviewPanel = () => (
|
||||||
<div
|
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
|
||||||
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(),
|
||||||
@@ -1719,9 +1713,7 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex-1 min-h-0 flex flex-col": true,
|
"flex-1 min-h-0 flex flex-col bg-background-stronger": 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,6 +1,5 @@
|
|||||||
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"
|
||||||
@@ -14,7 +13,6 @@ 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
|
||||||
@@ -24,11 +22,7 @@ export function SessionComposerRegion(props: {
|
|||||||
<div
|
<div
|
||||||
ref={controller.setDockRef}
|
ref={controller.setDockRef}
|
||||||
data-component="session-prompt-dock"
|
data-component="session-prompt-dock"
|
||||||
classList={{
|
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 bg-background-stronger pointer-events-none"
|
||||||
"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,13 +1019,7 @@ 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
|
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||||
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>
|
||||||
@@ -1294,11 +1288,7 @@ export function MessageTimeline(props: {
|
|||||||
<div
|
<div
|
||||||
data-session-title
|
data-session-title
|
||||||
classList={{
|
classList={{
|
||||||
"sticky top-0 z-30": true,
|
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": 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,
|
||||||
@@ -1519,11 +1509,7 @@ export function MessageTimeline(props: {
|
|||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class={
|
class="w-full shadow-none border border-border-weak-base"
|
||||||
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,8 +1,7 @@
|
|||||||
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, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog } 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"
|
||||||
@@ -31,14 +30,8 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const openAddWsl = () => {
|
const openAddWsl = () => {
|
||||||
dialog.push(() => (
|
dialog.push(() => (
|
||||||
<Dialog size="large" fit class="settings-v2-wsl-dialog">
|
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
|
||||||
<DialogHeader hideClose={true}>
|
<DialogAddWslServer />
|
||||||
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<DividerV2 />
|
|
||||||
<DialogBody>
|
|
||||||
<DialogAddWslServer />
|
|
||||||
</DialogBody>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -601,6 +601,14 @@ export type SessionsEventsInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 }
|
||||||
@@ -810,6 +818,20 @@ 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 }
|
||||||
@@ -824,6 +846,49 @@ 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 }
|
||||||
@@ -838,6 +903,20 @@ 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 }
|
||||||
@@ -932,35 +1011,6 @@ 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 }
|
||||||
@@ -994,6 +1044,19 @@ 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 }
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(activityEvent)}\n\n`, {
|
||||||
headers: { "content-type": "text/event-stream" },
|
headers: { "content-type": "text/event-stream" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -93,6 +93,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||||
expect(result.context).toEqual([])
|
expect(result.context).toEqual([])
|
||||||
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
expect(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" }))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -145,3 +147,9 @@ 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 },
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
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(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
return new Response(
|
||||||
headers: { "content-type": "text/event-stream" },
|
`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(activityEvent)}\n\n`,
|
||||||
})
|
{
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (url.includes("/prompt")) return Response.json(admission)
|
if (url.includes("/prompt")) return Response.json(admission)
|
||||||
if (url.includes("/context")) return Response.json({ data: [] })
|
if (url.includes("/context")) return Response.json({ data: [] })
|
||||||
@@ -65,7 +68,8 @@ 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])
|
expect(events).toEqual([modelSwitchedEvent, activityEvent])
|
||||||
|
expect(events[1]).not.toHaveProperty("durable")
|
||||||
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"],
|
||||||
@@ -153,3 +157,9 @@ 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 },
|
||||||
|
}
|
||||||
|
|||||||
+95
-26
@@ -1,9 +1,9 @@
|
|||||||
export * as EventV2 from "./event"
|
export * as EventV2 from "./event"
|
||||||
|
|
||||||
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Scope, Stream } from "effect"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||||
import { and, asc, eq, gt } from "drizzle-orm"
|
import { and, asc, eq, gt, lte } 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"
|
||||||
@@ -67,6 +67,19 @@ 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>
|
||||||
@@ -94,12 +107,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<void>>>(),
|
durable: new Map<string, Set<PubSub.PubSub<number>>>(),
|
||||||
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 Array<Subscriber>()
|
const listeners = new Set<Subscriber>()
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
const getOrCreate = (definition: Definition) =>
|
const getOrCreate = (definition: Definition) =>
|
||||||
@@ -275,7 +288,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, undefined),
|
(wake) => PubSub.publish(wake, committed.seq),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -472,13 +485,19 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const readAfter = (aggregateID: string, after: number) =>
|
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(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
|
.where(
|
||||||
|
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(),
|
||||||
),
|
),
|
||||||
@@ -498,7 +517,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<void>(1)
|
const wake = yield* PubSub.sliding<number>(1)
|
||||||
const subscription = yield* PubSub.subscribe(wake)
|
const subscription = yield* PubSub.subscribe(wake)
|
||||||
yield* Effect.acquireRelease(
|
yield* Effect.acquireRelease(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -516,34 +535,83 @@ 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)
|
||||||
let sequence = input.after ?? -1
|
const drain = aggregateDrain(input.aggregateID, input.after ?? -1)
|
||||||
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
|
const historical = yield* drain()
|
||||||
Effect.tap((events) =>
|
const live = Stream.fromSubscription(wakes).pipe(Stream.mapEffect(drain), Stream.flattenIterable)
|
||||||
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.push(listener)
|
listeners.add(listener)
|
||||||
return Effect.sync(() => {
|
return Effect.sync(() => listeners.delete(listener)).pipe(Effect.asVoid)
|
||||||
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> =>
|
||||||
@@ -558,6 +626,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
subscribe,
|
subscribe,
|
||||||
all: streamAll,
|
all: streamAll,
|
||||||
durable,
|
durable,
|
||||||
|
observeAggregate,
|
||||||
listen,
|
listen,
|
||||||
project,
|
project,
|
||||||
replay,
|
replay,
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
// Branded HTML pages for local OAuth callback servers.
|
|
||||||
//
|
|
||||||
// These are served by the loopback HTTP servers that finish an OAuth exchange
|
|
||||||
// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return
|
|
||||||
// a fully self-contained HTML string with no external assets, so they work
|
|
||||||
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
|
|
||||||
// etc.).
|
|
||||||
//
|
|
||||||
// The visual language mirrors the opencode app: the design tokens are a curated
|
|
||||||
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
|
|
||||||
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
|
|
||||||
// Keep this file in sync with those sources when the brand changes.
|
|
||||||
|
|
||||||
export interface CallbackPageOptions {
|
|
||||||
/** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */
|
|
||||||
provider?: string
|
|
||||||
/** Attempt to close the window shortly after success. Defaults to true. */
|
|
||||||
autoClose?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function success(options?: CallbackPageOptions) {
|
|
||||||
const provider = options?.provider
|
|
||||||
return renderDocument({
|
|
||||||
title: "Authorization successful",
|
|
||||||
body: renderCard({
|
|
||||||
status: "success",
|
|
||||||
headline: "Authorization successful",
|
|
||||||
message: provider ? `opencode is now connected to ${escapeHtml(provider)}.` : "opencode is now authorized.",
|
|
||||||
footnote: "You can close this window and return to opencode.",
|
|
||||||
}),
|
|
||||||
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function error(detail: string, options?: CallbackPageOptions) {
|
|
||||||
const provider = options?.provider
|
|
||||||
return renderDocument({
|
|
||||||
title: "Authorization failed",
|
|
||||||
body: renderCard({
|
|
||||||
status: "error",
|
|
||||||
headline: "Authorization failed",
|
|
||||||
message: provider
|
|
||||||
? `opencode couldn't finish connecting to ${escapeHtml(provider)}.`
|
|
||||||
: "opencode couldn't complete authorization.",
|
|
||||||
detail,
|
|
||||||
footnote: "Close this window and try again from opencode.",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BootstrapOptions {
|
|
||||||
/** Same-origin path the in-browser script POSTs the parsed callback to. */
|
|
||||||
tokenPath: string
|
|
||||||
provider?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// For flows where the credential arrives in the URL fragment (implicit grant),
|
|
||||||
// the browser must relay it back to the loopback server. This renders a pending
|
|
||||||
// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves
|
|
||||||
// to the success or error state in place.
|
|
||||||
export function bootstrap(options: BootstrapOptions) {
|
|
||||||
return renderDocument({
|
|
||||||
title: "Finishing sign-in",
|
|
||||||
body: renderCard({
|
|
||||||
status: "pending",
|
|
||||||
headline: "Finishing sign-in",
|
|
||||||
message: options.provider
|
|
||||||
? `Completing your ${escapeHtml(options.provider)} authorization.`
|
|
||||||
: "Completing authorization.",
|
|
||||||
footnote: "You can close this window once sign-in finishes.",
|
|
||||||
}),
|
|
||||||
script: bootstrapScript(options),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as OauthCallbackPage from "./page"
|
|
||||||
|
|
||||||
type Status = "pending" | "success" | "error"
|
|
||||||
|
|
||||||
function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) {
|
|
||||||
const detail = input.detail?.trim()
|
|
||||||
return `<main class="card" id="oc-card" data-status="${input.status}" role="status" aria-live="polite">
|
|
||||||
<div class="brand">${WORDMARK}</div>
|
|
||||||
<div class="status" aria-hidden="true">
|
|
||||||
<span class="icon icon-pending">${ICON_SPINNER}</span>
|
|
||||||
<span class="icon icon-success">${ICON_CHECK}</span>
|
|
||||||
<span class="icon icon-error">${ICON_CROSS}</span>
|
|
||||||
</div>
|
|
||||||
<h1 class="headline" id="oc-headline">${escapeHtml(input.headline)}</h1>
|
|
||||||
<p class="message" id="oc-message">${input.message}</p>
|
|
||||||
<pre class="detail" id="oc-detail"${detail ? "" : " hidden"}>${detail ? escapeHtml(detail) : ""}</pre>
|
|
||||||
<p class="footnote" id="oc-footnote">${escapeHtml(input.footnote)}</p>
|
|
||||||
</main>`
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDocument(input: { title: string; body: string; script?: string }) {
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="robots" content="noindex" />
|
|
||||||
<title>${escapeHtml(input.title)} · opencode</title>
|
|
||||||
<style>${STYLES}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${input.body}${input.script ? `\n <script>${input.script}</script>` : ""}
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
}
|
|
||||||
|
|
||||||
const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)`
|
|
||||||
|
|
||||||
function bootstrapScript(options: BootstrapOptions) {
|
|
||||||
return `var PROVIDER=${scriptString(options.provider ?? "")};
|
|
||||||
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
|
|
||||||
(function(){
|
|
||||||
var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote");
|
|
||||||
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("opencode couldn't finish connecting to "+PROVIDER+"."):"opencode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from opencode."}
|
|
||||||
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("opencode is now connected to "+PROVIDER+"."):"opencode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window and return to opencode.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
|
|
||||||
try{
|
|
||||||
var hash=new URLSearchParams((window.location.hash||"").slice(1));
|
|
||||||
var search=new URLSearchParams(window.location.search||"");
|
|
||||||
var err=hash.get("error")||search.get("error");
|
|
||||||
var errDescription=hash.get("error_description")||search.get("error_description");
|
|
||||||
var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""};
|
|
||||||
fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){
|
|
||||||
if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))});
|
|
||||||
if(err){fail(errDescription||err);return}
|
|
||||||
ok();
|
|
||||||
}).catch(function(e){fail(String(e&&e.message?e.message:e))});
|
|
||||||
}catch(e){fail(String(e&&e.message?e.message:e))}
|
|
||||||
})()`
|
|
||||||
}
|
|
||||||
|
|
||||||
function scriptString(value: string) {
|
|
||||||
return JSON.stringify(value).replaceAll("<", "\\u003c")
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value: string) {
|
|
||||||
return value
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is
|
|
||||||
// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a
|
|
||||||
// host force a scheme without changing the default.
|
|
||||||
const LIGHT_VARS = `
|
|
||||||
--oc-bg: #f8f8f8;
|
|
||||||
--oc-card: #fcfcfc;
|
|
||||||
--oc-text-strong: #171717;
|
|
||||||
--oc-text-base: #6f6f6f;
|
|
||||||
--oc-text-weak: #8f8f8f;
|
|
||||||
--oc-border-weak: #e5e5e5;
|
|
||||||
--oc-icon-strong: #171717;
|
|
||||||
--oc-icon-base: #8f8f8f;
|
|
||||||
--oc-icon-weak: #dbdbdb;
|
|
||||||
--oc-success: #2dba26;
|
|
||||||
--oc-error: #ed4831;
|
|
||||||
--oc-detail-bg: #fff8f6;
|
|
||||||
--oc-detail-border: #fdc3b7;
|
|
||||||
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);`
|
|
||||||
|
|
||||||
const DARK_VARS = `
|
|
||||||
--oc-bg: #101010;
|
|
||||||
--oc-card: #161616;
|
|
||||||
--oc-text-strong: rgba(255,255,255,.936);
|
|
||||||
--oc-text-base: rgba(255,255,255,.618);
|
|
||||||
--oc-text-weak: rgba(255,255,255,.422);
|
|
||||||
--oc-border-weak: #282828;
|
|
||||||
--oc-icon-strong: #ededed;
|
|
||||||
--oc-icon-base: #7e7e7e;
|
|
||||||
--oc-icon-weak: #343434;
|
|
||||||
--oc-success: #12c905;
|
|
||||||
--oc-error: #fc533a;
|
|
||||||
--oc-detail-bg: #28110c;
|
|
||||||
--oc-detail-border: #6a1206;
|
|
||||||
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);`
|
|
||||||
|
|
||||||
const STYLES = `
|
|
||||||
:root { color-scheme: light dark;${LIGHT_VARS}
|
|
||||||
--oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
--oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } }
|
|
||||||
:root[data-theme="dark"] {${DARK_VARS} }
|
|
||||||
:root[data-theme="light"] {${LIGHT_VARS} }
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body { margin: 0; height: 100%; }
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
background: var(--oc-bg);
|
|
||||||
color: var(--oc-text-base);
|
|
||||||
font-family: var(--oc-font-sans);
|
|
||||||
line-height: 1.5;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
width: min(100%, 25rem);
|
|
||||||
padding: 2.25rem 2rem 1.75rem;
|
|
||||||
background: var(--oc-card);
|
|
||||||
border: 1px solid var(--oc-border-weak);
|
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: var(--oc-shadow);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.brand { display: flex; justify-content: center; margin-bottom: 1.75rem; }
|
|
||||||
.brand svg { height: 19px; width: auto; }
|
|
||||||
.status { display: flex; justify-content: center; margin-bottom: 1.125rem; }
|
|
||||||
.icon { display: none; line-height: 0; }
|
|
||||||
.icon svg { display: block; }
|
|
||||||
.card[data-status="pending"] .icon-pending,
|
|
||||||
.card[data-status="success"] .icon-success,
|
|
||||||
.card[data-status="error"] .icon-error { display: block; }
|
|
||||||
.icon-success { color: var(--oc-success); }
|
|
||||||
.icon-error { color: var(--oc-error); }
|
|
||||||
.icon-pending { color: var(--oc-text-weak); }
|
|
||||||
.headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); }
|
|
||||||
.message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); }
|
|
||||||
.detail {
|
|
||||||
margin: 1.25rem 0 0;
|
|
||||||
padding: 0.75rem 0.875rem;
|
|
||||||
text-align: left;
|
|
||||||
font-family: var(--oc-font-mono);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--oc-text-strong);
|
|
||||||
background: var(--oc-detail-bg);
|
|
||||||
border: 1px solid var(--oc-detail-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
max-height: 9.5rem;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.detail[hidden] { display: none; }
|
|
||||||
.footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); }
|
|
||||||
.spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; }
|
|
||||||
@keyframes oc-spin { to { transform: rotate(360deg); } }
|
|
||||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
|
||||||
`
|
|
||||||
|
|
||||||
// opencode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
|
|
||||||
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="opencode" role="img">
|
|
||||||
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
|
|
||||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
|
|
||||||
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
|
|
||||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--oc-icon-base)" />
|
|
||||||
<path d="M84 24V30H66V24H84Z" fill="var(--oc-icon-weak)" />
|
|
||||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--oc-icon-base)" />
|
|
||||||
<path d="M108 36H96V18H108V36Z" fill="var(--oc-icon-weak)" />
|
|
||||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--oc-icon-base)" />
|
|
||||||
<path d="M144 30H126V18H144V30Z" fill="var(--oc-icon-weak)" />
|
|
||||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--oc-icon-strong)" />
|
|
||||||
<path d="M168 30H156V18H168V30Z" fill="var(--oc-icon-weak)" />
|
|
||||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--oc-icon-strong)" />
|
|
||||||
<path d="M198 30H186V18H198V30Z" fill="var(--oc-icon-weak)" />
|
|
||||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--oc-icon-strong)" />
|
|
||||||
<path d="M234 24V30H216V24H234Z" fill="var(--oc-icon-weak)" />
|
|
||||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--oc-icon-strong)" />
|
|
||||||
</svg>`
|
|
||||||
|
|
||||||
const ICON_CHECK = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m8.5 12.5 2.4 2.4 4.6-5.4" /></svg>`
|
|
||||||
|
|
||||||
const ICON_CROSS = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m9 9 6 6m0-6-6 6" /></svg>`
|
|
||||||
|
|
||||||
const ICON_SPINNER = `<svg class="spinner" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9" opacity="0.2" /><path d="M21 12a9 9 0 0 0-9-9" /></svg>`
|
|
||||||
@@ -7,7 +7,6 @@ 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"
|
||||||
|
|
||||||
@@ -59,21 +58,17 @@ 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
|
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
|
||||||
.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
|
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
|
||||||
.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(OauthCallbackPage.success({ provider: "ChatGPT" }))
|
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
|
||||||
})
|
})
|
||||||
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)))
|
||||||
@@ -290,3 +285,8 @@ 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>`
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export interface Interface {
|
|||||||
readonly events: (input: {
|
readonly events: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
after?: number
|
after?: number
|
||||||
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
}) => Stream.Stream<SessionEvent.StreamEvent, 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
|
||||||
@@ -185,7 +185,11 @@ export const layer = Layer.unwrap(
|
|||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap
|
const locations = yield* LocationServiceMap
|
||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
const sessionEventTypes = new Set<string>(SessionEvent.Definitions.map((definition) => definition.type))
|
||||||
|
const isSessionEvent = (event: EventV2.Payload): event is SessionEvent.Event =>
|
||||||
|
sessionEventTypes.has(event.type)
|
||||||
|
const isDurableSessionEvent = (event: EventV2.Payload): event is SessionEvent.DurableEvent =>
|
||||||
|
event.durable !== undefined && isSessionEvent(event)
|
||||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
@@ -341,10 +345,34 @@ export const layer = Layer.unwrap(
|
|||||||
}),
|
}),
|
||||||
events: (input) =>
|
events: (input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
result
|
Effect.gen(function* () {
|
||||||
.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
const activity = yield* execution.activity(input.sessionID)
|
||||||
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
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) =>
|
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||||
Effect.uninterruptible(
|
Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
export * as SessionExecution from "./execution"
|
export * as SessionExecution from "./execution"
|
||||||
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
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. */
|
||||||
@@ -23,6 +26,7 @@ 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,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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,
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ export * as SessionRunCoordinator from "./run-coordinator"
|
|||||||
|
|
||||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
/** Installs a synchronous observer and snapshots current ownership atomically. */
|
||||||
|
readonly attach: (observer: (active: boolean) => void) => Effect.Effect<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||||
export interface Coordinator<Key, E> {
|
export interface Coordinator<Key, E> {
|
||||||
/** Snapshots keys with an execution owned by this coordinator. */
|
/** Snapshots keys with an execution owned by this coordinator. */
|
||||||
readonly active: Effect.Effect<ReadonlySet<Key>>
|
readonly active: Effect.Effect<ReadonlySet<Key>>
|
||||||
|
/** Registers transition observation before taking its authoritative snapshot. */
|
||||||
|
readonly activity: (key: Key) => Effect.Effect<Activity, never, Scope.Scope>
|
||||||
/** Starts execution while idle or joins the active execution. */
|
/** Starts execution while idle or joins the active execution. */
|
||||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||||
/** Registers one coalesced follow-up after newly recorded work. */
|
/** Registers one coalesced follow-up after newly recorded work. */
|
||||||
@@ -26,6 +33,7 @@ export const make = <Key, E>(options: {
|
|||||||
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
|
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const active = new Map<Key, Entry<E>>()
|
const active = new Map<Key, Entry<E>>()
|
||||||
|
const activityObservers = new Map<Key, Set<(active: boolean) => void>>()
|
||||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||||
|
|
||||||
const makeEntry = (): Entry<E> => ({
|
const makeEntry = (): Entry<E> => ({
|
||||||
@@ -34,6 +42,10 @@ export const make = <Key, E>(options: {
|
|||||||
stopping: false,
|
stopping: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const notifyActivity = (key: Key, value: boolean) => {
|
||||||
|
for (const observer of activityObservers.get(key) ?? []) observer(value)
|
||||||
|
}
|
||||||
|
|
||||||
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
|
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
|
||||||
const ready = Deferred.makeUnsafe<void>()
|
const ready = Deferred.makeUnsafe<void>()
|
||||||
const owner = fork(
|
const owner = fork(
|
||||||
@@ -56,8 +68,10 @@ export const make = <Key, E>(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const successor = entry.pendingWake ? makeEntry() : undefined
|
const successor = entry.pendingWake ? makeEntry() : undefined
|
||||||
if (successor === undefined) active.delete(key)
|
if (successor === undefined) {
|
||||||
else {
|
active.delete(key)
|
||||||
|
notifyActivity(key, false)
|
||||||
|
} else {
|
||||||
active.set(key, successor)
|
active.set(key, successor)
|
||||||
start(key, successor, false, true)
|
start(key, successor, false, true)
|
||||||
}
|
}
|
||||||
@@ -74,6 +88,7 @@ export const make = <Key, E>(options: {
|
|||||||
|
|
||||||
const next = makeEntry()
|
const next = makeEntry()
|
||||||
active.set(key, next)
|
active.set(key, next)
|
||||||
|
notifyActivity(key, true)
|
||||||
start(key, next, true)
|
start(key, next, true)
|
||||||
return restore(Deferred.await(next.done))
|
return restore(Deferred.await(next.done))
|
||||||
})
|
})
|
||||||
@@ -88,6 +103,7 @@ export const make = <Key, E>(options: {
|
|||||||
|
|
||||||
const next = makeEntry()
|
const next = makeEntry()
|
||||||
active.set(key, next)
|
active.set(key, next)
|
||||||
|
notifyActivity(key, true)
|
||||||
start(key, next, false)
|
start(key, next, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -100,5 +116,33 @@ export const make = <Key, E>(options: {
|
|||||||
return Fiber.interrupt(entry.owner)
|
return Fiber.interrupt(entry.owner)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
|
const activity = (key: Key) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let attached: ((active: boolean) => void) | undefined
|
||||||
|
const observer = (value: boolean) => {
|
||||||
|
attached?.(value)
|
||||||
|
}
|
||||||
|
yield* Effect.acquireRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const observers = activityObservers.get(key) ?? new Set()
|
||||||
|
observers.add(observer)
|
||||||
|
activityObservers.set(key, observers)
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const observers = activityObservers.get(key)
|
||||||
|
observers?.delete(observer)
|
||||||
|
if (observers?.size === 0) activityObservers.delete(key)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
attach: (next: (active: boolean) => void) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
attached = next
|
||||||
|
return active.has(key)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { active: Effect.sync(() => new Set(active.keys())), activity, run, wake, interrupt }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ function isSafeRelativePath(value: string) {
|
|||||||
|
|
||||||
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
|
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
version: Schema.optional(Schema.String),
|
|
||||||
files: Schema.Array(Schema.String),
|
files: Schema.Array(Schema.String),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
@@ -81,15 +80,12 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) {
|
const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) {
|
||||||
if (yield* fs.exists(destination).pipe(Effect.orDie)) return true
|
if (yield* fs.exists(destination).pipe(Effect.orDie)) return
|
||||||
return yield* HttpClientRequest.get(url).pipe(
|
yield* HttpClientRequest.get(url).pipe(
|
||||||
http.execute,
|
http.execute,
|
||||||
Effect.flatMap((response) => response.arrayBuffer),
|
Effect.flatMap((response) => response.arrayBuffer),
|
||||||
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
|
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
|
||||||
Effect.as(true),
|
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
|
||||||
Effect.catch((error) =>
|
|
||||||
Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -124,7 +120,6 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
|
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
|
||||||
const versionFile = path.join(root, ".opencode-version")
|
|
||||||
const files = skill.files.map((file) => {
|
const files = skill.files.map((file) => {
|
||||||
if (!isSafeRelativePath(file)) return undefined
|
if (!isSafeRelativePath(file)) return undefined
|
||||||
let resource: URL
|
let resource: URL
|
||||||
@@ -140,66 +135,23 @@ export const layer = Layer.effect(
|
|||||||
return {
|
return {
|
||||||
url: resource.href,
|
url: resource.href,
|
||||||
destination,
|
destination,
|
||||||
file,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (files.some((file) => file === undefined)) {
|
if (files.some((file) => file === undefined)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }]
|
return [{ skill, root, files: files as { url: string; destination: string }[] }]
|
||||||
}),
|
}),
|
||||||
({ skill, root, versionFile, files }) =>
|
({ skill, root, files }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const version = skill.version
|
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
|
||||||
const current =
|
concurrency: fileConcurrency,
|
||||||
version === undefined
|
discard: true,
|
||||||
? undefined
|
})
|
||||||
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
|
||||||
if (version === undefined || current === version) {
|
|
||||||
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
|
|
||||||
concurrency: fileConcurrency,
|
|
||||||
discard: true,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const token = crypto.randomUUID()
|
|
||||||
const staging = `${root}.tmp-${token}`
|
|
||||||
const backup = `${root}.old-${token}`
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const downloaded = yield* Effect.forEach(
|
|
||||||
files,
|
|
||||||
(file) => download(file.url, path.resolve(staging, file.file)),
|
|
||||||
{ concurrency: fileConcurrency },
|
|
||||||
)
|
|
||||||
if (!downloaded.every(Boolean)) return
|
|
||||||
const exists =
|
|
||||||
(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) ||
|
|
||||||
(yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie))
|
|
||||||
if (!exists) return
|
|
||||||
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
|
|
||||||
yield* Effect.uninterruptible(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const cached = yield* fs.exists(root).pipe(Effect.orDie)
|
|
||||||
if (cached) yield* fs.rename(root, backup)
|
|
||||||
yield* fs.rename(staging, root).pipe(
|
|
||||||
Effect.catch((error) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
|
|
||||||
return yield* Effect.fail(error)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
|
|
||||||
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const exists =
|
|
||||||
(yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
|
|
||||||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
|
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
|
||||||
return exists ? [AbsolutePath.make(root)] : []
|
? [AbsolutePath.make(root)]
|
||||||
|
: []
|
||||||
}),
|
}),
|
||||||
{ concurrency: skillConcurrency },
|
{ concurrency: skillConcurrency },
|
||||||
).pipe(Effect.map((directories) => directories.flat()))
|
).pipe(Effect.map((directories) => directories.flat()))
|
||||||
|
|||||||
@@ -418,6 +418,127 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("observes a fixed replay cutoff and delivers commits during replay as updates", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const readStarted = yield* Deferred.make<void>()
|
||||||
|
const continueRead = yield* Deferred.make<void>()
|
||||||
|
let pause = true
|
||||||
|
const eventLayer = EventV2.layerWith({
|
||||||
|
beforeAggregateRead: () =>
|
||||||
|
pause
|
||||||
|
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
|
||||||
|
: Effect.void,
|
||||||
|
}).pipe(Layer.provide(Database.defaultLayer))
|
||||||
|
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const aggregateID = Session.ID.create()
|
||||||
|
const observer = yield* events.observeAggregate({ aggregateID, live: () => false }).pipe(Effect.forkScoped)
|
||||||
|
yield* Deferred.await(readStarted)
|
||||||
|
|
||||||
|
pause = false
|
||||||
|
yield* events.publish(DurableMessage, durableData(aggregateID, "after cutoff"))
|
||||||
|
yield* Deferred.succeed(continueRead, undefined)
|
||||||
|
const observed = yield* Fiber.join(observer)
|
||||||
|
const update = yield* observed.updates.pipe(Stream.take(1), Stream.runCollect)
|
||||||
|
|
||||||
|
expect(observed.replay).toEqual([])
|
||||||
|
expect(Array.from(update).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||||
|
[0, durableData(aggregateID, "after cutoff")],
|
||||||
|
])
|
||||||
|
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("observes replay commits that are not republished", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const aggregateID = Session.ID.create()
|
||||||
|
const observed = yield* events.observeAggregate({ aggregateID, live: () => false })
|
||||||
|
const update = yield* observed.updates.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* events.replay(
|
||||||
|
{
|
||||||
|
id: EventV2.ID.create(),
|
||||||
|
aggregateID,
|
||||||
|
seq: 0,
|
||||||
|
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||||
|
data: durableData(aggregateID, "replayed"),
|
||||||
|
},
|
||||||
|
{ publish: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(Array.from(yield* Fiber.join(update)).map((event) => event.data)).toEqual([
|
||||||
|
durableData(aggregateID, "replayed"),
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("drains causally preceding durable rows before a live payload", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const aggregateID = Session.ID.create()
|
||||||
|
const observed = yield* events.observeAggregate({
|
||||||
|
aggregateID,
|
||||||
|
live: (event) => event.type === Message.type,
|
||||||
|
})
|
||||||
|
const updates = yield* observed.updates.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* events.publish(DurableMessage, durableData(aggregateID, "start"))
|
||||||
|
const live = yield* events.publish(Message, { text: "delta" })
|
||||||
|
|
||||||
|
expect(Array.from(yield* Fiber.join(updates))).toEqual([
|
||||||
|
expect.objectContaining({ durable: expect.objectContaining({ seq: 0 }) }),
|
||||||
|
live,
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("coalesces saturated observer signals without losing durable rows", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const aggregateID = Session.ID.create()
|
||||||
|
const count = 300
|
||||||
|
const observed = yield* events.observeAggregate({ aggregateID, live: () => false })
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
yield* events.publish(DurableMessage, durableData(aggregateID, String(index)))
|
||||||
|
}
|
||||||
|
const updates = yield* observed.updates.pipe(Stream.take(count), Stream.runCollect)
|
||||||
|
|
||||||
|
expect(Array.from(updates, (event) => event.durable?.seq)).toEqual(
|
||||||
|
Array.from({ length: count }, (_, index) => index),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("coalesces durable notifications without repeated empty reads", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let reads = 0
|
||||||
|
const eventLayer = EventV2.layerWith({
|
||||||
|
beforeAggregateRead: () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
reads++
|
||||||
|
}),
|
||||||
|
}).pipe(Layer.provide(Database.defaultLayer))
|
||||||
|
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const aggregateID = Session.ID.create()
|
||||||
|
const count = 20
|
||||||
|
const observed = yield* events.observeAggregate({ aggregateID, live: () => false })
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
yield* events.publish(DurableMessage, durableData(aggregateID, String(index)))
|
||||||
|
}
|
||||||
|
const updates = yield* observed.updates.pipe(Stream.take(count), Stream.runCollect)
|
||||||
|
|
||||||
|
expect(updates).toHaveLength(count)
|
||||||
|
expect(reads).toBe(2)
|
||||||
|
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
|
it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { OauthCallbackPage } from "../src/oauth/page"
|
|
||||||
|
|
||||||
describe("OauthCallbackPage", () => {
|
|
||||||
test("escapes bootstrap options embedded in the inline script", () => {
|
|
||||||
const html = OauthCallbackPage.bootstrap({
|
|
||||||
provider: `xAI</script><script>alert("provider")</script>`,
|
|
||||||
tokenPath: `/token</script><script>alert("path")</script>`,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(html.match(/<\/script>/g)).toHaveLength(1)
|
|
||||||
expect(html).toContain(`xAI\\u003c/script>\\u003cscript>alert(\\\"provider\\\")\\u003c/script>`)
|
|
||||||
expect(html).toContain(`/token\\u003c/script>\\u003cscript>alert(\\\"path\\\")\\u003c/script>`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
import { DateTime, Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
@@ -23,10 +23,29 @@ const executionCalls: SessionV2.ID[] = []
|
|||||||
const interruptCalls: SessionV2.ID[] = []
|
const interruptCalls: SessionV2.ID[] = []
|
||||||
const wakeCalls: SessionV2.ID[] = []
|
const wakeCalls: SessionV2.ID[] = []
|
||||||
const activeSessions = new Set<SessionV2.ID>()
|
const activeSessions = new Set<SessionV2.ID>()
|
||||||
|
let activityObserver: ((active: boolean) => void) | undefined
|
||||||
|
let activityState = false
|
||||||
|
const setActivity = (active: boolean) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
activityState = active
|
||||||
|
activityObserver?.(active)
|
||||||
|
})
|
||||||
const execution = Layer.succeed(
|
const execution = Layer.succeed(
|
||||||
SessionExecution.Service,
|
SessionExecution.Service,
|
||||||
SessionExecution.Service.of({
|
SessionExecution.Service.of({
|
||||||
active: Effect.sync(() => new Set(activeSessions)),
|
active: Effect.sync(() => new Set(activeSessions)),
|
||||||
|
activity: () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
activityState = false
|
||||||
|
activityObserver = undefined
|
||||||
|
return {
|
||||||
|
attach: (observer: (active: boolean) => void) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
activityObserver = observer
|
||||||
|
return activityState
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
resume: (sessionID) =>
|
resume: (sessionID) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
executionCalls.push(sessionID)
|
executionCalls.push(sessionID)
|
||||||
@@ -179,7 +198,7 @@ describe("SessionV2.prompt", () => {
|
|||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(5), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||||
@@ -188,6 +207,7 @@ describe("SessionV2.prompt", () => {
|
|||||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||||
|
|
||||||
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
|
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
|
||||||
|
[undefined, "session.activity"],
|
||||||
[0, "session.next.prompt.admitted"],
|
[0, "session.next.prompt.admitted"],
|
||||||
[1, "session.next.prompt.admitted"],
|
[1, "session.next.prompt.admitted"],
|
||||||
[2, "session.next.prompted"],
|
[2, "session.next.prompted"],
|
||||||
@@ -196,10 +216,122 @@ describe("SessionV2.prompt", () => {
|
|||||||
expect(
|
expect(
|
||||||
Array.from(
|
Array.from(
|
||||||
yield* session
|
yield* session
|
||||||
.events({ sessionID, after: streamed[0]!.durable?.seq })
|
.events({ sessionID, after: streamed[2]?.durable?.seq })
|
||||||
.pipe(Stream.take(1), Stream.runCollect),
|
.pipe(Stream.take(3), Stream.runCollect),
|
||||||
).map((event) => [event.durable?.seq, event.type]),
|
).map((event) => [event.durable?.seq, event.type]),
|
||||||
).toEqual([[1, "session.next.prompt.admitted"]])
|
).toEqual([
|
||||||
|
[2, "session.next.prompted"],
|
||||||
|
[3, "session.next.prompted"],
|
||||||
|
[undefined, "session.activity"],
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("replays history, emits activity, then fences live deltas behind durable starts", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
|
yield* events.publish(SessionEvent.Text.Started, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
textID: "text-1",
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
})
|
||||||
|
const streamed = yield* session.events({ sessionID }).pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
yield* events.publish(SessionEvent.Text.Delta, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
textID: "text-1",
|
||||||
|
delta: "hello",
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Array.from(yield* Fiber.join(streamed)).map((event) => event.type)).toEqual([
|
||||||
|
"session.next.text.started",
|
||||||
|
"session.activity",
|
||||||
|
"session.next.text.delta",
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("orders activity around the durable rows owned by a run", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
|
const streamed = yield* session.events({ sessionID }).pipe(Stream.take(5), Stream.runCollect, Effect.forkScoped)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
|
yield* setActivity(true)
|
||||||
|
yield* events.publish(SessionEvent.Text.Started, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
textID: "text-activity",
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.Text.Ended, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
textID: "text-activity",
|
||||||
|
text: "done",
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
})
|
||||||
|
yield* setActivity(false)
|
||||||
|
|
||||||
|
expect(Array.from(yield* Fiber.join(streamed)).map((event) => [event.type, event.data])).toEqual([
|
||||||
|
["session.activity", { sessionID, active: false }],
|
||||||
|
["session.activity", { sessionID, active: true }],
|
||||||
|
["session.next.text.started", expect.objectContaining({ sessionID })],
|
||||||
|
["session.next.text.ended", expect.objectContaining({ sessionID })],
|
||||||
|
["session.activity", { sessionID, active: false }],
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("terminates the Session stream when live event buffering saturates", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const initial = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
let seenInitial = false
|
||||||
|
const streamed = yield* session.events({ sessionID }).pipe(
|
||||||
|
Stream.mapEffect((event) => {
|
||||||
|
if (!seenInitial) {
|
||||||
|
seenInitial = true
|
||||||
|
return Deferred.succeed(initial, undefined).pipe(Effect.as(event))
|
||||||
|
}
|
||||||
|
return Deferred.await(release).pipe(Effect.as(event))
|
||||||
|
}),
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
yield* Deferred.await(initial)
|
||||||
|
|
||||||
|
const timestamp = yield* DateTime.now
|
||||||
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
|
yield* Effect.forEach(
|
||||||
|
Array.from({ length: 1_000 }, (_, index) => index),
|
||||||
|
(index) =>
|
||||||
|
events.publish(SessionEvent.Text.Delta, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
textID: "text-saturation",
|
||||||
|
delta: String(index),
|
||||||
|
timestamp,
|
||||||
|
}),
|
||||||
|
{ concurrency: "unbounded", discard: true },
|
||||||
|
)
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
|
||||||
|
const result = Array.from(yield* Fiber.join(streamed))
|
||||||
|
expect(result[0]?.type).toBe("session.activity")
|
||||||
|
expect(result.length).toBeLessThan(1_001)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,27 @@ describe("SessionRunCoordinator", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("reflects activity races in the snapshot or subsequent transitions", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
const gate = yield* Deferred.make<void>()
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(gate))),
|
||||||
|
})
|
||||||
|
const activity = yield* coordinator.activity("session")
|
||||||
|
|
||||||
|
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
const transitions = new Array<boolean>()
|
||||||
|
expect(yield* activity.attach((active) => transitions.push(active))).toBeTrue()
|
||||||
|
yield* Deferred.succeed(gate, undefined)
|
||||||
|
yield* Fiber.join(run)
|
||||||
|
expect(transitions).toEqual([false])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("cleans active executions after failure and defect", () =>
|
it.effect("cleans active executions after failure and defect", () =>
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -137,7 +158,6 @@ describe("SessionRunCoordinator", () => {
|
|||||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("coalesces wakes received during active execution", () =>
|
it.effect("coalesces wakes received during active execution", () =>
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const execution = Layer.effect(
|
|||||||
})
|
})
|
||||||
return SessionExecution.Service.of({
|
return SessionExecution.Service.of({
|
||||||
active: coordinator.active,
|
active: coordinator.active,
|
||||||
|
activity: coordinator.activity,
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
interrupt: coordinator.interrupt,
|
interrupt: coordinator.interrupt,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const capture = () => {
|
|||||||
}),
|
}),
|
||||||
subscribe: () => Stream.empty,
|
subscribe: () => Stream.empty,
|
||||||
all: () => Stream.empty,
|
all: () => Stream.empty,
|
||||||
|
observeAggregate: () => Effect.die("not implemented"),
|
||||||
durable: () => Stream.empty,
|
durable: () => Stream.empty,
|
||||||
listen: () => Effect.succeed(Effect.void),
|
listen: () => Effect.succeed(Effect.void),
|
||||||
project: () => Effect.void,
|
project: () => Effect.void,
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ const execution = Layer.effect(
|
|||||||
})
|
})
|
||||||
return SessionExecution.Service.of({
|
return SessionExecution.Service.of({
|
||||||
active: coordinator.active,
|
active: coordinator.active,
|
||||||
|
activity: coordinator.activity,
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
interrupt: coordinator.interrupt,
|
interrupt: coordinator.interrupt,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { tmpdir } from "./fixture/tmpdir"
|
|||||||
|
|
||||||
const base = "https://skills.example.test/catalog/"
|
const base = "https://skills.example.test/catalog/"
|
||||||
|
|
||||||
async function pull(skills: unknown[], files: Record<string, string> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
|
async function pull(skills: unknown[], files: Record<string, string> = {}) {
|
||||||
const tmp = cache ?? (await tmpdir())
|
const tmp = await tmpdir()
|
||||||
const requests: string[] = []
|
const requests: string[] = []
|
||||||
const http = Layer.succeed(
|
const http = Layer.succeed(
|
||||||
HttpClient.HttpClient,
|
HttpClient.HttpClient,
|
||||||
@@ -101,64 +101,4 @@ describe("SkillDiscovery.pull", () => {
|
|||||||
await result.tmp[Symbol.asyncDispose]()
|
await result.tmp[Symbol.asyncDispose]()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("refreshes cached files when the version changes", async () => {
|
|
||||||
const tmp = await tmpdir()
|
|
||||||
try {
|
|
||||||
const first = await pull(
|
|
||||||
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
|
|
||||||
{
|
|
||||||
[`${base}deploy/SKILL.md`]: "# Old",
|
|
||||||
},
|
|
||||||
tmp,
|
|
||||||
)
|
|
||||||
const second = await pull(
|
|
||||||
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
|
||||||
{
|
|
||||||
[`${base}deploy/SKILL.md`]: "# New",
|
|
||||||
},
|
|
||||||
tmp,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
|
|
||||||
expect(second.requests).toContain(`${base}deploy/SKILL.md`)
|
|
||||||
const third = await pull(
|
|
||||||
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
|
||||||
{ [`${base}deploy/SKILL.md`]: "# Ignored" },
|
|
||||||
tmp,
|
|
||||||
)
|
|
||||||
expect(third.requests).toEqual([`${base}index.json`])
|
|
||||||
} finally {
|
|
||||||
await tmp[Symbol.asyncDispose]()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("publishes complete updates and removes stale files", async () => {
|
|
||||||
const tmp = await tmpdir()
|
|
||||||
try {
|
|
||||||
const first = await pull(
|
|
||||||
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
|
|
||||||
{
|
|
||||||
[`${base}deploy/SKILL.md`]: "# Old",
|
|
||||||
[`${base}deploy/old.md`]: "old reference",
|
|
||||||
},
|
|
||||||
tmp,
|
|
||||||
)
|
|
||||||
const root = first.directories[0]
|
|
||||||
|
|
||||||
await pull(
|
|
||||||
[{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
|
|
||||||
{ [`${base}deploy/SKILL.md`]: "# Partial" },
|
|
||||||
tmp,
|
|
||||||
)
|
|
||||||
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
|
|
||||||
expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
|
|
||||||
|
|
||||||
await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp)
|
|
||||||
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
|
|
||||||
expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
|
|
||||||
} finally {
|
|
||||||
await tmp[Symbol.asyncDispose]()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export class UnsupportedOperationError extends Schema.TaggedErrorClass<Unsupport
|
|||||||
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
|
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
|
||||||
safeMessage: Schema.String,
|
safeMessage: Schema.String,
|
||||||
service: Schema.optional(Schema.String),
|
service: Schema.optional(Schema.String),
|
||||||
errorName: Schema.optional(Schema.String),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export type Error =
|
export type Error =
|
||||||
@@ -82,13 +81,7 @@ export function toRequestError(error: Error) {
|
|||||||
case "ACPUnsupportedOperationError":
|
case "ACPUnsupportedOperationError":
|
||||||
return RequestError.methodNotFound(error.method)
|
return RequestError.methodNotFound(error.method)
|
||||||
case "ACPServiceFailureError":
|
case "ACPServiceFailureError":
|
||||||
return RequestError.internalError(
|
return RequestError.internalError({ service: error.service }, error.safeMessage)
|
||||||
{
|
|
||||||
...(error.service ? { service: error.service } : {}),
|
|
||||||
...(error.errorName ? { errorName: error.errorName } : {}),
|
|
||||||
},
|
|
||||||
error.safeMessage,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
type SetSessionModeResponse,
|
type SetSessionModeResponse,
|
||||||
} from "@agentclientprotocol/sdk"
|
} from "@agentclientprotocol/sdk"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import * as ACPError from "./error"
|
import * as ACPError from "./error"
|
||||||
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
||||||
@@ -521,7 +521,7 @@ export function make(input: {
|
|||||||
"session",
|
"session",
|
||||||
)
|
)
|
||||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||||
return yield* promptResponse(response.info, params.messageId)
|
return promptResponse(response.info, params.messageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const known = snapshot.availableCommands.find((item) => item.name === command.name)
|
const known = snapshot.availableCommands.find((item) => item.name === command.name)
|
||||||
@@ -543,7 +543,7 @@ export function make(input: {
|
|||||||
"session",
|
"session",
|
||||||
)
|
)
|
||||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||||
return yield* promptResponse(response.info, params.messageId)
|
return promptResponse(response.info, params.messageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.name === "compact") {
|
if (command.name === "compact") {
|
||||||
@@ -563,7 +563,7 @@ export function make(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||||
return yield* promptResponse(undefined, params.messageId)
|
return promptResponse(undefined, params.messageId)
|
||||||
}),
|
}),
|
||||||
cancel,
|
cancel,
|
||||||
}
|
}
|
||||||
@@ -695,8 +695,7 @@ type MessageInfo = {
|
|||||||
readonly agent?: Message["agent"]
|
readonly agent?: Message["agent"]
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssistantError = NonNullable<AssistantMessage["error"]>
|
type AssistantInfo = UsageService.AssistantTokenCost | undefined
|
||||||
type AssistantInfo = (UsageService.AssistantTokenCost & Pick<AssistantMessage, "error">) | undefined
|
|
||||||
|
|
||||||
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||||
return Effect.tryPromise({
|
return Effect.tryPromise({
|
||||||
@@ -812,60 +811,13 @@ function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
|
|||||||
return { name, args: rest.join(" ").trim() }
|
return { name, args: rest.join(" ").trim() }
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptResponse = Effect.fn("ACP.promptResponse")(function* (
|
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
|
||||||
info: AssistantInfo,
|
return {
|
||||||
messageId: string | null | undefined,
|
stopReason: "end_turn",
|
||||||
) {
|
...(info ? { usage: UsageService.buildUsage(info) } : {}),
|
||||||
if (!info?.error) {
|
|
||||||
return {
|
|
||||||
stopReason: "end_turn" as const,
|
|
||||||
...(info ? { usage: UsageService.buildUsage(info) } : {}),
|
|
||||||
...(messageId ? { userMessageId: messageId } : {}),
|
|
||||||
_meta: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = {
|
|
||||||
usage: UsageService.buildUsage(info),
|
|
||||||
...(messageId ? { userMessageId: messageId } : {}),
|
...(messageId ? { userMessageId: messageId } : {}),
|
||||||
_meta: {},
|
_meta: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.error.name === "MessageAbortedError") {
|
|
||||||
return {
|
|
||||||
stopReason: "cancelled" as const,
|
|
||||||
...base,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.error.name === "MessageOutputLengthError") {
|
|
||||||
return {
|
|
||||||
stopReason: "max_tokens" as const,
|
|
||||||
...base,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.error.name === "ContentFilterError") {
|
|
||||||
return {
|
|
||||||
stopReason: "refusal" as const,
|
|
||||||
...base,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.error.name === "ProviderAuthError") {
|
|
||||||
return yield* new ACPError.AuthRequiredError({ providerId: info.error.data.providerID })
|
|
||||||
}
|
|
||||||
|
|
||||||
return yield* new ACPError.ServiceFailureError({
|
|
||||||
service: "session",
|
|
||||||
safeMessage: promptErrorMessage(info.error),
|
|
||||||
errorName: info.error.name,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function promptErrorMessage(error: AssistantError) {
|
|
||||||
if ("message" in error.data && typeof error.data.message === "string") return error.data.message
|
|
||||||
return "OpenCode prompt failed"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendUsageUpdate(
|
function sendUsageUpdate(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import path from "path"
|
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import type { InstanceContext } from "@/project/instance-context"
|
import type { InstanceContext } from "@/project/instance-context"
|
||||||
@@ -133,19 +132,12 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
for (const item of yield* skill.all()) {
|
for (const item of yield* skill.all()) {
|
||||||
if (commands[item.name]) continue
|
if (commands[item.name]) continue
|
||||||
const dir = item.location === "<built-in>" ? undefined : path.dirname(item.location)
|
|
||||||
commands[item.name] = {
|
commands[item.name] = {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
source: "skill",
|
source: "skill",
|
||||||
get template() {
|
get template() {
|
||||||
if (!dir) return item.content
|
return item.content
|
||||||
return [
|
|
||||||
item.content,
|
|
||||||
"",
|
|
||||||
`Base directory for this skill: ${dir}`,
|
|
||||||
"Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.",
|
|
||||||
].join("\n")
|
|
||||||
},
|
},
|
||||||
hints: [],
|
hints: [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createConnection } from "net"
|
import { createConnection } from "net"
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
import { escapeHtml } from "@/util/html"
|
||||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
|
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
|
||||||
|
|
||||||
const OAUTH_CALLBACK_HOST = "127.0.0.1"
|
const OAUTH_CALLBACK_HOST = "127.0.0.1"
|
||||||
@@ -9,6 +9,47 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1"
|
|||||||
let currentPort = OAUTH_CALLBACK_PORT
|
let currentPort = OAUTH_CALLBACK_PORT
|
||||||
let currentPath = OAUTH_CALLBACK_PATH
|
let currentPath = OAUTH_CALLBACK_PATH
|
||||||
|
|
||||||
|
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenCode - Authorization Successful</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||||
|
.container { text-align: center; padding: 2rem; }
|
||||||
|
h1 { color: #4ade80; margin-bottom: 1rem; }
|
||||||
|
p { color: #aaa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Authorization Successful</h1>
|
||||||
|
<p>You can close this window and return to OpenCode.</p>
|
||||||
|
</div>
|
||||||
|
<script>setTimeout(() => window.close(), 2000);</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenCode - Authorization Failed</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||||
|
.container { text-align: center; padding: 2rem; }
|
||||||
|
h1 { color: #f87171; margin-bottom: 1rem; }
|
||||||
|
p { color: #aaa; }
|
||||||
|
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Authorization Failed</h1>
|
||||||
|
<p>An error occurred during authorization.</p>
|
||||||
|
<div class="error">${escapeHtml(error)}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
interface PendingAuth {
|
interface PendingAuth {
|
||||||
resolve: (code: string) => void
|
resolve: (code: string) => void
|
||||||
reject: (error: Error) => void
|
reject: (error: Error) => void
|
||||||
@@ -57,7 +98,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
|
res.end(HTML_ERROR(errorMsg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +112,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
|||||||
pending.reject(new Error(errorMsg))
|
pending.reject(new Error(errorMsg))
|
||||||
}
|
}
|
||||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
|
res.end(HTML_ERROR(errorMsg))
|
||||||
stopIfIdle()
|
stopIfIdle()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||||
res.end(OauthCallbackPage.error("No authorization code provided", { provider: "MCP" }))
|
res.end(HTML_ERROR("No authorization code provided"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +127,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
|||||||
if (!pendingAuths.has(state)) {
|
if (!pendingAuths.has(state)) {
|
||||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
|
res.end(HTML_ERROR(errorMsg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +139,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
|||||||
pending.resolve(code)
|
pending.resolve(code)
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||||
res.end(OauthCallbackPage.success({ provider: "MCP" }))
|
res.end(HTML_SUCCESS)
|
||||||
stopIfIdle()
|
stopIfIdle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { Model } from "@opencode-ai/sdk/v2"
|
import type { Model } from "@opencode-ai/sdk/v2"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
|
|
||||||
@@ -59,6 +58,65 @@ function buildAuthorizeUrl(state: string): string {
|
|||||||
return `${DO_AUTHORIZE_URL}?${params.toString()}`
|
return `${DO_AUTHORIZE_URL}?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTML_CALLBACK = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>OpenCode - DigitalOcean Authorization</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
|
||||||
|
.container { text-align: center; padding: 2rem; max-width: 32rem; }
|
||||||
|
h1 { color: #e8eef9; margin-bottom: 1rem; }
|
||||||
|
p { color: #9aa9c0; }
|
||||||
|
.error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1 id="title">Finishing sign-in...</h1>
|
||||||
|
<p id="msg">You can close this window once it says you're signed in.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(async function() {
|
||||||
|
const params = new URLSearchParams((window.location.hash || "").slice(1))
|
||||||
|
const search = new URLSearchParams(window.location.search)
|
||||||
|
const error = params.get("error") || search.get("error")
|
||||||
|
const errorDescription = params.get("error_description") || search.get("error_description")
|
||||||
|
const titleEl = document.getElementById("title")
|
||||||
|
const msgEl = document.getElementById("msg")
|
||||||
|
const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href
|
||||||
|
try {
|
||||||
|
const body = error
|
||||||
|
? { error, error_description: errorDescription || "" }
|
||||||
|
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
|
||||||
|
const res = await fetch(tokenUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(function () { return "" })
|
||||||
|
throw new Error(detail || ("callback failed (" + res.status + ")"))
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
titleEl.textContent = "Authorization Failed"
|
||||||
|
msgEl.textContent = errorDescription || error
|
||||||
|
msgEl.className = "error"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
titleEl.textContent = "Authorization Successful"
|
||||||
|
msgEl.textContent = "You can close this window and return to OpenCode."
|
||||||
|
setTimeout(function () { window.close() }, 2000)
|
||||||
|
} catch (e) {
|
||||||
|
titleEl.textContent = "Authorization Failed"
|
||||||
|
msgEl.textContent = String(e && e.message ? e.message : e)
|
||||||
|
msgEl.className = "error"
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
async function startOAuthServer(): Promise<void> {
|
async function startOAuthServer(): Promise<void> {
|
||||||
if (oauthServer) return
|
if (oauthServer) return
|
||||||
oauthServer = createServer((req, res) => {
|
oauthServer = createServer((req, res) => {
|
||||||
@@ -66,7 +124,7 @@ async function startOAuthServer(): Promise<void> {
|
|||||||
|
|
||||||
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
|
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
res.end(OauthCallbackPage.bootstrap({ tokenPath: OAUTH_TOKEN_PATH, provider: "DigitalOcean" }))
|
res.end(HTML_CALLBACK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import os from "os"
|
|||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import { OpenAIWebSocketPool } from "./ws-pool"
|
import { OpenAIWebSocketPool } from "./ws-pool"
|
||||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
import { escapeHtml } from "@/util/html"
|
||||||
|
|
||||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
const ISSUER = "https://auth.openai.com"
|
const ISSUER = "https://auth.openai.com"
|
||||||
@@ -138,8 +138,95 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept as a named export for plugin.codex tests; delegates to the shared branded page.
|
const HTML_SUCCESS = `<!doctype html>
|
||||||
export const renderOAuthError = (error: string) => OauthCallbackPage.error(error, { provider: "ChatGPT" })
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenCode - Codex Authorization Successful</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #131010;
|
||||||
|
color: #f1ecec;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #f1ecec;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #b7b1b1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Authorization Successful</h1>
|
||||||
|
<p>You can close this window and return to OpenCode.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setTimeout(() => window.close(), 2000)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
export const renderOAuthError = (error: string) => `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenCode - Codex Authorization Failed</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #131010;
|
||||||
|
color: #f1ecec;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #fc533a;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #b7b1b1;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #ff917b;
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #3c140d;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Authorization Failed</h1>
|
||||||
|
<p>An error occurred during authorization.</p>
|
||||||
|
<div class="error">${escapeHtml(error)}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
interface PendingOAuth {
|
interface PendingOAuth {
|
||||||
pkce: PkceCodes
|
pkce: PkceCodes
|
||||||
@@ -200,7 +287,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
|||||||
.catch((err) => current.reject(err))
|
.catch((err) => current.reject(err))
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||||
res.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
|
res.end(HTML_SUCCESS)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
|
|
||||||
@@ -157,6 +156,29 @@ async function refreshAccessToken(account: string, refreshToken: string) {
|
|||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTML_SUCCESS = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>OpenCode - Snowflake Authorization Successful</title></head>
|
||||||
|
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
|
||||||
|
<div style="text-align:center; max-width:36rem; padding:2rem;">
|
||||||
|
<h1 style="color:#7ee787;">Authorization Successful</h1>
|
||||||
|
<p>You can close this window and return to OpenCode.</p>
|
||||||
|
</div>
|
||||||
|
<script>setTimeout(() => window.close(), 1500)</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
const htmlError = (message: string) => `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>OpenCode - Snowflake Authorization Failed</title></head>
|
||||||
|
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
|
||||||
|
<div style="text-align:center; max-width:48rem; padding:2rem;">
|
||||||
|
<h1 style="color:#ff7b72;">Authorization Failed</h1>
|
||||||
|
<pre style="white-space:pre-wrap; color:#ffb3ad; background:#2a1210; padding:1rem; border-radius:.5rem;">${message}</pre>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
async function startOAuthServer() {
|
async function startOAuthServer() {
|
||||||
if (oauthServer) return
|
if (oauthServer) return
|
||||||
|
|
||||||
@@ -181,7 +203,7 @@ async function startOAuthServer() {
|
|||||||
pendingOAuth?.reject(new Error(message))
|
pendingOAuth?.reject(new Error(message))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
res.writeHead(400, { "Content-Type": "text/html" })
|
res.writeHead(400, { "Content-Type": "text/html" })
|
||||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
res.end(htmlError(message))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +214,7 @@ async function startOAuthServer() {
|
|||||||
const message = errorDescription || error
|
const message = errorDescription || error
|
||||||
current.reject(new Error(message))
|
current.reject(new Error(message))
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
res.end(htmlError(message))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +222,7 @@ async function startOAuthServer() {
|
|||||||
const message = "Missing authorization code"
|
const message = "Missing authorization code"
|
||||||
current.reject(new Error(message))
|
current.reject(new Error(message))
|
||||||
res.writeHead(400, { "Content-Type": "text/html" })
|
res.writeHead(400, { "Content-Type": "text/html" })
|
||||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
res.end(htmlError(message))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +231,7 @@ async function startOAuthServer() {
|
|||||||
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
|
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
res.end(OauthCallbackPage.success({ provider: "Snowflake" }))
|
res.end(HTML_SUCCESS)
|
||||||
})
|
})
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
|||||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
import { escapeHtml } from "@/util/html"
|
||||||
|
|
||||||
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
|
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
|
||||||
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
|
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
|
||||||
@@ -285,6 +285,96 @@ export async function pollDeviceCodeToken(
|
|||||||
throw new Error("xAI device authorization timed out")
|
throw new Error("xAI device authorization timed out")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTML_SUCCESS = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenCode - xAI Authorization Successful</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #131010;
|
||||||
|
color: #f1ecec;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #f1ecec;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #b7b1b1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Authorization Successful</h1>
|
||||||
|
<p>You can close this window and return to OpenCode.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setTimeout(() => window.close(), 2000)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
const HTML_ERROR = (error: string) => `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenCode - xAI Authorization Failed</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #131010;
|
||||||
|
color: #f1ecec;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #fc533a;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #b7b1b1;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #ff917b;
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #3c140d;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Authorization Failed</h1>
|
||||||
|
<p>An error occurred during authorization.</p>
|
||||||
|
<div class="error">${escapeHtml(error)}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
// CORS allowlist for the loopback callback. The redirect_uri itself is
|
// CORS allowlist for the loopback callback. The redirect_uri itself is
|
||||||
// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept
|
// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept
|
||||||
// xAI's own auth origins for additional defense-in-depth on the OPTIONS
|
// xAI's own auth origins for additional defense-in-depth on the OPTIONS
|
||||||
@@ -335,7 +425,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
|||||||
pendingOAuth?.reject(new Error(errorMsg))
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
res.end(HTML_ERROR(errorMsg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +434,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
|||||||
pendingOAuth?.reject(new Error(errorMsg))
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
res.writeHead(400, { "Content-Type": "text/html" })
|
res.writeHead(400, { "Content-Type": "text/html" })
|
||||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
res.end(HTML_ERROR(errorMsg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +443,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
|||||||
pendingOAuth?.reject(new Error(errorMsg))
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
res.writeHead(400, { "Content-Type": "text/html" })
|
res.writeHead(400, { "Content-Type": "text/html" })
|
||||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
res.end(HTML_ERROR(errorMsg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +455,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
|||||||
.catch((err) => current.reject(err))
|
.catch((err) => current.reject(err))
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
res.end(OauthCallbackPage.success({ provider: "xAI" }))
|
res.end(HTML_SUCCESS)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const fileConcurrency = 8
|
|||||||
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
files: Schema.Array(Schema.String),
|
files: Schema.Array(Schema.String),
|
||||||
version: Schema.optional(Schema.String),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
class Index extends Schema.Class<Index>("Index")({
|
class Index extends Schema.Class<Index>("Index")({
|
||||||
@@ -77,53 +76,17 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
|
|||||||
(skill) =>
|
(skill) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const root = path.join(cache, skill.name)
|
const root = path.join(cache, skill.name)
|
||||||
const versionFile = path.join(root, ".opencode-version")
|
|
||||||
const version = skill.version
|
|
||||||
const current =
|
|
||||||
version === undefined
|
|
||||||
? undefined
|
|
||||||
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
|
|
||||||
if (version === undefined || current === version) {
|
yield* Effect.forEach(
|
||||||
yield* Effect.forEach(
|
skill.files,
|
||||||
skill.files,
|
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
{
|
||||||
{ concurrency: fileConcurrency, discard: true },
|
concurrency: fileConcurrency,
|
||||||
)
|
},
|
||||||
} else {
|
)
|
||||||
const token = crypto.randomUUID()
|
|
||||||
const staging = `${root}.tmp-${token}`
|
const md = path.join(root, "SKILL.md")
|
||||||
const backup = `${root}.old-${token}`
|
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const downloaded = yield* Effect.forEach(
|
|
||||||
skill.files,
|
|
||||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)),
|
|
||||||
{ concurrency: fileConcurrency },
|
|
||||||
)
|
|
||||||
if (!downloaded.every(Boolean)) return
|
|
||||||
if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return
|
|
||||||
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
|
|
||||||
yield* Effect.uninterruptible(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const cached = yield* fs.exists(root).pipe(Effect.orDie)
|
|
||||||
if (cached) yield* fs.rename(root, backup)
|
|
||||||
yield* fs.rename(staging, root).pipe(
|
|
||||||
Effect.catch((error) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
|
|
||||||
return yield* Effect.fail(error)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
|
|
||||||
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null
|
|
||||||
}),
|
}),
|
||||||
{ concurrency: skillConcurrency },
|
{ concurrency: skillConcurrency },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
import { Effect, Layer, Context, Schema } from "effect"
|
import { Effect, Layer, Context, Schema } from "effect"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import type { Agent } from "@/agent/agent"
|
import type { Agent } from "@/agent/agent"
|
||||||
@@ -16,7 +17,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
|||||||
import { Glob } from "@opencode-ai/core/util/glob"
|
import { Glob } from "@opencode-ai/core/util/glob"
|
||||||
import { Discovery } from "./discovery"
|
import { Discovery } from "./discovery"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { escapeHtml } from "@/util/html"
|
|
||||||
|
|
||||||
const CLAUDE_EXTERNAL_DIR = ".claude"
|
const CLAUDE_EXTERNAL_DIR = ".claude"
|
||||||
const AGENTS_EXTERNAL_DIR = ".agents"
|
const AGENTS_EXTERNAL_DIR = ".agents"
|
||||||
@@ -339,7 +339,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
|
|||||||
" <skill>",
|
" <skill>",
|
||||||
` <name>${skill.name}</name>`,
|
` <name>${skill.name}</name>`,
|
||||||
` <description>${skill.description}</description>`,
|
` <description>${skill.description}</description>`,
|
||||||
` <location>${escapeHtml(skill.location)}</location>`,
|
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||||
" </skill>",
|
" </skill>",
|
||||||
]),
|
]),
|
||||||
"</available_skills>",
|
"</available_skills>",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
SessionConfigSelectOption,
|
SessionConfigSelectOption,
|
||||||
SetSessionConfigOptionResponse,
|
SetSessionConfigOptionResponse,
|
||||||
} from "@agentclientprotocol/sdk"
|
} from "@agentclientprotocol/sdk"
|
||||||
import type { AssistantMessage, OpencodeClient } from "@opencode-ai/sdk/v2"
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
@@ -144,10 +144,7 @@ const provider: Provider.Info = {
|
|||||||
describe("ACP service sessions", () => {
|
describe("ACP service sessions", () => {
|
||||||
const makeService = (
|
const makeService = (
|
||||||
messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
|
messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
|
||||||
options?: {
|
options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> },
|
||||||
abort?: (input: { sessionID: string }) => Promise<{ data: boolean }>
|
|
||||||
prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
|
|
||||||
},
|
|
||||||
) => {
|
) => {
|
||||||
const updates: SessionNotification[] = []
|
const updates: SessionNotification[] = []
|
||||||
const mcpAdds: string[] = []
|
const mcpAdds: string[] = []
|
||||||
@@ -196,21 +193,19 @@ describe("ACP service sessions", () => {
|
|||||||
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
|
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
|
||||||
}),
|
}),
|
||||||
messages: () => Promise.resolve({ data: messages }),
|
messages: () => Promise.resolve({ data: messages }),
|
||||||
prompt:
|
prompt: (input: unknown) => {
|
||||||
options?.prompt ??
|
prompts.push(input)
|
||||||
((input: unknown) => {
|
return Promise.resolve({
|
||||||
prompts.push(input)
|
data: {
|
||||||
return Promise.resolve({
|
info: assistantInfo({
|
||||||
data: {
|
input: 100,
|
||||||
info: assistantInfo({
|
output: 40,
|
||||||
input: 100,
|
reasoning: 7,
|
||||||
output: 40,
|
cache: { read: 11, write: 13 },
|
||||||
reasoning: 7,
|
}),
|
||||||
cache: { read: 11, write: 13 },
|
},
|
||||||
}),
|
})
|
||||||
},
|
},
|
||||||
})
|
|
||||||
}),
|
|
||||||
command: (input: unknown) => {
|
command: (input: unknown) => {
|
||||||
commands.push(input)
|
commands.push(input)
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -999,52 +994,6 @@ describe("ACP service sessions", () => {
|
|||||||
expect(usageUpdates).toEqual([session.sessionId])
|
expect(usageUpdates).toEqual([session.sessionId])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("maps assistant prompt errors to request errors instead of end turn", async () => {
|
|
||||||
const { service } = makeService([], {
|
|
||||||
prompt: () =>
|
|
||||||
Promise.resolve({
|
|
||||||
data: {
|
|
||||||
info: assistantInfo(
|
|
||||||
{ input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
{ name: "APIError", data: { message: "Provider request failed", isRetryable: false } },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
|
||||||
|
|
||||||
const error = await Effect.runPromise(
|
|
||||||
service
|
|
||||||
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
|
|
||||||
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(error.code).toBe(-32603)
|
|
||||||
expect(error.message).toBe("Internal error: Provider request failed")
|
|
||||||
expect(error.data).toEqual({ service: "session", errorName: "APIError" })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("maps aborted assistant prompt errors to cancelled", async () => {
|
|
||||||
const { service } = makeService([], {
|
|
||||||
prompt: () =>
|
|
||||||
Promise.resolve({
|
|
||||||
data: {
|
|
||||||
info: assistantInfo(
|
|
||||||
{ input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
{ name: "MessageAbortedError", data: { message: "Aborted" } },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
|
||||||
|
|
||||||
const result = await Effect.runPromise(
|
|
||||||
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.stopReason).toBe("cancelled")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("prompt maps assistant and user audience annotations", async () => {
|
it("prompt maps assistant and user audience annotations", async () => {
|
||||||
const { service, prompts } = makeService()
|
const { service, prompts } = makeService()
|
||||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||||
@@ -1196,17 +1145,13 @@ describe("ACP service sessions", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function assistantInfo(
|
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
|
||||||
tokens: UsageService.AssistantTokenCost["tokens"],
|
|
||||||
error?: AssistantMessage["error"],
|
|
||||||
): UsageService.AssistantMessage & Pick<AssistantMessage, "error"> {
|
|
||||||
return {
|
return {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
providerID: "test",
|
providerID: "test",
|
||||||
modelID: "test-model",
|
modelID: "test-model",
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens,
|
tokens,
|
||||||
...(error ? { error } : {}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe("McpOAuthCallback.ensureRunning", () => {
|
|||||||
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`,
|
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(await response.text()).toContain('<pre class="detail" id="oc-detail">The user denied access</pre>')
|
expect(await response.text()).toContain('<div class="error">The user denied access</div>')
|
||||||
})
|
})
|
||||||
|
|
||||||
test("binds the callback server to IPv4 loopback", async () => {
|
test("binds the callback server to IPv4 loopback", async () => {
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ import { testEffect } from "../lib/effect"
|
|||||||
let CLOUDFLARE_SKILLS_URL: string
|
let CLOUDFLARE_SKILLS_URL: string
|
||||||
let server: ReturnType<typeof Bun.serve>
|
let server: ReturnType<typeof Bun.serve>
|
||||||
let downloadCount = 0
|
let downloadCount = 0
|
||||||
let mutableVersion = "1"
|
|
||||||
let mutableContent = "# Old"
|
|
||||||
let mutableDownloadCount = 0
|
|
||||||
let mutableFiles = ["SKILL.md"]
|
|
||||||
|
|
||||||
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
|
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
|
||||||
const cacheDir = path.join(Global.Path.cache, "skills")
|
const cacheDir = path.join(Global.Path.cache, "skills")
|
||||||
@@ -28,15 +24,6 @@ beforeAll(async () => {
|
|||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
|
|
||||||
if (url.pathname === "/mutable/index.json") {
|
|
||||||
return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] })
|
|
||||||
}
|
|
||||||
if (url.pathname === "/mutable/mutable/SKILL.md") {
|
|
||||||
mutableDownloadCount++
|
|
||||||
return new Response(mutableContent)
|
|
||||||
}
|
|
||||||
if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference")
|
|
||||||
|
|
||||||
// route /.well-known/skills/* to the fixture directory
|
// route /.well-known/skills/* to the fixture directory
|
||||||
if (url.pathname.startsWith("/.well-known/skills/")) {
|
if (url.pathname.startsWith("/.well-known/skills/")) {
|
||||||
const filePath = url.pathname.replace("/.well-known/skills/", "")
|
const filePath = url.pathname.replace("/.well-known/skills/", "")
|
||||||
@@ -149,37 +136,4 @@ describe("Discovery.pull", () => {
|
|||||||
expect(downloadCount).toBe(firstCount)
|
expect(downloadCount).toBe(firstCount)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("refreshes a remote skill when its version changes", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
|
|
||||||
mutableVersion = "1"
|
|
||||||
mutableContent = "# Old"
|
|
||||||
mutableDownloadCount = 0
|
|
||||||
mutableFiles = ["SKILL.md", "old.md"]
|
|
||||||
const discovery = yield* Discovery.Service
|
|
||||||
const url = `http://localhost:${server.port}/mutable/`
|
|
||||||
|
|
||||||
const first = yield* discovery.pull(url)
|
|
||||||
expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old")
|
|
||||||
|
|
||||||
mutableVersion = "2"
|
|
||||||
mutableContent = "# Partial"
|
|
||||||
mutableFiles = ["SKILL.md", "missing.md"]
|
|
||||||
const second = yield* discovery.pull(url)
|
|
||||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old")
|
|
||||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference")
|
|
||||||
|
|
||||||
mutableVersion = "3"
|
|
||||||
mutableContent = "# New"
|
|
||||||
mutableFiles = ["SKILL.md"]
|
|
||||||
yield* discovery.pull(url)
|
|
||||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New")
|
|
||||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false)
|
|
||||||
expect(mutableDownloadCount).toBe(3)
|
|
||||||
|
|
||||||
yield* discovery.pull(url)
|
|
||||||
expect(mutableDownloadCount).toBe(3)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -77,33 +77,6 @@ const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
describe("skill", () => {
|
describe("skill", () => {
|
||||||
it.effect("formats verbose locations as XML-safe filesystem paths", () =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
const output = Skill.fmt(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: "tagged-skill",
|
|
||||||
description: "A tagged skill.",
|
|
||||||
location: "/tmp/plugin.git#v1.3.0/SKILL.md",
|
|
||||||
content: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "built-in-skill",
|
|
||||||
description: "A built-in skill.",
|
|
||||||
location: "<built-in>",
|
|
||||||
content: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{ verbose: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(output).toContain("<location>/tmp/plugin.git#v1.3.0/SKILL.md</location>")
|
|
||||||
expect(output).toContain("<location><built-in></location>")
|
|
||||||
expect(output).not.toContain("file://")
|
|
||||||
expect(output).not.toContain("%23")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("discovers skills from .opencode/skill/ directory", () =>
|
it.live("discovers skills from .opencode/skill/ directory", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||||||
query: {
|
query: {
|
||||||
after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
|
after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
|
||||||
},
|
},
|
||||||
success: HttpApiSchema.StreamSse({ data: SessionEvent.Durable }),
|
success: HttpApiSchema.StreamSse({ data: SessionEvent.Stream }),
|
||||||
error: SessionNotFoundError,
|
error: SessionNotFoundError,
|
||||||
})
|
})
|
||||||
.middleware(sessionLocationMiddleware)
|
.middleware(sessionLocationMiddleware)
|
||||||
@@ -303,7 +303,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "v2.session.events",
|
identifier: "v2.session.events",
|
||||||
summary: "Subscribe to session events",
|
summary: "Subscribe to session events",
|
||||||
description: "Replay durable events after an aggregate sequence, then continue with new durable events.",
|
description:
|
||||||
|
"Replay durable events after an aggregate sequence, emit current process-local activity, then continue with durable and live-only Session events.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ const stepSettlementOptions = {
|
|||||||
export const UnknownError = SessionMessage.UnknownError
|
export const UnknownError = SessionMessage.UnknownError
|
||||||
export type UnknownError = SessionMessage.UnknownError
|
export type UnknownError = SessionMessage.UnknownError
|
||||||
|
|
||||||
|
export const Activity = Event.define({
|
||||||
|
type: "session.activity",
|
||||||
|
schema: {
|
||||||
|
sessionID: SessionID,
|
||||||
|
active: Schema.Boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export type Activity = typeof Activity.Type
|
||||||
|
|
||||||
|
export const makeActivity = (sessionID: SessionID, active: boolean): Activity => ({
|
||||||
|
id: Event.ID.create(),
|
||||||
|
type: Activity.type,
|
||||||
|
data: { sessionID, active },
|
||||||
|
})
|
||||||
|
|
||||||
export const AgentSwitched = Event.define({
|
export const AgentSwitched = Event.define({
|
||||||
type: "session.next.agent.switched",
|
type: "session.next.agent.switched",
|
||||||
...options,
|
...options,
|
||||||
@@ -517,3 +532,6 @@ export type DurableEvent = typeof Durable.Type
|
|||||||
export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
|
export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
|
||||||
export type Event = typeof All.Type
|
export type Event = typeof All.Type
|
||||||
export type Type = Event["type"]
|
export type Type = Event["type"]
|
||||||
|
|
||||||
|
export const Stream = Schema.Union([Activity, All], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
export type StreamEvent = typeof Stream.Type
|
||||||
|
|||||||
@@ -5713,7 +5713,7 @@ export class Session3 extends HeyApiClient {
|
|||||||
/**
|
/**
|
||||||
* Subscribe to session events
|
* Subscribe to session events
|
||||||
*
|
*
|
||||||
* Replay durable events after an aggregate sequence, then continue with new durable events.
|
* Replay durable events after an aggregate sequence, emit current process-local activity, then continue with durable and live-only Session events.
|
||||||
*/
|
*/
|
||||||
public events<ThrowOnError extends boolean = false>(
|
public events<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
|
|||||||
@@ -4036,6 +4036,24 @@ export type SessionMessage =
|
|||||||
| SessionMessageAssistant
|
| SessionMessageAssistant
|
||||||
| SessionMessageCompaction
|
| SessionMessageCompaction
|
||||||
|
|
||||||
|
export type SessionActivity = {
|
||||||
|
id: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
type: "session.activity"
|
||||||
|
durable?: {
|
||||||
|
aggregateID: string
|
||||||
|
seq: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
version: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
}
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
sessionID: string
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionNextAgentSwitched = {
|
export type SessionNextAgentSwitched = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
@@ -4313,6 +4331,27 @@ export type SessionNextTextStarted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionNextTextDelta = {
|
||||||
|
id: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
type: "session.next.text.delta"
|
||||||
|
durable?: {
|
||||||
|
aggregateID: string
|
||||||
|
seq: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
version: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
}
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
assistantMessageID: string
|
||||||
|
textID: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionNextTextEnded = {
|
export type SessionNextTextEnded = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
@@ -4334,6 +4373,70 @@ export type SessionNextTextEnded = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionNextReasoningStarted = {
|
||||||
|
id: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
type: "session.next.reasoning.started"
|
||||||
|
durable?: {
|
||||||
|
aggregateID: string
|
||||||
|
seq: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
version: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
}
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
assistantMessageID: string
|
||||||
|
reasoningID: string
|
||||||
|
providerMetadata?: LlmProviderMetadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionNextReasoningDelta = {
|
||||||
|
id: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
type: "session.next.reasoning.delta"
|
||||||
|
durable?: {
|
||||||
|
aggregateID: string
|
||||||
|
seq: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
version: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
}
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
assistantMessageID: string
|
||||||
|
reasoningID: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionNextReasoningEnded = {
|
||||||
|
id: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
type: "session.next.reasoning.ended"
|
||||||
|
durable?: {
|
||||||
|
aggregateID: string
|
||||||
|
seq: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
version: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
}
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
assistantMessageID: string
|
||||||
|
reasoningID: string
|
||||||
|
text: string
|
||||||
|
providerMetadata?: LlmProviderMetadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionNextToolInputStarted = {
|
export type SessionNextToolInputStarted = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
@@ -4355,6 +4458,27 @@ export type SessionNextToolInputStarted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionNextToolInputDelta = {
|
||||||
|
id: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
type: "session.next.tool.input.delta"
|
||||||
|
durable?: {
|
||||||
|
aggregateID: string
|
||||||
|
seq: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
version: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
}
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
assistantMessageID: string
|
||||||
|
callID: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionNextToolInputEnded = {
|
export type SessionNextToolInputEnded = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
@@ -4484,49 +4608,6 @@ export type SessionNextToolFailed = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionNextReasoningStarted = {
|
|
||||||
id: string
|
|
||||||
metadata?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
type: "session.next.reasoning.started"
|
|
||||||
durable?: {
|
|
||||||
aggregateID: string
|
|
||||||
seq: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
version: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
}
|
|
||||||
location?: LocationRef
|
|
||||||
data: {
|
|
||||||
timestamp: number
|
|
||||||
sessionID: string
|
|
||||||
assistantMessageID: string
|
|
||||||
reasoningID: string
|
|
||||||
providerMetadata?: LlmProviderMetadata
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionNextReasoningEnded = {
|
|
||||||
id: string
|
|
||||||
metadata?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
type: "session.next.reasoning.ended"
|
|
||||||
durable?: {
|
|
||||||
aggregateID: string
|
|
||||||
seq: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
version: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
}
|
|
||||||
location?: LocationRef
|
|
||||||
data: {
|
|
||||||
timestamp: number
|
|
||||||
sessionID: string
|
|
||||||
assistantMessageID: string
|
|
||||||
reasoningID: string
|
|
||||||
text: string
|
|
||||||
providerMetadata?: LlmProviderMetadata
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionNextRetried = {
|
export type SessionNextRetried = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
@@ -4567,6 +4648,26 @@ export type SessionNextCompactionStarted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionNextCompactionDelta = {
|
||||||
|
id: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
type: "session.next.compaction.delta"
|
||||||
|
durable?: {
|
||||||
|
aggregateID: string
|
||||||
|
seq: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
version: number | "NaN" | "Infinity" | "-Infinity"
|
||||||
|
}
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionNextCompactionEnded = {
|
export type SessionNextCompactionEnded = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--v2-text-text-faint);
|
color: var(--text-weak);
|
||||||
|
|
||||||
[data-component="spinner"] {
|
[data-component="spinner"] {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@@ -89,14 +89,14 @@
|
|||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
|
|
||||||
&.capitalize {
|
&.capitalize {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.agent-title {
|
&.agent-title {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
|
|
||||||
&.clickable {
|
&.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
transition: color 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-action"] {
|
[data-slot="basic-tool-tool-action"] {
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 0.5px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||||
background: color-mix(in srgb, var(--background-base) 92%, transparent);
|
background: color-mix(in srgb, var(--background-base) 92%, transparent);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.15s ease,
|
border-color 0.15s ease,
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--v2-text-text-faint);
|
color: var(--icon-weak);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition:
|
transition:
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-subtitle"] {
|
[data-slot="basic-tool-tool-subtitle"] {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
@@ -246,34 +246,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not([data-new-layout]) {
|
|
||||||
[data-component="tool-trigger"] {
|
|
||||||
[data-slot="basic-tool-tool-spinner"] {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-title"],
|
|
||||||
[data-slot="basic-tool-tool-title"].agent-title {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-subtitle"],
|
|
||||||
[data-slot="basic-tool-tool-subtitle"].clickable:hover,
|
|
||||||
[data-slot="basic-tool-tool-arg"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="task-tool-card"] {
|
|
||||||
border-width: 1px;
|
|
||||||
|
|
||||||
[data-component="task-tool-action"] {
|
|
||||||
color: var(--icon-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-subtitle"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { checksum } from "@opencode-ai/core/util/encode"
|
import { checksum } from "@opencode-ai/core/util/encode"
|
||||||
import DOMPurify from "dompurify"
|
import DOMPurify from "dompurify"
|
||||||
import type { MarkdownToken } from "./markdown-worker-protocol"
|
|
||||||
import { project } from "./markdown-stream"
|
import { project } from "./markdown-stream"
|
||||||
|
|
||||||
export type MarkdownCacheEntry = {
|
export type MarkdownCacheEntry = {
|
||||||
@@ -9,18 +8,8 @@ export type MarkdownCacheEntry = {
|
|||||||
html: string
|
html: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MarkdownCodeCacheEntry = {
|
|
||||||
raw: string
|
|
||||||
hash: string
|
|
||||||
language: string
|
|
||||||
generation: number
|
|
||||||
stable: MarkdownToken[]
|
|
||||||
unstable: MarkdownToken[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = 200
|
const max = 200
|
||||||
const cache = new Map<string, MarkdownCacheEntry>()
|
const cache = new Map<string, MarkdownCacheEntry>()
|
||||||
const codeCache = new Map<string, MarkdownCodeCacheEntry>()
|
|
||||||
const config = {
|
const config = {
|
||||||
USE_PROFILES: { html: true, mathMl: true },
|
USE_PROFILES: { html: true, mathMl: true },
|
||||||
SANITIZE_NAMED_PROPS: true,
|
SANITIZE_NAMED_PROPS: true,
|
||||||
@@ -63,21 +52,6 @@ export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
|
|||||||
cache.delete(first)
|
cache.delete(first)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCachedMarkdownCode(key: string) {
|
|
||||||
return codeCache.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function touchCachedMarkdownCode(key: string, value: MarkdownCodeCacheEntry) {
|
|
||||||
codeCache.delete(key)
|
|
||||||
codeCache.set(key, value)
|
|
||||||
|
|
||||||
if (codeCache.size <= max) return
|
|
||||||
|
|
||||||
const first = codeCache.keys().next().value
|
|
||||||
if (!first) return
|
|
||||||
codeCache.delete(first)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function preloadMarkdown(
|
export async function preloadMarkdown(
|
||||||
text: string,
|
text: string,
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { inlineCodeKind } from "./markdown-inline-code-kind"
|
|
||||||
|
|
||||||
describe("inlineCodeKind", () => {
|
|
||||||
test("leaves code expressions as normal inline code", () => {
|
|
||||||
expect(
|
|
||||||
inlineCodeKind(
|
|
||||||
`case "question.asked": ... input.setStore("question", question.sessionID, [question]) / splice/insert`,
|
|
||||||
),
|
|
||||||
).toBeUndefined()
|
|
||||||
expect(inlineCodeKind(`<SessionQuestionDock request={request} ... />`)).toBeUndefined()
|
|
||||||
expect(inlineCodeKind(`from sync.data.question + sync.data.session.`)).toBeUndefined()
|
|
||||||
expect(inlineCodeKind(`@opencode-ai/app <StatusPopover />)`)).toBeUndefined()
|
|
||||||
expect(inlineCodeKind(`sync.data.session`)).toBeUndefined()
|
|
||||||
expect(inlineCodeKind(`window.api`)).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("detects file and directory paths", () => {
|
|
||||||
expect(inlineCodeKind(`app.tsx`)).toBe("path")
|
|
||||||
expect(inlineCodeKind(`packages/desktop-electron`)).toBe("path")
|
|
||||||
expect(inlineCodeKind(`~/.config/opencode`)).toBe("path")
|
|
||||||
expect(inlineCodeKind(`@opencode-ai/app`)).toBe("path")
|
|
||||||
expect(inlineCodeKind(`session/status`)).toBe("path")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("detects urls", () => {
|
|
||||||
expect(inlineCodeKind(`https://opencode.ai/docs`)).toBe("url")
|
|
||||||
expect(inlineCodeKind(`http://localhost:4444`)).toBe("url")
|
|
||||||
expect(inlineCodeKind(`file:///tmp/opencode`)).toBeUndefined()
|
|
||||||
expect(inlineCodeKind(`ftp://opencode.ai/docs`)).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export function inlineCodeKind(text: string): "path" | "url" | undefined {
|
|
||||||
if (/^https?:\/\//i.test(text)) return "url"
|
|
||||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return
|
|
||||||
if (/^\/[a-z][a-z0-9-]*$/i.test(text)) return
|
|
||||||
if (/\s/.test(text)) return
|
|
||||||
if (/[()\[\]{}*+=<>|&^"';]/.test(text)) return
|
|
||||||
if (
|
|
||||||
/[/\\]/.test(text) ||
|
|
||||||
/^\.\.?[/\\]/.test(text) ||
|
|
||||||
/\.(tsx?|jsx?|json|py|go|rs|rb|php|css|html|md|sh|ya?ml|toml|sql|c|cpp|h|java|kt|swift|scala|xml|png|jpe?g|gif|svg|ico)$/i.test(
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return "path"
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { getCachedMarkdownCode, preloadMarkdown, touchCachedMarkdownCode } from "./markdown-cache"
|
import { preloadMarkdown } from "./markdown-cache"
|
||||||
|
|
||||||
test("preloads completed markdown into the render cache", async () => {
|
test("preloads completed markdown into the render cache", async () => {
|
||||||
const parsed: string[] = []
|
const parsed: string[] = []
|
||||||
@@ -16,24 +16,3 @@ test("preloads completed markdown into the render cache", async () => {
|
|||||||
|
|
||||||
expect(parsed).toEqual(["prepared response"])
|
expect(parsed).toEqual(["prepared response"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps completed code highlights by stable block key", () => {
|
|
||||||
const key = `markdown-code-${crypto.randomUUID()}:0:code`
|
|
||||||
touchCachedMarkdownCode(key, {
|
|
||||||
raw: "```ts\nconst value = 1\n```",
|
|
||||||
hash: "23",
|
|
||||||
language: "ts",
|
|
||||||
generation: 1,
|
|
||||||
stable: [["const", "color: red"]],
|
|
||||||
unstable: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(getCachedMarkdownCode(key)).toEqual({
|
|
||||||
raw: "```ts\nconst value = 1\n```",
|
|
||||||
hash: "23",
|
|
||||||
language: "ts",
|
|
||||||
generation: 1,
|
|
||||||
stable: [["const", "color: red"]],
|
|
||||||
unstable: [],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
[data-component="markdown"] {
|
[data-component="markdown"] {
|
||||||
--markdown-inline-code-path-color: var(--v2-text-text-code-accent);
|
|
||||||
--markdown-inline-code-color: var(--v2-text-text-code-accent);
|
|
||||||
--markdown-inline-code-bg-mix: 8%;
|
|
||||||
--markdown-shell-code-background: var(--v2-background-bg-layer-02);
|
|
||||||
--markdown-shell-code-border: var(--v2-border-border-muted);
|
|
||||||
--markdown-shell-code-color: var(--v2-text-text-base);
|
|
||||||
--markdown-shell-code-token-color: currentColor;
|
|
||||||
|
|
||||||
/* Reset & Base Typography */
|
/* Reset & Base Typography */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: var(--font-size-base); /* 14px */
|
font-size: var(--font-size-base); /* 14px */
|
||||||
line-height: 160%;
|
line-height: 160%;
|
||||||
@@ -30,31 +22,15 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Headings: Sized by level, distinguished by color and spacing */
|
/* Headings: Same size, distinguished by color and spacing */
|
||||||
h1 {
|
h1,
|
||||||
font-size: 17px;
|
h2,
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 0px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
line-height: var(--line-height-large);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 0px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
line-height: var(--line-height-large);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3,
|
h3,
|
||||||
h4,
|
h4,
|
||||||
h5,
|
h5,
|
||||||
h6 {
|
h6 {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -64,7 +40,7 @@
|
|||||||
/* Emphasis & Strong: Neutral strong color */
|
/* Emphasis & Strong: Neutral strong color */
|
||||||
strong,
|
strong,
|
||||||
b {
|
b {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +95,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
li::marker {
|
li::marker {
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nested lists spacing */
|
/* Nested lists spacing */
|
||||||
@@ -136,10 +112,10 @@
|
|||||||
|
|
||||||
/* Blockquotes */
|
/* Blockquotes */
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 0.5px solid var(--v2-border-border-base);
|
border-left: 2px solid var(--border-weak-base);
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,27 +132,13 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
border: 0.5px solid var(--border-weak-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="markdown-code"] {
|
[data-component="markdown-code"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="markdown-code"][data-code-kind="shell"] .shiki {
|
|
||||||
background: var(--markdown-shell-code-background);
|
|
||||||
border-color: var(--markdown-shell-code-border);
|
|
||||||
color: var(--markdown-shell-code-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="markdown-code"][data-code-kind="shell"] code {
|
|
||||||
color: var(--markdown-shell-code-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="markdown-code"][data-code-kind="shell"] code span {
|
|
||||||
color: var(--markdown-shell-code-token-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="markdown-copy-button"] {
|
[data-slot="markdown-copy-button"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
@@ -198,7 +160,7 @@
|
|||||||
background: var(--surface-float-base);
|
background: var(--surface-float-base);
|
||||||
color: var(--text-invert-strong);
|
color: var(--text-invert-strong);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border: 0.5px solid var(--v2-border-border-base, rgba(0, 0, 0, 0.07));
|
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -223,11 +185,11 @@
|
|||||||
|
|
||||||
[data-slot="markdown-copy-button"][data-variant="secondary"] {
|
[data-slot="markdown-copy-button"][data-variant="secondary"] {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
border: 1px solid var(--border-weak-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
|
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
|
||||||
color: var(--v2-icon-icon-base);
|
color: var(--icon-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
|
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
|
||||||
@@ -260,28 +222,15 @@
|
|||||||
:not(pre) > code {
|
:not(pre) > code {
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||||
color: var(--v2-text-text-base);
|
color: var(--syntax-string);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
padding: 2px 4px;
|
/* font-size: 13px; */
|
||||||
border-radius: 4px;
|
|
||||||
background: color-mix(in oklch, var(--v2-text-text-base) var(--markdown-inline-code-bg-mix), transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
:not(pre) > code[data-inline-code-kind="path"] {
|
/* padding: 2px 2px; */
|
||||||
color: var(--markdown-inline-code-path-color);
|
/* margin: 0 1.5px; */
|
||||||
background: color-mix(
|
/* border-radius: 2px; */
|
||||||
in oklch,
|
/* background: var(--surface-base); */
|
||||||
var(--markdown-inline-code-path-color) var(--markdown-inline-code-bg-mix),
|
/* box-shadow: 0 0 0 0.5px var(--border-weak-base); */
|
||||||
transparent
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
a.external-link > code[data-inline-code-kind="url"] {
|
|
||||||
color: var(--markdown-inline-code-color);
|
|
||||||
background: none;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-color: color-mix(in oklch, var(--markdown-inline-code-color) 25%, transparent);
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tables */
|
/* Tables */
|
||||||
@@ -297,16 +246,16 @@
|
|||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
/* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */
|
/* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */
|
||||||
border-bottom: 0.5px solid var(--v2-border-border-muted);
|
border-bottom: 1px solid var(--border-weaker-base);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
border-bottom: 1px solid var(--border-weak-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Images */
|
/* Images */
|
||||||
@@ -319,68 +268,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not([data-new-layout]) [data-component="markdown"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-strong);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
strong,
|
|
||||||
b {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
li::marker {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: 2px solid var(--border-weak-base);
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shiki {
|
|
||||||
border-color: var(--border-weak-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
:not(pre) > code {
|
|
||||||
color: var(--syntax-string);
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="markdown-copy-button"]::after {
|
|
||||||
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="markdown-copy-button"][data-variant="secondary"] {
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
|
|
||||||
color: var(--icon-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border-bottom: 1px solid var(--border-weaker-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
color: var(--text-strong);
|
|
||||||
border-bottom-color: var(--border-weak-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="markdown"] a.external-link:hover > code {
|
[data-component="markdown"] a.external-link:hover > code {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
|
|||||||
@@ -24,15 +24,7 @@ import {
|
|||||||
} from "./markdown-worker"
|
} from "./markdown-worker"
|
||||||
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
|
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
|
||||||
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
|
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
|
||||||
import {
|
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
|
||||||
getCachedMarkdown,
|
|
||||||
getCachedMarkdownCode,
|
|
||||||
sanitizeMarkdown,
|
|
||||||
touchCachedMarkdown,
|
|
||||||
touchCachedMarkdownCode,
|
|
||||||
type MarkdownCacheEntry,
|
|
||||||
} from "./markdown-cache"
|
|
||||||
import { inlineCodeKind } from "./markdown-inline-code-kind"
|
|
||||||
|
|
||||||
type RenderedBlock =
|
type RenderedBlock =
|
||||||
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
||||||
@@ -73,12 +65,8 @@ function fallback(markdown: string) {
|
|||||||
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightLanguage(language: string | undefined) {
|
|
||||||
return language && language in bundledLanguages ? language : "text"
|
|
||||||
}
|
|
||||||
|
|
||||||
async function code(text: string, language: string | undefined, key: string, complete = false) {
|
async function code(text: string, language: string | undefined, key: string, complete = false) {
|
||||||
const name = highlightLanguage(language)
|
const name = language && language in bundledLanguages ? language : "text"
|
||||||
try {
|
try {
|
||||||
const result = await highlightStreamingCode(key, text, name, complete)
|
const result = await highlightStreamingCode(key, text, name, complete)
|
||||||
return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
|
return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
|
||||||
@@ -152,35 +140,6 @@ function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boo
|
|||||||
button.setAttribute("data-tooltip", labels.copy)
|
button.setAttribute("data-tooltip", labels.copy)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
|
|
||||||
|
|
||||||
function codeKind(language: string | undefined) {
|
|
||||||
const value = language?.toLowerCase()
|
|
||||||
if (!value) return
|
|
||||||
if (shellLanguages.has(value)) return "shell"
|
|
||||||
}
|
|
||||||
|
|
||||||
function codeLanguage(block: HTMLPreElement) {
|
|
||||||
const code = block.querySelector("code")
|
|
||||||
if (!(code instanceof HTMLElement)) return
|
|
||||||
return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
|
|
||||||
if (!document.body.hasAttribute("data-new-layout")) {
|
|
||||||
delete wrapper.dataset.language
|
|
||||||
delete wrapper.dataset.codeKind
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (language) wrapper.dataset.language = language
|
|
||||||
else delete wrapper.dataset.language
|
|
||||||
|
|
||||||
const kind = codeKind(language)
|
|
||||||
if (kind) wrapper.dataset.codeKind = kind
|
|
||||||
else delete wrapper.dataset.codeKind
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
||||||
const parent = block.parentElement
|
const parent = block.parentElement
|
||||||
if (!parent) return
|
if (!parent) return
|
||||||
@@ -188,15 +147,12 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
|||||||
if (!wrapped) {
|
if (!wrapped) {
|
||||||
const wrapper = document.createElement("div")
|
const wrapper = document.createElement("div")
|
||||||
wrapper.setAttribute("data-component", "markdown-code")
|
wrapper.setAttribute("data-component", "markdown-code")
|
||||||
applyCodeMetadata(wrapper, codeLanguage(block))
|
|
||||||
parent.replaceChild(wrapper, block)
|
parent.replaceChild(wrapper, block)
|
||||||
wrapper.appendChild(block)
|
wrapper.appendChild(block)
|
||||||
wrapper.appendChild(createCopyButton(labels))
|
wrapper.appendChild(createCopyButton(labels))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
applyCodeMetadata(parent, codeLanguage(block))
|
|
||||||
|
|
||||||
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
||||||
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
|
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
|
||||||
)
|
)
|
||||||
@@ -240,23 +196,11 @@ function markCodeLinks(root: HTMLDivElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markInlineCode(root: HTMLDivElement) {
|
|
||||||
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
|
|
||||||
for (const code of codeNodes) {
|
|
||||||
if (!(code instanceof HTMLElement)) continue
|
|
||||||
delete code.dataset.inlineCodeKind
|
|
||||||
const kind = inlineCodeKind(code.textContent ?? "")
|
|
||||||
if (kind) code.dataset.inlineCodeKind = kind
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decorate(root: HTMLDivElement, labels: CopyLabels) {
|
function decorate(root: HTMLDivElement, labels: CopyLabels) {
|
||||||
const blocks = Array.from(root.querySelectorAll("pre"))
|
const blocks = Array.from(root.querySelectorAll("pre"))
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
ensureCodeWrapper(block, labels)
|
ensureCodeWrapper(block, labels)
|
||||||
}
|
}
|
||||||
if (!document.body.hasAttribute("data-new-layout")) return
|
|
||||||
markInlineCode(root)
|
|
||||||
markCodeLinks(root)
|
markCodeLinks(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,15 +252,9 @@ function initialResult(text: string, key: string | undefined, projection: Projec
|
|||||||
if (!text) return { text, blocks: [] }
|
if (!text) return { text, blocks: [] }
|
||||||
const base = key ?? checksum(text)
|
const base = key ?? checksum(text)
|
||||||
if (base) {
|
if (base) {
|
||||||
const blocks = projection.blocks.flatMap((block, index): RenderedBlock[] => {
|
const blocks = projection.blocks.flatMap((block, index) => {
|
||||||
|
if (block.mode === "code") return []
|
||||||
const cacheKey = `${base}:${index}:${block.mode}`
|
const cacheKey = `${base}:${index}:${block.mode}`
|
||||||
if (block.mode === "code") {
|
|
||||||
if (!block.complete) return []
|
|
||||||
const cached = getCachedMarkdownCode(cacheKey)
|
|
||||||
if (cached?.raw !== block.raw) return []
|
|
||||||
touchCachedMarkdownCode(cacheKey, cached)
|
|
||||||
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, complete: true, ...cached }]
|
|
||||||
}
|
|
||||||
const cached = getCachedMarkdown(cacheKey)
|
const cached = getCachedMarkdown(cacheKey)
|
||||||
if (cached?.raw !== block.raw) return []
|
if (cached?.raw !== block.raw) return []
|
||||||
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
|
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
|
||||||
@@ -387,17 +325,9 @@ export function Markdown(
|
|||||||
const blockKey = markdownBlockKey(owner, src.key, index, block.mode)
|
const blockKey = markdownBlockKey(owner, src.key, index, block.mode)
|
||||||
|
|
||||||
if (block.mode === "code") {
|
if (block.mode === "code") {
|
||||||
const language = highlightLanguage(block.language)
|
|
||||||
if (block.complete && key) {
|
|
||||||
const shared = getCachedMarkdownCode(key)
|
|
||||||
if (shared?.raw === block.raw && shared.language === language) {
|
|
||||||
touchCachedMarkdownCode(key, shared)
|
|
||||||
return { key: blockKey, mode: block.mode, complete: true, ...shared }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const cached = completedCode.get(blockKey)
|
const cached = completedCode.get(blockKey)
|
||||||
if (block.complete && cached?.raw === block.raw) return cached
|
if (block.complete && cached?.raw === block.raw) return cached
|
||||||
const result = await code(block.src, language, blockKey, block.complete)
|
const result = await code(block.src, block.language, blockKey, block.complete)
|
||||||
const rendered = {
|
const rendered = {
|
||||||
key: blockKey,
|
key: blockKey,
|
||||||
mode: block.mode,
|
mode: block.mode,
|
||||||
@@ -406,18 +336,7 @@ export function Markdown(
|
|||||||
complete: !!block.complete,
|
complete: !!block.complete,
|
||||||
...result,
|
...result,
|
||||||
}
|
}
|
||||||
if (block.complete) {
|
if (block.complete) completedCode.set(blockKey, rendered)
|
||||||
completedCode.set(blockKey, rendered)
|
|
||||||
if (key && rendered.generation > 0)
|
|
||||||
touchCachedMarkdownCode(key, {
|
|
||||||
raw: rendered.raw,
|
|
||||||
hash: rendered.hash,
|
|
||||||
language: rendered.language,
|
|
||||||
generation: rendered.generation,
|
|
||||||
stable: rendered.stable,
|
|
||||||
unstable: rendered.unstable,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return rendered
|
return rendered
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,8 +522,6 @@ function updateCodeBlock(
|
|||||||
|
|
||||||
const code = existing?.querySelector("code")
|
const code = existing?.querySelector("code")
|
||||||
if (code instanceof HTMLElement) {
|
if (code instanceof HTMLElement) {
|
||||||
const wrapper = code.closest('[data-component="markdown-code"]')
|
|
||||||
if (wrapper instanceof HTMLElement) applyCodeMetadata(wrapper, block.language)
|
|
||||||
code.className = `language-${block.language}`
|
code.className = `language-${block.language}`
|
||||||
const previous = renderedCodeTokens.get(next)
|
const previous = renderedCodeTokens.get(next)
|
||||||
const reset = shouldResetCodeTokens(previous, {
|
const reset = shouldResetCodeTokens(previous, {
|
||||||
@@ -635,7 +552,6 @@ function updateCodeBlock(
|
|||||||
|
|
||||||
const wrapper = document.createElement("div")
|
const wrapper = document.createElement("div")
|
||||||
wrapper.setAttribute("data-component", "markdown-code")
|
wrapper.setAttribute("data-component", "markdown-code")
|
||||||
applyCodeMetadata(wrapper, block.language)
|
|
||||||
const pre = document.createElement("pre")
|
const pre = document.createElement("pre")
|
||||||
pre.className = "shiki OpenCode"
|
pre.className = "shiki OpenCode"
|
||||||
const codeElement = document.createElement("code")
|
const codeElement = document.createElement("code")
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -41,15 +41,15 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--v2-background-bg-layer-02);
|
background: var(--surface-weak);
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
border: 1px solid var(--border-weak-base);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
transition:
|
transition:
|
||||||
border-color 0.15s ease,
|
border-color 0.15s ease,
|
||||||
opacity 0.3s ease;
|
opacity 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--v2-border-border-strong);
|
border-color: var(--border-strong-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-clickable] {
|
&[data-clickable] {
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
}
|
}
|
||||||
@@ -130,10 +130,10 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--v2-background-bg-layer-02);
|
background: var(--surface-base);
|
||||||
border: none;
|
border: 1px solid var(--border-weak-base);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 6px;
|
||||||
|
|
||||||
[data-highlight="file"] {
|
[data-highlight="file"] {
|
||||||
color: var(--syntax-property);
|
color: var(--syntax-property);
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-text-strong {
|
.text-text-strong {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-medium {
|
.font-medium {
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
[data-slot="compaction-part-line"] {
|
[data-slot="compaction-part-line"] {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--v2-border-border-base);
|
background: var(--border-weak-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="compaction-part-label"] {
|
[data-slot="compaction-part-label"] {
|
||||||
@@ -279,18 +279,18 @@
|
|||||||
|
|
||||||
[data-component="reasoning-part"] {
|
[data-component="reasoning-part"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
line-height: var(--line-height-normal);
|
line-height: var(--line-height-normal);
|
||||||
|
|
||||||
[data-component="markdown"] {
|
[data-component="markdown"] {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
|
|
||||||
strong,
|
strong,
|
||||||
b {
|
b {
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
p:has(strong) {
|
p:has(strong) {
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
|
|
||||||
[data-component="bash-output"] {
|
[data-component="bash-output"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
border: 1px solid var(--border-weak-base);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -380,7 +380,7 @@
|
|||||||
|
|
||||||
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
|
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
border: 1px solid var(--border-weak-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] {
|
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] {
|
||||||
@@ -442,7 +442,7 @@
|
|||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="message-part-title-spinner"] {
|
[data-slot="message-part-title-spinner"] {
|
||||||
@@ -453,7 +453,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
|
|
||||||
[data-component="spinner"] {
|
[data-component="spinner"] {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@@ -464,7 +464,7 @@
|
|||||||
[data-slot="message-part-title-text"] {
|
[data-slot="message-part-title-text"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="message-part-title-filename"] {
|
[data-slot="message-part-title-filename"] {
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="message-part-directory"] {
|
[data-slot="message-part-directory"] {
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="message-part-filename"] {
|
[data-slot="message-part-filename"] {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
|
|
||||||
[data-component="edit-content"] {
|
[data-component="edit-content"] {
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
border-top: 0.5px solid var(--v2-border-border-muted);
|
border-top: 1px solid var(--border-weaker-base);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
|
|
||||||
@@ -527,7 +527,7 @@
|
|||||||
|
|
||||||
[data-component="write-content"] {
|
[data-component="write-content"] {
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
border-top: 0.5px solid var(--v2-border-border-muted);
|
border-top: 1px solid var(--border-weaker-base);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
|
|
||||||
@@ -561,7 +561,7 @@
|
|||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-subtitle"].exa-tool-query {
|
[data-slot="basic-tool-tool-subtitle"].exa-tool-query {
|
||||||
@@ -583,7 +583,7 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
color: var(--v2-text-text-accent);
|
color: var(--text-interactive-base);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -591,11 +591,11 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--v2-text-text-accent);
|
color: var(--text-interactive-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:visited {
|
&:visited {
|
||||||
color: var(--v2-text-text-accent);
|
color: var(--text-interactive-base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +615,7 @@
|
|||||||
|
|
||||||
&[data-completed="completed"] {
|
&[data-completed="completed"] {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
color: var(--v2-text-text-faint);
|
color: var(--text-weaker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -660,8 +660,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background-color: var(--v2-state-bg-danger);
|
background-color: var(--surface-critical-weak);
|
||||||
border-top: 0.5px solid var(--v2-state-border-danger);
|
border-top: 1px solid var(--border-critical-base);
|
||||||
|
|
||||||
[data-slot="diagnostic"] {
|
[data-slot="diagnostic"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -673,7 +673,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="diagnostic-label"] {
|
[data-slot="diagnostic-label"] {
|
||||||
color: var(--v2-state-fg-danger);
|
color: var(--text-on-critical-base);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
@@ -681,12 +681,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="diagnostic-location"] {
|
[data-slot="diagnostic-location"] {
|
||||||
color: var(--v2-state-fg-danger);
|
color: var(--text-on-critical-weak);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="diagnostic-message"] {
|
[data-slot="diagnostic-message"] {
|
||||||
color: var(--v2-state-fg-danger);
|
color: var(--text-on-critical-base);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -761,7 +761,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,7 +778,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,7 +856,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,7 +916,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
-webkit-user-select: text;
|
-webkit-user-select: text;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
@@ -927,7 +927,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,8 +953,8 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 8px 8px 8px 10px;
|
padding: 8px 8px 8px 10px;
|
||||||
background-color: var(--v2-background-bg-layer-01);
|
background-color: var(--surface-raised-stronger-non-alpha);
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
border: 1px solid var(--border-weak-base);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -966,11 +966,11 @@
|
|||||||
box-shadow 0.15s ease;
|
box-shadow 0.15s ease;
|
||||||
|
|
||||||
&:hover:not([data-picked="true"]) {
|
&:hover:not([data-picked="true"]) {
|
||||||
background-color: var(--v2-background-bg-base);
|
background-color: var(--background-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-picked="true"] {
|
&[data-picked="true"] {
|
||||||
background-color: var(--v2-state-bg-info);
|
background-color: var(--surface-interactive-weak);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
box-shadow: var(--shadow-xs-border-hover);
|
box-shadow: var(--shadow-xs-border-hover);
|
||||||
}
|
}
|
||||||
@@ -991,7 +991,7 @@
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
border: 1px solid var(--border-weak-base);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1014,7 +1014,7 @@
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background-color: var(--v2-background-bg-layer-01);
|
background-color: var(--background-stronger);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,7 +1052,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="option-description"] {
|
[data-slot="option-description"] {
|
||||||
@@ -1060,7 +1060,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
@@ -1088,7 +1088,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
resize: none;
|
resize: none;
|
||||||
@@ -1096,11 +1096,11 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 0.5px solid var(--v2-border-border-focus);
|
outline: 1px solid var(--border-interactive-base);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: var(--radius-xs);
|
border-radius: var(--radius-xs);
|
||||||
}
|
}
|
||||||
@@ -1139,11 +1139,11 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
||||||
[data-slot="question-text"] {
|
[data-slot="question-text"] {
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="answer-text"] {
|
[data-slot="answer-text"] {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1166,13 +1166,13 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
height: calc(32px + var(--tool-content-gap));
|
height: calc(32px + var(--tool-content-gap));
|
||||||
padding-bottom: var(--tool-content-gap);
|
padding-bottom: var(--tool-content-gap);
|
||||||
background-color: var(--v2-background-bg-base);
|
background-color: var(--background-stronger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="accordion"][data-scope="apply-patch"] {
|
[data-component="accordion"][data-scope="apply-patch"] {
|
||||||
[data-slot="accordion-trigger"] {
|
[data-slot="accordion-trigger"] {
|
||||||
background-color: var(--v2-background-bg-base) !important;
|
background-color: var(--background-stronger) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="apply-patch-trigger-content"] {
|
[data-slot="apply-patch-trigger-content"] {
|
||||||
@@ -1199,7 +1199,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="apply-patch-directory"] {
|
[data-slot="apply-patch-directory"] {
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1208,7 +1208,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="apply-patch-filename"] {
|
[data-slot="apply-patch-filename"] {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1268,199 +1268,10 @@
|
|||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-weak);
|
||||||
|
|
||||||
[data-component="icon"] {
|
[data-component="icon"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--icon-weak);
|
color: var(--icon-weak);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not([data-new-layout]) {
|
|
||||||
[data-component="user-message"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
|
|
||||||
[data-slot="user-message-attachment"] {
|
|
||||||
background: var(--surface-weak);
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--border-strong-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="user-message-attachment-name"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="user-message-text"] {
|
|
||||||
background: var(--surface-base);
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-text-strong {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="compaction-part-line"] {
|
|
||||||
background: var(--border-weak-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="reasoning-part"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
|
|
||||||
[data-component="markdown"],
|
|
||||||
[data-component="markdown"] :is(strong, b) {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="bash-output"] {
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
|
|
||||||
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="edit-trigger"],
|
|
||||||
[data-component="write-trigger"] {
|
|
||||||
[data-slot="message-part-title"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="message-part-title-spinner"],
|
|
||||||
[data-slot="message-part-directory"] {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="message-part-title-text"],
|
|
||||||
[data-slot="message-part-filename"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="edit-content"],
|
|
||||||
[data-component="write-content"] {
|
|
||||||
border-top: 1px solid var(--border-weaker-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="exa-tool-output"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="exa-tool-link"],
|
|
||||||
[data-slot="exa-tool-link"]:hover,
|
|
||||||
[data-slot="exa-tool-link"]:visited {
|
|
||||||
color: var(--text-interactive-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="message-part-todo-content"][data-completed="completed"] {
|
|
||||||
color: var(--text-weaker);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="diagnostics"] {
|
|
||||||
background-color: var(--surface-critical-weak);
|
|
||||||
border-top: 1px solid var(--border-critical-base);
|
|
||||||
|
|
||||||
[data-slot="diagnostic-label"],
|
|
||||||
[data-slot="diagnostic-message"] {
|
|
||||||
color: var(--text-on-critical-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="diagnostic-location"] {
|
|
||||||
color: var(--text-on-critical-weak);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="dock-prompt"][data-kind="permission"] {
|
|
||||||
[data-slot="permission-header-title"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="permission-hint"] {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="dock-prompt"][data-kind="question"] {
|
|
||||||
[data-slot="question-header-title"],
|
|
||||||
[data-slot="question-text"],
|
|
||||||
[data-slot="option-label"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="question-hint"] {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="question-option"] {
|
|
||||||
background-color: var(--surface-raised-stronger-non-alpha);
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
|
|
||||||
&:hover:not([data-picked="true"]) {
|
|
||||||
background-color: var(--background-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-picked="true"] {
|
|
||||||
background-color: var(--surface-interactive-weak);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="question-option-box"] {
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
|
|
||||||
[data-slot="question-option-radio-dot"] {
|
|
||||||
background-color: var(--background-stronger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="option-description"],
|
|
||||||
[data-slot="question-custom-input"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="question-custom-input"] {
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 1px solid var(--border-interactive-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="question-answers"] {
|
|
||||||
[data-slot="question-text"] {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="answer-text"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:is([data-component="edit-tool"], [data-component="write-tool"], [data-component="apply-patch-tool"])
|
|
||||||
> [data-component="collapsible"]
|
|
||||||
> [data-slot="collapsible-trigger"][aria-expanded="true"],
|
|
||||||
[data-component="accordion"][data-scope="apply-patch"] [data-slot="accordion-trigger"] {
|
|
||||||
background-color: var(--background-stronger) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="accordion"][data-scope="apply-patch"] {
|
|
||||||
[data-slot="apply-patch-directory"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="apply-patch-filename"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="tool-loaded-file"] {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
[data-slot="session-review-header"] {
|
[data-slot="session-review-header"] {
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
background-color: var(--v2-background-bg-base);
|
background-color: var(--background-stronger);
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
|
|
||||||
[data-slot="session-review-large-diff"] {
|
[data-slot="session-review-large-diff"] {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--v2-background-bg-base);
|
background: var(--background-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-review-large-diff-title"] {
|
[data-slot="session-review-large-diff-title"] {
|
||||||
@@ -235,13 +235,3 @@
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not([data-new-layout]) [data-component="session-review"] {
|
|
||||||
[data-slot="session-review-header"] {
|
|
||||||
background-color: var(--background-stronger);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-review-large-diff"] {
|
|
||||||
background: var(--background-stronger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-card {
|
.error-card {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-on-critical-base);
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--sticky-accordion-top, 0px);
|
top: var(--sticky-accordion-top, 0px);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
background-color: var(--v2-background-bg-base);
|
background-color: var(--background-stronger);
|
||||||
height: 44px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,16 +226,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not([data-new-layout]) [data-component="session-turn"] {
|
|
||||||
.error-card {
|
|
||||||
color: var(--text-on-critical-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-diffs-header"] {
|
|
||||||
background-color: var(--background-stronger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-list"] {
|
[data-slot="session-turn-list"] {
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,6 @@
|
|||||||
--card-pad-y: 8px;
|
--card-pad-y: 8px;
|
||||||
--card-line-pad: 12px;
|
--card-line-pad: 12px;
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-title"] {
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-subtitle"] {
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="collapsible-arrow"],
|
|
||||||
[data-slot="collapsible-arrow-icon"] {
|
|
||||||
color: var(--v2-text-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
> [data-component="collapsible"].tool-collapsible {
|
> [data-component="collapsible"].tool-collapsible {
|
||||||
gap: 0px;
|
gap: 0px;
|
||||||
}
|
}
|
||||||
@@ -56,34 +43,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="tool-error-card-content"] :where(*)::selection {
|
[data-slot="tool-error-card-content"] :where(*)::selection {
|
||||||
background: var(--v2-state-bg-danger);
|
background: var(--surface-critical-base);
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-on-critical-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="tool-error-card-content"] :where(*)::-moz-selection {
|
|
||||||
background: var(--v2-state-bg-danger);
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body:not([data-new-layout]) [data-component="card"][data-kind="tool-error-card"] {
|
|
||||||
[data-slot="basic-tool-tool-title"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-subtitle"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="collapsible-arrow"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="collapsible-arrow-icon"] {
|
|
||||||
color: var(--icon-weaker);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="tool-error-card-content"] :where(*)::selection,
|
|
||||||
[data-slot="tool-error-card-content"] :where(*)::-moz-selection {
|
[data-slot="tool-error-card-content"] :where(*)::-moz-selection {
|
||||||
background: var(--surface-critical-base);
|
background: var(--surface-critical-base);
|
||||||
color: var(--text-on-critical-base);
|
color: var(--text-on-critical-base);
|
||||||
|
|||||||
+16
-18
@@ -8,7 +8,7 @@ import { ClipboardProvider, useClipboard } from "./context/clipboard"
|
|||||||
import { ExitProvider, useExit } from "./context/exit"
|
import { ExitProvider, useExit } from "./context/exit"
|
||||||
import { EpilogueProvider } from "./context/epilogue"
|
import { EpilogueProvider } from "./context/epilogue"
|
||||||
import * as Selection from "./util/selection"
|
import * as Selection from "./util/selection"
|
||||||
import { createCliRenderer, MouseButton } from "@opentui/core"
|
import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
|
||||||
import { RouteProvider, useRoute } from "./context/route"
|
import { RouteProvider, useRoute } from "./context/route"
|
||||||
import {
|
import {
|
||||||
Switch,
|
Switch,
|
||||||
@@ -183,23 +183,21 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
const result = yield* Effect.scoped(
|
const result = yield* Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const renderer = yield* Effect.acquireRelease(
|
const renderer = yield* Effect.acquireRelease(
|
||||||
Effect.tryPromise({
|
Effect.tryPromise(() =>
|
||||||
try: () =>
|
createCliRenderer({
|
||||||
createCliRenderer({
|
externalOutputMode: "passthrough",
|
||||||
externalOutputMode: "passthrough",
|
targetFps: 60,
|
||||||
targetFps: 60,
|
gatherStats: false,
|
||||||
gatherStats: false,
|
exitOnCtrlC: false,
|
||||||
exitOnCtrlC: false,
|
useKittyKeyboard: {},
|
||||||
useKittyKeyboard: {},
|
autoFocus: false,
|
||||||
autoFocus: false,
|
openConsoleOnError: false,
|
||||||
openConsoleOnError: false,
|
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
|
||||||
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
|
consoleOptions: {
|
||||||
consoleOptions: {
|
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
},
|
||||||
},
|
}),
|
||||||
}),
|
),
|
||||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
|
||||||
}),
|
|
||||||
(renderer) =>
|
(renderer) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
destroyRenderer(renderer)
|
destroyRenderer(renderer)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"**/*.css"
|
"**/*.css"
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*.d.ts",
|
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"src/**/*.tsx",
|
"src/**/*.tsx",
|
||||||
"!src/**/*.test.ts",
|
"!src/**/*.test.ts",
|
||||||
@@ -56,7 +55,6 @@
|
|||||||
"./v2/styles/*": "./src/v2/styles/*"
|
"./v2/styles/*": "./src/v2/styles/*"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "bun test src --only-failures",
|
"test": "bun test src --only-failures",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -246,7 +246,6 @@
|
|||||||
--v2-text-text-contrast: var(--v2-grey-50);
|
--v2-text-text-contrast: var(--v2-grey-50);
|
||||||
--v2-text-text-accent: var(--v2-blue-600);
|
--v2-text-text-accent: var(--v2-blue-600);
|
||||||
--v2-text-text-accent-hover: var(--v2-blue-700);
|
--v2-text-text-accent-hover: var(--v2-blue-700);
|
||||||
--v2-text-text-code-accent: var(--v2-blue-900);
|
|
||||||
|
|
||||||
--v2-icon-icon-base: var(--v2-grey-800);
|
--v2-icon-icon-base: var(--v2-grey-800);
|
||||||
--v2-icon-icon-muted: var(--v2-grey-600);
|
--v2-icon-icon-muted: var(--v2-grey-600);
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { $ } from "bun"
|
|
||||||
import { rm } from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
|
||||||
|
|
||||||
export async function pack() {
|
|
||||||
const original = await Bun.file("package.json").text()
|
|
||||||
const pkg = JSON.parse(original) as {
|
|
||||||
name: string
|
|
||||||
version: string
|
|
||||||
exports: Record<string, string | { types: string; import: string }>
|
|
||||||
}
|
|
||||||
const tarball = path.resolve(`${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`)
|
|
||||||
|
|
||||||
await $`bun run build`
|
|
||||||
pkg.exports = Object.fromEntries(
|
|
||||||
Object.entries(pkg.exports).map(([key, value]) => {
|
|
||||||
if (typeof value !== "string" || (!value.endsWith(".ts") && !value.endsWith(".tsx"))) return [key, value]
|
|
||||||
return [
|
|
||||||
key,
|
|
||||||
{
|
|
||||||
types: value.replace("./src/", "./dist/").replace(/\.tsx?$/, ".d.ts"),
|
|
||||||
import: value,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
await rm(tarball, { force: true })
|
|
||||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
|
||||||
try {
|
|
||||||
await $`bun pm pack`
|
|
||||||
return tarball
|
|
||||||
} finally {
|
|
||||||
await Bun.write("package.json", original)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) console.log(await pack())
|
|
||||||
@@ -4,7 +4,6 @@ import { Script } from "@opencode-ai/script"
|
|||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { rm } from "node:fs/promises"
|
import { rm } from "node:fs/promises"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { pack } from "./pack"
|
|
||||||
|
|
||||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||||
|
|
||||||
@@ -19,7 +18,8 @@ if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode ==
|
|||||||
try {
|
try {
|
||||||
await $`bun run typecheck`
|
await $`bun run typecheck`
|
||||||
await $`bun run test`
|
await $`bun run test`
|
||||||
await pack()
|
await rm(tarball, { force: true })
|
||||||
|
await $`bun pm pack`
|
||||||
await $`npm publish ${tarball} --access public --tag ${Script.channel}`
|
await $`npm publish ${tarball} --access public --tag ${Script.channel}`
|
||||||
} finally {
|
} finally {
|
||||||
await rm(tarball, { force: true })
|
await rm(tarball, { force: true })
|
||||||
|
|||||||
@@ -35,12 +35,12 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
background-color: var(--v2-background-bg-base);
|
background-color: var(--background-stronger);
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
border: 1px solid var(--border-weak-base);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
/* text-12-regular */
|
/* text-12-regular */
|
||||||
@@ -52,10 +52,10 @@
|
|||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
|
|
||||||
&:hover:not([data-disabled]) {
|
&:hover:not([data-disabled]) {
|
||||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
background-color: var(--surface-base-hover);
|
||||||
}
|
}
|
||||||
&:active:not([data-disabled]) {
|
&:active:not([data-disabled]) {
|
||||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
background-color: var(--surface-base-active);
|
||||||
}
|
}
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -82,9 +82,9 @@
|
|||||||
|
|
||||||
&[data-expanded] {
|
&[data-expanded] {
|
||||||
[data-slot="accordion-content"] {
|
[data-slot="accordion-content"] {
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
border: 1px solid var(--border-weak-base);
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
background-color: var(--v2-background-bg-layer-01);
|
background-color: var(--background-stronger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,30 +104,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not([data-new-layout]) [data-component="accordion"] {
|
|
||||||
[data-slot="accordion-item"] [data-slot="accordion-header"] [data-slot="accordion-trigger"] {
|
|
||||||
background-color: var(--background-stronger);
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: var(--border-weak-base);
|
|
||||||
color: var(--text-strong);
|
|
||||||
|
|
||||||
&:hover:not([data-disabled]) {
|
|
||||||
background-color: var(--surface-base-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not([data-disabled]) {
|
|
||||||
background-color: var(--surface-base-active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="accordion-item"][data-expanded] [data-slot="accordion-content"] {
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: var(--border-weak-base);
|
|
||||||
border-top: 0;
|
|
||||||
background-color: var(--background-stronger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
@keyframes slideDown {
|
||||||
from {
|
from {
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|||||||
@@ -19,14 +19,14 @@
|
|||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
|
|
||||||
--card-gap: 8px;
|
--card-gap: 8px;
|
||||||
--card-icon: 16px;
|
--card-icon: 16px;
|
||||||
--card-indent: 0px;
|
--card-indent: 0px;
|
||||||
--card-line-pad: 8px;
|
--card-line-pad: 8px;
|
||||||
|
|
||||||
--card-accent: var(--v2-icon-icon-base);
|
--card-accent: var(--icon-active);
|
||||||
|
|
||||||
&:has([data-slot="card-title"]) {
|
&:has([data-slot="card-title"]) {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:where([data-card="title"], [data-slot="card-title"]) {
|
:where([data-card="title"], [data-slot="card-title"]) {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--text-strong);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
|
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
|
||||||
color: var(--v2-text-text-faint);
|
color: var(--text-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
:where([data-slot="card-title-icon"])
|
:where([data-slot="card-title-icon"])
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:where([data-card="description"], [data-slot="card-description"]) {
|
:where([data-card="description"], [data-slot="card-description"]) {
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--text-base);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -92,24 +92,3 @@
|
|||||||
padding-left: var(--card-indent);
|
padding-left: var(--card-indent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not([data-new-layout]) [data-component="card"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
--card-accent: var(--icon-active);
|
|
||||||
|
|
||||||
:where([data-card="title"], [data-slot="card-title"]) {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
:where([data-card="description"], [data-slot="card-description"]) {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-variant="error"] {
|
|
||||||
--card-accent: var(--icon-critical-base) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function Card(props: CardProps) {
|
|||||||
const variant = () => split.variant ?? "normal"
|
const variant = () => split.variant ?? "normal"
|
||||||
const accent = () => {
|
const accent = () => {
|
||||||
const v = variant()
|
const v = variant()
|
||||||
if (v === "error") return "var(--v2-state-fg-danger)"
|
if (v === "error") return "var(--icon-critical-base)"
|
||||||
if (v === "warning") return "var(--icon-warning-active)"
|
if (v === "warning") return "var(--icon-warning-active)"
|
||||||
if (v === "success") return "var(--icon-success-active)"
|
if (v === "success") return "var(--icon-success-active)"
|
||||||
if (v === "info") return "var(--icon-info-active)"
|
if (v === "info") return "var(--icon-info-active)"
|
||||||
|
|||||||
@@ -249,7 +249,6 @@
|
|||||||
--color-v2-text-text-contrast: var(--v2-text-text-contrast);
|
--color-v2-text-text-contrast: var(--v2-text-text-contrast);
|
||||||
--color-v2-text-text-accent: var(--v2-text-text-accent);
|
--color-v2-text-text-accent: var(--v2-text-text-accent);
|
||||||
--color-v2-text-text-accent-hover: var(--v2-text-text-accent-hover);
|
--color-v2-text-text-accent-hover: var(--v2-text-text-accent-hover);
|
||||||
--color-v2-text-text-code-accent: var(--v2-text-text-code-accent);
|
|
||||||
--color-v2-icon-icon-base: var(--v2-icon-icon-base);
|
--color-v2-icon-icon-base: var(--v2-icon-icon-base);
|
||||||
--color-v2-icon-icon-muted: var(--v2-icon-icon-muted);
|
--color-v2-icon-icon-muted: var(--v2-icon-icon-muted);
|
||||||
--color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse);
|
--color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse);
|
||||||
|
|||||||
@@ -20,8 +20,6 @@
|
|||||||
"text-base": "#6F6F6F",
|
"text-base": "#6F6F6F",
|
||||||
"text-weak": "#8F8F8F",
|
"text-weak": "#8F8F8F",
|
||||||
"text-weaker": "#C7C7C7",
|
"text-weaker": "#C7C7C7",
|
||||||
"text-diff-add-base": "var(--v2-state-fg-success)",
|
|
||||||
"text-diff-delete-base": "var(--v2-state-fg-danger)",
|
|
||||||
"border-weak-base": "#DBDBDB",
|
"border-weak-base": "#DBDBDB",
|
||||||
"border-weaker-base": "#E8E8E8",
|
"border-weaker-base": "#E8E8E8",
|
||||||
"icon-base": "#8F8F8F",
|
"icon-base": "#8F8F8F",
|
||||||
@@ -33,14 +31,14 @@
|
|||||||
"surface-interactive-weak": "#F5FAFF",
|
"surface-interactive-weak": "#F5FAFF",
|
||||||
"icon-success-base": "#0ABE00",
|
"icon-success-base": "#0ABE00",
|
||||||
"surface-success-base": "#E6FFE5",
|
"surface-success-base": "#E6FFE5",
|
||||||
"syntax-comment": "var(--v2-text-text-muted)",
|
"syntax-comment": "#7a7a7a",
|
||||||
"syntax-keyword": "var(--v2-pink-800)",
|
"syntax-keyword": "#a753ae",
|
||||||
"syntax-string": "var(--v2-green-800)",
|
"syntax-string": "#00ceb9",
|
||||||
"syntax-primitive": "var(--v2-pink-800)",
|
"syntax-primitive": "#034cff",
|
||||||
"syntax-property": "var(--v2-orange-800)",
|
"syntax-property": "#a753ae",
|
||||||
"syntax-type": "var(--v2-purple-800)",
|
"syntax-type": "#8a6f00",
|
||||||
"syntax-constant": "#007b80",
|
"syntax-constant": "#007b80",
|
||||||
"syntax-critical": "var(--v2-red-800)",
|
"syntax-critical": "#ff8c00",
|
||||||
"syntax-diff-delete": "#ff8c00",
|
"syntax-diff-delete": "#ff8c00",
|
||||||
"syntax-diff-unknown": "#a753ae",
|
"syntax-diff-unknown": "#a753ae",
|
||||||
"surface-critical-base": "#FFF2F0"
|
"surface-critical-base": "#FFF2F0"
|
||||||
@@ -172,7 +170,6 @@
|
|||||||
"v2-text-text-contrast": "var(--v2-grey-50)",
|
"v2-text-text-contrast": "var(--v2-grey-50)",
|
||||||
"v2-text-text-accent": "var(--v2-blue-600)",
|
"v2-text-text-accent": "var(--v2-blue-600)",
|
||||||
"v2-text-text-accent-hover": "var(--v2-blue-700)",
|
"v2-text-text-accent-hover": "var(--v2-blue-700)",
|
||||||
"v2-text-text-code-accent": "var(--v2-blue-900)",
|
|
||||||
"v2-icon-icon-base": "var(--v2-grey-800)",
|
"v2-icon-icon-base": "var(--v2-grey-800)",
|
||||||
"v2-icon-icon-muted": "var(--v2-grey-600)",
|
"v2-icon-icon-muted": "var(--v2-grey-600)",
|
||||||
"v2-icon-icon-inverse": "var(--v2-grey-50)",
|
"v2-icon-icon-inverse": "var(--v2-grey-50)",
|
||||||
@@ -255,8 +252,6 @@
|
|||||||
"text-base": "#A0A0A0",
|
"text-base": "#A0A0A0",
|
||||||
"text-weak": "#707070",
|
"text-weak": "#707070",
|
||||||
"text-weaker": "#505050",
|
"text-weaker": "#505050",
|
||||||
"text-diff-add-base": "var(--v2-state-fg-success)",
|
|
||||||
"text-diff-delete-base": "var(--v2-state-fg-danger)",
|
|
||||||
"border-weak-base": "#282828",
|
"border-weak-base": "#282828",
|
||||||
"border-weaker-base": "#232323",
|
"border-weaker-base": "#232323",
|
||||||
"icon-base": "#7E7E7E",
|
"icon-base": "#7E7E7E",
|
||||||
@@ -267,14 +262,14 @@
|
|||||||
"surface-base-hover": "#FFFFFF0D",
|
"surface-base-hover": "#FFFFFF0D",
|
||||||
"surface-interactive-weak": "#0D172B",
|
"surface-interactive-weak": "#0D172B",
|
||||||
"surface-success-base": "#022B00",
|
"surface-success-base": "#022B00",
|
||||||
"syntax-comment": "var(--v2-text-text-muted)",
|
"syntax-comment": "#8f8f8f",
|
||||||
"syntax-keyword": "var(--v2-pink-400)",
|
"syntax-keyword": "#edb2f1",
|
||||||
"syntax-string": "var(--v2-green-400)",
|
"syntax-string": "#00ceb9",
|
||||||
"syntax-primitive": "var(--v2-pink-400)",
|
"syntax-primitive": "#8cb0ff",
|
||||||
"syntax-property": "var(--v2-orange-400)",
|
"syntax-property": "#fab283",
|
||||||
"syntax-type": "var(--v2-purple-400)",
|
"syntax-type": "#fcd53a",
|
||||||
"syntax-constant": "#93e9f6",
|
"syntax-constant": "#93e9f6",
|
||||||
"syntax-critical": "var(--v2-red-400)",
|
"syntax-critical": "#fab283",
|
||||||
"syntax-diff-delete": "#fab283",
|
"syntax-diff-delete": "#fab283",
|
||||||
"syntax-diff-unknown": "#edb2f1",
|
"syntax-diff-unknown": "#edb2f1",
|
||||||
"surface-critical-base": "#1F0603"
|
"surface-critical-base": "#1F0603"
|
||||||
@@ -406,7 +401,6 @@
|
|||||||
"v2-text-text-contrast": "var(--v2-grey-50)",
|
"v2-text-text-contrast": "var(--v2-grey-50)",
|
||||||
"v2-text-text-accent": "var(--v2-blue-400)",
|
"v2-text-text-accent": "var(--v2-blue-400)",
|
||||||
"v2-text-text-accent-hover": "var(--v2-blue-300)",
|
"v2-text-text-accent-hover": "var(--v2-blue-300)",
|
||||||
"v2-text-text-code-accent": "var(--v2-blue-400)",
|
|
||||||
"v2-icon-icon-base": "var(--v2-grey-400)",
|
"v2-icon-icon-base": "var(--v2-grey-400)",
|
||||||
"v2-icon-icon-muted": "var(--v2-grey-600)",
|
"v2-icon-icon-muted": "var(--v2-grey-600)",
|
||||||
"v2-icon-icon-inverse": "var(--v2-grey-1100)",
|
"v2-icon-icon-inverse": "var(--v2-grey-1100)",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const light: Record<string, V2ColorValue> = {
|
|||||||
"v2-text-text-contrast": ref("v2-grey-100"),
|
"v2-text-text-contrast": ref("v2-grey-100"),
|
||||||
"v2-text-text-accent": ref("v2-blue-600"),
|
"v2-text-text-accent": ref("v2-blue-600"),
|
||||||
"v2-text-text-accent-hover": ref("v2-blue-700"),
|
"v2-text-text-accent-hover": ref("v2-blue-700"),
|
||||||
"v2-text-text-code-accent": ref("v2-blue-900"),
|
|
||||||
"v2-border-border-muted": ref("v2-alpha-dark-8"),
|
"v2-border-border-muted": ref("v2-alpha-dark-8"),
|
||||||
"v2-border-border-base": ref("v2-alpha-dark-10"),
|
"v2-border-border-base": ref("v2-alpha-dark-10"),
|
||||||
"v2-border-border-strong": ref("v2-alpha-dark-20"),
|
"v2-border-border-strong": ref("v2-alpha-dark-20"),
|
||||||
@@ -82,7 +81,6 @@ const dark: Record<string, V2ColorValue> = {
|
|||||||
"v2-text-text-contrast": ref("v2-grey-100"),
|
"v2-text-text-contrast": ref("v2-grey-100"),
|
||||||
"v2-text-text-accent": ref("v2-blue-400"),
|
"v2-text-text-accent": ref("v2-blue-400"),
|
||||||
"v2-text-text-accent-hover": ref("v2-blue-300"),
|
"v2-text-text-accent-hover": ref("v2-blue-300"),
|
||||||
"v2-text-text-code-accent": ref("v2-blue-400"),
|
|
||||||
"v2-border-border-muted": ref("v2-alpha-light-8"),
|
"v2-border-border-muted": ref("v2-alpha-light-8"),
|
||||||
"v2-border-border-base": ref("v2-alpha-light-10"),
|
"v2-border-border-base": ref("v2-alpha-light-10"),
|
||||||
"v2-border-border-strong": ref("v2-alpha-light-20"),
|
"v2-border-border-strong": ref("v2-alpha-light-20"),
|
||||||
|
|||||||
@@ -89,30 +89,6 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Danger */
|
|
||||||
[data-component="button-v2"][data-variant="danger"] {
|
|
||||||
background-color: var(--v2-background-bg-button-neutral);
|
|
||||||
color: var(--v2-state-fg-danger);
|
|
||||||
box-shadow: var(--v2-elevation-button-neutral);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="button-v2"][data-variant="danger"]:is(:hover, [data-state="hover"]):not(:disabled) {
|
|
||||||
background-image:
|
|
||||||
linear-gradient(90deg, var(--v2-overlay-simple-overlay-hover) 0%, var(--v2-overlay-simple-overlay-hover) 100%),
|
|
||||||
linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="button-v2"][data-variant="danger"]:is(:active, [data-state="pressed"]):not(:disabled) {
|
|
||||||
background-image:
|
|
||||||
linear-gradient(90deg, var(--v2-overlay-simple-overlay-pressed) 0%, var(--v2-overlay-simple-overlay-pressed) 100%),
|
|
||||||
linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="button-v2"][data-variant="danger"]:is(:disabled, [data-state="disabled"]) {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Contrast */
|
/* Contrast */
|
||||||
[data-component="button-v2"][data-variant="contrast"] {
|
[data-component="button-v2"][data-variant="contrast"] {
|
||||||
background-image:
|
background-image:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const docs = `### Overview
|
|||||||
Button v2 with three visual variants and two sizes.
|
Button v2 with three visual variants and two sizes.
|
||||||
|
|
||||||
### API
|
### API
|
||||||
- \`variant\`: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted".
|
- \`variant\`: "neutral" | "contrast" | "ghost" | "ghost-muted".
|
||||||
- \`size\`: "normal" | "large".
|
- \`size\`: "normal" | "large".
|
||||||
- \`icon\`: Optional icon name.
|
- \`icon\`: Optional icon name.
|
||||||
- Inherits Kobalte Button props and native button attributes.
|
- Inherits Kobalte Button props and native button attributes.
|
||||||
@@ -39,7 +39,7 @@ export default {
|
|||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
control: "select",
|
control: "select",
|
||||||
options: ["neutral", "danger", "contrast", "ghost", "ghost-muted"],
|
options: ["neutral", "contrast", "ghost", "ghost-muted"],
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
control: "select",
|
control: "select",
|
||||||
@@ -61,7 +61,6 @@ export const Variants = {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ButtonV2 variant="neutral">Neutral</ButtonV2>
|
<ButtonV2 variant="neutral">Neutral</ButtonV2>
|
||||||
<ButtonV2 variant="danger">Danger</ButtonV2>
|
|
||||||
<ButtonV2 variant="contrast">Contrast</ButtonV2>
|
<ButtonV2 variant="contrast">Contrast</ButtonV2>
|
||||||
<ButtonV2 variant="ghost">Ghost</ButtonV2>
|
<ButtonV2 variant="ghost">Ghost</ButtonV2>
|
||||||
<ButtonV2 variant="ghost-muted" icon="edit">
|
<ButtonV2 variant="ghost-muted" icon="edit">
|
||||||
@@ -116,7 +115,7 @@ export const Icon = {
|
|||||||
|
|
||||||
export const AllStates = {
|
export const AllStates = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const variants = ["neutral", "danger", "contrast", "ghost", "ghost-muted"] as const
|
const variants = ["neutral", "contrast", "ghost", "ghost-muted"] as const
|
||||||
const states = ["default", "hover", "pressed", "focus", "disabled"] as const
|
const states = ["default", "hover", "pressed", "focus", "disabled"] as const
|
||||||
const toTitleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
|
const toTitleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface ButtonV2Props
|
|||||||
extends ComponentProps<typeof Kobalte>,
|
extends ComponentProps<typeof Kobalte>,
|
||||||
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
|
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
|
||||||
size?: "small" | "normal" | "large"
|
size?: "small" | "normal" | "large"
|
||||||
variant?: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted"
|
variant?: "neutral" | "contrast" | "ghost" | "ghost-muted"
|
||||||
icon?: IconProps["name"]
|
icon?: IconProps["name"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,51 +49,34 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
align-self: stretch;
|
|
||||||
width: 100%;
|
|
||||||
gap: 0;
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
[data-slot="dialog-header-title"] {
|
|
||||||
margin: 0;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-weight: 530;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 16px;
|
|
||||||
letter-spacing: -0.04px;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-variation-settings: "slnt" 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="dialog-title-group"] {
|
[data-slot="dialog-title-group"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="dialog-title"] {
|
[data-slot="dialog-title"] {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: none;
|
|
||||||
flex-grow: 0;
|
|
||||||
font-weight: 530;
|
font-weight: 530;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 20px;
|
line-height: 100%;
|
||||||
letter-spacing: -0.13px;
|
letter-spacing: -0.13px;
|
||||||
color: var(--v2-text-text-base);
|
color: var(--v2-text-text-base);
|
||||||
font-variation-settings: "slnt" 0;
|
font-variation-settings: "slnt" 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="dialog-description"] {
|
[data-slot="dialog-description"] {
|
||||||
flex: none;
|
|
||||||
flex-grow: 0;
|
|
||||||
font-weight: 440;
|
font-weight: 440;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 20px;
|
line-height: 100%;
|
||||||
letter-spacing: -0.04px;
|
letter-spacing: -0.04px;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--v2-text-text-muted);
|
||||||
font-variation-settings: "slnt" 0;
|
font-variation-settings: "slnt" 0;
|
||||||
@@ -103,11 +86,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 20px;
|
width: 24px;
|
||||||
height: 20px;
|
height: 24px;
|
||||||
margin-top: -2px;
|
|
||||||
margin-right: -2px;
|
|
||||||
margin-left: auto;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -117,10 +97,6 @@
|
|||||||
background: var(--v2-overlay-simple-overlay-hover);
|
background: var(--v2-overlay-simple-overlay-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-hide-close] [data-slot="dialog-close-button"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="dialog-footer"] {
|
[data-slot="dialog-footer"] {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Dialog as KobalteDialog } from "@kobalte/core/dialog"
|
import { Dialog as KobalteDialog } from "@kobalte/core/dialog"
|
||||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle, DialogTitleGroup } from "./dialog-v2"
|
import { Dialog, DialogFooter } from "./dialog-v2"
|
||||||
import { ButtonV2 } from "./button-v2"
|
import { ButtonV2 } from "./button-v2"
|
||||||
|
|
||||||
const docs = `### Overview
|
const docs = `### Overview
|
||||||
Dialog content wrapper built on Kobalte's dialog primitive with v2 styling.
|
Dialog content wrapper built on Kobalte's dialog primitive with v2 styling.
|
||||||
|
|
||||||
Compose with \`DialogHeader\`, \`DialogTitle\`, \`DialogTitleGroup\`, \`DialogBody\`, and \`DialogFooter\`.
|
|
||||||
|
|
||||||
### API
|
### API
|
||||||
- \`Dialog\`: \`size\` (normal | large | x-large), \`variant\`, \`fit\`.
|
- Optional: \`title\`, \`description\`, \`action\`.
|
||||||
- \`DialogHeader\`: row container with optional \`closeLabel\` and \`hideClose\`.
|
- \`size\`: normal | large | x-large.
|
||||||
- \`DialogTitle\`: accessible single-line header title.
|
- \`fit\` and \`transition\` control layout and animation.
|
||||||
- \`DialogTitleGroup\`: column with \`title\` and required \`description\`.
|
|
||||||
|
### Variants and states
|
||||||
|
- Sizes and optional header/action controls.
|
||||||
|
|
||||||
### Accessibility
|
### Accessibility
|
||||||
- Focus trapping and aria attributes provided by Kobalte Dialog.
|
- Focus trapping and aria attributes provided by Kobalte Dialog.
|
||||||
@@ -34,14 +34,6 @@ export default {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function dialogHeader(title: string, description: string) {
|
|
||||||
return (
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitleGroup title={title} description={description} />
|
|
||||||
</DialogHeader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Basic = {
|
export const Basic = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<KobalteDialog defaultOpen>
|
<KobalteDialog defaultOpen>
|
||||||
@@ -50,9 +42,8 @@ export const Basic = {
|
|||||||
</KobalteDialog.Trigger>
|
</KobalteDialog.Trigger>
|
||||||
<KobalteDialog.Portal>
|
<KobalteDialog.Portal>
|
||||||
<KobalteDialog.Overlay />
|
<KobalteDialog.Overlay />
|
||||||
<Dialog>
|
<Dialog title="Dialog" description="Description">
|
||||||
{dialogHeader("Dialog", "Description")}
|
Dialog body content.
|
||||||
<DialogBody>Dialog body content.</DialogBody>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</KobalteDialog.Portal>
|
</KobalteDialog.Portal>
|
||||||
</KobalteDialog>
|
</KobalteDialog>
|
||||||
@@ -68,9 +59,8 @@ export const Sizes = {
|
|||||||
</KobalteDialog.Trigger>
|
</KobalteDialog.Trigger>
|
||||||
<KobalteDialog.Portal>
|
<KobalteDialog.Portal>
|
||||||
<KobalteDialog.Overlay />
|
<KobalteDialog.Overlay />
|
||||||
<Dialog>
|
<Dialog title="Normal" description="Normal size">
|
||||||
{dialogHeader("Normal", "Normal size")}
|
Normal dialog content.
|
||||||
<DialogBody>Normal dialog content.</DialogBody>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</KobalteDialog.Portal>
|
</KobalteDialog.Portal>
|
||||||
</KobalteDialog>
|
</KobalteDialog>
|
||||||
@@ -81,9 +71,8 @@ export const Sizes = {
|
|||||||
</KobalteDialog.Trigger>
|
</KobalteDialog.Trigger>
|
||||||
<KobalteDialog.Portal>
|
<KobalteDialog.Portal>
|
||||||
<KobalteDialog.Overlay />
|
<KobalteDialog.Overlay />
|
||||||
<Dialog size="large">
|
<Dialog size="large" title="Large" description="Large size">
|
||||||
{dialogHeader("Large", "Large size")}
|
Large dialog content.
|
||||||
<DialogBody>Large dialog content.</DialogBody>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</KobalteDialog.Portal>
|
</KobalteDialog.Portal>
|
||||||
</KobalteDialog>
|
</KobalteDialog>
|
||||||
@@ -94,9 +83,8 @@ export const Sizes = {
|
|||||||
</KobalteDialog.Trigger>
|
</KobalteDialog.Trigger>
|
||||||
<KobalteDialog.Portal>
|
<KobalteDialog.Portal>
|
||||||
<KobalteDialog.Overlay />
|
<KobalteDialog.Overlay />
|
||||||
<Dialog size="x-large">
|
<Dialog size="x-large" title="Extra large" description="X-large size">
|
||||||
{dialogHeader("Extra large", "X-large size")}
|
X-large dialog content.
|
||||||
<DialogBody>X-large dialog content.</DialogBody>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</KobalteDialog.Portal>
|
</KobalteDialog.Portal>
|
||||||
</KobalteDialog>
|
</KobalteDialog>
|
||||||
@@ -104,41 +92,24 @@ export const Sizes = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TitleOnly = {
|
export const CustomAction = {
|
||||||
render: () => (
|
|
||||||
<KobalteDialog defaultOpen>
|
|
||||||
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
|
|
||||||
Open dialog
|
|
||||||
</KobalteDialog.Trigger>
|
|
||||||
<KobalteDialog.Portal>
|
|
||||||
<KobalteDialog.Overlay />
|
|
||||||
<Dialog>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Open project</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogBody>Dialog body content.</DialogBody>
|
|
||||||
</Dialog>
|
|
||||||
</KobalteDialog.Portal>
|
|
||||||
</KobalteDialog>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HeaderControls = {
|
|
||||||
render: () => (
|
render: () => (
|
||||||
<KobalteDialog>
|
<KobalteDialog>
|
||||||
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
|
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
|
||||||
Open dialog
|
Open action dialog
|
||||||
</KobalteDialog.Trigger>
|
</KobalteDialog.Trigger>
|
||||||
<KobalteDialog.Portal>
|
<KobalteDialog.Portal>
|
||||||
<KobalteDialog.Overlay />
|
<KobalteDialog.Overlay />
|
||||||
<Dialog>
|
<Dialog
|
||||||
<DialogHeader>
|
title="Custom action"
|
||||||
<DialogTitleGroup title="Custom header" description="Dialog with an extra header control" />
|
description="Dialog with a custom header action"
|
||||||
|
action={
|
||||||
<ButtonV2 variant="neutral" size="small">
|
<ButtonV2 variant="neutral" size="small">
|
||||||
Help
|
Help
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</DialogHeader>
|
}
|
||||||
<DialogBody>Dialog body content.</DialogBody>
|
>
|
||||||
|
Dialog body content.
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</KobalteDialog.Portal>
|
</KobalteDialog.Portal>
|
||||||
</KobalteDialog>
|
</KobalteDialog>
|
||||||
@@ -153,8 +124,7 @@ export const WithFooter = {
|
|||||||
</KobalteDialog.Trigger>
|
</KobalteDialog.Trigger>
|
||||||
<KobalteDialog.Portal>
|
<KobalteDialog.Portal>
|
||||||
<KobalteDialog.Overlay />
|
<KobalteDialog.Overlay />
|
||||||
<Dialog fit>
|
<Dialog title="Save changes" description="Your changes will be lost if you don't save them." fit>
|
||||||
{dialogHeader("Save changes", "Your changes will be lost if you don't save them.")}
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<ButtonV2 variant="neutral">Cancel</ButtonV2>
|
<ButtonV2 variant="neutral">Cancel</ButtonV2>
|
||||||
<ButtonV2 variant="contrast">Save</ButtonV2>
|
<ButtonV2 variant="contrast">Save</ButtonV2>
|
||||||
@@ -173,8 +143,7 @@ export const WithFooterThreeButtons = {
|
|||||||
</KobalteDialog.Trigger>
|
</KobalteDialog.Trigger>
|
||||||
<KobalteDialog.Portal>
|
<KobalteDialog.Portal>
|
||||||
<KobalteDialog.Overlay />
|
<KobalteDialog.Overlay />
|
||||||
<Dialog fit>
|
<Dialog title="Unsaved changes" description="You have unsaved changes. What would you like to do?" fit>
|
||||||
{dialogHeader("Unsaved changes", "You have unsaved changes. What would you like to do?")}
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<span style={{ "margin-right": "auto" }}>
|
<span style={{ "margin-right": "auto" }}>
|
||||||
<ButtonV2 variant="ghost">Remind me later</ButtonV2>
|
<ButtonV2 variant="ghost">Remind me later</ButtonV2>
|
||||||
@@ -196,9 +165,8 @@ export const Fit = {
|
|||||||
</KobalteDialog.Trigger>
|
</KobalteDialog.Trigger>
|
||||||
<KobalteDialog.Portal>
|
<KobalteDialog.Portal>
|
||||||
<KobalteDialog.Overlay />
|
<KobalteDialog.Overlay />
|
||||||
<Dialog fit>
|
<Dialog title="Fit content" fit>
|
||||||
{dialogHeader("Fit content", "Dialog fits its content.")}
|
Dialog fits its content.
|
||||||
<DialogBody>Dialog fits its content.</DialogBody>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</KobalteDialog.Portal>
|
</KobalteDialog.Portal>
|
||||||
</KobalteDialog>
|
</KobalteDialog>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { type ComponentProps, type JSXElement, type ParentProps, Show, children,
|
|||||||
import "./dialog-v2.css"
|
import "./dialog-v2.css"
|
||||||
|
|
||||||
export interface DialogProps extends ParentProps {
|
export interface DialogProps extends ParentProps {
|
||||||
|
title?: JSXElement
|
||||||
|
description?: JSXElement
|
||||||
|
action?: JSXElement
|
||||||
size?: "normal" | "large" | "x-large"
|
size?: "normal" | "large" | "x-large"
|
||||||
variant?: "default" | "settings"
|
variant?: "default" | "settings"
|
||||||
class?: ComponentProps<"div">["class"]
|
class?: ComponentProps<"div">["class"]
|
||||||
@@ -10,76 +13,26 @@ export interface DialogProps extends ParentProps {
|
|||||||
fit?: boolean
|
fit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogHeaderProps extends ParentProps {
|
|
||||||
closeLabel?: string
|
|
||||||
hideClose?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DialogTitleGroupProps {
|
|
||||||
title?: JSXElement
|
|
||||||
description: JSXElement
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogFooter(props: ParentProps) {
|
export function DialogFooter(props: ParentProps) {
|
||||||
return <div data-slot="dialog-footer">{props.children}</div>
|
return <div data-slot="dialog-footer">{props.children}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogBody(props: ParentProps & { class?: ComponentProps<"div">["class"] }) {
|
|
||||||
const [local] = splitProps(props, ["class", "children"])
|
|
||||||
return (
|
|
||||||
<div data-slot="dialog-body" class={local.class}>
|
|
||||||
{local.children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogTitle(props: ParentProps) {
|
|
||||||
return <Kobalte.Title data-slot="dialog-header-title">{props.children}</Kobalte.Title>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogTitleGroup(props: DialogTitleGroupProps) {
|
|
||||||
const title = children(() => props.title)
|
|
||||||
const description = children(() => props.description)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-slot="dialog-title-group">
|
|
||||||
<Show when={title()}>{(t) => <Kobalte.Title data-slot="dialog-title">{t()}</Kobalte.Title>}</Show>
|
|
||||||
<Kobalte.Description data-slot="dialog-description">{description()}</Kobalte.Description>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogHeader(props: DialogHeaderProps) {
|
|
||||||
const [local] = splitProps(props, ["closeLabel", "hideClose", "children"])
|
|
||||||
const hideClose = () => local.hideClose === true
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-slot="dialog-header" data-hide-close={hideClose() ? "" : undefined}>
|
|
||||||
{local.children}
|
|
||||||
{!hideClose() && (
|
|
||||||
<Kobalte.CloseButton data-slot="dialog-close-button" aria-label={local.closeLabel ?? "Close"}>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12.4446 3.55469L3.55566 12.4436M3.55566 3.55469L12.4446 12.4436"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Kobalte.CloseButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Dialog(props: DialogProps) {
|
export function Dialog(props: DialogProps) {
|
||||||
const [local] = splitProps(props, ["size", "variant", "class", "classList", "fit", "children"])
|
const [local] = splitProps(props, [
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"action",
|
||||||
|
"size",
|
||||||
|
"variant",
|
||||||
|
"class",
|
||||||
|
"classList",
|
||||||
|
"fit",
|
||||||
|
"children",
|
||||||
|
])
|
||||||
|
const title = children(() => local.title)
|
||||||
|
const description = children(() => local.description)
|
||||||
|
const action = children(() => local.action)
|
||||||
|
const hasHeader = () => title() || action()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -91,6 +44,7 @@ export function Dialog(props: DialogProps) {
|
|||||||
<div data-slot="dialog-container">
|
<div data-slot="dialog-container">
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
|
data-no-header={!hasHeader() ? "" : undefined}
|
||||||
classList={{
|
classList={{
|
||||||
...local.classList,
|
...local.classList,
|
||||||
[local.class ?? ""]: !!local.class,
|
[local.class ?? ""]: !!local.class,
|
||||||
@@ -104,7 +58,34 @@ export function Dialog(props: DialogProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{local.children}
|
<Show when={hasHeader()}>
|
||||||
|
<div data-slot="dialog-header">
|
||||||
|
<div data-slot="dialog-title-group">
|
||||||
|
<Show when={title()}>{(t) => <Kobalte.Title data-slot="dialog-title">{t()}</Kobalte.Title>}</Show>
|
||||||
|
<Show when={description()}>
|
||||||
|
{(d) => <Kobalte.Description data-slot="dialog-description">{d()}</Kobalte.Description>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={action()}>{(a) => a()}</Show>
|
||||||
|
<Kobalte.CloseButton data-slot="dialog-close-button" aria-label="Close">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12.4446 3.55469L3.55566 12.4436M3.55566 3.55469L12.4446 12.4436"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Kobalte.CloseButton>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div data-slot="dialog-body">{local.children}</div>
|
||||||
</Kobalte.Content>
|
</Kobalte.Content>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
[data-component="divider-v2"] {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
height: 0.5px;
|
|
||||||
margin-block: 0.25px;
|
|
||||||
border: none;
|
|
||||||
background: var(--v2-border-border-strong);
|
|
||||||
flex: none;
|
|
||||||
align-self: stretch;
|
|
||||||
flex-grow: 0;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { DividerV2 } from "./divider-v2"
|
|
||||||
|
|
||||||
const docs = `### Overview
|
|
||||||
Horizontal hairline divider for v2 layouts.
|
|
||||||
|
|
||||||
### API
|
|
||||||
- Inherits native div attributes.
|
|
||||||
- Stretches to full width of its flex parent.
|
|
||||||
|
|
||||||
### Theming/tokens
|
|
||||||
- Uses \`data-component="divider-v2"\`.
|
|
||||||
- Border color: \`--v2-border-border-strong\`.
|
|
||||||
`
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: "UI V2/Divider",
|
|
||||||
id: "components-divider-v2",
|
|
||||||
component: DividerV2,
|
|
||||||
tags: ["autodocs"],
|
|
||||||
parameters: {
|
|
||||||
frameWidth: "320px",
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component: docs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Basic = {
|
|
||||||
render: () => (
|
|
||||||
<div style={{ display: "flex", "flex-direction": "column", gap: "16px", padding: "16px" }}>
|
|
||||||
<span>Above</span>
|
|
||||||
<DividerV2 />
|
|
||||||
<span>Below</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { type ComponentProps, splitProps } from "solid-js"
|
|
||||||
import "./divider-v2.css"
|
|
||||||
|
|
||||||
export interface DividerV2Props extends ComponentProps<"div"> {}
|
|
||||||
|
|
||||||
export function DividerV2(props: DividerV2Props) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "classList"])
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...rest}
|
|
||||||
role="separator"
|
|
||||||
aria-orientation="horizontal"
|
|
||||||
data-component="divider-v2"
|
|
||||||
classList={{
|
|
||||||
...local.classList,
|
|
||||||
[local.class ?? ""]: !!local.class,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -49,10 +49,6 @@ const icons = {
|
|||||||
viewBox: "0 0 16 16",
|
viewBox: "0 0 16 16",
|
||||||
body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,
|
body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,
|
||||||
},
|
},
|
||||||
check: {
|
|
||||||
viewBox: "0 0 16 16",
|
|
||||||
body: `<path d="M3.53613 8.17857L6.39328 11.75L12.4647 4.25" stroke="currentColor"/>`,
|
|
||||||
},
|
|
||||||
close: {
|
close: {
|
||||||
viewBox: "0 0 20 20",
|
viewBox: "0 0 20 20",
|
||||||
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,
|
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,
|
||||||
|
|||||||
@@ -1,16 +1,4 @@
|
|||||||
@layer theme {
|
@layer theme {
|
||||||
html[data-theme="oc-2"] body:not([data-new-layout]) {
|
|
||||||
--text-diff-add-base: light-dark(#167517, #c4ffc0);
|
|
||||||
--text-diff-delete-base: light-dark(#fa3012, #ec2f14);
|
|
||||||
--syntax-comment: light-dark(#7a7a7a, #8f8f8f);
|
|
||||||
--syntax-keyword: light-dark(#a753ae, #edb2f1);
|
|
||||||
--syntax-string: #00ceb9;
|
|
||||||
--syntax-primitive: light-dark(#034cff, #8cb0ff);
|
|
||||||
--syntax-property: light-dark(#a753ae, #fab283);
|
|
||||||
--syntax-type: light-dark(#8a6f00, #fcd53a);
|
|
||||||
--syntax-critical: light-dark(#ff8c00, #fab283);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
@@ -34,7 +22,6 @@
|
|||||||
--v2-text-text-contrast: var(--v2-grey-50);
|
--v2-text-text-contrast: var(--v2-grey-50);
|
||||||
--v2-text-text-accent: var(--v2-blue-600);
|
--v2-text-text-accent: var(--v2-blue-600);
|
||||||
--v2-text-text-accent-hover: var(--v2-blue-700);
|
--v2-text-text-accent-hover: var(--v2-blue-700);
|
||||||
--v2-text-text-code-accent: var(--v2-blue-900);
|
|
||||||
|
|
||||||
/* ── Icon ── */
|
/* ── Icon ── */
|
||||||
--v2-icon-icon-base: var(--v2-grey-800);
|
--v2-icon-icon-base: var(--v2-grey-800);
|
||||||
@@ -154,7 +141,6 @@
|
|||||||
--v2-text-text-contrast: var(--v2-grey-100);
|
--v2-text-text-contrast: var(--v2-grey-100);
|
||||||
--v2-text-text-accent: var(--v2-blue-400);
|
--v2-text-text-accent: var(--v2-blue-400);
|
||||||
--v2-text-text-accent-hover: var(--v2-blue-300);
|
--v2-text-text-accent-hover: var(--v2-blue-300);
|
||||||
--v2-text-text-code-accent: var(--v2-blue-400);
|
|
||||||
|
|
||||||
--v2-icon-icon-base: var(--v2-grey-400);
|
--v2-icon-icon-base: var(--v2-grey-400);
|
||||||
--v2-icon-icon-muted: var(--v2-grey-600);
|
--v2-icon-icon-muted: var(--v2-grey-600);
|
||||||
@@ -257,7 +243,6 @@
|
|||||||
--v2-text-text-contrast: var(--v2-grey-50);
|
--v2-text-text-contrast: var(--v2-grey-50);
|
||||||
--v2-text-text-accent: var(--v2-blue-600);
|
--v2-text-text-accent: var(--v2-blue-600);
|
||||||
--v2-text-text-accent-hover: var(--v2-blue-700);
|
--v2-text-text-accent-hover: var(--v2-blue-700);
|
||||||
--v2-text-text-code-accent: var(--v2-blue-900);
|
|
||||||
|
|
||||||
--v2-icon-icon-base: var(--v2-grey-800);
|
--v2-icon-icon-base: var(--v2-grey-800);
|
||||||
--v2-icon-icon-muted: var(--v2-grey-600);
|
--v2-icon-icon-muted: var(--v2-grey-600);
|
||||||
@@ -367,7 +352,6 @@
|
|||||||
--v2-text-text-contrast: var(--v2-grey-50);
|
--v2-text-text-contrast: var(--v2-grey-50);
|
||||||
--v2-text-text-accent: var(--v2-blue-400);
|
--v2-text-text-accent: var(--v2-blue-400);
|
||||||
--v2-text-text-accent-hover: var(--v2-blue-300);
|
--v2-text-text-accent-hover: var(--v2-blue-300);
|
||||||
--v2-text-text-code-accent: var(--v2-blue-400);
|
|
||||||
|
|
||||||
--v2-icon-icon-base: var(--v2-grey-400);
|
--v2-icon-icon-base: var(--v2-grey-400);
|
||||||
--v2-icon-icon-muted: var(--v2-grey-600);
|
--v2-icon-icon-muted: var(--v2-grey-600);
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/tsconfig",
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"noEmit": false,
|
|
||||||
// Publish declarations so consumer compiler options do not typecheck our TSX implementation.
|
|
||||||
"declaration": true,
|
|
||||||
"emitDeclarationOnly": true
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["**/*.stories.*", "**/*.test.*", "**/*.mdx"]
|
|
||||||
}
|
|
||||||
+3
-3
@@ -172,11 +172,11 @@ Inbox promotion coalesces pending steers in durable admission order. Once contin
|
|||||||
|
|
||||||
Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets.
|
Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets.
|
||||||
|
|
||||||
The synchronized `session.next.*` event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs or timestamps do not. Consumers can use `sessions.events({ sessionID, after? })` to replay durable `session.next.*` events after an aggregate sequence cursor, then tail durable events without a race. Live-only text, reasoning, and tool-input fragments remain available through EventV2 subscriptions for connected renderers; they are intentionally absent from the replayable Session stream.
|
The synchronized `session.next.*` event family and projected Session-message model predate this branch. Projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs or timestamps do not. Consumers use `sessions.events({ sessionID, after? })` for one Session-scoped stream containing replayable lifecycle/output events, process-local activity, and connected live-only output fragments.
|
||||||
|
|
||||||
The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries.
|
The stream registers observation, captures one fixed durable cutoff `B`, emits durable rows in `(after, B]` in aggregate-sequence order, and then emits exactly one authoritative `session.activity` value. Only events carrying `durable` metadata advance the cursor. Activity and text, reasoning, compaction, or tool-input deltas are process-local and never replay after reconnect. Activity means foreground execution ownership in this process; `false` does not describe a terminal outcome, and background work does not activate its parent Session.
|
||||||
|
|
||||||
Durable event tail wakeups are advisory and edge-triggered. Each active tail owns one sliding-capacity-1 dirty signal for its aggregate and re-queries SQLite after a wake. Repeated commits coalesce while the tail is busy because durable rows, not in-memory notifications, preserve every event and sequence. Subscribe and register the dirty signal before historical replay, then remove it when the tail closes, so replay handoff cannot miss a commit and inactive aggregates retain no wake state.
|
Durable event tail notifications are advisory. Each active tail uses bounded non-blocking observation and re-queries SQLite after a wake. Repeated commits may coalesce because durable rows, not in-memory notifications, preserve every event and sequence. A live-only fragment is emitted only after draining later durable rows, which fences dependent deltas behind their committed start events. If a live consumer cannot keep up, the stream closes rather than blocking Session execution or SQLite writers. Aggregate deletion still removes durable history and remains a documented replay limitation rather than being redesigned here.
|
||||||
|
|
||||||
Event replay owner claims are separate from clustered Session execution ownership. The former already fences synchronized projection reconstruction; the latter still needs distributed active-run acquisition, stale-runtime rejection, interruption, and placement orchestration.
|
Event replay owner claims are separate from clustered Session execution ownership. The former already fences synchronized projection reconstruction; the latter still needs distributed active-run acquisition, stale-runtime rejection, interruption, and placement orchestration.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user